From 5fcbb796ca7aae39cb437acdd75b3e02a3670a81 Mon Sep 17 00:00:00 2001 From: llbbl Date: Wed, 25 Jun 2025 11:52:16 -0500 Subject: [PATCH] feat: Add comprehensive Python testing infrastructure with Poetry - Set up Poetry as package manager with pyproject.toml configuration - Add pytest, pytest-cov, and pytest-mock as development dependencies - Configure pytest with coverage reporting (80% threshold) - Create tests directory structure with shared fixtures in conftest.py - Add custom pytest markers (unit, integration, slow) - Configure Poetry scripts for running tests (test/tests commands) - Add comprehensive .gitignore for Python projects - Include validation tests to verify infrastructure works correctly --- .gitignore | 190 ++++++++++++++++++++++++ pyproject.toml | 81 ++++++++++ tests/__init__.py | 0 tests/conftest.py | 174 ++++++++++++++++++++++ tests/integration/__init__.py | 0 tests/test_infrastructure_validation.py | 174 ++++++++++++++++++++++ tests/unit/__init__.py | 0 7 files changed, 619 insertions(+) create mode 100644 .gitignore 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_infrastructure_validation.py create mode 100644 tests/unit/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cf749b --- /dev/null +++ b/.gitignore @@ -0,0 +1,190 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# pdm +.pdm.toml + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# VS Code +.vscode/ + +# Claude AI +.claude/* + +# TensorFlow specific +*.ckpt +*.ckpt.* +checkpoint +*.pb +*.pbtxt +events.out.tfevents.* +model/ +models/ +logs/ +log/ + +# Dataset files +*.tfrecord +*.record + +# Image files (if not needed in repo) +# *.jpg +# *.jpeg +# *.png +# *.gif +# *.bmp + +# OS specific +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*~ +.*.swp +.*.swo + +# Archives +*.zip +*.tar.gz +*.tgz +*.rar + +# Custom +/output/ +/results/ +/experiments/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..52d6bbc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[tool.poetry] +name = "tf-slim-models" +version = "0.1.0" +description = "TensorFlow-Slim models for image classification" +authors = ["Your Name "] +readme = "README.md" +packages = [{include = "slim"}] + +[tool.poetry.dependencies] +python = "^3.8" +tensorflow = "^2.0.0" +numpy = "^1.19.0" +scipy = "^1.5.0" +pillow = "^8.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-cov = "^4.1.0" +pytest-mock = "^3.11.0" + +[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 = [ + "-v", + "--strict-markers", + "--tb=short", + "--cov=slim", + "--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 = ["slim"] +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/test_*.py", + "*/*_test.py", + "*/setup.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if __name__ == .__main__.:", + "raise AssertionError", + "raise NotImplementedError", +] +show_missing = true +precision = 2 + +[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..21d4bc9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,174 @@ +"""Shared pytest fixtures and configuration for all tests.""" + +import os +import tempfile +import shutil +from pathlib import Path +from typing import Generator, Dict, Any + +import pytest +import numpy as np +from PIL import Image + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for test files.""" + temp_path = tempfile.mkdtemp() + yield Path(temp_path) + shutil.rmtree(temp_path) + + +@pytest.fixture +def mock_image_file(temp_dir: Path) -> Path: + """Create a mock image file for testing.""" + image_path = temp_dir / "test_image.jpg" + # Create a simple RGB image + img_array = np.random.randint(0, 255, (224, 224, 3), dtype=np.uint8) + img = Image.fromarray(img_array) + img.save(image_path) + return image_path + + +@pytest.fixture +def mock_model_checkpoint(temp_dir: Path) -> Path: + """Create a mock model checkpoint directory.""" + checkpoint_dir = temp_dir / "model_checkpoint" + checkpoint_dir.mkdir() + # Create dummy checkpoint files + (checkpoint_dir / "checkpoint").write_text("model_checkpoint_path: \"model.ckpt-1000\"") + (checkpoint_dir / "model.ckpt-1000.index").touch() + (checkpoint_dir / "model.ckpt-1000.data-00000-of-00001").touch() + return checkpoint_dir + + +@pytest.fixture +def mock_tfrecord_file(temp_dir: Path) -> Path: + """Create a mock TFRecord file.""" + tfrecord_path = temp_dir / "test.tfrecord" + # Create an empty file as placeholder + tfrecord_path.touch() + return tfrecord_path + + +@pytest.fixture +def mock_labels_file(temp_dir: Path) -> Dict[int, str]: + """Create a mock labels file and return labels dict.""" + labels = { + 0: "background", + 1: "cat", + 2: "dog", + 3: "bird", + 4: "fish" + } + labels_path = temp_dir / "labels.txt" + with open(labels_path, 'w') as f: + for idx, label in labels.items(): + f.write(f"{idx}:{label}\n") + return labels + + +@pytest.fixture +def mock_config() -> Dict[str, Any]: + """Return a mock configuration dictionary.""" + return { + "model_name": "inception_v3", + "batch_size": 32, + "learning_rate": 0.001, + "num_epochs": 10, + "num_classes": 5, + "image_size": 224, + "dataset_dir": "/path/to/dataset", + "checkpoint_path": "/path/to/checkpoint", + "log_dir": "/path/to/logs", + } + + +@pytest.fixture +def mock_dataset_split(temp_dir: Path) -> Dict[str, Path]: + """Create a mock dataset split structure.""" + dataset_dir = temp_dir / "dataset" + splits = {} + + for split in ["train", "validation", "test"]: + split_dir = dataset_dir / split + split_dir.mkdir(parents=True) + + # Create some mock image files + for i in range(3): + img_path = split_dir / f"image_{i}.jpg" + img = Image.new('RGB', (224, 224), color=(i*50, i*50, i*50)) + img.save(img_path) + + splits[split] = split_dir + + return splits + + +@pytest.fixture(autouse=True) +def reset_tensorflow_graph(): + """Reset TensorFlow graph before each test.""" + try: + import tensorflow as tf + if hasattr(tf, 'reset_default_graph'): + tf.reset_default_graph() + elif hasattr(tf, 'keras'): + tf.keras.backend.clear_session() + except ImportError: + pass + + +@pytest.fixture +def capture_logs(caplog): + """Fixture to capture log messages during tests.""" + with caplog.at_level("DEBUG"): + yield caplog + + +@pytest.fixture +def mock_env_vars(monkeypatch): + """Fixture to safely set environment variables for tests.""" + def _set_env(**kwargs): + for key, value in kwargs.items(): + monkeypatch.setenv(key, value) + return _set_env + + +@pytest.fixture +def cleanup_files(): + """Fixture to track and cleanup files created during tests.""" + files_to_cleanup = [] + + def _add_file(filepath): + files_to_cleanup.append(filepath) + + yield _add_file + + # Cleanup + for filepath in files_to_cleanup: + if os.path.exists(filepath): + if os.path.isdir(filepath): + shutil.rmtree(filepath) + else: + os.remove(filepath) + + +# Pytest configuration hooks +def pytest_configure(config): + """Configure pytest with custom settings.""" + config.addinivalue_line( + "markers", "gpu: tests that require GPU (deselect with '-m \"not gpu\"')" + ) + config.addinivalue_line( + "markers", "network: tests that require network access" + ) + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to add markers based on test location.""" + for item in items: + # Add markers based on test file location + if "unit" in str(item.fspath): + item.add_marker(pytest.mark.unit) + elif "integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) \ 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_infrastructure_validation.py b/tests/test_infrastructure_validation.py new file mode 100644 index 0000000..7292848 --- /dev/null +++ b/tests/test_infrastructure_validation.py @@ -0,0 +1,174 @@ +"""Validation tests to ensure the testing infrastructure is properly set up.""" + +import os +import sys +from pathlib import Path + +import pytest +import numpy as np +from PIL import Image + + +class TestInfrastructureValidation: + """Test class to validate the testing infrastructure setup.""" + + def test_pytest_installed(self): + """Verify pytest is installed and importable.""" + import pytest + assert pytest.__version__ + + def test_pytest_cov_installed(self): + """Verify pytest-cov is installed.""" + import pytest_cov + assert pytest_cov + + def test_pytest_mock_installed(self): + """Verify pytest-mock is installed.""" + import pytest_mock + assert pytest_mock + + def test_project_structure(self): + """Verify the project structure is correctly set up.""" + project_root = Path(__file__).parent.parent + assert (project_root / "tests").exists() + assert (project_root / "tests" / "__init__.py").exists() + assert (project_root / "tests" / "conftest.py").exists() + assert (project_root / "tests" / "unit").exists() + assert (project_root / "tests" / "integration").exists() + assert (project_root / "pyproject.toml").exists() + assert (project_root / ".gitignore").exists() + + def test_slim_package_importable(self): + """Verify the slim package can be imported.""" + # Add parent directory to path to import slim + sys.path.insert(0, str(Path(__file__).parent.parent)) + try: + import slim + assert slim + except ImportError: + pytest.skip("Slim package not yet installed") + + @pytest.mark.unit + def test_unit_marker(self): + """Test that unit test marker works.""" + assert True + + @pytest.mark.integration + def test_integration_marker(self): + """Test that integration test marker works.""" + assert True + + @pytest.mark.slow + def test_slow_marker(self): + """Test that slow test marker works.""" + assert True + + def test_temp_dir_fixture(self, temp_dir): + """Test the temp_dir fixture creates and cleans up properly.""" + assert temp_dir.exists() + assert temp_dir.is_dir() + test_file = temp_dir / "test.txt" + test_file.write_text("test content") + assert test_file.exists() + + def test_mock_image_file_fixture(self, mock_image_file): + """Test the mock_image_file fixture creates a valid image.""" + assert mock_image_file.exists() + assert mock_image_file.suffix == ".jpg" + # Verify it's a valid image + img = Image.open(mock_image_file) + assert img.size == (224, 224) + assert img.mode == "RGB" + + def test_mock_config_fixture(self, mock_config): + """Test the mock_config fixture provides expected configuration.""" + assert isinstance(mock_config, dict) + assert "model_name" in mock_config + assert "batch_size" in mock_config + assert "learning_rate" in mock_config + assert mock_config["model_name"] == "inception_v3" + assert mock_config["batch_size"] == 32 + + def test_mock_labels_file_fixture(self, temp_dir, mock_labels_file): + """Test the mock_labels_file fixture creates proper labels.""" + assert isinstance(mock_labels_file, dict) + assert len(mock_labels_file) == 5 + assert mock_labels_file[0] == "background" + assert mock_labels_file[1] == "cat" + # Verify file was created + labels_file = temp_dir / "labels.txt" + assert labels_file.exists() + + def test_mock_dataset_split_fixture(self, mock_dataset_split): + """Test the mock_dataset_split fixture creates proper dataset structure.""" + assert "train" in mock_dataset_split + assert "validation" in mock_dataset_split + assert "test" in mock_dataset_split + + for split_name, split_path in mock_dataset_split.items(): + assert split_path.exists() + assert split_path.is_dir() + # Check that images exist + images = list(split_path.glob("*.jpg")) + assert len(images) == 3 + + def test_coverage_configuration(self): + """Verify coverage is properly configured.""" + # This test will be meaningful when running with coverage + assert True + + def test_capture_logs_fixture(self, capture_logs): + """Test the capture_logs fixture works properly.""" + import logging + logger = logging.getLogger(__name__) + logger.info("Test log message") + + assert len(capture_logs.records) > 0 + assert "Test log message" in capture_logs.text + + def test_mock_env_vars_fixture(self, mock_env_vars): + """Test the mock_env_vars fixture sets environment variables.""" + mock_env_vars(TEST_VAR="test_value", ANOTHER_VAR="another_value") + assert os.environ.get("TEST_VAR") == "test_value" + assert os.environ.get("ANOTHER_VAR") == "another_value" + + def test_cleanup_files_fixture(self, cleanup_files, temp_dir): + """Test the cleanup_files fixture tracks files for cleanup.""" + test_file = temp_dir / "cleanup_test.txt" + test_file.write_text("test") + cleanup_files(str(test_file)) + assert test_file.exists() # File still exists during test + + @pytest.mark.parametrize("value,expected", [ + (1, 1), + (2, 4), + (3, 9), + (4, 16), + ]) + def test_parametrize_works(self, value, expected): + """Test that pytest parametrize decorator works.""" + assert value ** 2 == expected + + def test_mock_works(self, mocker): + """Test that pytest-mock mocker fixture works.""" + mock_func = mocker.Mock(return_value=42) + result = mock_func() + assert result == 42 + mock_func.assert_called_once() + + +class TestPoetryScripts: + """Test that Poetry scripts are properly configured.""" + + def test_poetry_scripts_configured(self): + """Verify Poetry test scripts are 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 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ 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