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 ee0ae659..8735e51f 100644 --- a/libs/python/computer-server/pyproject.toml +++ b/libs/python/computer-server/pyproject.toml @@ -5,6 +5,7 @@ build-backend = "pdm.backend" [project] name = "cua-computer-server" version = "0.1.26" + description = "Server component for the Computer-Use Interface (CUI) framework powering Cua" authors = [ { name = "TryCua", email = "gh@trycua.com" }