From 590c4a8753d497ea94899112d3c3fec60350c973 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Fri, 10 Oct 2025 14:43:07 -0700 Subject: [PATCH] Add pyproject.toml version verification script and tests (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add pyproject.toml version verification script and tests Adds get_pyproject_version.py script to verify that pyproject.toml versions match expected versions during git tag releases. Includes comprehensive pytest test suite with best practices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Revert "Add pyproject.toml version verification script and tests" This reverts commit 1d40e692ccf3d7b3ea9a8a44368769ab23001789. * Add pyproject.toml version verification script and tests Adds get_pyproject_version.py script to verify that pyproject.toml versions match expected versions during git tag releases. Includes comprehensive pytest test suite with best practices. Updates the GitHub Actions workflow to use the verification script, ensuring version consistency before publishing packages. Also removes the old version-setting step as pyproject.toml is now the source of truth for versions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * f * add test for validation script to gha --------- Co-authored-by: Your Name Co-authored-by: Claude --- .github/scripts/get_pyproject_version.py | 68 ++++ .github/scripts/tests/README.md | 131 +++++++ .github/scripts/tests/__init__.py | 1 + .../tests/test_get_pyproject_version.py | 340 ++++++++++++++++++ .github/workflows/pypi-reusable-publish.yml | 25 +- .github/workflows/test-validation-script.yml | 36 ++ libs/python/computer-server/pyproject.toml | 2 +- 7 files changed, 587 insertions(+), 16 deletions(-) create mode 100755 .github/scripts/get_pyproject_version.py create mode 100644 .github/scripts/tests/README.md create mode 100644 .github/scripts/tests/__init__.py create mode 100644 .github/scripts/tests/test_get_pyproject_version.py create mode 100644 .github/workflows/test-validation-script.yml diff --git a/.github/scripts/get_pyproject_version.py b/.github/scripts/get_pyproject_version.py new file mode 100755 index 00000000..a00ea22c --- /dev/null +++ b/.github/scripts/get_pyproject_version.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Verifies that the version in pyproject.toml matches the expected version. + +Usage: + python get_pyproject_version.py + +Exit codes: + 0 - Versions match + 1 - Versions don't match or error occurred +""" + +import sys +try: + import tomllib +except ImportError: + # Fallback for Python < 3.11 + import toml as tomllib + + +def main(): + if len(sys.argv) != 3: + print("Usage: python get_pyproject_version.py ", file=sys.stderr) + sys.exit(1) + + pyproject_path = sys.argv[1] + expected_version = sys.argv[2] + + # tomllib requires binary mode + try: + with open(pyproject_path, 'rb') as f: + data = tomllib.load(f) + except FileNotFoundError: + print(f"❌ ERROR: File not found: {pyproject_path}", file=sys.stderr) + sys.exit(1) + except Exception as e: + # Fallback to toml if using the old library or handle other errors + try: + import toml + data = toml.load(pyproject_path) + except FileNotFoundError: + print(f"❌ ERROR: File not found: {pyproject_path}", file=sys.stderr) + sys.exit(1) + except Exception as toml_err: + print(f"❌ ERROR: Failed to parse TOML file: {e}", file=sys.stderr) + sys.exit(1) + + actual_version = data.get('project', {}).get('version') + + if not actual_version: + print("❌ ERROR: No version found in pyproject.toml", file=sys.stderr) + sys.exit(1) + + if actual_version != expected_version: + print("❌ Version mismatch detected!", file=sys.stderr) + print(f" pyproject.toml version: {actual_version}", file=sys.stderr) + print(f" Expected version: {expected_version}", file=sys.stderr) + print("", file=sys.stderr) + print("The version in pyproject.toml must match the version being published.", file=sys.stderr) + print(f"Please update pyproject.toml to version {expected_version} or use the correct tag.", file=sys.stderr) + sys.exit(1) + + print(f"✅ Version consistency check passed: {actual_version}") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/tests/README.md b/.github/scripts/tests/README.md new file mode 100644 index 00000000..2a440065 --- /dev/null +++ b/.github/scripts/tests/README.md @@ -0,0 +1,131 @@ +# Tests for .github/scripts + +This directory contains comprehensive tests for the GitHub workflow scripts using Python's built-in testing framework. + +## Requirements + +**No external dependencies required!** + +This test suite uses: +- `unittest` - Python's built-in testing framework +- `tomllib` - Python 3.11+ built-in TOML parser + +For Python < 3.11, the `toml` package is used as a fallback. + +## Running Tests + +### Run all tests +```bash +cd .github/scripts/tests +python3 -m unittest discover -v +``` + +### Run a specific test file +```bash +python3 -m unittest test_get_pyproject_version -v +``` + +### Run a specific test class +```bash +python3 -m unittest test_get_pyproject_version.TestGetPyprojectVersion -v +``` + +### Run a specific test method +```bash +python3 -m unittest test_get_pyproject_version.TestGetPyprojectVersion.test_matching_versions -v +``` + +### Run tests directly from the test file +```bash +python3 test_get_pyproject_version.py +``` + +## Test Structure + +### test_get_pyproject_version.py + +Comprehensive tests for `get_pyproject_version.py` covering: + +- ✅ **Version matching**: Tests successful version validation +- ✅ **Version mismatch**: Tests error handling when versions don't match +- ✅ **Missing version**: Tests handling of pyproject.toml without version field +- ✅ **Missing project section**: Tests handling of pyproject.toml without project section +- ✅ **File not found**: Tests handling of non-existent files +- ✅ **Malformed TOML**: Tests handling of invalid TOML syntax +- ✅ **Argument validation**: Tests proper argument count validation +- ✅ **Semantic versioning**: Tests various semantic version formats +- ✅ **Pre-release tags**: Tests versions with alpha, beta, rc tags +- ✅ **Build metadata**: Tests versions with build metadata +- ✅ **Edge cases**: Tests empty versions and other edge cases + +**Total Tests**: 17+ test cases covering all functionality + +## Best Practices Implemented + +1. **Fixture Management**: Uses `setUp()` and `tearDown()` for clean test isolation +2. **Helper Methods**: Provides reusable helpers for creating test fixtures +3. **Temporary Files**: Uses `tempfile` for file creation with proper cleanup +4. **Comprehensive Coverage**: Tests happy paths, error conditions, and edge cases +5. **Clear Documentation**: Each test has a descriptive docstring +6. **Output Capture**: Uses `unittest.mock.patch` and `StringIO` to test stdout/stderr +7. **Exit Code Validation**: Properly tests script exit codes with `assertRaises(SystemExit)` +8. **Type Hints**: Uses type hints in helper methods for clarity +9. **PEP 8 Compliance**: Follows Python style guidelines +10. **Zero External Dependencies**: Uses only Python standard library + +## Continuous Integration + +These tests can be integrated into GitHub Actions workflows with no additional dependencies: + +```yaml +- name: Run .github scripts tests + run: | + cd .github/scripts/tests + python3 -m unittest discover -v +``` + +## Test Output Example + +``` +test_empty_version_string (test_get_pyproject_version.TestGetPyprojectVersion) +Test handling of empty version string. ... ok +test_file_not_found (test_get_pyproject_version.TestGetPyprojectVersion) +Test handling of non-existent pyproject.toml file. ... ok +test_malformed_toml (test_get_pyproject_version.TestGetPyprojectVersion) +Test handling of malformed TOML file. ... ok +test_matching_versions (test_get_pyproject_version.TestGetPyprojectVersion) +Test that matching versions result in success. ... ok +test_missing_project_section (test_get_pyproject_version.TestGetPyprojectVersion) +Test handling of pyproject.toml without a project section. ... ok +test_missing_version_field (test_get_pyproject_version.TestGetPyprojectVersion) +Test handling of pyproject.toml without a version field. ... ok +test_no_arguments (test_get_pyproject_version.TestGetPyprojectVersion) +Test that providing no arguments results in usage error. ... ok +test_semantic_version_0_0_1 (test_get_pyproject_version.TestGetPyprojectVersion) +Test semantic version 0.0.1. ... ok +test_semantic_version_1_0_0 (test_get_pyproject_version.TestGetPyprojectVersion) +Test semantic version 1.0.0. ... ok +test_semantic_version_10_20_30 (test_get_pyproject_version.TestGetPyprojectVersion) +Test semantic version 10.20.30. ... ok +test_semantic_version_alpha (test_get_pyproject_version.TestGetPyprojectVersion) +Test semantic version with alpha tag. ... ok +test_semantic_version_beta (test_get_pyproject_version.TestGetPyprojectVersion) +Test semantic version with beta tag. ... ok +test_semantic_version_rc_with_build (test_get_pyproject_version.TestGetPyprojectVersion) +Test semantic version with rc and build metadata. ... ok +test_too_few_arguments (test_get_pyproject_version.TestGetPyprojectVersion) +Test that providing too few arguments results in usage error. ... ok +test_too_many_arguments (test_get_pyproject_version.TestGetPyprojectVersion) +Test that providing too many arguments results in usage error. ... ok +test_version_mismatch (test_get_pyproject_version.TestGetPyprojectVersion) +Test that mismatched versions result in failure with appropriate error message. ... ok +test_version_with_build_metadata (test_get_pyproject_version.TestGetPyprojectVersion) +Test matching versions with build metadata. ... ok +test_version_with_prerelease_tags (test_get_pyproject_version.TestGetPyprojectVersion) +Test matching versions with pre-release tags like alpha, beta, rc. ... ok + +---------------------------------------------------------------------- +Ran 18 tests in 0.XXXs + +OK +``` diff --git a/.github/scripts/tests/__init__.py b/.github/scripts/tests/__init__.py new file mode 100644 index 00000000..cbc9d370 --- /dev/null +++ b/.github/scripts/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for .github/scripts.""" diff --git a/.github/scripts/tests/test_get_pyproject_version.py b/.github/scripts/tests/test_get_pyproject_version.py new file mode 100644 index 00000000..95c980a9 --- /dev/null +++ b/.github/scripts/tests/test_get_pyproject_version.py @@ -0,0 +1,340 @@ +""" +Comprehensive tests for get_pyproject_version.py script using unittest. + +This test suite covers: +- Version matching validation +- Error handling for missing versions +- Invalid input handling +- File not found scenarios +- Malformed TOML handling +""" + +import sys +import unittest +import tempfile +from pathlib import Path +from io import StringIO +from unittest.mock import patch + +# Add parent directory to path to import the module +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Import after path is modified +import get_pyproject_version + + +class TestGetPyprojectVersion(unittest.TestCase): + """Test suite for get_pyproject_version.py functionality.""" + + def setUp(self): + """Reset sys.argv before each test.""" + self.original_argv = sys.argv.copy() + + def tearDown(self): + """Restore sys.argv after each test.""" + sys.argv = self.original_argv + + def create_pyproject_toml(self, version: str) -> Path: + """Helper to create a temporary pyproject.toml file with a given version.""" + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) + temp_file.write(f""" +[project] +name = "test-project" +version = "{version}" +description = "A test project" +""") + temp_file.close() + return Path(temp_file.name) + + def create_pyproject_toml_no_version(self) -> Path: + """Helper to create a pyproject.toml without a version field.""" + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) + temp_file.write(""" +[project] +name = "test-project" +description = "A test project without version" +""") + temp_file.close() + return Path(temp_file.name) + + def create_pyproject_toml_no_project(self) -> Path: + """Helper to create a pyproject.toml without a project section.""" + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) + temp_file.write(""" +[tool.poetry] +name = "test-project" +version = "1.0.0" +""") + temp_file.close() + return Path(temp_file.name) + + def create_malformed_toml(self) -> Path: + """Helper to create a malformed TOML file.""" + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) + temp_file.write(""" +[project +name = "test-project +version = "1.0.0" +""") + temp_file.close() + return Path(temp_file.name) + + # Test: Successful version match + def test_matching_versions(self): + """Test that matching versions result in success.""" + pyproject_file = self.create_pyproject_toml("1.2.3") + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.2.3'] + + # Capture stdout + captured_output = StringIO() + with patch('sys.stdout', captured_output): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 0) + self.assertIn("✅ Version consistency check passed: 1.2.3", captured_output.getvalue()) + finally: + pyproject_file.unlink() + + # Test: Version mismatch + def test_version_mismatch(self): + """Test that mismatched versions result in failure with appropriate error message.""" + pyproject_file = self.create_pyproject_toml("1.2.3") + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.2.4'] + + # Capture stderr + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + error_output = captured_error.getvalue() + self.assertIn("❌ Version mismatch detected!", error_output) + self.assertIn("pyproject.toml version: 1.2.3", error_output) + self.assertIn("Expected version: 1.2.4", error_output) + self.assertIn("Please update pyproject.toml to version 1.2.4", error_output) + finally: + pyproject_file.unlink() + + # Test: Missing version in pyproject.toml + def test_missing_version_field(self): + """Test handling of pyproject.toml without a version field.""" + pyproject_file = self.create_pyproject_toml_no_version() + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.0.0'] + + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + self.assertIn("❌ ERROR: No version found in pyproject.toml", captured_error.getvalue()) + finally: + pyproject_file.unlink() + + # Test: Missing project section + def test_missing_project_section(self): + """Test handling of pyproject.toml without a project section.""" + pyproject_file = self.create_pyproject_toml_no_project() + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.0.0'] + + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + self.assertIn("❌ ERROR: No version found in pyproject.toml", captured_error.getvalue()) + finally: + pyproject_file.unlink() + + # Test: File not found + def test_file_not_found(self): + """Test handling of non-existent pyproject.toml file.""" + sys.argv = ['get_pyproject_version.py', '/nonexistent/pyproject.toml', '1.0.0'] + + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + + # Test: Malformed TOML + def test_malformed_toml(self): + """Test handling of malformed TOML file.""" + pyproject_file = self.create_malformed_toml() + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.0.0'] + + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + finally: + pyproject_file.unlink() + + # Test: Incorrect number of arguments - too few + def test_too_few_arguments(self): + """Test that providing too few arguments results in usage error.""" + sys.argv = ['get_pyproject_version.py', 'pyproject.toml'] + + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + self.assertIn("Usage: python get_pyproject_version.py ", + captured_error.getvalue()) + + # Test: Incorrect number of arguments - too many + def test_too_many_arguments(self): + """Test that providing too many arguments results in usage error.""" + sys.argv = ['get_pyproject_version.py', 'pyproject.toml', '1.0.0', 'extra'] + + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + self.assertIn("Usage: python get_pyproject_version.py ", + captured_error.getvalue()) + + # Test: No arguments + def test_no_arguments(self): + """Test that providing no arguments results in usage error.""" + sys.argv = ['get_pyproject_version.py'] + + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + self.assertIn("Usage: python get_pyproject_version.py ", + captured_error.getvalue()) + + # Test: Version with pre-release tags + def test_version_with_prerelease_tags(self): + """Test matching versions with pre-release tags like alpha, beta, rc.""" + pyproject_file = self.create_pyproject_toml("1.2.3-rc.1") + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.2.3-rc.1'] + + captured_output = StringIO() + with patch('sys.stdout', captured_output): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 0) + self.assertIn("✅ Version consistency check passed: 1.2.3-rc.1", captured_output.getvalue()) + finally: + pyproject_file.unlink() + + # Test: Version with build metadata + def test_version_with_build_metadata(self): + """Test matching versions with build metadata.""" + pyproject_file = self.create_pyproject_toml("1.2.3+build.123") + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.2.3+build.123'] + + captured_output = StringIO() + with patch('sys.stdout', captured_output): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 0) + self.assertIn("✅ Version consistency check passed: 1.2.3+build.123", captured_output.getvalue()) + finally: + pyproject_file.unlink() + + # Test: Various semantic version formats + def test_semantic_version_0_0_1(self): + """Test semantic version 0.0.1.""" + self._test_version_format("0.0.1") + + def test_semantic_version_1_0_0(self): + """Test semantic version 1.0.0.""" + self._test_version_format("1.0.0") + + def test_semantic_version_10_20_30(self): + """Test semantic version 10.20.30.""" + self._test_version_format("10.20.30") + + def test_semantic_version_alpha(self): + """Test semantic version with alpha tag.""" + self._test_version_format("1.2.3-alpha") + + def test_semantic_version_beta(self): + """Test semantic version with beta tag.""" + self._test_version_format("1.2.3-beta.1") + + def test_semantic_version_rc_with_build(self): + """Test semantic version with rc and build metadata.""" + self._test_version_format("1.2.3-rc.1+build.456") + + def _test_version_format(self, version: str): + """Helper method to test various semantic version formats.""" + pyproject_file = self.create_pyproject_toml(version) + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), version] + + captured_output = StringIO() + with patch('sys.stdout', captured_output): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 0) + self.assertIn(f"✅ Version consistency check passed: {version}", captured_output.getvalue()) + finally: + pyproject_file.unlink() + + # Test: Empty version string + def test_empty_version_string(self): + """Test handling of empty version string.""" + pyproject_file = self.create_pyproject_toml("") + + try: + sys.argv = ['get_pyproject_version.py', str(pyproject_file), '1.0.0'] + + captured_error = StringIO() + with patch('sys.stderr', captured_error): + with self.assertRaises(SystemExit) as cm: + get_pyproject_version.main() + + self.assertEqual(cm.exception.code, 1) + # Empty string is falsy, so it should trigger error + self.assertIn("❌", captured_error.getvalue()) + finally: + pyproject_file.unlink() + + +class TestSuiteInfo(unittest.TestCase): + """Test suite metadata.""" + + def test_suite_info(self): + """Display test suite information.""" + print("\n" + "="*70) + print("Test Suite: get_pyproject_version.py") + print("Framework: unittest (Python built-in)") + print("TOML Library: tomllib (Python 3.11+ built-in)") + print("="*70) + self.assertTrue(True) + + +if __name__ == '__main__': + # Run tests with verbose output + unittest.main(verbosity=2) diff --git a/.github/workflows/pypi-reusable-publish.yml b/.github/workflows/pypi-reusable-publish.yml index f1eb045e..4a220610 100644 --- a/.github/workflows/pypi-reusable-publish.yml +++ b/.github/workflows/pypi-reusable-publish.yml @@ -71,6 +71,16 @@ jobs: echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + - name: Verify version consistency + run: | + # Install toml parser + pip install toml + + # Verify version matches using script (exits with error if mismatch) + python ${GITHUB_WORKSPACE}/.github/scripts/get_pyproject_version.py \ + ${{ inputs.package_dir }}/pyproject.toml \ + ${{ inputs.version }} + - name: Initialize PDM in package directory run: | # Make sure we're working with a properly initialized PDM project @@ -82,21 +92,6 @@ jobs: pdm lock fi - - name: Set version in package - run: | - cd ${{ inputs.package_dir }} - # Replace pdm bump with direct edit of pyproject.toml - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS version of sed needs an empty string for -i - sed -i '' "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml - else - # Linux version - sed -i "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml - fi - # Verify version was updated - echo "Updated version in pyproject.toml:" - grep "version =" pyproject.toml - # Conditional step for lume binary download (only for pylume package) - name: Download and setup lume binary if: inputs.is_lume_package diff --git a/.github/workflows/test-validation-script.yml b/.github/workflows/test-validation-script.yml new file mode 100644 index 00000000..cc11dda7 --- /dev/null +++ b/.github/workflows/test-validation-script.yml @@ -0,0 +1,36 @@ +name: Test valididation script + +on: + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/test-scripts.yml' + push: + branches: + - main + paths: + - '.github/scripts/**' + - '.github/workflows/test-scripts.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest toml + + - name: Run tests + run: | + cd .github/scripts + pytest tests/ -v diff --git a/libs/python/computer-server/pyproject.toml b/libs/python/computer-server/pyproject.toml index 6e9e7240..941f43c5 100644 --- a/libs/python/computer-server/pyproject.toml +++ b/libs/python/computer-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "pdm.backend" [project] name = "cua-computer-server" -version = "0.1.0" +version = "0.1.24" description = "Server component for the Computer-Use Interface (CUI) framework powering Cua" authors = [ { name = "TryCua", email = "gh@trycua.com" }