diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 11a2d98..d883ff9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -29,7 +29,7 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: - languages: python + languages: python, actions - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/release-homebrew.yml b/.github/workflows/release-homebrew.yml index 6c93b48..e4e973d 100644 --- a/.github/workflows/release-homebrew.yml +++ b/.github/workflows/release-homebrew.yml @@ -1,12 +1,13 @@ +--- name: Update Homebrew Formula -on: +"on": release: types: [published] workflow_dispatch: inputs: version: - description: "Version to update (e.g., v0.6.5)" + description: "Version to update (e.g., v0.9.0)" required: true type: string @@ -15,19 +16,20 @@ permissions: env: HOMEBREW_NO_AUTO_UPDATE: 1 + TAP_REPO: docdyhr/homebrew-tap + FORMULA_PATH: Formula/macversiontracker.rb jobs: update-homebrew: + name: Update Homebrew Tap runs-on: macos-latest + permissions: + contents: read if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' steps: - - name: Checkout repository - uses: actions/checkout@v6 - - name: Set up environment run: | - # Set version from tag or manual input if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" else @@ -38,105 +40,58 @@ jobs: - name: Download release tarball and calculate SHA256 run: | - # Download the source tarball for this release - curl -L -o versiontracker-${VERSION_NUMBER}.tar.gz \ + curl -fSL -o release.tar.gz \ "https://github.com/docdyhr/versiontracker/archive/refs/tags/${VERSION}.tar.gz" - - # Calculate SHA256 checksum - SHA256=$(shasum -a 256 versiontracker-${VERSION_NUMBER}.tar.gz | cut -d' ' -f1) + SHA256=$(shasum -a 256 release.tar.gz | cut -d' ' -f1) echo "SHA256=${SHA256}" >> $GITHUB_ENV + echo "Tarball SHA256: ${SHA256}" - # Verify the download - ls -la versiontracker-${VERSION_NUMBER}.tar.gz - echo "Calculated SHA256: ${SHA256}" + - name: Clone tap repository + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh repo clone "${TAP_REPO}" homebrew-tap + cd homebrew-tap + echo "Current formula:" + head -10 "${FORMULA_PATH}" - - name: Update Homebrew formula + - name: Update formula version and checksum run: | set -euo pipefail - # Update the version URL line and sha256 in the formula deterministically + cd homebrew-tap awk -v ver="${VERSION}" -v sha="${SHA256}" ' - BEGIN{updated_url=0; updated_sha=0} /archive\/refs\/tags\/v[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz/ { sub(/v[0-9]+\.[0-9]+\.[0-9]+/, ver) - updated_url=1 } - /sha256 "/ { + /^ sha256 "/ { sub(/"[0-9a-f]+"/, "\"" sha "\"") - updated_sha=1 } { print } - END{ - if (!updated_url) { exit 2 } - if (!updated_sha) { exit 3 } - } - ' versiontracker.rb > versiontracker.rb.tmp - mv versiontracker.rb.tmp versiontracker.rb - - echo "Updated formula preview:" - sed -n '1,20p' versiontracker.rb + ' "${FORMULA_PATH}" > formula.tmp + mv formula.tmp "${FORMULA_PATH}" + echo "Updated formula:" + head -10 "${FORMULA_PATH}" - - name: Install and test formula + - name: Test formula installation run: | - # Install from the local formula - brew install --build-from-source ./versiontracker.rb - - # Test the installation + cd homebrew-tap + brew install --build-from-source "./${FORMULA_PATH}" versiontracker --help versiontracker --version + brew uninstall macversiontracker || true - # Test core functionality - echo "Testing core functionality..." - timeout 30s versiontracker list || echo "List command completed" - - - name: Clean up test installation - run: | - brew uninstall versiontracker || true - - - name: Commit and open PR + - name: Push updated formula to tap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - if git diff --quiet versiontracker.rb; then - echo "No changes to commit" + cd homebrew-tap + if git diff --quiet "${FORMULA_PATH}"; then + echo "Formula already up to date" exit 0 fi - - BRANCH="homebrew/update-${VERSION}" - git checkout -b "$BRANCH" - git add versiontracker.rb - git commit -m "Update Homebrew formula to ${VERSION}\n\n- Updated URL to ${VERSION}\n- Updated SHA256 to ${SHA256}\n- Verified installation and CLI availability" - git push -u origin "$BRANCH" - gh pr create --fill --base master --head "$BRANCH" || echo "PR creation skipped (gh not available)" - - - name: Create or update release notes - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Add Homebrew installation instructions to release notes if this is a new release - if [ "${{ github.event_name }}" = "release" ]; then - echo " - ## Homebrew Installation - - This version is now available via Homebrew: - - \`\`\`bash - brew tap thomas/versiontracker - brew install versiontracker - \`\`\` - - ### Installation Verification - - \`\`\`bash - versiontracker --version - versiontracker --help - \`\`\` - - The formula has been tested and verified to work correctly." > homebrew_notes.md - - # Note: In a real implementation, you would append this to the release notes - echo "Homebrew installation notes prepared for release ${VERSION}" - fi + git config user.email "action@github.com" + git config user.name "GitHub Action" + git add "${FORMULA_PATH}" + git commit -m "Update macversiontracker to ${VERSION}" + git push origin main + echo "Tap updated to ${VERSION}" diff --git a/TODO.md b/TODO.md index 4dbe60d..d50f166 100644 --- a/TODO.md +++ b/TODO.md @@ -1,20 +1,30 @@ # VersionTracker TODO -## Current Status (February 2026) +## Current Status (March 2026) ### Project Health - **Version**: 0.9.0 -- **Tests**: 1,962+ passing, 16 skipped -- **Coverage**: ~78% overall (up from 61%) -- **CI/CD**: All 11 workflows passing on master (all green) +- **Tests**: 1,993 collected, 16 skipped +- **Coverage**: ~78% overall +- **CI/CD**: All workflows passing on master (all green) - **Python Support**: 3.12+ (with 3.13 compatibility) -- **Security**: 0 dependabot alerts, 0 secret scanning alerts; CodeQL alerts resolved +- **Security**: 0 dependabot alerts, 0 secret scanning alerts, 0 CodeQL findings - **Linting**: ruff clean, mypy clean - **Open Issues**: 0 - **Open PRs**: 0 -### Recent Completions (v0.9.0) +### Recent Completions + +- ~~PR #108~~ **CodeQL security fixes** — 3 high-severity URL sanitization + alerts resolved in `verify_badges.py` (strict scheme+hostname allowlist); + 12 medium-severity missing-workflow-permissions alerts resolved across + `ci.yml`, `lint.yml`, `performance.yml`, `release.yml`, `security.yml` + (job-level `permissions:` blocks added to all jobs) +- ~~PR #106~~ **Dependency update** — `actions/upload-artifact` v6→v7, + `actions/download-artifact` v7→v8 across all workflows + +### Previous Completions (v0.9.0) - ~~P10~~ **Async Homebrew wiring** — `check_brew_install_candidates()` and `check_brew_update_candidates()` now route through async Homebrew API by @@ -51,26 +61,14 @@ All skips are environment-specific or CI-specific — no action needed for most. --- -## Homebrew Release Preparation (v0.9.0) - -### Phase 1: Pre-Release Validation +## Homebrew Release (v0.9.0) — Complete - [x] Bump version to 0.9.0 in `__init__.py` and `pyproject.toml` - [x] Update CHANGELOG.md with v0.9.0 entry -- [ ] Run full test suite locally: `pytest` -- [ ] Validate packaging: `python -m build && twine check dist/*` - -### Phase 2: Formula Creation - -- [ ] Download canonical GitHub tag archive -- [ ] Compute SHA256 checksum -- [ ] Update `versiontracker.rb` formula with new version/checksum -- [ ] Run: `brew audit --new-formula --strict ./versiontracker.rb` - -### Phase 3: Tap Repository - -- [ ] Update `homebrew-versiontracker` repository -- [ ] Test tap: `brew tap docdyhr/versiontracker && brew install versiontracker` +- [x] Formula created at `docdyhr/homebrew-tap` with verified SHA256 +- [x] `brew install docdyhr/tap/macversiontracker` tested and working +- [x] Legacy root `versiontracker.rb` removed (superseded by tap formula) +- [x] `release-homebrew.yml` workflow updated to push to tap repo --- @@ -127,5 +125,5 @@ For detailed strategic planning see `docs/future_roadmap.md`. --- -**Last Updated**: February 2026 +**Last Updated**: March 2026 **Maintainer**: @docdyhr diff --git a/tests/handlers/test_error_handlers_coverage.py b/tests/handlers/test_error_handlers_coverage.py new file mode 100644 index 0000000..9b92330 --- /dev/null +++ b/tests/handlers/test_error_handlers_coverage.py @@ -0,0 +1,366 @@ +"""Tests for versiontracker.handlers.error_handlers — coverage improvement. + +Covers all 8 functions: handle_permission_error, handle_timeout_error, +handle_network_error, handle_homebrew_not_found, handle_config_error, +handle_keyboard_interrupt, handle_generic_error, handle_top_level_exception. +""" + +from unittest.mock import MagicMock, patch + +from versiontracker.exceptions import ConfigError +from versiontracker.handlers.error_handlers import ( + handle_config_error, + handle_generic_error, + handle_homebrew_not_found, + handle_keyboard_interrupt, + handle_network_error, + handle_permission_error, + handle_timeout_error, + handle_top_level_exception, +) + + +def _mock_progress_bar(): + """Create a mock progress_bar that returns identity for color calls.""" + pb = MagicMock() + pb.color.return_value = lambda x: x + return pb + + +# --------------------------------------------------------------------------- +# handle_permission_error +# --------------------------------------------------------------------------- + + +class TestHandlePermissionError: + """Tests for handle_permission_error().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_1(self, _pb): + assert handle_permission_error() == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_default_context(self, _pb, capsys): + handle_permission_error() + output = capsys.readouterr().out + assert "application data" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_custom_context(self, _pb, capsys): + handle_permission_error("system preferences") + output = capsys.readouterr().out + assert "system preferences" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_suggestion(self, _pb, capsys): + handle_permission_error() + output = capsys.readouterr().out + assert "sudo" in output or "permissions" in output + + +# --------------------------------------------------------------------------- +# handle_timeout_error +# --------------------------------------------------------------------------- + + +class TestHandleTimeoutError: + """Tests for handle_timeout_error().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_1(self, _pb): + assert handle_timeout_error() == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_default_context(self, _pb, capsys): + handle_timeout_error() + output = capsys.readouterr().out + assert "application data" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_custom_context(self, _pb, capsys): + handle_timeout_error("brew database") + output = capsys.readouterr().out + assert "brew database" in output + + +# --------------------------------------------------------------------------- +# handle_network_error +# --------------------------------------------------------------------------- + + +class TestHandleNetworkError: + """Tests for handle_network_error().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_1(self, _pb): + assert handle_network_error() == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_default_context(self, _pb, capsys): + handle_network_error() + output = capsys.readouterr().out + assert "checking for updates" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_custom_context(self, _pb, capsys): + handle_network_error("fetching cask info") + output = capsys.readouterr().out + assert "fetching cask info" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_connection_hint(self, _pb, capsys): + handle_network_error() + output = capsys.readouterr().out + assert "internet connection" in output + + +# --------------------------------------------------------------------------- +# handle_homebrew_not_found +# --------------------------------------------------------------------------- + + +class TestHandleHomebrewNotFound: + """Tests for handle_homebrew_not_found().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_1(self, _pb): + assert handle_homebrew_not_found() == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_not_found_message(self, _pb, capsys): + handle_homebrew_not_found() + output = capsys.readouterr().out + assert "Homebrew" in output + assert "not found" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_install_suggestion(self, _pb, capsys): + handle_homebrew_not_found() + output = capsys.readouterr().out + assert "installed" in output or "configured" in output + + +# --------------------------------------------------------------------------- +# handle_config_error +# --------------------------------------------------------------------------- + + +class TestHandleConfigError: + """Tests for handle_config_error().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_1(self, _pb): + err = ConfigError("bad yaml format") + assert handle_config_error(err) == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_error_message(self, _pb, capsys): + err = ConfigError("missing key") + handle_config_error(err) + output = capsys.readouterr().out + assert "Configuration Error" in output + assert "missing key" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_config_suggestion(self, _pb, capsys): + err = ConfigError("parse failure") + handle_config_error(err) + output = capsys.readouterr().out + assert "configuration file" in output + + +# --------------------------------------------------------------------------- +# handle_keyboard_interrupt +# --------------------------------------------------------------------------- + + +class TestHandleKeyboardInterrupt: + """Tests for handle_keyboard_interrupt().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_130(self, _pb): + assert handle_keyboard_interrupt() == 130 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_prints_cancel_message(self, _pb, capsys): + handle_keyboard_interrupt() + output = capsys.readouterr().out + assert "canceled" in output or "cancelled" in output + + +# --------------------------------------------------------------------------- +# handle_generic_error +# --------------------------------------------------------------------------- + + +class TestHandleGenericError: + """Tests for handle_generic_error().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_returns_exit_code_1(self, _pb): + assert handle_generic_error(RuntimeError("fail")) == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_without_context(self, _pb, capsys): + handle_generic_error(ValueError("oops"), debug=False) + output = capsys.readouterr().out + assert "Error: oops" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_with_context(self, _pb, capsys): + handle_generic_error(ValueError("oops"), context="loading apps", debug=False) + output = capsys.readouterr().out + assert "Error loading apps" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + @patch("versiontracker.handlers.error_handlers.traceback") + def test_debug_true_shows_traceback(self, mock_tb, _pb): + handle_generic_error(RuntimeError("boom"), debug=True) + mock_tb.print_exc.assert_called_once() + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_debug_false_shows_hint(self, _pb, capsys): + handle_generic_error(RuntimeError("boom"), debug=False) + output = capsys.readouterr().out + assert "--debug" in output + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + @patch("versiontracker.handlers.error_handlers.get_config") + def test_debug_none_uses_config_true(self, mock_config, _pb): + mock_config.return_value = MagicMock(debug=True) + with patch("versiontracker.handlers.error_handlers.traceback") as mock_tb: + handle_generic_error(RuntimeError("boom"), debug=None) + mock_tb.print_exc.assert_called_once() + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + @patch("versiontracker.handlers.error_handlers.get_config") + def test_debug_none_uses_config_false(self, mock_config, _pb, capsys): + mock_config.return_value = MagicMock(debug=False) + handle_generic_error(RuntimeError("boom"), debug=None) + output = capsys.readouterr().out + assert "--debug" in output + + +# --------------------------------------------------------------------------- +# handle_top_level_exception +# --------------------------------------------------------------------------- + + +class TestHandleTopLevelException: + """Tests for handle_top_level_exception().""" + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_config_error_routes_to_handle_config_error(self, _pb): + err = ConfigError("bad config") + result = handle_top_level_exception(err) + assert result == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + def test_keyboard_interrupt_routes_to_handle_keyboard_interrupt(self, _pb): + result = handle_top_level_exception(KeyboardInterrupt()) + assert result == 130 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + @patch("versiontracker.handlers.error_handlers.get_config") + def test_runtime_error_routes_to_handle_generic_error(self, mock_config, _pb): + mock_config.return_value = MagicMock(debug=False) + result = handle_top_level_exception(RuntimeError("unexpected")) + assert result == 1 + + @patch( + "versiontracker.handlers.error_handlers.create_progress_bar", + return_value=_mock_progress_bar(), + ) + @patch("versiontracker.handlers.error_handlers.get_config") + def test_base_exception_wraps_as_exception(self, mock_config, _pb): + """Non-Exception BaseException gets wrapped via Exception(str(e)).""" + mock_config.return_value = MagicMock(debug=False) + result = handle_top_level_exception(SystemExit(42)) + assert result == 1 diff --git a/tests/test_async_homebrew_coverage.py b/tests/test_async_homebrew_coverage.py new file mode 100644 index 0000000..a9388d2 --- /dev/null +++ b/tests/test_async_homebrew_coverage.py @@ -0,0 +1,245 @@ +"""Tests for versiontracker.async_homebrew — coverage improvement. + +Targets uncovered exception paths in search_casks() and uncovered +methods in HomebrewBatchProcessor (fuzzy match, empty app, NetworkError). +""" + +import builtins +from unittest.mock import MagicMock, patch + +import pytest +from aiohttp import ClientError, ClientResponseError + +from versiontracker.async_homebrew import ( + HomebrewBatchProcessor, + search_casks, +) +from versiontracker.exceptions import HomebrewError, NetworkError +from versiontracker.exceptions import TimeoutError as VTTimeoutError + +# --------------------------------------------------------------------------- +# Helper: create a session factory whose .get() raises the given exception +# --------------------------------------------------------------------------- + + +def _make_error_session_factory(error: Exception): + """Return a factory for aiohttp.ClientSession that raises on .get().""" + + class _ErrorSession: + """Session mock whose get() raises an error.""" + + def get(self, url, **kwargs): + raise error + + async def close(self): + pass + + class _ErrorClientSession: + def __init__(self, *args, **kwargs): + self._session = _ErrorSession() + + async def __aenter__(self): + return self._session + + async def __aexit__(self, *args): + pass + + return _ErrorClientSession + + +# --------------------------------------------------------------------------- +# search_casks — exception paths +# --------------------------------------------------------------------------- + + +class TestSearchCasksExceptions: + """Tests for search_casks() error handling branches.""" + + @pytest.mark.asyncio + async def test_builtin_timeout_error(self): + """search_casks raises VT TimeoutError on builtins.TimeoutError (lines 102-104).""" + factory = _make_error_session_factory(builtins.TimeoutError("connection timed out")) + + with patch("versiontracker.async_homebrew.read_cache", return_value=None): + with patch("aiohttp.ClientSession", factory): + with pytest.raises(VTTimeoutError, match="Search request timed out"): + await search_casks("firefox", use_cache=False) + + @pytest.mark.asyncio + async def test_client_response_error(self): + """search_casks raises NetworkError on ClientResponseError (lines 105-107).""" + error = ClientResponseError( + request_info=MagicMock(), + history=(), + status=503, + message="Service Unavailable", + ) + factory = _make_error_session_factory(error) + + with patch("versiontracker.async_homebrew.read_cache", return_value=None): + with patch("aiohttp.ClientSession", factory): + with pytest.raises(NetworkError, match="HTTP error 503"): + await search_casks("firefox", use_cache=False) + + @pytest.mark.asyncio + async def test_client_error(self): + """search_casks raises NetworkError on ClientError (lines 108-110).""" + factory = _make_error_session_factory(ClientError("connection reset")) + + with patch("versiontracker.async_homebrew.read_cache", return_value=None): + with patch("aiohttp.ClientSession", factory): + with pytest.raises(NetworkError, match="Network error"): + await search_casks("firefox", use_cache=False) + + +# --------------------------------------------------------------------------- +# HomebrewBatchProcessor._check_fuzzy_match +# --------------------------------------------------------------------------- + + +class TestHomebrewBatchProcessorFuzzyMatch: + """Tests for HomebrewBatchProcessor._check_fuzzy_match().""" + + @pytest.mark.asyncio + async def test_fuzzy_match_with_matching_cask(self): + """_check_fuzzy_match returns True when search returns a significant match (lines 208-213).""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=False) + # search_casks returns results with a token that significantly matches "Firefox" + mock_results = [{"token": "firefox", "name": "Firefox"}] + with patch("versiontracker.async_homebrew.search_casks", return_value=mock_results): + result = await processor._check_fuzzy_match("Firefox") + assert result is True + + @pytest.mark.asyncio + async def test_fuzzy_match_with_empty_results(self): + """_check_fuzzy_match returns False when search returns empty list.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=False) + with patch("versiontracker.async_homebrew.search_casks", return_value=[]): + result = await processor._check_fuzzy_match("SomeObscureApp") + assert result is False + + @pytest.mark.asyncio + async def test_fuzzy_match_no_significant_match(self): + """_check_fuzzy_match returns False when results don't significantly match.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=False) + # Token "completely-different-tool" does not significantly match "Firefox" + mock_results = [{"token": "completely-different-tool", "name": "Other"}] + with patch("versiontracker.async_homebrew.search_casks", return_value=mock_results): + result = await processor._check_fuzzy_match("Firefox") + assert result is False + + +# --------------------------------------------------------------------------- +# HomebrewBatchProcessor.process_item +# --------------------------------------------------------------------------- + + +class TestHomebrewBatchProcessorProcessItem: + """Tests for HomebrewBatchProcessor.process_item().""" + + @pytest.mark.asyncio + async def test_empty_app_name_returns_false(self): + """process_item returns (app_name, version, False) for empty app names.""" + processor = HomebrewBatchProcessor(use_cache=False) + result = await processor.process_item(("", "1.0")) + assert result == ("", "1.0", False) + + @pytest.mark.asyncio + async def test_network_error_returns_false(self): + """process_item catches NetworkError and returns False.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=True) + with patch.object( + processor, + "_check_exact_match", + side_effect=NetworkError("connection refused"), + ): + result = await processor.process_item(("TestApp", "2.0")) + assert result == ("TestApp", "2.0", False) + + @pytest.mark.asyncio + async def test_timeout_error_returns_false(self): + """process_item catches TimeoutError and returns False.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=True) + with patch.object( + processor, + "_check_exact_match", + side_effect=VTTimeoutError("timed out"), + ): + result = await processor.process_item(("TestApp", "2.0")) + assert result == ("TestApp", "2.0", False) + + @pytest.mark.asyncio + async def test_homebrew_error_returns_false(self): + """process_item catches HomebrewError and returns False.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=True) + with patch.object( + processor, + "_check_exact_match", + side_effect=HomebrewError("brew failed"), + ): + result = await processor.process_item(("TestApp", "2.0")) + assert result == ("TestApp", "2.0", False) + + @pytest.mark.asyncio + async def test_exact_match_found(self): + """process_item returns True when exact match succeeds.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=True) + with patch.object(processor, "_check_exact_match", return_value=True): + result = await processor.process_item(("Firefox", "100.0")) + assert result == ("Firefox", "100.0", True) + + @pytest.mark.asyncio + async def test_no_exact_match_falls_through_to_fuzzy(self): + """process_item tries fuzzy match when exact fails and strict_match=False.""" + processor = HomebrewBatchProcessor(use_cache=False, strict_match=False) + with patch.object(processor, "_check_exact_match", return_value=False): + with patch.object(processor, "_check_fuzzy_match", return_value=True): + result = await processor.process_item(("Firefox", "100.0")) + assert result == ("Firefox", "100.0", True) + + +# --------------------------------------------------------------------------- +# HomebrewBatchProcessor.handle_error +# --------------------------------------------------------------------------- + + +class TestHomebrewBatchProcessorHandleError: + """Tests for HomebrewBatchProcessor.handle_error().""" + + def test_handle_error_returns_false(self): + """handle_error returns (app_name, version, False) tuple.""" + processor = HomebrewBatchProcessor() + result = processor.handle_error(("MyApp", "1.0"), RuntimeError("fail")) + assert result == ("MyApp", "1.0", False) + + +# --------------------------------------------------------------------------- +# HomebrewBatchProcessor._is_significant_match +# --------------------------------------------------------------------------- + + +class TestIsSignificantMatch: + """Tests for _is_significant_match().""" + + def test_exact_match(self): + processor = HomebrewBatchProcessor() + assert processor._is_significant_match("firefox", "firefox") is True + + def test_contained_with_low_ratio(self): + processor = HomebrewBatchProcessor() + # "firefox" (7 chars) vs "firefoxbrowser" (14 chars) — ratio 0.5 < 0.6 + assert processor._is_significant_match("firefox", "firefox-browser") is False + + def test_no_overlap(self): + processor = HomebrewBatchProcessor() + assert processor._is_significant_match("chrome", "firefox") is False + + def test_partial_contained_below_threshold(self): + processor = HomebrewBatchProcessor() + # "vlc" (3 chars) in "vlcmedia" (8 chars) — ratio 3/8 = 0.375 < 0.6 + assert processor._is_significant_match("vlc", "vlcmedia") is False + + def test_close_length_match(self): + processor = HomebrewBatchProcessor() + # "iterm2" vs "iterm" — contained, ratio 5/6 = 0.83 > 0.6 + assert processor._is_significant_match("iTerm2", "iterm") is True diff --git a/tests/test_auto_updates.py b/tests/test_auto_updates.py index da31471..1a1ea61 100644 --- a/tests/test_auto_updates.py +++ b/tests/test_auto_updates.py @@ -88,9 +88,12 @@ def test_has_auto_updates_error_handling(self, mock_get_cask_info): self.assertFalse(result) + @patch( + "versiontracker.async_homebrew.async_get_casks_with_auto_updates", side_effect=Exception("force sync fallback") + ) @patch("versiontracker.homebrew.has_auto_updates") - def test_get_casks_with_auto_updates(self, mock_has_auto_updates): - """Test getting list of casks with auto-updates.""" + def test_get_casks_with_auto_updates(self, mock_has_auto_updates, _mock_async): + """Test getting list of casks with auto-updates (sync fallback).""" test_casks = ["visual-studio-code", "slack", "firefox", "iterm2"] # Mock that first two have auto-updates mock_has_auto_updates.side_effect = [True, True, False, True] @@ -247,8 +250,11 @@ def test_has_auto_updates_empty_cask_info(self, mock_get_cask_info): result = has_auto_updates("test-app") self.assertFalse(result) + @patch( + "versiontracker.async_homebrew.async_get_casks_with_auto_updates", side_effect=Exception("force sync fallback") + ) @patch("versiontracker.homebrew.has_auto_updates") - def test_get_casks_with_auto_updates_mixed_results(self, mock_has_auto_updates): + def test_get_casks_with_auto_updates_mixed_results(self, mock_has_auto_updates, _mock_async): """Test with mixed auto-update results and errors.""" test_casks = ["app1", "app2", "app3", "app4", "app5"] # app1: True, app2: False, app3: True, app4: Error (returns False), app5: True @@ -369,8 +375,11 @@ def test_has_auto_updates_performance_with_large_caveats(self, mock_get_cask_inf # Should complete in less than 0.1 seconds even with large text self.assertLess(end_time - start_time, 0.1) + @patch( + "versiontracker.async_homebrew.async_get_casks_with_auto_updates", side_effect=Exception("force sync fallback") + ) @patch("versiontracker.homebrew.has_auto_updates") - def test_get_casks_with_auto_updates_performance(self, mock_has_auto_updates): + def test_get_casks_with_auto_updates_performance(self, mock_has_auto_updates, _mock_async): """Test performance with large number of casks.""" # Test with 1000 casks test_casks = [f"app{i}" for i in range(1000)] diff --git a/tests/test_config_validation_coverage.py b/tests/test_config_validation_coverage.py new file mode 100644 index 0000000..467286f --- /dev/null +++ b/tests/test_config_validation_coverage.py @@ -0,0 +1,138 @@ +"""Tests for versiontracker.config — coverage improvement. + +Covers check_dependencies() platform branches, and Config.get() +with dot-notation nested keys and default values. +""" + +from unittest.mock import patch + +import pytest + +from versiontracker.config import Config, ConfigLoader, check_dependencies +from versiontracker.exceptions import ConfigError + +# --------------------------------------------------------------------------- +# check_dependencies — platform branches +# --------------------------------------------------------------------------- + + +class TestCheckDependencies: + """Tests for check_dependencies().""" + + @patch("versiontracker.config.shutil.which", return_value="/usr/sbin/system_profiler") + @patch("versiontracker.config.platform.system", return_value="Darwin") + def test_darwin_with_system_profiler(self, _system, _which): + """Returns True on Darwin when system_profiler is found.""" + assert check_dependencies() is True + + @patch("versiontracker.config.shutil.which", return_value=None) + @patch("versiontracker.config.platform.system", return_value="Darwin") + def test_darwin_without_system_profiler(self, _system, _which): + """Raises ConfigError on Darwin when system_profiler is missing.""" + with pytest.raises(ConfigError, match="system_profiler"): + check_dependencies() + + @patch("versiontracker.config.platform.system", return_value="Linux") + def test_non_darwin_skips_system_profiler(self, _system): + """Returns True on non-Darwin platforms without checking system_profiler.""" + assert check_dependencies() is True + + @patch("versiontracker.config.platform.system", return_value="Windows") + def test_windows_returns_true(self, _system): + """Returns True on Windows (no macOS-specific deps checked).""" + assert check_dependencies() is True + + +# --------------------------------------------------------------------------- +# Config.get — dot notation and defaults +# --------------------------------------------------------------------------- + + +class TestConfigGet: + """Tests for Config.get() with nested keys and defaults.""" + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_nested_key_with_dot_notation(self, _env, _file, _brew): + """Config.get('ui.use_color') retrieves a nested value.""" + config = Config() + result = config.get("ui.use_color") + assert result is True + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_nested_key_version_comparison(self, _env, _file, _brew): + """Config.get('version_comparison.rate_limit') retrieves nested value.""" + config = Config() + result = config.get("version_comparison.rate_limit") + assert result == 2 + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_nonexistent_key_with_default(self, _env, _file, _brew): + """Config.get('nonexistent', default) returns the default value.""" + config = Config() + result = config.get("no_such_key", "fallback") + assert result == "fallback" + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_nonexistent_key_no_default(self, _env, _file, _brew): + """Config.get('nonexistent') returns None when no default is provided.""" + config = Config() + result = config.get("no_such_key") + assert result is None + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_nonexistent_nested_key_with_default(self, _env, _file, _brew): + """Config.get('ui.nonexistent', default) returns default for missing nested key.""" + config = Config() + result = config.get("ui.nonexistent_key", "default_val") + assert result == "default_val" + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_nonexistent_nested_key_raises_without_default(self, _env, _file, _brew): + """Config.get('ui.nonexistent') raises KeyError when no default and default is None.""" + config = Config() + # When default is None (the default parameter), and key is not found, + # it returns None per the code logic (default is not None check fails + # because default IS None) + # Actually looking at the code: if default is not None: return default + # else: raise KeyError + with pytest.raises(KeyError, match="not found"): + config.get("ui.nonexistent_key") + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_deeply_nested_nonexistent_section(self, _env, _file, _brew): + """Config.get('nonexistent_section.key') raises KeyError for missing section.""" + config = Config() + with pytest.raises(KeyError, match="not found"): + config.get("nonexistent_section.some_key") + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_top_level_key(self, _env, _file, _brew): + """Config.get('max_workers') retrieves a top-level value.""" + config = Config() + assert config.get("max_workers") == 10 + + @patch.object(ConfigLoader, "detect_brew_path", return_value="/usr/local/bin/brew") + @patch.object(ConfigLoader, "load_from_file") + @patch.object(ConfigLoader, "load_from_env") + def test_get_log_level_default(self, _env, _file, _brew): + """Config.get('log_level') returns the configured log level.""" + config = Config() + import logging + + assert config.get("log_level") == logging.INFO diff --git a/tests/test_profiling_coverage.py b/tests/test_profiling_coverage.py new file mode 100644 index 0000000..bf03356 --- /dev/null +++ b/tests/test_profiling_coverage.py @@ -0,0 +1,395 @@ +"""Tests for versiontracker.profiling — coverage improvement. + +Covers PerformanceProfiler (enabled/disabled paths), nested call tracking, +memory usage, timing stats, time_function decorator, report/print_report, +and module-level convenience functions. +""" + +from unittest.mock import patch + +import versiontracker.profiling as profiling_module +from versiontracker.profiling import ( + PerformanceProfiler, + disable_profiling, + enable_profiling, + generate_report, + get_profiler, + print_report, + profile_function, +) + +# --------------------------------------------------------------------------- +# PerformanceProfiler — init +# --------------------------------------------------------------------------- + + +class TestPerformanceProfilerInit: + """Tests for PerformanceProfiler initialization.""" + + def test_enabled_creates_real_profiler(self): + """enabled=True creates a cProfile.Profile instance.""" + p = PerformanceProfiler(enabled=True) + assert p.enabled is True + assert p.profiler is not None + + def test_disabled_has_no_profiler(self): + """enabled=False sets profiler to None.""" + p = PerformanceProfiler(enabled=False) + assert p.enabled is False + assert p.profiler is None + + +# --------------------------------------------------------------------------- +# start / stop +# --------------------------------------------------------------------------- + + +class TestStartStop: + """Tests for start() and stop().""" + + def test_start_stop_when_enabled(self): + """start() and stop() succeed without error when enabled.""" + p = PerformanceProfiler(enabled=True) + p.start() + p.stop() + # No exception means success + + def test_start_stop_when_disabled(self): + """start() and stop() are no-ops when disabled.""" + p = PerformanceProfiler(enabled=False) + p.start() + p.stop() + # No exception means success + + +# --------------------------------------------------------------------------- +# get_stats +# --------------------------------------------------------------------------- + + +class TestGetStats: + """Tests for get_stats().""" + + def test_get_stats_when_enabled(self): + """get_stats returns a string when enabled (may be empty for trivial code).""" + p = PerformanceProfiler(enabled=True) + p.start() + # Run some code that is heavy enough for cProfile to capture + for _ in range(1000): + _ = sum(range(100)) + p.stop() + stats = p.get_stats() + assert isinstance(stats, str) + + def test_get_stats_when_disabled(self): + """get_stats returns None when disabled.""" + p = PerformanceProfiler(enabled=False) + assert p.get_stats() is None + + +# --------------------------------------------------------------------------- +# _track_nested_calls +# --------------------------------------------------------------------------- + + +class TestTrackNestedCalls: + """Tests for _track_nested_calls().""" + + def test_first_call_returns_true(self): + """First call for a function name returns True (outermost).""" + p = PerformanceProfiler(enabled=True) + assert p._track_nested_calls("my_func") is True + assert "my_func" in p._active_functions + + def test_second_call_returns_false(self): + """Second call for same function name returns False (nested).""" + p = PerformanceProfiler(enabled=True) + p._track_nested_calls("my_func") + assert p._track_nested_calls("my_func") is False + assert p._nested_calls["my_func"] == 1 + + def test_different_functions_both_outermost(self): + """Different function names are independently tracked as outermost.""" + p = PerformanceProfiler(enabled=True) + assert p._track_nested_calls("func_a") is True + assert p._track_nested_calls("func_b") is True + + +# --------------------------------------------------------------------------- +# _get_memory_usage +# --------------------------------------------------------------------------- + + +class TestGetMemoryUsage: + """Tests for _get_memory_usage().""" + + def test_with_psutil_available(self): + """Returns float > 0 when psutil is available.""" + p = PerformanceProfiler(enabled=True) + # Only test if psutil is actually available + if profiling_module.HAS_PSUTIL: + result = p._get_memory_usage() + assert isinstance(result, float) + assert result > 0.0 + + def test_without_psutil(self): + """Returns 0.0 when HAS_PSUTIL is False.""" + p = PerformanceProfiler(enabled=True) + with patch.object(profiling_module, "HAS_PSUTIL", False): + result = p._get_memory_usage() + assert result == 0.0 + + +# --------------------------------------------------------------------------- +# _update_timing_stats +# --------------------------------------------------------------------------- + + +class TestUpdateTimingStats: + """Tests for _update_timing_stats().""" + + def test_first_call_creates_entry(self): + """First call creates a FunctionTimingInfo entry.""" + p = PerformanceProfiler(enabled=True) + p._update_timing_stats("func", 0.5, 100.0, 101.0) + assert "func" in p.function_timings + info = p.function_timings["func"] + assert info.calls == 1 + assert info.total_time == 0.5 + assert info.min_time == 0.5 + assert info.max_time == 0.5 + assert info.avg_time == 0.5 + assert info.memory_diff == 1.0 + + def test_multiple_calls_update_stats(self): + """Multiple calls update min, max, avg correctly.""" + p = PerformanceProfiler(enabled=True) + p._update_timing_stats("func", 0.2, 100.0, 100.5) + p._update_timing_stats("func", 0.8, 100.0, 101.0) + info = p.function_timings["func"] + assert info.calls == 2 + assert info.total_time == 1.0 + assert info.min_time == 0.2 + assert info.max_time == 0.8 + assert abs(info.avg_time - 0.5) < 1e-9 + + +# --------------------------------------------------------------------------- +# _cleanup_nested_calls +# --------------------------------------------------------------------------- + + +class TestCleanupNestedCalls: + """Tests for _cleanup_nested_calls().""" + + def test_outermost_removes_from_active(self): + """Outermost call cleanup removes function from _active_functions.""" + p = PerformanceProfiler(enabled=True) + p._active_functions.add("func") + p._cleanup_nested_calls("func", is_outermost_call=True) + assert "func" not in p._active_functions + + def test_nested_decrements_count(self): + """Nested call cleanup decrements the nested call counter.""" + p = PerformanceProfiler(enabled=True) + p._nested_calls["func"] = 2 + p._cleanup_nested_calls("func", is_outermost_call=False) + assert p._nested_calls["func"] == 1 + + def test_nested_removes_when_count_reaches_zero(self): + """Nested call cleanup removes entry when count reaches 0.""" + p = PerformanceProfiler(enabled=True) + p._nested_calls["func"] = 1 + p._cleanup_nested_calls("func", is_outermost_call=False) + assert "func" not in p._nested_calls + + +# --------------------------------------------------------------------------- +# time_function decorator +# --------------------------------------------------------------------------- + + +class TestTimeFunctionDecorator: + """Tests for time_function() decorator.""" + + def test_enabled_wraps_function_and_records_timing(self): + """Decorator wraps function and records timing when enabled.""" + p = PerformanceProfiler(enabled=True) + + @p.time_function("test_add") + def add(a, b): + return a + b + + result = add(2, 3) + assert result == 5 + assert "test_add" in p.function_timings + assert p.function_timings["test_add"].calls == 1 + + def test_disabled_returns_original_function(self): + """Decorator returns original function without wrapping when disabled.""" + p = PerformanceProfiler(enabled=False) + + def original(x): + return x * 2 + + decorated = p.time_function("test")(original) + # When disabled, the decorator should return the original function + assert decorated is original + + def test_uses_qualname_when_no_name_given(self): + """Decorator uses func.__qualname__ when func_name is None.""" + p = PerformanceProfiler(enabled=True) + + @p.time_function() + def my_special_func(): + return 42 + + my_special_func() + # The key should contain the qualname + keys = list(p.function_timings.keys()) + assert any("my_special_func" in k for k in keys) + + def test_nested_calls_only_time_outermost(self): + """Nested calls to the same decorated function only time the outermost.""" + p = PerformanceProfiler(enabled=True) + + call_count = 0 + + @p.time_function("recursive") + def recursive_func(n): + nonlocal call_count + call_count += 1 + if n <= 0: + return 0 + return recursive_func(n - 1) + + recursive_func(3) + # Should record timing data (outermost call updates stats) + assert "recursive" in p.function_timings + # The function was called 4 times (n=3,2,1,0) but outermost call is once + assert p.function_timings["recursive"].calls == 1 + + +# --------------------------------------------------------------------------- +# report +# --------------------------------------------------------------------------- + + +class TestReport: + """Tests for report().""" + + def test_report_when_enabled_with_data(self): + """report() returns dict with 'timings' and 'profile' when enabled.""" + p = PerformanceProfiler(enabled=True) + # The profiler must have been started/stopped with actual code to + # produce valid stats for pstats.Stats + p.start() + _ = sum(range(100)) + p.stop() + p._update_timing_stats("func_a", 0.1, 50.0, 50.5) + report = p.report() + assert "timings" in report + assert "profile" in report + assert "func_a" in report["timings"] + assert report["timings"]["func_a"]["calls"] == 1 + + def test_report_when_disabled(self): + """report() returns empty dict when disabled.""" + p = PerformanceProfiler(enabled=False) + assert p.report() == {} + + +# --------------------------------------------------------------------------- +# print_report +# --------------------------------------------------------------------------- + + +class TestPrintReport: + """Tests for print_report().""" + + def test_print_report_when_disabled(self): + """print_report prints 'Profiling is disabled.' when disabled.""" + p = PerformanceProfiler(enabled=False) + with patch("builtins.print") as mock_print: + p.print_report() + mock_print.assert_called_once_with("Profiling is disabled.") + + def test_print_report_when_enabled_no_data(self): + """print_report prints 'No timing data collected.' when no data.""" + p = PerformanceProfiler(enabled=True) + with patch("builtins.print") as mock_print: + p.print_report() + mock_print.assert_called_once_with("No timing data collected.") + + def test_print_report_with_data(self): + """print_report prints table with timing data.""" + p = PerformanceProfiler(enabled=True) + p._update_timing_stats("func_x", 1.234, 100.0, 101.0) + with patch("builtins.print") as mock_print: + p.print_report() + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "Performance Report" in printed + assert "func_x" in printed + + def test_print_report_detailed(self): + """print_report(detailed=True) prints detailed profile stats.""" + p = PerformanceProfiler(enabled=True) + p.start() + _ = sum(range(100)) + p.stop() + p._update_timing_stats("func_y", 0.01, 50.0, 50.1) + with patch("builtins.print") as mock_print: + p.print_report(detailed=True) + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "Performance Report" in printed + assert "Detailed Profile" in printed + + +# --------------------------------------------------------------------------- +# Module-level convenience functions +# --------------------------------------------------------------------------- + + +class TestModuleLevelFunctions: + """Tests for module-level convenience functions.""" + + def test_get_profiler_returns_instance(self): + """get_profiler() returns a PerformanceProfiler instance.""" + p = get_profiler() + assert isinstance(p, PerformanceProfiler) + + def test_enable_profiling(self): + """enable_profiling() sets enabled=True on the global profiler.""" + original = profiling_module._global_profiler + try: + profiling_module._global_profiler = PerformanceProfiler(enabled=False) + enable_profiling() + assert profiling_module._global_profiler.enabled is True + finally: + profiling_module._global_profiler = original + + def test_disable_profiling(self): + """disable_profiling() sets enabled=False on the global profiler.""" + original = profiling_module._global_profiler + try: + profiling_module._global_profiler = PerformanceProfiler(enabled=True) + disable_profiling() + assert profiling_module._global_profiler.enabled is False + finally: + profiling_module._global_profiler = original + + def test_profile_function_returns_decorator(self): + """profile_function() returns a decorator callable.""" + decorator = profile_function("test_func") + assert callable(decorator) + + def test_generate_report_returns_dict(self): + """generate_report() returns a dict (empty when profiling disabled).""" + result = generate_report() + assert isinstance(result, dict) + + def test_print_report_module_level(self): + """Module-level print_report() delegates to global profiler.""" + with patch("builtins.print") as mock_print: + print_report() + mock_print.assert_called_once_with("Profiling is disabled.") diff --git a/versiontracker.rb b/versiontracker.rb deleted file mode 100644 index 7fdb602..0000000 --- a/versiontracker.rb +++ /dev/null @@ -1,64 +0,0 @@ -class Versiontracker < Formula - include Language::Python::Virtualenv - - desc "Track and update third-party (non-App Store) software on macOS with Homebrew awareness" - homepage "https://github.com/docdyhr/versiontracker" - url "https://github.com/docdyhr/versiontracker/archive/refs/tags/v0.7.2.tar.gz" - sha256 "d5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed" # pragma: allowlist secret - license "MIT" - - # Project requires Python >= 3.12 - depends_on "python@3.12" - resource "fuzzywuzzy" do - url "https://files.pythonhosted.org/packages/source/f/fuzzywuzzy/fuzzywuzzy-0.18.0.tar.gz" - sha256 "a0d013fb62b5e21658ab4b63a62cb2a7ab3392a1b3f7f004b586eaf8b22302fe" # pragma: allowlist secret - end - - resource "rapidfuzz" do - url "https://files.pythonhosted.org/packages/source/r/rapidfuzz/rapidfuzz-3.9.1.tar.gz" - sha256 "a42eb645241f39a59c45a7fc15e3faf61886bff3a4a22263fd0f7cfb90e91b7f" # pragma: allowlist secret - end - - resource "tqdm" do - url "https://files.pythonhosted.org/packages/source/t/tqdm/tqdm-4.66.0.tar.gz" - sha256 "cc6e7e52202d894e66632c5c8a9330bd0e3ff35d2965c93ca832114a3d865362" # pragma: allowlist secret - end - - resource "PyYAML" do - url "https://files.pythonhosted.org/packages/source/P/PyYAML/PyYAML-6.0.1.tar.gz" - sha256 "bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43" # pragma: allowlist secret - end - - resource "termcolor" do - url "https://files.pythonhosted.org/packages/source/t/termcolor/termcolor-2.3.0.tar.gz" - sha256 "b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78b39a2be16483d9f5af" # pragma: allowlist secret - end - - resource "tabulate" do - url "https://files.pythonhosted.org/packages/source/t/tabulate/tabulate-0.9.0.tar.gz" - sha256 "0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c" # pragma: allowlist secret - end - - resource "psutil" do - url "https://files.pythonhosted.org/packages/source/p/psutil/psutil-6.1.0.tar.gz" - sha256 "353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a" # pragma: allowlist secret - end - - resource "aiohttp" do - url "https://files.pythonhosted.org/packages/source/a/aiohttp/aiohttp-3.8.6.tar.gz" - sha256 "b0cf2a4501bff9330a8a5248b4ce951851e415bdcce9dc158e76cfd55e15085c" # pragma: allowlist secret - end - - def install - # Use pip to install the package directly from the source - virtualenv_install_with_resources - end - - test do - help_output = shell_output("#{bin}/versiontracker --help") - assert_match "versiontracker", help_output - - version_output = shell_output("#{bin}/versiontracker --version") - assert_match "0.7.2", version_output - end -end diff --git a/versiontracker/async_homebrew.py b/versiontracker/async_homebrew.py index 0ce0b82..25cbd0e 100644 --- a/versiontracker/async_homebrew.py +++ b/versiontracker/async_homebrew.py @@ -404,3 +404,82 @@ async def async_check_brew_update_candidates( # process_all() (also @async_to_sync wrapped) would create nested # event loops and deadlock. return await processor.process_all_async(data) + + +class CaskAutoUpdateChecker(AsyncBatchProcessor[str, str | None]): + """Check which casks have auto-updates enabled (async batch processor).""" + + AUTO_UPDATE_PATTERNS = [ + "auto.?update", + "automatically update", + "self.?update", + "sparkle", + "update automatically", + ] + + def __init__( + self, + batch_size: int = 10, + max_concurrency: int = 5, + rate_limit: float = 1.0, + use_cache: bool = True, + ): + """Initialize the auto-update checker. + + Args: + batch_size: Number of casks per batch + max_concurrency: Maximum concurrent requests + rate_limit: Seconds between batches + use_cache: Whether to use cached cask info + """ + super().__init__(batch_size, max_concurrency, rate_limit) + self.use_cache = use_cache + + async def process_item(self, cask_name: str) -> str | None: + """Check if a single cask has auto-updates enabled.""" + try: + cask_info = await fetch_cask_info(cask_name, use_cache=self.use_cache) + if not cask_info: + return None + if cask_info.get("auto_updates"): + return cask_name + caveats = cask_info.get("caveats", "") + if caveats and isinstance(caveats, str): + caveats_lower = caveats.lower() + for pattern in self.AUTO_UPDATE_PATTERNS: + if re.search(pattern, caveats_lower): + return cask_name + return None + except (NetworkError, TimeoutError, HomebrewError) as e: + logging.error("Error checking auto-updates for %s: %s", cask_name, e) + return None + + def handle_error(self, item: str, error: Exception) -> str | None: + """Handle errors during processing.""" + logging.error("Error checking auto-updates for %s: %s", item, error) + return None + + +@async_to_sync +async def async_get_casks_with_auto_updates(cask_names: list[str], rate_limit: float = 1.0) -> list[str]: + """Get casks with auto-updates enabled (async version). + + Args: + cask_names: List of cask names to check + rate_limit: Rate limit between requests + + Returns: + List of cask names that have auto-updates enabled + """ + if not cask_names: + return [] + + processor = CaskAutoUpdateChecker( + batch_size=10, + max_concurrency=int(5 / rate_limit), + rate_limit=rate_limit, + ) + + # Use process_all_async() — NOT process_all() — to avoid deadlock. + results = await processor.process_all_async(cask_names) + return [name for name in results if name is not None] diff --git a/versiontracker/homebrew.py b/versiontracker/homebrew.py index 2789ba9..8d77dc5 100644 --- a/versiontracker/homebrew.py +++ b/versiontracker/homebrew.py @@ -644,10 +644,19 @@ def get_casks_with_auto_updates(cask_names: list[str]) -> list[str]: Returns: List[str]: List of cask names with auto-updates enabled """ - auto_update_casks = [] + # Try async implementation first + try: + from versiontracker.async_homebrew import async_get_casks_with_auto_updates + logging.debug("Using async Homebrew for auto-update check (%d casks)", len(cask_names)) + result: list[str] = async_get_casks_with_auto_updates(cask_names) + return result + except Exception as e: + logging.warning("Async auto-update check failed, falling back to sync: %s", e) + + # Synchronous fallback + auto_update_casks = [] for cask_name in cask_names: if has_auto_updates(cask_name): auto_update_casks.append(cask_name) - return auto_update_casks