From 2d4dce7529edfed503542ec8862d777f5eff4e0a Mon Sep 17 00:00:00 2001 From: llbbl Date: Fri, 27 Jun 2025 17:48:04 -0500 Subject: [PATCH] feat: Add complete Python testing infrastructure with Poetry - Set up Poetry as package manager with pyproject.toml configuration - Migrate dependencies from requirements.txt and setup.py - Add pytest, pytest-cov, and pytest-mock as dev dependencies - Configure pytest with coverage reporting (80% threshold) - Create test directory structure (unit/integration) - Add comprehensive test fixtures in conftest.py - Update .gitignore with testing and development patterns - Create validation tests to verify infrastructure setup - Add Poetry script commands for running tests --- .gitignore | 39 +++++++++ pyproject.toml | 101 ++++++++++++++++++++++ run_tests.py | 36 ++++++++ tests/__init__.py | 0 tests/conftest.py | 152 +++++++++++++++++++++++++++++++++ tests/integration/__init__.py | 0 tests/test_setup_validation.py | 105 +++++++++++++++++++++++ tests/unit/__init__.py | 0 8 files changed, 433 insertions(+) create mode 100644 pyproject.toml create mode 100755 run_tests.py 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 37a7b35..e9c74dc 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,42 @@ docs/_build/ # PyBuilder target/ +# Testing related +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml +*.cover +.hypothesis/ + +# Claude settings +.claude/* + +# Poetry +dist/ +poetry.toml + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE files +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3f520ea --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +[tool.poetry] +name = "hypernerf" +version = "0.0.1" +description = "Code for 'HyperNeRF'." +authors = ["Keunhong Park "] +readme = "README.md" +homepage = "https://github.com/google/hypernerf" +repository = "https://github.com/google/hypernerf" +license = "Apache-2.0" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +packages = [{include = "hypernerf"}] + +[tool.poetry.dependencies] +python = ">=3.8,<3.12" +absl-py = ">=0.13.0" +flax = "^0.3.4" +gin-config = {git = "https://github.com/google/gin-config", rev = "243ba87b3fcfeb2efb4a920b8f19679b61a6f0dc"} +imageio = "^2.9.0" +immutabledict = "^2.2.0" +jax = ">=0.2.20,<0.5.0" +jaxlib = ">=0.1.71,<0.5.0" +Markdown = "^3.3.4" +matplotlib = "^3.4.3" +numpy = ">=1.19.2,<1.24" +oauthlib = "^3.1.1" +opencv-python = "^4.5.3.56" +opt-einsum = "^3.3.0" +optax = "^0.0.9" +Pillow = "^9.0.0" +scipy = "^1.7.1" +tensorflow = ">=2.6.3,<2.12" +tqdm = "^4.62.2" + +[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", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", + "--cov=hypernerf", + "--cov-branch", + "--cov-report=term-missing:skip-covered", + "--cov-report=html", + "--cov-report=xml", + "--cov-fail-under=80", +] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "slow: Slow running tests", +] + +[tool.coverage.run] +source = ["hypernerf"] +omit = [ + "*/tests/*", + "*/test_*", + "*/__pycache__/*", + "*/site-packages/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "if typing.TYPE_CHECKING:", +] +precision = 2 +show_missing = true +skip_covered = true + +[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/run_tests.py b/run_tests.py new file mode 100755 index 0000000..39fcd42 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Simple test runner script for when Poetry is not available.""" +import subprocess +import sys +import os + +def main(): + """Run pytest with configured options.""" + # Add the project root to Python path + project_root = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, project_root) + + # Build pytest command with options from pyproject.toml + pytest_args = [ + "pytest", + "-ra", + "--strict-markers", + "--strict-config", + "--cov=hypernerf", + "--cov-branch", + "--cov-report=term-missing:skip-covered", + "--cov-report=html", + "--cov-report=xml", + "--cov-fail-under=80", + "tests" + ] + + # Add any additional arguments passed to the script + pytest_args.extend(sys.argv[1:]) + + # Run pytest + result = subprocess.run(pytest_args) + sys.exit(result.returncode) + +if __name__ == "__main__": + main() \ 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..1f7ce0f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,152 @@ +import tempfile +from pathlib import Path +from typing import Generator + +import pytest + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Provides a temporary directory that is cleaned up after the test.""" + with tempfile.TemporaryDirectory() as tmp_dir: + yield Path(tmp_dir) + + +@pytest.fixture +def mock_config_file(temp_dir: Path) -> Path: + """Creates a mock gin configuration file for testing.""" + config_path = temp_dir / "test_config.gin" + config_content = """ +# Test configuration +train.batch_size = 16 +train.learning_rate = 0.001 +model.num_layers = 3 +""" + config_path.write_text(config_content) + return config_path + + +@pytest.fixture +def sample_image_path(temp_dir: Path) -> Path: + """Creates a sample image file for testing.""" + import numpy as np + from PIL import Image + + # Create a simple test image + img_array = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) + img = Image.fromarray(img_array) + + img_path = temp_dir / "test_image.png" + img.save(img_path) + return img_path + + +@pytest.fixture +def mock_camera_data() -> dict: + """Provides sample camera parameters for testing.""" + return { + "camera_to_world": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "focal_length": 50.0, + "pixel_aspect_ratio": 1.0, + "principal_point": [640.0, 360.0], + "image_size": [1280, 720], + "radial_distortion": [0.0, 0.0], + "tangential_distortion": [0.0, 0.0], + "camera_type": "PERSPECTIVE" + } + + +@pytest.fixture +def mock_dataset_config() -> dict: + """Provides a mock dataset configuration.""" + return { + "dataset_name": "test_dataset", + "data_dir": "/tmp/test_data", + "image_scale": 1, + "shuffle_pixels": False, + "batch_size": 16, + "num_epochs": 10, + "use_appearance_id": True, + "use_camera_id": False, + "use_warp_id": True, + "use_time_id": True, + } + + +@pytest.fixture(autouse=True) +def setup_test_environment(): + """Automatically set up test environment for all tests.""" + import os + + # Set environment variables for testing + os.environ["JAX_PLATFORM_NAME"] = "cpu" + os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # Suppress TensorFlow warnings + + yield + + # Cleanup if needed + pass + + +@pytest.fixture +def mock_jax_device(): + """Mock JAX device for testing without GPU.""" + import jax + + # Force CPU device for testing + return jax.devices("cpu")[0] + + +@pytest.fixture +def small_model_config() -> dict: + """Provides a small model configuration suitable for testing.""" + return { + "num_coarse_samples": 64, + "num_fine_samples": 128, + "use_viewdirs": True, + "use_stratified_sampling": True, + "near": 0.0, + "far": 1.0, + "noise_std": 0.0, + "net_depth": 4, # Smaller for testing + "net_width": 128, # Smaller for testing + "net_depth_condition": 1, + "net_width_condition": 64, + "skip_layer": 2, + "num_rgb_channels": 3, + "num_sigma_channels": 1, + "white_background": False, + "min_deg_point": 0, + "max_deg_point": 10, + "deg_view": 4, + "num_levels": 2, + } + + +@pytest.fixture +def capture_logs(): + """Fixture to capture logs during tests.""" + import logging + from io import StringIO + + log_capture = StringIO() + handler = logging.StreamHandler(log_capture) + handler.setLevel(logging.DEBUG) + + # Get root logger + logger = logging.getLogger() + original_level = logger.level + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + + yield log_capture + + # Restore original state + logger.removeHandler(handler) + logger.setLevel(original_level) + log_capture.close() \ 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..9c45f49 --- /dev/null +++ b/tests/test_setup_validation.py @@ -0,0 +1,105 @@ +"""Validation tests to verify the testing infrastructure is set up correctly.""" +import sys +from pathlib import Path + +import pytest + + +class TestInfrastructureSetup: + """Tests to validate the testing infrastructure setup.""" + + def test_project_structure_exists(self): + """Verify that the project structure is set up correctly.""" + project_root = Path(__file__).parent.parent + + # Check main package exists + assert (project_root / "hypernerf").exists() + assert (project_root / "hypernerf" / "__init__.py").exists() + + # Check test directories exist + assert (project_root / "tests").exists() + assert (project_root / "tests" / "__init__.py").exists() + assert (project_root / "tests" / "unit").exists() + assert (project_root / "tests" / "unit" / "__init__.py").exists() + assert (project_root / "tests" / "integration").exists() + assert (project_root / "tests" / "integration" / "__init__.py").exists() + + # Check configuration files exist + assert (project_root / "pyproject.toml").exists() + assert (project_root / ".gitignore").exists() + + def test_pytest_configuration(self): + """Verify pytest is configured correctly.""" + # This test itself running proves pytest is working + assert True + + def test_markers_available(self): + """Verify custom markers are available.""" + # Check that our custom markers are registered + marker_names = [marker.name for marker in pytest.mark._markers] + assert "unit" in marker_names or hasattr(pytest.mark, "unit") + assert "integration" in marker_names or hasattr(pytest.mark, "integration") + assert "slow" in marker_names or hasattr(pytest.mark, "slow") + + @pytest.mark.unit + def test_unit_marker(self): + """Test that unit marker works.""" + assert True + + @pytest.mark.integration + def test_integration_marker(self): + """Test that integration marker works.""" + assert True + + @pytest.mark.slow + def test_slow_marker(self): + """Test that slow marker works.""" + assert True + + def test_fixtures_available(self, temp_dir, mock_config_file, mock_camera_data): + """Verify that conftest fixtures are available.""" + # Test temp_dir fixture + assert temp_dir.exists() + assert temp_dir.is_dir() + + # Test mock_config_file fixture + assert mock_config_file.exists() + assert mock_config_file.suffix == ".gin" + assert "train.batch_size" in mock_config_file.read_text() + + # Test mock_camera_data fixture + assert isinstance(mock_camera_data, dict) + assert "camera_to_world" in mock_camera_data + assert "focal_length" in mock_camera_data + + def test_coverage_configured(self): + """Verify coverage is configured.""" + # If this test is running with coverage, it's configured + # The actual coverage threshold will be enforced by pytest-cov + assert True + + def test_imports_work(self): + """Verify key project imports work.""" + try: + import hypernerf + assert hypernerf is not None + except ImportError as e: + pytest.fail(f"Failed to import hypernerf package: {e}") + + def test_python_version(self): + """Verify Python version meets requirements.""" + assert sys.version_info >= (3, 8), "Python 3.8 or higher is required" + + +class TestPoetryCommands: + """Tests to validate Poetry script commands work correctly.""" + + def test_poetry_scripts_configured(self): + """Verify Poetry scripts are configured in pyproject.toml.""" + project_root = Path(__file__).parent.parent + pyproject_path = project_root / "pyproject.toml" + + content = pyproject_path.read_text() + assert "[tool.poetry.scripts]" in content + assert 'test = "pytest:main"' in content + assert 'tests = "pytest:main"' 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