From 6c548249c53837f4a15f53040dc23b0237a0ae3e Mon Sep 17 00:00:00 2001 From: llbbl Date: Sat, 14 Jun 2025 08:23:03 -0500 Subject: [PATCH] chore: Set up Python testing infrastructure with Poetry and pytest - Add Poetry package management with pyproject.toml configuration - Configure pytest with coverage reporting (80% threshold) - Create test directory structure (unit/integration) - Add comprehensive test fixtures in conftest.py - Update .gitignore for testing artifacts and Poetry - Add validation tests to verify setup --- .gitignore | 38 ++++++ pyproject.toml | 97 ++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 222 +++++++++++++++++++++++++++++++++ tests/integration/__init__.py | 0 tests/test_setup_validation.py | 143 +++++++++++++++++++++ tests/unit/__init__.py | 0 7 files changed, 500 insertions(+) create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/test_setup_validation.py create mode 100644 tests/unit/__init__.py diff --git a/.gitignore b/.gitignore index 85fa4ef..41497fc 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,41 @@ dmypy.json # Datasets data/ logs/ + +# Claude settings +.claude/* + +# Poetry +poetry.lock + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Testing artifacts +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml +*.cover +.hypothesis/ +.tox/ +.nox/ + +# Build artifacts +build/ +dist/ +*.egg-info/ +.eggs/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg +MANIFEST diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bfb8413 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[tool.poetry] +name = "hashnerf-pytorch" +version = "0.1.0" +description = "A pure PyTorch implementation of Instant-NGP for Neural Radiance Fields" +authors = ["Your Name "] +readme = "README.md" +packages = [{include = "*.py"}] + +[tool.poetry.dependencies] +python = "^3.8" +torch = "^2.0.0" +numpy = "^1.21.0" +imageio = "^2.31.0" +matplotlib = "^3.5.0" +opencv-python = "^4.8.0" +tqdm = "^4.65.0" +kornia = "^0.7.0" +pyvista = {version = "^0.42.0", optional = true} +pillow = "^10.0.0" + +[tool.poetry.extras] +scannet = ["pyvista"] + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-cov = "^4.1.0" +pytest-mock = "^3.11.1" + +[tool.poetry.scripts] +test = "pytest:main" +tests = "pytest:main" + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = [ + "--strict-markers", + "--verbose", + "--cov=.", + "--cov-config=pyproject.toml", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", + "--cov-fail-under=80", +] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "slow: Slow running tests", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] + +[tool.coverage.run] +source = ["."] +omit = [ + "*/tests/*", + "*/test_*", + "*/__pycache__/*", + "*/site-packages/*", + "setup.py", + "*/migrations/*", + "*/config/*", + "*/scripts/*", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "def __str__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..336eda5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,222 @@ +import pytest +import tempfile +import shutil +import os +import json +import numpy as np +import torch +from pathlib import Path +from unittest.mock import Mock, MagicMock + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for test files.""" + temp_dir = tempfile.mkdtemp() + yield temp_dir + shutil.rmtree(temp_dir) + + +@pytest.fixture +def temp_file(temp_dir): + """Create a temporary file path.""" + def _temp_file(filename="test_file.txt"): + return os.path.join(temp_dir, filename) + return _temp_file + + +@pytest.fixture +def mock_config(): + """Create a mock configuration dictionary.""" + return { + "expname": "test_experiment", + "basedir": "./logs", + "datadir": "./data/test", + "N_rand": 1024, + "N_samples": 64, + "N_importance": 128, + "perturb": 1.0, + "use_viewdirs": True, + "i_embed": 0, + "multires": 10, + "multires_views": 4, + "raw_noise_std": 0.0, + "render_only": False, + "render_test": False, + "render_factor": 0, + "precrop_iters": 0, + "precrop_frac": 0.5, + "dataset_type": "blender", + "testskip": 8, + "shape": "greek", + "white_bkgd": False, + "half_res": False, + "factor": 8, + "no_ndc": True, + "lindisp": False, + "spherify": False, + "llffhold": 8, + "i_print": 100, + "i_img": 500, + "i_weights": 10000, + "i_testset": 50000, + "i_video": 50000, + "N_iters": 200000, + "finest_res": 512, + "log2_hashmap_size": 19, + "sparse_loss_weight": 0.0, + "tv_loss_weight": 0.0, + "lrate": 0.01, + "lrate_decay": 10, + "chunk": 1024*32, + "netchunk": 1024*64, + "no_batching": False, + "no_reload": False, + "ft_path": None, + "random_seed": None + } + + +@pytest.fixture +def sample_images(): + """Create sample image tensors for testing.""" + batch_size = 4 + height, width = 100, 100 + channels = 3 + images = torch.rand(batch_size, height, width, channels) + return images + + +@pytest.fixture +def sample_poses(): + """Create sample camera pose matrices.""" + num_poses = 10 + poses = torch.eye(4).unsqueeze(0).repeat(num_poses, 1, 1) + poses[:, :3, 3] = torch.randn(num_poses, 3) + return poses + + +@pytest.fixture +def sample_rays(): + """Create sample ray origins and directions.""" + num_rays = 1000 + rays_o = torch.randn(num_rays, 3) + rays_d = torch.randn(num_rays, 3) + rays_d = rays_d / rays_d.norm(dim=-1, keepdim=True) + return rays_o, rays_d + + +@pytest.fixture +def mock_model(): + """Create a mock neural network model.""" + model = Mock() + model.forward = MagicMock(return_value=torch.randn(100, 4)) + model.parameters = MagicMock(return_value=[torch.randn(10, 10)]) + return model + + +@pytest.fixture +def sample_training_data(): + """Create sample training data for NeRF.""" + return { + "images": torch.rand(100, 100, 100, 3), + "poses": torch.eye(4).unsqueeze(0).repeat(100, 1, 1), + "render_poses": torch.eye(4).unsqueeze(0).repeat(40, 1, 1), + "hwf": [100, 100, 50.0], + "i_split": [[0, 80], [80, 90], [90, 100]] + } + + +@pytest.fixture +def mock_hash_encoding(): + """Create a mock hash encoding module.""" + mock = Mock() + mock.n_levels = 16 + mock.n_features_per_level = 2 + mock.forward = MagicMock(return_value=torch.randn(1000, 32)) + return mock + + +@pytest.fixture +def device(): + """Get the appropriate device for testing.""" + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +@pytest.fixture +def random_seed(): + """Set random seed for reproducibility.""" + seed = 42 + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed(seed) + return seed + + +@pytest.fixture +def sample_config_file(temp_dir): + """Create a sample configuration file.""" + config_path = os.path.join(temp_dir, "test_config.txt") + config_content = """ +expname = test_experiment +basedir = ./logs +datadir = ./data/nerf_synthetic/chair + +dataset_type = blender +no_batching = True + +use_viewdirs = True +finest_res = 512 +log2_hashmap_size = 19 + +N_samples = 64 +N_importance = 64 + +perturb = 1. +raw_noise_std = 0. + +render_only = False +render_test = False + +chunk = 32768 +netchunk = 65536 + +lrate = 0.01 +lrate_decay = 10 + +N_iters = 30000 +i_testset = 2500 +i_video = 10000 +i_print = 100 +""" + with open(config_path, 'w') as f: + f.write(config_content) + return config_path + + +@pytest.fixture +def mock_dataloader(): + """Create a mock data loader.""" + loader = Mock() + loader.__iter__ = MagicMock(return_value=iter([ + (torch.randn(32, 3), torch.randn(32, 3), torch.randn(32)) + for _ in range(10) + ])) + loader.__len__ = MagicMock(return_value=10) + return loader + + +@pytest.fixture(autouse=True) +def cleanup_gpu(): + """Clean up GPU memory after each test.""" + yield + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + +@pytest.fixture +def capture_logs(caplog): + """Fixture to capture log messages during tests.""" + with caplog.at_level("DEBUG"): + yield caplog \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_setup_validation.py b/tests/test_setup_validation.py new file mode 100644 index 0000000..4a4f6bc --- /dev/null +++ b/tests/test_setup_validation.py @@ -0,0 +1,143 @@ +import pytest +import sys +import os +from pathlib import Path + + +class TestSetupValidation: + """Validation tests to ensure the testing infrastructure is properly configured.""" + + def test_python_version(self): + """Test that Python version meets requirements.""" + assert sys.version_info >= (3, 8), "Python 3.8 or higher is required" + + def test_project_structure(self): + """Test that the project structure is set up correctly.""" + project_root = Path(__file__).parent.parent + + # Check main directories exist + assert project_root.exists() + assert (project_root / "tests").exists() + assert (project_root / "tests" / "unit").exists() + assert (project_root / "tests" / "integration").exists() + + # Check __init__.py files + assert (project_root / "tests" / "__init__.py").exists() + assert (project_root / "tests" / "unit" / "__init__.py").exists() + assert (project_root / "tests" / "integration" / "__init__.py").exists() + + # Check configuration files + assert (project_root / "pyproject.toml").exists() + assert (project_root / ".gitignore").exists() + + def test_conftest_fixtures(self): + """Test that conftest fixtures are available.""" + # These imports should work if conftest.py is properly set up + from tests.conftest import ( + temp_dir, temp_file, mock_config, sample_images, + sample_poses, sample_rays, mock_model + ) + assert True # If we get here, imports worked + + def test_imports(self): + """Test that main project modules can be imported.""" + try: + # Test importing main modules + import run_nerf + import hash_encoding + import run_nerf_helpers + assert True + except ImportError as e: + pytest.fail(f"Failed to import project modules: {e}") + + @pytest.mark.unit + def test_unit_marker(self): + """Test that the unit test marker works.""" + assert True + + @pytest.mark.integration + def test_integration_marker(self): + """Test that the integration test marker works.""" + assert True + + @pytest.mark.slow + def test_slow_marker(self): + """Test that the slow test marker works.""" + assert True + + def test_fixture_temp_dir(self, temp_dir): + """Test the temp_dir fixture.""" + assert os.path.exists(temp_dir) + assert os.path.isdir(temp_dir) + + # Create a test file + test_file = os.path.join(temp_dir, "test.txt") + with open(test_file, "w") as f: + f.write("test content") + + assert os.path.exists(test_file) + + def test_fixture_mock_config(self, mock_config): + """Test the mock_config fixture.""" + assert isinstance(mock_config, dict) + assert "expname" in mock_config + assert "basedir" in mock_config + assert "datadir" in mock_config + assert mock_config["expname"] == "test_experiment" + + def test_fixture_sample_images(self, sample_images): + """Test the sample_images fixture.""" + import torch + + assert isinstance(sample_images, torch.Tensor) + assert sample_images.dim() == 4 # batch, height, width, channels + assert sample_images.shape[-1] == 3 # RGB channels + + def test_fixture_device(self, device): + """Test the device fixture.""" + import torch + + assert isinstance(device, torch.device) + assert device.type in ["cpu", "cuda"] + + def test_coverage_import(self): + """Test that coverage tools are available.""" + try: + import coverage + import pytest_cov + assert True + except ImportError as e: + pytest.fail(f"Coverage tools not available: {e}") + + +@pytest.mark.unit +class TestPytestConfiguration: + """Test pytest configuration.""" + + def test_pytest_ini_options(self): + """Test that pytest.ini options are configured in pyproject.toml.""" + project_root = Path(__file__).parent.parent + pyproject_path = project_root / "pyproject.toml" + + with open(pyproject_path, "r") as f: + content = f.read() + + # Check key pytest configurations + assert "[tool.pytest.ini_options]" in content + assert "testpaths" in content + assert "--cov" in content + assert "--cov-report" in content + assert "markers" in content + + def test_coverage_configuration(self): + """Test that coverage is configured in pyproject.toml.""" + project_root = Path(__file__).parent.parent + pyproject_path = project_root / "pyproject.toml" + + with open(pyproject_path, "r") as f: + content = f.read() + + # Check coverage configurations + assert "[tool.coverage.run]" in content + assert "[tool.coverage.report]" in content + assert "fail_under = 80" in content \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29