diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98475fd7..b5bfae94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,8 @@ jobs: outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} + prs_created: ${{ steps.release.outputs.prs_created }} + pr: ${{ steps.release.outputs.pr }} steps: - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 id: release @@ -25,6 +27,64 @@ jobs: config-file: .github/release-please-config.json manifest-file: .github/.release-please-manifest.json + sync-release-pr: + name: Sync lockfile and floors on the release PR + needs: release-please + if: ${{ needs.release-please.outputs.prs_created == 'true' && needs.release-please.outputs.pr }} + runs-on: ubuntu-latest + # This job pushes to the release branch. Two commits landing on main in + # quick succession would otherwise run two of these against the same + # branch and the loser fails on a non-fast-forward push, reddening the + # release workflow for no real reason. Serialize instead of cancelling: + # the second run still has work to do once the first one's commit lands. + concurrency: + group: sync-release-pr + cancel-in-progress: false + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }} + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + # release-please bumps every pyproject version but updates neither + # uv.lock (CI's `uv sync --locked` then fails all jobs at the install + # step -- the 0.2.28/0.3.0 wall, twice) nor the root dependency floors + # (without which `dg update` on pip delivers the wrapper and skips the + # sub-packages the changelog advertises). Both are mechanical + # consequences of the version bumps, so regenerate them here. + # + # Caveat: this push uses GITHUB_TOKEN, which does not retrigger the + # PR's checks. The routine hand-edit of the release notes retriggers + # them; for an untouched release PR, re-run checks from the UI. If + # releases ever need to go out unattended, switch this push to a + # dedicated PAT or GitHub App token. + - name: Regenerate uv.lock and pin root floors + run: | + set -euo pipefail + uv run python scripts/check_dependency_floors.py --fix + uv lock + uv run python scripts/check_dependency_floors.py + + - name: Commit and push if changed + run: | + set -euo pipefail + if git diff --quiet; then + echo "uv.lock and floors already in sync" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add uv.lock pyproject.toml + git commit -m "chore: sync uv.lock and root dependency floors with release-please bumps" + git push + build: name: Build packages needs: release-please @@ -77,9 +137,85 @@ jobs: pip install --find-links "$DIST_DIR" dist/deepctl-*.whl deepctl --version + verify-published: + name: Verify release is installable from PyPI + needs: [release-please, publish] + # `pip install deepctl==X` must resolve the full dependency closure from + # PyPI before anything advertises the release. Two real failure modes: + # 0.2.27 published partially (root uninstallable, and pip *silently + # backtracks* to the previous version, exit 0), and the 0.3.0 rollout + # showed a fresh install backtracking to 0.2.26 during the CDN + # propagation window. Poll the actual resolver, not just the JSON + # endpoint, so mark-latest / deploy-web / brew never point at a version + # a user cannot install. + if: | + needs.release-please.outputs.release_created == 'true' && + startsWith(needs.release-please.outputs.tag_name, 'v') + runs-on: ubuntu-latest + # Reads nothing and writes nothing: it installs from public PyPI. Drop the + # workflow-level contents/pull-requests write it would otherwise inherit. + permissions: {} + # Belt-and-braces ceiling for a hung runner or a wedged pip, so three + # release-advertising jobs can never sit queued behind this one + # indefinitely. It has to clear the poll's own worst case, which is 20 + # minutes of sleeps *plus* 60 resolution attempts -- a ceiling that cuts + # the loop short would kill the job before it prints the diagnosis below. + timeout-minutes: 45 + steps: + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + # Poll with --dry-run: it stops after resolution, which is cheap and is + # exactly what distinguishes "not propagated yet" from "broken". + - name: Wait until pip can resolve the full release + run: | + set -euo pipefail + VERSION="${TAG_NAME#v}" + python -m venv /tmp/verify + /tmp/verify/bin/pip install --quiet --upgrade pip + # The loop exits the moment resolution succeeds, so the ceiling only + # costs anything when it is actually hit. Propagation is normally + # seconds; 20 minutes is insurance, not an expected wait. + for i in $(seq 1 60); do + if /tmp/verify/bin/pip install --dry-run --no-cache-dir \ + "deepctl==${VERSION}" >/dev/null 2>&1; then + echo "deepctl==${VERSION} resolves with its full closure" + exit 0 + fi + echo "Waiting for deepctl==${VERSION} to resolve (${i}/60)..." + sleep 20 + done + # Every attempt above discarded its output, so the operator would + # otherwise get a bare error line and have to reproduce by hand. + # Run once more with the output visible: pip's resolver says which + # requirement it could not satisfy. + echo "Resolution still failing. Final attempt, with output:" + /tmp/verify/bin/pip install --dry-run --no-cache-dir "deepctl==${VERSION}" || true + echo "::error::deepctl==${VERSION} did not resolve from PyPI after 20 minutes -- a dependency is missing or the index has not propagated. Once PyPI has propagated, use 'Re-run all jobs' on this workflow run -- re-running only this job leaves mark-latest, deploy-web and bump-brew-formula skipped. Re-running all jobs is safe: the PyPI publish step uses skip-existing." + exit 1 + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + + # Resolving is not installing. --dry-run is satisfied by PyPI metadata + # (often via PEP 658, without fetching a single wheel), so a corrupt + # artifact or an entry point that cannot import still passes it. The + # build job smoke-tests `deepctl --version`, but against the local + # dist/ artifacts -- this is the only check against what PyPI serves, + # and it is the last gate before three jobs advertise the release. + - name: Install for real and run the CLI + run: | + set -euo pipefail + VERSION="${TAG_NAME#v}" + /tmp/verify/bin/pip install --quiet --no-cache-dir "deepctl==${VERSION}" + /tmp/verify/bin/deepctl --version + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + mark-latest: name: Mark root release as latest - needs: [release-please, publish] + needs: [release-please, publish, verify-published] # Re-assert latest on the root package tag (vX.Y.Z) after PyPI publish so # users clicking "latest" land on a tag whose artifact is actually # installable. Also re-asserts after all sub-package releases since @@ -97,7 +233,7 @@ jobs: deploy-web: name: Deploy web to production - needs: [release-please, publish] + needs: [release-please, publish, verify-published] # Only fire on root-package releases (v0.2.4, v1.0.0, …) and only after # PyPI publish so cli.deepgram.com never advertises a version that isn't # installable yet. Sub-package tags look like deepctl-cmd-listen-v0.0.3 — @@ -128,7 +264,7 @@ jobs: bump-brew-formula: name: Bump Homebrew formula - needs: [release-please, publish] + needs: [release-please, publish, verify-published] # Only fire on root-package releases (v0.2.4, v1.0.0, …). # Sub-package tags look like deepctl-cmd-listen-v0.0.3 — skip those. if: | @@ -143,27 +279,10 @@ jobs: with: python-version: "3.13" - - name: Wait for new deepctl version on PyPI - run: | - set -euo pipefail - VERSION="${TAG_NAME#v}" - # Poll the PyPI JSON API rather than `pip index versions`. The - # JSON endpoint flips the moment a release is published, while - # the simple index pip queries can lag 5-15 minutes behind a - # successful publish (CDN caching of project metadata). - URL="https://pypi.org/pypi/deepctl/${VERSION}/json" - for i in $(seq 1 30); do - if [ "$(curl -fsS -o /dev/null -w '%{http_code}' "${URL}")" = "200" ]; then - echo "deepctl==${VERSION} is live on PyPI" - exit 0 - fi - echo "Waiting for deepctl==${VERSION} on PyPI (${i}/30)..." - sleep 20 - done - echo "::error::deepctl==${VERSION} did not appear on PyPI after 10 minutes" - exit 1 - env: - TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + # No PyPI poll here: verify-published is in `needs`, and it does not + # finish until `pip install deepctl==X` resolves its full closure and + # the installed CLI runs. A second, weaker poll behind that gate can + # only cost time. - name: Checkout homebrew-tap uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78b465b5..102a946a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,11 @@ on: push: pull_request: +# Every job here only reads the repository: no job comments, labels, or +# pushes. Least privilege rather than whatever the repo default happens to be. +permissions: + contents: read + jobs: test: name: Test Python ${{ matrix.python-version }} / ${{ matrix.os }} @@ -56,13 +61,33 @@ jobs: run: uv sync --group testing --locked - name: Check formatting - run: uv run ruff format --check src/ packages/*/src + run: uv run ruff format --check src/ packages/*/src scripts/ - name: Lint - run: uv run ruff check src/ packages/*/src + run: uv run ruff check src/ packages/*/src scripts/ - name: Type check - run: uv run mypy src/ packages/*/src + run: uv run mypy src/ packages/*/src scripts/ + + floors: + name: Dependency floors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + # Root floors must equal workspace versions or `pip install --upgrade + # deepctl` (what `dg update` runs) silently skips the packages whose + # fixes the release advertises. See scripts/check_dependency_floors.py. + - name: Check dependency floors + run: make floors-check build-test: name: Test Build Process @@ -86,3 +111,31 @@ jobs: - name: Verify packages run: make verify-packages + + # Single rollup so branch protection needs exactly one required check. + # Requiring the matrix contexts directly means editing repo settings every + # time a Python version or OS is added or dropped, and a required context + # that stops reporting blocks every merge until someone notices. + # + # `if: always()` is load-bearing: without it this job is *skipped* when a + # prerequisite fails, and a skipped required check never reports failure -- + # it just stalls. Run always, then fail on anything that is not success, so + # cancelled and skipped prerequisites are failures here too. + all-checks: + name: All checks + needs: [test, lint, floors, build-test] + if: always() + runs-on: ubuntu-latest + steps: + - name: Report prerequisite results + run: | + echo "test: ${{ needs.test.result }}" + echo "lint: ${{ needs.lint.result }}" + echo "floors: ${{ needs.floors.result }}" + echo "build-test: ${{ needs.build-test.result }}" + + - name: Fail unless every prerequisite succeeded + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + run: | + echo "::error::One or more required jobs did not succeed" + exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b625b659..9a6f453d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,36 @@ Or manually create a package under `packages/` following the existing pattern. E Then run `make readmes` to update all READMEs. +The root `pyproject.toml` dependency is not optional bookkeeping — it is the +delivery manifest. `pip install --upgrade deepctl` (what `dg update` runs) +only installs what root depends on, so a published package missing from that +list never reaches anyone. `make floors-check` fails on both that omission and +a floor left below the workspace version; run `make floors-fix` to pin floors. + +### Releasing + +Releases are driven by release-please. Two things are worth knowing before you +run one: + +**Land your release-notes edits as a commit, not a PR description edit.** +`sync-release-pr` regenerates `uv.lock` and the root dependency floors on the +release branch automatically, but it pushes with `GITHUB_TOKEN`, which by +design retriggers nothing — so the PR's checks still reflect the bot's first +commit and stay red on content that is now correct. Editing `CHANGELOG.md` on +the branch is a commit and retriggers them. Editing the PR *description* does +not: that fires `pull_request: edited`, which is outside the default trigger +types. If a release ever needs to go out unattended, switch that push to a +dedicated PAT or GitHub App token. + +**Nothing advertises a release until it is installable.** `verify-published` +polls PyPI until `pip install deepctl==X` resolves its full closure, then +installs it for real and runs the CLI. `mark-latest`, `deploy-web`, and the +Homebrew bump all wait on it. If it times out, PyPI has not propagated or a +sub-package failed to publish — re-run all jobs on that workflow run once +PyPI has caught up; re-running only the failed job leaves the three +downstream jobs skipped. Re-running all jobs is safe: the publish step uses +`skip-existing`. + ### Testing - Unit tests: `packages/*/tests/unit/` diff --git a/Makefile b/Makefile index 54e1dba9..49b13b83 100644 --- a/Makefile +++ b/Makefile @@ -1,201 +1,207 @@ -# =================================================================== -# deepctl Makefile - Development Tools -# =================================================================== -# -# Quick Start: -# make dev-setup # First time setup -# make dev # Daily development (format + lint + test) -# make help # Show organized help -# -# For new contributors: see README.md -# =================================================================== - -.PHONY: help -.DEFAULT_GOAL := help - -# =================================================================== -# HELP & INFO -# =================================================================== - -help: ## Show this help message - @echo "🔧 deepctl - Deepgram CLI Development Tools" - @echo "" - @echo "Usage: make [target]" - @echo "" - @echo "🚀 \033[1mQuick Start:\033[0m" - @echo " \033[36mdev-setup\033[0m Set up development environment" - @echo " \033[36mdev\033[0m Format, lint, and test (full dev cycle)" - @echo " \033[36mtest\033[0m Run tests" - @echo "" - @echo "🧪 \033[1mTesting:\033[0m" - @echo " \033[36mtest\033[0m Run tests (development)" - @echo " \033[36mcheck\033[0m Quick quality check (no tests)" - @echo "" - @echo "🔧 \033[1mCode Quality:\033[0m" - @echo " \033[36mformat\033[0m Auto-format code" - @echo " \033[36mlint\033[0m Run all linters" - @echo " \033[36mtypecheck\033[0m Run mypy type checker" - @echo "" - @echo "🧹 \033[1mUtilities:\033[0m" - @echo " \033[36mclean\033[0m Clean build artifacts" - @echo " \033[36minfo\033[0m Show project information" - @echo " \033[36mhelp-all\033[0m Show all available targets" - @echo "" - @echo "For more targets, run: \033[36mmake help-all\033[0m" - -help-all: ## Show all available targets - @echo "🔧 deepctl - All Available Targets" - @echo "" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v '^\.' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}' - -info: ## Show project information - @echo "🔧 deepctl - Deepgram CLI" - @echo "📁 $(shell pwd)" - @echo "🐍 Python: $(shell python --version 2>/dev/null || echo 'Not found')" - @echo "📦 uv: $(shell uv --version 2>/dev/null || echo 'Not found')" - @echo "🎯 Virtual env: $(shell echo $$VIRTUAL_ENV || echo 'Not activated')" - -# =================================================================== -# DEVELOPMENT SETUP -# =================================================================== - -dev-setup: ## Set up complete development environment - uv venv - uv pip install -e ".[dev]" - @echo "✅ Development environment ready!" - @echo "Activate with: source .venv/bin/activate (Linux/macOS) or .venv\\Scripts\\activate (Windows)" - -install: ## Install runtime dependencies only - uv pip install -e . - -install-dev: ## Install all development dependencies (includes testing) - uv pip install -e ".[dev]" - -# =================================================================== -# QUICK DEVELOPMENT WORKFLOWS -# =================================================================== - -dev: format lint-fix test ## Run full development cycle: format, fix lints, test - @echo "✅ Development cycle complete!" - -check: format-check lint-check typecheck ## Quick quality check (no tests) - @echo "✅ Quick check complete!" - -# =================================================================== -# TESTING -# =================================================================== - -test: ## Run tests with pytest - uv run pytest - -test-quick: ## Run tests quickly (no coverage) - uv run pytest -x - -test-verbose: ## Run tests with verbose output - uv run pytest -xvs - -test-watch: ## Run tests in watch mode (requires pytest-watch) - uv run ptw - -# =================================================================== -# CODE QUALITY -# =================================================================== - -## Formatting -format: ## Auto-format code with ruff - uv run ruff format src/ packages/**/src - -format-check: ## Check code formatting (no changes) - uv run ruff format --check src/ packages/**/src - -## Linting -lint: format-check lint-check typecheck ## Run all linters - @echo "✅ All linters passed!" - -lint-fix: ## Run ruff with auto-fix - uv run ruff check --fix src/ packages/**/src - -lint-check: ## Run ruff without fixes - uv run ruff check src/ packages/**/src - -## Type Checking -typecheck: ## Run mypy type checker - uv run mypy src/ packages/**/src - -## All Checks -quality: format-check lint-check typecheck ## Run all quality checks - -# =================================================================== -# RELEASE MANAGEMENT -# =================================================================== - -build: clean ## Build all packages into dist/ - @pip install build - @for pkg in . packages/*; do \ - if [ -f "$$pkg/pyproject.toml" ]; then \ - echo " Building $$pkg..."; \ - python -m build "$$pkg" --outdir dist/; \ - fi; \ - done - -verify-packages: ## Verify built packages with twine - @pip install twine - twine check dist/* - -readmes: ## Generate sub-package READMEs from pyproject.toml metadata - python3 scripts/generate_readmes.py - -readmes-check: ## Check sub-package READMEs are up to date - python3 scripts/generate_readmes.py --check - -# =================================================================== -# RUNNING THE CLI -# =================================================================== - -run: ## Run the CLI (show help) - uv run python -m deepctl --help - -run-version: ## Show CLI version - uv run python -m deepctl --version - -# =================================================================== -# CLEANUP -# =================================================================== - -clean: ## Clean all build artifacts and caches - rm -rf build/ - rm -rf dist/ - rm -rf *.egg-info/ - rm -rf packages/**/*.egg-info/ - find . -type d -name __pycache__ -exec rm -rf {} + - find . -type f -name "*.pyc" -delete - rm -rf .pytest_cache/ - rm -rf .coverage - rm -rf htmlcov/ - rm -rf .mypy_cache/ - rm -rf .ruff_cache/ - -clean-env: ## Remove virtual environment - rm -rf .venv/ - -# =================================================================== -# PRE-COMMIT HOOKS -# =================================================================== - -pre-commit-install: ## Install pre-commit hooks - uv run pre-commit install - -pre-commit-run: ## Run pre-commit on all files - uv run pre-commit run --all-files - -# =================================================================== -# ALIASES (for convenience) -# =================================================================== - -.PHONY: t tl q f l - -t: test ## Alias for test -tl: lint ## Alias for lint -q: check ## Alias for check (quick) -f: format ## Alias for format -l: lint-fix ## Alias for lint-fix +# =================================================================== +# deepctl Makefile - Development Tools +# =================================================================== +# +# Quick Start: +# make dev-setup # First time setup +# make dev # Daily development (format + lint + test) +# make help # Show organized help +# +# For new contributors: see README.md +# =================================================================== + +.PHONY: help +.DEFAULT_GOAL := help + +# =================================================================== +# HELP & INFO +# =================================================================== + +help: ## Show this help message + @echo "🔧 deepctl - Deepgram CLI Development Tools" + @echo "" + @echo "Usage: make [target]" + @echo "" + @echo "🚀 \033[1mQuick Start:\033[0m" + @echo " \033[36mdev-setup\033[0m Set up development environment" + @echo " \033[36mdev\033[0m Format, lint, and test (full dev cycle)" + @echo " \033[36mtest\033[0m Run tests" + @echo "" + @echo "🧪 \033[1mTesting:\033[0m" + @echo " \033[36mtest\033[0m Run tests (development)" + @echo " \033[36mcheck\033[0m Quick quality check (no tests)" + @echo "" + @echo "🔧 \033[1mCode Quality:\033[0m" + @echo " \033[36mformat\033[0m Auto-format code" + @echo " \033[36mlint\033[0m Run all linters" + @echo " \033[36mtypecheck\033[0m Run mypy type checker" + @echo "" + @echo "🧹 \033[1mUtilities:\033[0m" + @echo " \033[36mclean\033[0m Clean build artifacts" + @echo " \033[36minfo\033[0m Show project information" + @echo " \033[36mhelp-all\033[0m Show all available targets" + @echo "" + @echo "For more targets, run: \033[36mmake help-all\033[0m" + +help-all: ## Show all available targets + @echo "🔧 deepctl - All Available Targets" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v '^\.' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}' + +info: ## Show project information + @echo "🔧 deepctl - Deepgram CLI" + @echo "📁 $(shell pwd)" + @echo "🐍 Python: $(shell python --version 2>/dev/null || echo 'Not found')" + @echo "📦 uv: $(shell uv --version 2>/dev/null || echo 'Not found')" + @echo "🎯 Virtual env: $(shell echo $$VIRTUAL_ENV || echo 'Not activated')" + +# =================================================================== +# DEVELOPMENT SETUP +# =================================================================== + +dev-setup: ## Set up complete development environment + uv venv + uv pip install -e ".[dev]" + @echo "✅ Development environment ready!" + @echo "Activate with: source .venv/bin/activate (Linux/macOS) or .venv\\Scripts\\activate (Windows)" + +install: ## Install runtime dependencies only + uv pip install -e . + +install-dev: ## Install all development dependencies (includes testing) + uv pip install -e ".[dev]" + +# =================================================================== +# QUICK DEVELOPMENT WORKFLOWS +# =================================================================== + +dev: format lint-fix test ## Run full development cycle: format, fix lints, test + @echo "✅ Development cycle complete!" + +check: format-check lint-check typecheck ## Quick quality check (no tests) + @echo "✅ Quick check complete!" + +# =================================================================== +# TESTING +# =================================================================== + +test: ## Run tests with pytest + uv run pytest + +test-quick: ## Run tests quickly (no coverage) + uv run pytest -x + +test-verbose: ## Run tests with verbose output + uv run pytest -xvs + +test-watch: ## Run tests in watch mode (requires pytest-watch) + uv run ptw + +# =================================================================== +# CODE QUALITY +# =================================================================== + +## Formatting +format: ## Auto-format code with ruff + uv run ruff format src/ packages/**/src scripts/ + +format-check: ## Check code formatting (no changes) + uv run ruff format --check src/ packages/**/src scripts/ + +## Linting +lint: format-check lint-check typecheck ## Run all linters + @echo "✅ All linters passed!" + +lint-fix: ## Run ruff with auto-fix + uv run ruff check --fix src/ packages/**/src scripts/ + +lint-check: ## Run ruff without fixes + uv run ruff check src/ packages/**/src scripts/ + +## Type Checking +typecheck: ## Run mypy type checker + uv run mypy src/ packages/**/src scripts/ + +## All Checks +quality: format-check lint-check typecheck ## Run all quality checks + +# =================================================================== +# RELEASE MANAGEMENT +# =================================================================== + +build: clean ## Build all packages into dist/ + @pip install build + @for pkg in . packages/*; do \ + if [ -f "$$pkg/pyproject.toml" ]; then \ + echo " Building $$pkg..."; \ + python -m build "$$pkg" --outdir dist/; \ + fi; \ + done + +verify-packages: ## Verify built packages with twine + @pip install twine + twine check dist/* + +readmes: ## Generate sub-package READMEs from pyproject.toml metadata + python3 scripts/generate_readmes.py + +readmes-check: ## Check sub-package READMEs are up to date + python3 scripts/generate_readmes.py --check + +floors-check: ## Check intra-workspace dependency floors (root must pin workspace versions) + uv run python scripts/check_dependency_floors.py + +floors-fix: ## Pin root dependency floors to the current workspace versions + uv run python scripts/check_dependency_floors.py --fix + +# =================================================================== +# RUNNING THE CLI +# =================================================================== + +run: ## Run the CLI (show help) + uv run python -m deepctl --help + +run-version: ## Show CLI version + uv run python -m deepctl --version + +# =================================================================== +# CLEANUP +# =================================================================== + +clean: ## Clean all build artifacts and caches + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + rm -rf packages/**/*.egg-info/ + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + rm -rf .pytest_cache/ + rm -rf .coverage + rm -rf htmlcov/ + rm -rf .mypy_cache/ + rm -rf .ruff_cache/ + +clean-env: ## Remove virtual environment + rm -rf .venv/ + +# =================================================================== +# PRE-COMMIT HOOKS +# =================================================================== + +pre-commit-install: ## Install pre-commit hooks + uv run pre-commit install + +pre-commit-run: ## Run pre-commit on all files + uv run pre-commit run --all-files + +# =================================================================== +# ALIASES (for convenience) +# =================================================================== + +.PHONY: t tl q f l + +t: test ## Alias for test +tl: lint ## Alias for lint +q: check ## Alias for check (quick) +f: format ## Alias for format +l: lint-fix ## Alias for lint-fix diff --git a/README.md b/README.md index 0be41ebb..4d93a960 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,24 @@ dg usage --last-week -o yaml When running in a non-TTY environment (pipes, CI, or AI coding tools), the CLI automatically switches to structured JSON output with plain-text status messages. +### Exit codes + +Since 0.3.0, `dg` exits non-zero when a command fails — branch on the exit +code, not on parsing output: + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Error — a failed command, a crash, or a usage error (bad flag, unknown command) | +| `2` | Cancelled by the user (Ctrl-C, or declining a confirmation prompt) | + +Note that `dg` reports `2` for an interrupt rather than the shell's +conventional `130`, so the code is the same whether the cancellation came from +Ctrl-C or from declining a prompt. + +If a CI step relied on `dg` always exiting `0` (every command did, before +0.3.0), it will now fail where it previously passed silently. + ### Forcing non-interactive mode Three explicit ways to skip every prompt and run with defaults — useful from a diff --git a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py index 17fd9f77..755112c6 100644 --- a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py +++ b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py @@ -474,8 +474,17 @@ def _handle_remove( package = kwargs["package"] yes = kwargs.get("yes", False) - if not yes and not click.confirm(f"Are you sure you want to remove {package}?"): - return + # err=True: the prompt is interaction, not output. Keeping it off + # stdout is what lets `dg plugin remove ... -o json` stay parseable, + # and matches the sibling prompt in deepctl-cmd-keys. + if not yes and not click.confirm( + f"Are you sure you want to remove {package}?", err=True + ): + # Abort rather than return: this is a group subcommand, so there is + # no result for BaseCommand.EXIT_CODES to map, and a bare return + # exits 0 -- indistinguishable from a successful removal. main.py + # turns Abort into the documented exit 2 for user cancellation. + raise click.Abort() result = self.remove_plugin(config, auth_manager, client, package) diff --git a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py index 486040eb..5c4cfa4f 100644 --- a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py +++ b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import click import pytest from click.testing import CliRunner from deepctl_cmd_plugin.command import PluginCommand @@ -234,6 +235,54 @@ def test_remove_plugin_not_installed(self) -> None: assert result.success is False assert "not installed" in result.message + def test_remove_declined_aborts_instead_of_exiting_zero(self) -> None: + """Declining the prompt must exit 2, not 0. + + `_handle_remove` is a group subcommand returning None, so there is no + result for BaseCommand.EXIT_CODES to map to an exit code. A bare + return made a declined removal indistinguishable from a successful + one for any script branching on the exit code, contradicting the + contract published in the README. Abort is what main.py turns into 2. + """ + with ( + patch("click.confirm", return_value=False), + patch.object(self.command, "remove_plugin") as mock_remove, + ): + with pytest.raises(click.Abort): + self.command._handle_remove( + self.config, + self.auth_manager, + self.client, + package="test-plugin", + ) + + mock_remove.assert_not_called() + + def test_remove_with_yes_skips_the_prompt(self) -> None: + """Positive control: --yes must not prompt and must not abort.""" + with ( + patch("click.confirm") as mock_confirm, + patch.object(self.command, "remove_plugin") as mock_remove, + patch.object(self.command, "_maybe_update_skills"), + ): + mock_remove.return_value = PluginOperationResult( + success=True, + action="remove", + package="test-plugin", + message="Successfully removed test-plugin", + ) + + self.command._handle_remove( + self.config, + self.auth_manager, + self.client, + package="test-plugin", + yes=True, + ) + + mock_confirm.assert_not_called() + mock_remove.assert_called_once() + @patch("deepctl_cmd_plugin.command.subprocess.run") def test_discover_from_environment(self, mock_run: MagicMock) -> None: """Test discovering plugins from a specific environment.""" diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py index 837cd5c8..9db92d92 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py @@ -11,7 +11,7 @@ from deepctl_core.base_group_command import BaseGroupCommand from deepctl_core.client import DeepgramClient from deepctl_core.config import Config -from deepctl_core.output import print_error, print_info, print_success, print_warning +from deepctl_core.output import print_info, print_success, print_warning from rich.console import Console from rich.table import Table @@ -261,17 +261,24 @@ def _handle_install( if cli_name: generators = [g for g in get_all_generators() if g.cli_name == cli_name] if not generators: - print_error( + # ClickException, not print_error + return: a bare return + # exits 0, and the README documents 1 for a command that + # fails. main.py prints the message and exits 1. + raise click.ClickException( f"Unknown AI CLI: {cli_name}. " "Run 'deepctl skills status' to see supported CLIs." ) - return if not generators[0].detect(): print_warning( f"{generators[0].display_name} was not detected on this system." ) if not self.confirm("Install anyway?", default=False): - return + # Abort rather than return: these subcommands are plain + # click callbacks, so nothing maps a returned result to an + # exit code and a bare return exits 0 -- indistinguishable + # from a successful install. main.py turns Abort into the + # documented exit 2 for user cancellation. + raise click.Abort() else: generators = detect_ai_clis() @@ -397,8 +404,10 @@ def _handle_remove( if cli_name: targets = [cli_name] if cli_name in installed else [] if not targets: - print_error(f"No skills installed for '{cli_name}'.") - return + # Same contract as the unknown-CLI path above: asking to + # remove something that is not there is a failed command, + # which the README documents as exit 1, not 0. + raise click.ClickException(f"No skills installed for '{cli_name}'.") elif remove_all: targets = list(installed.keys()) else: diff --git a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py index 4094d870..8564186f 100644 --- a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py +++ b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import click import pytest from deepctl_cmd_skills.command import SkillsCommand @@ -36,6 +37,92 @@ def test_setup_commands_returns_subcommands(self): assert "list" in names assert "status" in names + def test_declining_install_anyway_aborts_instead_of_exiting_zero(self): + """Declining the prompt must exit 2, not 0. + + `_handle_install` is a plain click callback returning None, so there + is no result for BaseCommand.EXIT_CODES to map to an exit code. A + bare return made a declined install indistinguishable from a + successful one, contradicting the exit-code table in the README. + Abort is what main.py turns into 2. + """ + cmd = SkillsCommand() + generator = MagicMock() + generator.cli_name = "claude" + generator.display_name = "Claude Code" + generator.detect.return_value = False + + with ( + patch( + "deepctl_core.skill_generator.get_all_generators", + return_value=[generator], + ), + patch.object(cmd, "confirm", return_value=False), + ): + with pytest.raises(click.Abort): + cmd._handle_install(cli_name="claude") + + generator.install.assert_not_called() + + def test_accepting_install_anyway_does_not_abort(self): + """Positive control: confirming must proceed to the install.""" + cmd = SkillsCommand() + generator = MagicMock() + generator.cli_name = "claude" + generator.display_name = "Claude Code" + generator.detect.return_value = False + generator.install.return_value = [] + + with ( + patch( + "deepctl_core.skill_generator.get_all_generators", + return_value=[generator], + ), + patch( + "deepctl_core.skill_generator.collect_command_metadata", + return_value={}, + ), + patch( + "deepctl_core.skill_generator.get_skills_state", + return_value={"installed_skills": {}}, + ), + patch("deepctl_core.skill_generator.save_skills_state"), + patch.object(cmd, "confirm", return_value=True), + ): + cmd._handle_install(cli_name="claude") + + generator.install.assert_called_once() + + def test_unknown_cli_exits_one(self): + """An unknown --cli must exit 1, not print an error and exit 0.""" + cmd = SkillsCommand() + with patch( + "deepctl_core.skill_generator.get_all_generators", + return_value=[], + ): + with pytest.raises(click.ClickException) as exc: + cmd._handle_install(cli_name="nonexistent-cli") + + assert "Unknown AI CLI: nonexistent-cli" in str(exc.value) + + def test_removing_skills_that_are_not_installed_exits_one(self): + """`skills remove --cli X` with nothing installed for X exits 1.""" + cmd = SkillsCommand() + with ( + patch( + "deepctl_core.skill_generator.get_skills_state", + return_value={"installed_skills": {"claude": {"paths": []}}}, + ), + patch( + "deepctl_core.skill_generator.get_all_generators", + return_value=[], + ), + ): + with pytest.raises(click.ClickException) as exc: + cmd._handle_remove(cli_name="cursor", remove_all=False) + + assert "No skills installed for 'cursor'" in str(exc.value) + class TestSkillsStartupCheck: """Test startup check module.""" diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/command.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/command.py index f38d4785..9bdc2773 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/command.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/command.py @@ -69,8 +69,16 @@ def handle( auth_manager: Any, # Not used for update command client: Any, # Not used for update command **kwargs: Any, - ) -> dict[str, Any]: - """Handle the update command execution.""" + ) -> UpdateResult | dict[str, Any]: + """Handle the update command execution. + + Returns the model rather than a dict wherever the exit code has to + be non-zero: BaseCommand.exit_code_for reads `.status` off the + result, and a dict has none, so a dict always exits 0. The three + failure paths carry status="error" (exit 1) and the decline path + status="cancelled" (exit 2). output_result unwraps either, so the + JSON payload is the same shape from both. + """ console = get_console() # Extract arguments from kwargs @@ -93,10 +101,13 @@ def handle( version_info = asyncio.run(version_checker.check_version(force=True)) except Exception as e: print_error(f"Failed to check for updates: {e}") + # status="error" on the model, not a dict: see the docstring. + # A command that failed has to exit 1, as the README says. return UpdateResult( + status="error", success=False, message=f"Failed to check for updates: {e}", - ).model_dump() + ) # Display version info message = format_version_message(version_info) @@ -167,14 +178,20 @@ def handle( # Confirm update if not yes and not Confirm.ask("\nDo you want to proceed with the update?"): print_info("Update cancelled") + # Return the model, not .model_dump(): BaseCommand.exit_code_for + # reads `.status` off the result, and a plain dict has none -- a + # declined update used to exit 0 while its own payload said + # "Update cancelled by user". status="cancelled" is the documented + # exit 2. return UpdateResult( + status="cancelled", success=False, message="Update cancelled by user", current_version=version_info.current_version, latest_version=version_info.latest_version, update_available=version_info.update_available, installation_method=install_info.method.value, - ).model_dump() + ) # Execute update print_info("Updating deepctl...") @@ -212,13 +229,14 @@ def handle( console.print(f"[yellow]{shlex.join(update_command)}[/yellow]") return UpdateResult( + status="error", success=False, message=f"Update failed: {error_msg}", current_version=version_info.current_version, latest_version=version_info.latest_version, update_available=version_info.update_available, installation_method=install_info.method.value, - ).model_dump() + ) except Exception as e: print_error(f"Failed to execute update: {e}") @@ -228,10 +246,11 @@ def handle( console.print(f"[yellow]{update_command}[/yellow]") return UpdateResult( + status="error", success=False, message=f"Failed to execute update: {e}", current_version=version_info.current_version, latest_version=version_info.latest_version, update_available=version_info.update_available, installation_method=install_info.method.value, - ).model_dump() + ) diff --git a/packages/deepctl-cmd-update/tests/unit/test_update_command.py b/packages/deepctl-cmd-update/tests/unit/test_update_command.py index 04d52142..0a9cb54d 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_update_command.py +++ b/packages/deepctl-cmd-update/tests/unit/test_update_command.py @@ -316,3 +316,224 @@ def test_development_installation( assert result["success"] is False assert result["installation_method"] == "development" mock_print_warning.assert_called_once() + + # ------------------------------------------------------------------ + # declining the confirmation prompt + # ------------------------------------------------------------------ + + @patch("deepctl_cmd_update.command.subprocess.run") + @patch("deepctl_cmd_update.command.InstallationDetector") + @patch("deepctl_cmd_update.command.asyncio.run") + @patch("deepctl_cmd_update.command.VersionChecker") + @patch("deepctl_cmd_update.command.get_console") + @patch("deepctl_cmd_update.command.format_version_message") + @patch("deepctl_cmd_update.command.print_info") + @patch("deepctl_cmd_update.command.Confirm.ask") + def test_declining_the_prompt_reports_cancelled_and_exits_two( + self, + mock_confirm, + mock_print_info, + mock_format_msg, + mock_console, + mock_checker_class, + mock_asyncio_run, + mock_detector_class, + mock_subprocess, + command, + ): + """Declining must exit 2, and say "cancelled" in the payload. + + The README documents exit 2 for declining a confirmation prompt. + This path used to return `.model_dump()` -- a plain dict, which + `BaseCommand.exit_code_for` cannot read a status off -- so the + process exited 0 while the very same JSON payload carried + `"status": "success"` next to "Update cancelled by user". + """ + mock_asyncio_run.return_value = VersionInfo( + current_version="0.1.0", + latest_version="0.2.0", + update_available=True, + ) + mock_format_msg.return_value = "Update available!" + + mock_detector = mock_detector_class.return_value + mock_detector.detect.return_value = InstallationInfo( + method=InstallMethod.PIP, + path="/path/to/deepctl", + virtual_env=True, + editable=False, + python_executable="/usr/bin/python3", + ) + mock_detector.get_update_command.return_value = [ + "pip", + "install", + "--upgrade", + "deepctl", + ] + + mock_confirm.return_value = False + + result = command.handle( + config=MagicMock(), + auth_manager=MagicMock(), + client=MagicMock(), + yes=False, + ) + + assert command.exit_code_for(result) == 2 + assert result.status == "cancelled" + assert result.success is False + assert result.message == "Update cancelled by user" + mock_subprocess.assert_not_called() + + @patch("deepctl_cmd_update.command.subprocess.run") + @patch("deepctl_cmd_update.command.InstallationDetector") + @patch("deepctl_cmd_update.command.asyncio.run") + @patch("deepctl_cmd_update.command.VersionChecker") + @patch("deepctl_cmd_update.command.get_console") + @patch("deepctl_cmd_update.command.format_version_message") + @patch("deepctl_cmd_update.command.print_info") + @patch("deepctl_cmd_update.command.print_success") + @patch("deepctl_cmd_update.command.Confirm.ask") + def test_successful_update_still_exits_zero( + self, + mock_confirm, + mock_print_success, + mock_print_info, + mock_format_msg, + mock_console, + mock_checker_class, + mock_asyncio_run, + mock_detector_class, + mock_subprocess, + command, + ): + """Negative control: accepting the prompt must still exit 0. + + Only the decline path carries a status, so the success payload maps + to 0 -- the point is that adding "cancelled" did not make an accepted + update exit non-zero. + """ + mock_asyncio_run.return_value = VersionInfo( + current_version="0.1.0", + latest_version="0.2.0", + update_available=True, + ) + mock_format_msg.return_value = "Update available!" + + mock_detector = mock_detector_class.return_value + mock_detector.detect.return_value = InstallationInfo( + method=InstallMethod.PIP, + path="/path/to/deepctl", + virtual_env=True, + editable=False, + python_executable="/usr/bin/python3", + ) + mock_detector.get_update_command.return_value = [ + "pip", + "install", + "--upgrade", + "deepctl", + ] + mock_subprocess.return_value = MagicMock(returncode=0, stderr="") + mock_confirm.return_value = True + + result = command.handle( + config=MagicMock(), + auth_manager=MagicMock(), + client=MagicMock(), + yes=False, + ) + + assert command.exit_code_for(result) == 0 + assert result["success"] is True + mock_subprocess.assert_called_once() + + # ------------------------------------------------------------------ + # failure paths + # ------------------------------------------------------------------ + + @patch("deepctl_cmd_update.command.subprocess.run") + @patch("deepctl_cmd_update.command.InstallationDetector") + @patch("deepctl_cmd_update.command.asyncio.run") + @patch("deepctl_cmd_update.command.VersionChecker") + @patch("deepctl_cmd_update.command.get_console") + @patch("deepctl_cmd_update.command.format_version_message") + @patch("deepctl_cmd_update.command.print_info") + @patch("deepctl_cmd_update.command.print_error") + @patch("deepctl_cmd_update.command.Confirm.ask") + def test_failed_update_exits_one( + self, + mock_confirm, + mock_print_error, + mock_print_info, + mock_format_msg, + mock_console, + mock_checker_class, + mock_asyncio_run, + mock_detector_class, + mock_subprocess, + command, + ): + """A pip that exits non-zero must make `dg update` exit 1.""" + mock_asyncio_run.return_value = VersionInfo( + current_version="0.1.0", + latest_version="0.2.0", + update_available=True, + ) + mock_format_msg.return_value = "Update available!" + + mock_detector = mock_detector_class.return_value + mock_detector.detect.return_value = InstallationInfo( + method=InstallMethod.PIP, + path="/path/to/deepctl", + virtual_env=True, + editable=False, + python_executable="/usr/bin/python3", + ) + mock_detector.get_update_command.return_value = [ + "pip", + "install", + "--upgrade", + "deepctl", + ] + mock_subprocess.return_value = MagicMock( + returncode=1, stderr="No matching distribution" + ) + mock_confirm.return_value = True + + result = command.handle( + config=MagicMock(), + auth_manager=MagicMock(), + client=MagicMock(), + yes=True, + ) + + assert command.exit_code_for(result) == 1 + assert result.status == "error" + assert "No matching distribution" in result.message + + @patch("deepctl_cmd_update.command.asyncio.run") + @patch("deepctl_cmd_update.command.VersionChecker") + @patch("deepctl_cmd_update.command.get_console") + @patch("deepctl_cmd_update.command.print_error") + def test_failed_version_check_exits_one( + self, + mock_print_error, + mock_console, + mock_checker_class, + mock_asyncio_run, + command, + ): + """An unreachable PyPI must make `dg update` exit 1, not 0.""" + mock_asyncio_run.side_effect = RuntimeError("connection refused") + + result = command.handle( + config=MagicMock(), + auth_manager=MagicMock(), + client=MagicMock(), + ) + + assert command.exit_code_for(result) == 1 + assert result.status == "error" + assert "connection refused" in result.message diff --git a/packages/deepctl-core/src/deepctl_core/plugin_manager.py b/packages/deepctl-core/src/deepctl_core/plugin_manager.py index e25628cc..854bb02d 100644 --- a/packages/deepctl-core/src/deepctl_core/plugin_manager.py +++ b/packages/deepctl-core/src/deepctl_core/plugin_manager.py @@ -7,12 +7,11 @@ from typing import Any, cast import click -from rich.console import Console from .base_command import BaseCommand from .base_group_command import BaseGroupCommand from .models import ErrorResult, PluginInfo -from .output import print_warning +from .output import print_warning, stderr_console from .plugin_env import ( PLUGIN_VENV, get_plugin_state, @@ -21,7 +20,9 @@ ) from .timing import TimingContext -console = Console() +# Load errors are diagnostics: they go to stderr so a broken plugin can't +# corrupt `dg ... -o json` payloads on stdout. +console = stderr_console class PluginManager: diff --git a/packages/deepctl-core/tests/unit/test_plugin_manager.py b/packages/deepctl-core/tests/unit/test_plugin_manager.py index ad5d16da..e40ec067 100644 --- a/packages/deepctl-core/tests/unit/test_plugin_manager.py +++ b/packages/deepctl-core/tests/unit/test_plugin_manager.py @@ -575,3 +575,39 @@ def test_warn_if_plugin_venv_python_mismatch_warns_on_major_diff( ) as mock_warn: plugin_manager._warn_if_plugin_venv_python_mismatch() mock_warn.assert_called_once() + + +class TestLoadErrorStream: + """Plugin-load diagnostics must go to stderr, never stdout. + + A broken plugin's ImportError used to print through a stdout Console, + corrupting `dg ... -o json` payloads for every remaining command (the + amplifier in the 0.2.x core-floor incident). The module console is the + shared stderr console so that cannot recur. + """ + + def test_module_console_is_the_shared_stderr_console(self): + from deepctl_core import plugin_manager + from deepctl_core.output import stderr_console + + assert plugin_manager.console is stderr_console + assert plugin_manager.console.stderr is True + + def test_load_error_writes_to_stderr_not_stdout(self, capsys): + from deepctl_core import plugin_manager + + mock_entry_point = Mock() + mock_entry_point.name = "broken-command" + mock_entry_point.load.side_effect = ImportError("Module not found") + + with patch( + "deepctl_core.plugin_manager.metadata.entry_points" + ) as mock_eps: + mock_eps.return_value.select.return_value = [mock_entry_point] + plugin_manager.PluginManager()._load_builtin_commands( + click.Group("dg") + ) + + captured = capsys.readouterr() + assert "broken-command" in captured.err + assert captured.out == "" diff --git a/pyproject.toml b/pyproject.toml index 0f0a29cc..d629115c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,21 +38,21 @@ dependencies = [ "deepctl-core>=0.2.16", "deepctl-cmd-login>=0.1.17", "deepctl-cmd-projects>=0.2.0", - "deepctl-cmd-transcribe>=0.1.10", + "deepctl-cmd-transcribe>=0.1.12", "deepctl-cmd-usage>=0.2.0", "deepctl-cmd-mcp>=0.1.15", - "deepctl-cmd-api>=0.0.1", - "deepctl-cmd-debug>=0.1.10", - "deepctl-cmd-debug-browser>=0.1.10", - "deepctl-cmd-debug-network>=0.1.10", - "deepctl-cmd-debug-audio>=0.1.10", - "deepctl-cmd-debug-probe>=0.0.1", - "deepctl-cmd-debug-toolkit>=0.0.1", - "deepctl-cmd-ffprobe>=0.0.1", + "deepctl-cmd-api>=0.0.2", + "deepctl-cmd-debug>=0.1.12", + "deepctl-cmd-debug-browser>=0.1.12", + "deepctl-cmd-debug-network>=0.1.12", + "deepctl-cmd-debug-audio>=0.1.13", + "deepctl-cmd-debug-probe>=0.0.2", + "deepctl-cmd-debug-toolkit>=0.1.0", + "deepctl-cmd-ffprobe>=0.0.2", "deepctl-cmd-update>=0.2.6", - "deepctl-cmd-plugin>=0.1.10", + "deepctl-cmd-plugin>=0.1.12", "deepctl-cmd-skills>=0.0.7", - "deepctl-cmd-init>=0.0.1", + "deepctl-cmd-init>=0.0.4", "deepctl-cmd-models>=0.1.0", "deepctl-cmd-speak>=0.0.4", "deepctl-cmd-keys>=0.1.0", @@ -61,8 +61,8 @@ dependencies = [ "deepctl-cmd-requests>=0.1.0", "deepctl-cmd-billing>=0.1.0", "deepctl-cmd-members>=0.1.0", - "deepctl-cmd-completion>=0.0.1", - "deepctl-shared-utils>=0.1.10", + "deepctl-cmd-completion>=0.0.3", + "deepctl-shared-utils>=0.1.12", "deepctl-telemetry>=0.0.6", "pydantic>=2.0.0", "rich>=13.0.0", @@ -87,6 +87,7 @@ dev = [ # Code Quality "ruff>=0.8.0", "mypy>=1.0.0", + "packaging>=23.0", # Type Stubs "types-PyYAML>=6.0.0", "types-requests>=2.31.0", @@ -128,7 +129,13 @@ include = ["deepctl*"] [tool.mypy] python_version = "3.10" strict = true -files = "src/,packages/*/src" +files = "src/,packages/*/src,scripts/" + +[[tool.mypy.overrides]] +module = ["tomli"] +# The pre-3.11 fallback for tomllib in scripts/. It is not installed, and +# mypy checks that branch because python_version is pinned to 3.10. +ignore_missing_imports = true [[tool.mypy.overrides]] module = "deepctl_cmd_mcp.*" @@ -232,6 +239,10 @@ members = ["packages/*"] [dependency-groups] testing = [ "pytest>=7.0.0", + # scripts/check_dependency_floors.py reads pyproject files; on 3.10 it + # falls back to tomli, and tests/unit/test_dependency_floors.py drives + # the real script on every version in the CI matrix. + "tomli>=2.0; python_version < '3.11'", "pytest-asyncio>=0.21.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", @@ -244,6 +255,9 @@ dev = [ # Code Quality "ruff>=0.8.0", "mypy>=1.0.0", + # PEP 440 comparisons in scripts/check_dependency_floors.py. Declared + # rather than leaned on as a transitive of build/twine. + "packaging>=23.0", # Type Stubs "types-pyyaml>=6.0.12.20250516", "types-requests>=2.32.4.20250611", diff --git a/scripts/build_standalone.py b/scripts/build_standalone.py index a2a0449f..acb75c66 100644 --- a/scripts/build_standalone.py +++ b/scripts/build_standalone.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 """Build a standalone deepctl binary for testing system installations.""" -import os +import importlib.util import shutil import subprocess import sys from pathlib import Path -def build_standalone(): +def build_standalone() -> None: """Build a standalone deepctl binary using PyInstaller.""" # Check if we're in the right directory if not Path("pyproject.toml").exists(): @@ -18,9 +18,7 @@ def build_standalone(): print("🔨 Building standalone deepctl binary...") # Install PyInstaller if needed - try: - import PyInstaller - except ImportError: + if importlib.util.find_spec("PyInstaller") is None: print("📦 Installing PyInstaller...") subprocess.run( [sys.executable, "-m", "pip", "install", "pyinstaller"], check=True @@ -132,9 +130,7 @@ def build_standalone(): print(" ./dist_standalone/deepctl plugin search") print(" ./dist_standalone/deepctl plugin install deepctl-plugin-example") print("\n💡 The standalone binary should detect as 'system' installation") - print( - " and create an isolated plugin environment at ~/.deepctl/plugins/" - ) + print(" and create an isolated plugin environment at ~/.deepctl/plugins/") if __name__ == "__main__": diff --git a/scripts/check_dependency_floors.py b/scripts/check_dependency_floors.py new file mode 100644 index 00000000..61da1c04 --- /dev/null +++ b/scripts/check_dependency_floors.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Check (or fix) intra-workspace dependency floors. + +Three rules, learned from the 0.3.0 release (PRs #100/#102): + +1. **Root floors equal workspace versions.** `dg update` runs + `pip install --upgrade deepctl`, and pip's default `only-if-needed` + strategy upgrades a sub-package only when the root floor forces it. Any + root floor below the current version means that package's fixes are + published but never delivered on upgrade — `dg --version` reports the new + release while 13 of 17 packages stay stale, which is what happened before + 0.3.0. Root's dependency list is the delivery manifest, so each + `deepctl-*` floor must equal that package's current workspace version. + +2. **Sub-package floors stay satisfiable.** Sub-package floors are API + contracts (e.g. deepctl-cmd-keys needs the deepctl-core that provides + `get_status_console`), hand-raised when a package starts using a newer + sibling API. They must never exceed the sibling's current version. + Keeping them *accurate* is still on the developer: raise the floor in the + same PR that starts importing the new API. + +3. **Root's dependency list covers every published package.** Rule 1 only + validates the floors that are already listed. A package that release-please + versions and publishes but that nobody added to root's `dependencies` is + never installed by `pip install --upgrade deepctl` at all — the same + delivery gap as rule 1, through the door rule 1 leaves open. Anything + deliberately not shipped as part of the CLI goes in NOT_SHIPPED, so that + intent is stated in the diff rather than inferred from an omission. + +Versions are compared with `packaging.version.Version`, not as strings, so a +pre-release anywhere in the workspace (0.4.0rc1, 1.0.0.dev1, 0.3.0.post1) +behaves like any other version instead of turning `--fix` into a loop the +following check can never satisfy. + +Run with --fix to rewrite root floors in place (used by the release +workflow's sync job so rule 1 holds automatically on every release PR). +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - only taken on Python 3.10 + try: + import tomli as tomllib + except ModuleNotFoundError: + print( + "Python 3.11+ required (tomllib), or install tomli: pip install tomli", + file=sys.stderr, + ) + sys.exit(1) + +from packaging.version import InvalidVersion, Version + +REPO = Path(__file__).resolve().parent.parent +MANIFEST = REPO / ".github" / ".release-please-manifest.json" + +# Published packages that are deliberately not part of what `dg` installs. +# Everything else in the manifest must be a root dependency (rule 3). +NOT_SHIPPED = { + "deepctl", # the root package itself + "deepctl-plugin-example", # sample plugin, installed on demand +} + +# A PEP 508 requirement, narrowed to intra-workspace packages: the name, an +# optional extras group, then whatever the author wrote after it. Matching +# only bare `name>=version` used to make `deepctl-cmd-keys[extra]>=0.0.1` or +# `deepctl-cmd-keys==0.1.0` invisible to rules 1 and 3, so the guard reported +# a listed package as missing from root entirely. +_DEP_RE = re.compile( + r"^\s*(?Pdeepctl[A-Za-z0-9._-]*)" + r"\s*(?:\[[^\]]*\])?" + r"\s*(?P.*)$" +) +# The floor itself. The version runs to the first delimiter, so an upper +# bound (`,<2`), an environment marker (`; python_version < "3.12"`) and a +# PEP 440 suffix (`0.4.0rc1`) all survive intact. +_FLOOR_RE = re.compile(r"^(?P>=\s*(?P[^\s,;]+))") + + +@dataclass(frozen=True) +class Dep: + """One intra-workspace dependency as root or a package declares it.""" + + name: str + #: The pinned floor, or None when the spec is not a plain `>=`. + floor: str | None + #: Exact `>=…` text matched, so --fix can rewrite it in place. + spec: str + #: The whole dependency string, as written. + raw: str + + +def workspace_versions() -> dict[str, str]: + """Map package name -> current workspace version, per the manifest.""" + versions: dict[str, str] = {} + for path in json.loads(MANIFEST.read_text(encoding="utf-8")): + pyproject = REPO / ( + "pyproject.toml" if path == "." else f"{path}/pyproject.toml" + ) + project = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"] + versions[project["name"]] = project["version"] + return versions + + +def floors(pyproject: Path) -> list[Dep]: + """Return every intra-workspace dependency declared by `pyproject`.""" + deps = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"].get( + "dependencies", [] + ) + out: list[Dep] = [] + for dep in deps: + m = _DEP_RE.match(dep) + if not m: + continue + floor = _FLOOR_RE.match(m.group("rest").strip()) + out.append( + Dep( + name=m.group("name"), + floor=floor.group("version") if floor else None, + spec=floor.group("spec") if floor else "", + raw=dep, + ) + ) + return out + + +def parse(version: str) -> Version | None: + """PEP 440 version, or None when the string is not a valid version. + + Returning None rather than raising keeps one hand-typed oddity from + turning `make floors-check` into a bare traceback naming no package; the + caller reports it as an ordinary problem instead. + """ + try: + return Version(version) + except InvalidVersion: + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--fix", + action="store_true", + help="rewrite root pyproject.toml floors to the workspace versions", + ) + args = parser.parse_args() + + versions = workspace_versions() + problems: list[str] = [] + + # Rule 1: root floors == workspace versions. + root = REPO / "pyproject.toml" + root_text = root.read_text(encoding="utf-8") + fixed = root_text + root_floors = floors(root) + for dep in root_floors: + current = versions.get(dep.name) + if current is None: + problems.append(f"root depends on {dep.name}, which is not in the manifest") + continue + if dep.floor is None: + problems.append( + f"root dependency {dep.raw!r} does not pin a `>=` floor" + f" (root is the delivery manifest: write" + f" {dep.name}>={current} so pip upgrades deliver it)" + ) + continue + floor_version, current_version = parse(dep.floor), parse(current) + if floor_version is None: + problems.append( + f"root floor {dep.name}>={dep.floor} is not a valid PEP 440 version" + ) + continue + if current_version is None: + problems.append( + f"{dep.name} workspace version {current} is not a valid PEP 440 version" + ) + continue + if floor_version == current_version: + continue + if args.fix: + # Rewrite the version inside the spec we matched, so any upper + # bound, extra, or environment marker survives. A whole-spec + # literal replace silently no-ops on those, and an unfixable + # floor has to fall through to `problems` -- reporting "OK" + # after failing to fix is worse than not fixing. + new_raw = dep.raw.replace(dep.spec, f">={current}", 1) + if new_raw != dep.raw and f'"{dep.raw}"' in fixed: + fixed = fixed.replace(f'"{dep.raw}"', f'"{new_raw}"', 1) + continue + problems.append( + f"root floor {dep.name}>={dep.floor} != workspace version {current}" + " (published fixes will not be delivered by pip upgrades)" + ) + if args.fix and fixed != root_text: + root.write_text(fixed, encoding="utf-8") + print(f"fixed: root floors pinned to workspace versions in {root}") + + # Rule 2: every sub-package floor must be satisfiable at co-release. + for pkg_dir in sorted((REPO / "packages").iterdir()): + pyproject = pkg_dir / "pyproject.toml" + if not pyproject.is_file(): + continue + for dep in floors(pyproject): + current = versions.get(dep.name) + if current is None: + problems.append( + f"{pkg_dir.name} depends on {dep.name}," + " which is not in the manifest" + ) + continue + if dep.floor is None: + continue + floor_version, current_version = parse(dep.floor), parse(current) + if floor_version is None or current_version is None: + problems.append( + f"{pkg_dir.name}: floor {dep.name}>={dep.floor} or workspace" + f" version {current} is not a valid PEP 440 version" + ) + elif floor_version > current_version: + problems.append( + f"{pkg_dir.name}: floor {dep.name}>={dep.floor} exceeds" + f" workspace version {current} (unsatisfiable)" + ) + + # Rule 3: every published package is a root dependency. + listed = {dep.name for dep in root_floors} + for name in sorted(set(versions) - listed - NOT_SHIPPED): + problems.append( + f"{name} is published but is not a root dependency" + " (pip upgrades will never install it; add it to root" + " pyproject.toml or to NOT_SHIPPED in this script)" + ) + + if problems: + print("dependency floor check FAILED:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print( + "\nRun `make floors-fix` to pin stale root floors. Sub-package" + " floors and missing root dependencies are hand-maintained.", + file=sys.stderr, + ) + return 1 + + print("dependency floors OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_readmes.py b/scripts/generate_readmes.py index 63262ef8..cbc2b554 100644 --- a/scripts/generate_readmes.py +++ b/scripts/generate_readmes.py @@ -12,16 +12,17 @@ import re import sys from pathlib import Path +from typing import Any -try: +if sys.version_info >= (3, 11): import tomllib -except ModuleNotFoundError: +else: # pragma: no cover - only taken on Python 3.10 try: - import tomli as tomllib # type: ignore[no-redef] + import tomli as tomllib except ModuleNotFoundError: print( - "Python 3.11+ required (tomllib), " - "or install tomli: pip install tomli" + "Python 3.11+ required (tomllib), or install tomli: pip install tomli", + file=sys.stderr, ) sys.exit(1) @@ -35,13 +36,14 @@ INTERNAL_PREFIXES = ("deepctl-core", "deepctl-cmd-", "deepctl-shared-") -def load_pyproject(package_dir: Path) -> dict: +def load_pyproject(package_dir: Path) -> dict[str, Any]: """Load and return parsed pyproject.toml.""" with open(package_dir / "pyproject.toml", "rb") as f: - return tomllib.load(f) + data: dict[str, Any] = tomllib.load(f) + return data -def classify_package(name: str, data: dict) -> str: +def classify_package(name: str, data: dict[str, Any]) -> str: """Classify a package as 'command', 'debug-subcommand', or 'core'.""" if re.match(r"^deepctl-cmd-debug-.+$", name): return "debug-subcommand" @@ -64,7 +66,7 @@ def get_external_deps(deps: list[str]) -> list[str]: return external -def get_entry_points(data: dict) -> dict[str, str]: +def get_entry_points(data: dict[str, Any]) -> dict[str, str]: """Extract command entry points from pyproject.toml.""" entry_points = {} eps = data.get("project", {}).get("entry-points", {}) @@ -122,16 +124,15 @@ def render_extra_content(package_dir: Path) -> list[str]: def render_command_readme( name: str, description: str, - entry_points: dict, - external_deps: list, + entry_points: dict[str, str], + external_deps: list[str], package_dir: Path, ) -> str: """Render README for a command package (deepctl-cmd-*).""" lines = [ f"# {name}", "", - "> Part of [deepctl](https://github.com/deepgram/cli)" - " — Official Deepgram CLI", + "> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI", "", description, "", @@ -159,25 +160,22 @@ def render_command_readme( else: lines.append("No external dependencies.") - lines.extend( - ["", "## License", "", "MIT — see [LICENSE](../../LICENSE)", ""] - ) + lines.extend(["", "## License", "", "MIT — see [LICENSE](../../LICENSE)", ""]) return "\n".join(lines) def render_debug_subcommand_readme( name: str, description: str, - entry_points: dict, - external_deps: list, + entry_points: dict[str, str], + external_deps: list[str], package_dir: Path, ) -> str: """Render README for a debug subcommand package.""" lines = [ f"# {name}", "", - "> Part of [deepctl](https://github.com/deepgram/cli)" - " — Official Deepgram CLI", + "> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI", "", description, "", @@ -207,24 +205,21 @@ def render_debug_subcommand_readme( else: lines.append("No external dependencies.") - lines.extend( - ["", "## License", "", "MIT — see [LICENSE](../../LICENSE)", ""] - ) + lines.extend(["", "## License", "", "MIT — see [LICENSE](../../LICENSE)", ""]) return "\n".join(lines) def render_core_readme( name: str, description: str, - external_deps: list, + external_deps: list[str], package_dir: Path, ) -> str: """Render README for a core/utility package.""" lines = [ f"# {name}", "", - "> Part of [deepctl](https://github.com/deepgram/cli)" - " — Official Deepgram CLI", + "> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI", "", description, "", @@ -243,9 +238,7 @@ def render_core_readme( else: lines.append("No external dependencies.") - lines.extend( - ["", "## License", "", "MIT — see [LICENSE](../../LICENSE)", ""] - ) + lines.extend(["", "## License", "", "MIT — see [LICENSE](../../LICENSE)", ""]) return "\n".join(lines) @@ -269,9 +262,7 @@ def generate_readme(package_dir: Path) -> str: name, description, entry_points, external_deps, package_dir ) else: - return render_core_readme( - name, description, external_deps, package_dir - ) + return render_core_readme(name, description, external_deps, package_dir) def get_package_dirs(single: str | None = None) -> list[Path]: @@ -294,7 +285,7 @@ def get_package_dirs(single: str | None = None) -> list[Path]: # ── Root README section generators ────────────────────────────── -def get_all_packages() -> list[dict]: +def get_all_packages() -> list[dict[str, Any]]: """Load metadata for all packages in the workspace.""" packages = [] for pkg_dir in sorted(PACKAGES_DIR.iterdir()): @@ -307,19 +298,15 @@ def get_all_packages() -> list[dict]: { "dir_name": pkg_dir.name, "name": project["name"], - "description": project.get( - "description", project["name"] - ), + "description": project.get("description", project["name"]), "commands": eps.get("deepctl.commands", {}), - "debug_subcommands": eps.get( - "deepctl.subcommands.debug", {} - ), + "debug_subcommands": eps.get("deepctl.subcommands.debug", {}), } ) return packages -def generate_commands_section(packages: list[dict]) -> str: +def generate_commands_section(packages: list[dict[str, Any]]) -> str: """Generate the commands markdown table.""" rows = [] for pkg in packages: @@ -328,9 +315,7 @@ def generate_commands_section(packages: list[dict]) -> str: for cmd_name in pkg["commands"]: rows.append((f"`deepctl {cmd_name}`", pkg["description"])) for cmd_name in pkg["debug_subcommands"]: - rows.append( - (f"`deepctl debug {cmd_name}`", pkg["description"]) - ) + rows.append((f"`deepctl debug {cmd_name}`", pkg["description"])) rows.sort(key=lambda r: r[0]) lines = [ "| Command | Description |", @@ -341,7 +326,7 @@ def generate_commands_section(packages: list[dict]) -> str: return "\n".join(lines) -def generate_packages_section(packages: list[dict]) -> str: +def generate_packages_section(packages: list[dict[str, Any]]) -> str: """Generate the packages markdown table.""" lines = [ "| Package | Description |", @@ -349,13 +334,12 @@ def generate_packages_section(packages: list[dict]) -> str: ] for pkg in sorted(packages, key=lambda p: p["name"]): lines.append( - f"| [`{pkg['name']}`](packages/{pkg['dir_name']})" - f" | {pkg['description']} |" + f"| [`{pkg['name']}`](packages/{pkg['dir_name']}) | {pkg['description']} |" ) return "\n".join(lines) -def generate_architecture_section(packages: list[dict]) -> str: +def generate_architecture_section(packages: list[dict[str, Any]]) -> str: """Generate the ASCII architecture tree.""" comment_col = 38 @@ -365,9 +349,7 @@ def tree_line(prefix: str, name: str, desc: str) -> str: return f"{line}{' ' * padding}# {desc}" lines = ["```", "cli/"] - lines.append( - tree_line("├── ", "src/deepctl/", "Main CLI entry point") - ) + lines.append(tree_line("├── ", "src/deepctl/", "Main CLI entry point")) lines.append("├── packages/") sorted_pkgs = sorted(packages, key=lambda p: p["dir_name"]) @@ -382,19 +364,13 @@ def tree_line(prefix: str, name: str, desc: str) -> str: ) ) - lines.append( - tree_line("├── ", "tests/", "Integration tests") - ) - lines.append( - tree_line("└── ", "Makefile", "Development tasks") - ) + lines.append(tree_line("├── ", "tests/", "Integration tests")) + lines.append(tree_line("└── ", "Makefile", "Development tasks")) lines.append("```") return "\n".join(lines) -def replace_section( - content: str, section: str, replacement: str -) -> str: +def replace_section(content: str, section: str, replacement: str) -> str: """Replace content between BEGIN:section and END:section markers.""" pattern = re.compile( rf"(\n)" @@ -403,15 +379,15 @@ def replace_section( re.DOTALL, ) - def repl(m: re.Match) -> str: - return m.group(1) + replacement + "\n" + m.group(2) + def repl(m: re.Match[str]) -> str: + begin: str = m.group(1) + end: str = m.group(2) + return begin + replacement + "\n" + end return pattern.sub(repl, content) -def update_root_readme( - *, dry_run: bool = False, check: bool = False -) -> bool: +def update_root_readme(*, dry_run: bool = False, check: bool = False) -> bool: """Update auto-generated sections in the root README.md. Returns True if the file was (or needs to be) changed. @@ -452,7 +428,7 @@ def update_root_readme( return True -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="Generate README files from pyproject.toml metadata" ) @@ -506,9 +482,7 @@ def main(): # Update root README sections (skip when targeting a single package) if not args.package: - root_changed = update_root_readme( - dry_run=args.dry_run, check=args.check - ) + root_changed = update_root_readme(dry_run=args.dry_run, check=args.check) if root_changed: stale.append("README.md") @@ -522,10 +496,7 @@ def main(): if not args.check and not args.dry_run: total = len(package_dirs) + (0 if args.package else 1) updated = len(stale) - print( - f"\nDone: {updated} updated, " - f"{total - updated} already current" - ) + print(f"\nDone: {updated} updated, {total - updated} already current") if __name__ == "__main__": diff --git a/tests/unit/test_dependency_floors.py b/tests/unit/test_dependency_floors.py new file mode 100644 index 00000000..cb1cbae5 --- /dev/null +++ b/tests/unit/test_dependency_floors.py @@ -0,0 +1,242 @@ +"""Tests for scripts/check_dependency_floors.py. + +The guard runs in CI (`make floors-check`) and, with `--fix`, twice in the +release workflow's sync job: fix, `uv lock`, check. Those two commands run +back to back, so anything that makes `--fix` disagree with the following +check wedges the release PR -- the exact wall the guard exists to remove. +The tests below drive the real script over throwaway workspaces. +""" + +from __future__ import annotations + +import ast +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / "scripts" / "check_dependency_floors.py" + + +def make_workspace( + tmp_path: Path, + root_deps: list[str], + packages: dict[str, str], +) -> Path: + """Build a minimal workspace the guard can be pointed at. + + `packages` maps package name -> version; each becomes + packages//pyproject.toml and a manifest entry. + """ + (tmp_path / "scripts").mkdir() + shutil.copy(SCRIPT, tmp_path / "scripts" / SCRIPT.name) + + deps = ", ".join(f'"{dep}"' for dep in root_deps) + (tmp_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "deepctl"\n' + 'version = "1.0.0"\n' + f"dependencies = [{deps}]\n", + encoding="utf-8", + ) + + manifest: dict[str, str] = {".": "1.0.0"} + for name, version in packages.items(): + pkg_dir = tmp_path / "packages" / name + pkg_dir.mkdir(parents=True) + (pkg_dir / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "{version}"\n', + encoding="utf-8", + ) + manifest[f"packages/{name}"] = version + + github = tmp_path / ".github" + github.mkdir() + (github / ".release-please-manifest.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + return tmp_path + + +def run_guard(workspace: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(workspace / "scripts" / SCRIPT.name), *args], + capture_output=True, + text=True, + ) + + +def root_dependencies(workspace: Path) -> list[str]: + """Read back the dependency list this module wrote, after --fix. + + Parsed with ast rather than tomllib so the test itself runs on 3.10, + where the guard uses its tomli fallback. + """ + text = (workspace / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r"^dependencies = (\[.*\])$", text, re.MULTILINE) + assert match, text + deps: list[str] = ast.literal_eval(match.group(1)) + return deps + + +@pytest.mark.parametrize( + "version", + ["0.4.0rc1", "1.0.0.dev1", "0.3.0.post1", "0.4.0b2"], +) +def test_fix_then_check_round_trips_on_pre_release_versions( + tmp_path: Path, version: str +) -> None: + """`--fix` must produce a tree the very next check accepts. + + The version regex used to stop at the first non-digit, so a workspace + version of 0.4.0rc1 was read as a floor of "0.4.0": --fix wrote + `deepctl-core>=0.4.0rc1` and reported success, and the check that runs + immediately after it in the release sync job exited 1 on the floor it + had just written. `make floors-check` could then never be made to pass. + """ + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core>=0.1.0"], + packages={"deepctl-core": version}, + ) + + fix = run_guard(workspace, "--fix") + assert fix.returncode == 0, fix.stderr + assert root_dependencies(workspace) == [f"deepctl-core>={version}"] + + check = run_guard(workspace) + assert check.returncode == 0, check.stderr + assert "dependency floors OK" in check.stdout + + +def test_fix_is_idempotent_when_already_pinned(tmp_path: Path) -> None: + """A second --fix on an already-correct tree changes nothing.""" + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core>=0.4.0rc1"], + packages={"deepctl-core": "0.4.0rc1"}, + ) + before = (workspace / "pyproject.toml").read_text(encoding="utf-8") + + assert run_guard(workspace, "--fix").returncode == 0 + assert (workspace / "pyproject.toml").read_text(encoding="utf-8") == before + + +def test_fix_preserves_upper_bounds_and_extras(tmp_path: Path) -> None: + """Only the floor is rewritten; the rest of the spec survives.""" + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core[speech]>=0.1.0,<2"], + packages={"deepctl-core": "0.4.0rc1"}, + ) + + assert run_guard(workspace, "--fix").returncode == 0 + assert root_dependencies(workspace) == ["deepctl-core[speech]>=0.4.0rc1,<2"] + assert run_guard(workspace).returncode == 0 + + +def test_extras_do_not_look_like_a_missing_root_dependency(tmp_path: Path) -> None: + """A correctly pinned dep with extras is not reported as missing. + + Rule 3's "published but is not a root dependency" message is derived + from what rule 1's parser saw. When that parser only matched bare + `name>=version`, writing an extras group made the guard tell the + maintainer to add a dependency that was already right there. + """ + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core[speech]>=0.4.0"], + packages={"deepctl-core": "0.4.0"}, + ) + + result = run_guard(workspace) + assert result.returncode == 0, result.stderr + assert "is not a root dependency" not in result.stderr + + +def test_non_floor_specifier_is_named_for_what_it_is(tmp_path: Path) -> None: + """`==` is reported as an unsupported specifier, not as an omission.""" + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core==0.4.0"], + packages={"deepctl-core": "0.4.0"}, + ) + + result = run_guard(workspace) + assert result.returncode == 1 + assert "does not pin a `>=` floor" in result.stderr + assert "is not a root dependency" not in result.stderr + + +def test_stale_root_floor_still_fails(tmp_path: Path) -> None: + """Positive control: rule 1 keeps catching what it was written for.""" + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core>=0.3.0"], + packages={"deepctl-core": "0.4.0"}, + ) + + result = run_guard(workspace) + assert result.returncode == 1 + assert "root floor deepctl-core>=0.3.0 != workspace version 0.4.0" in result.stderr + + +def test_missing_root_dependency_still_fails(tmp_path: Path) -> None: + """Positive control: rule 3 keeps catching an unlisted package.""" + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core>=0.4.0"], + packages={"deepctl-core": "0.4.0", "deepctl-cmd-keys": "0.1.0"}, + ) + + result = run_guard(workspace) + assert result.returncode == 1 + assert "deepctl-cmd-keys is published but is not a root dependency" in result.stderr + + +def test_unsatisfiable_sub_package_floor_still_fails(tmp_path: Path) -> None: + """Positive control: rule 2 keeps catching a floor above the sibling.""" + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core>=0.4.0", "deepctl-cmd-keys>=0.1.0"], + packages={"deepctl-core": "0.4.0", "deepctl-cmd-keys": "0.1.0"}, + ) + keys = workspace / "packages" / "deepctl-cmd-keys" / "pyproject.toml" + keys.write_text( + '[project]\nname = "deepctl-cmd-keys"\nversion = "0.1.0"\n' + 'dependencies = ["deepctl-core>=9.9.9"]\n', + encoding="utf-8", + ) + + result = run_guard(workspace) + assert result.returncode == 1 + assert "exceeds workspace version 0.4.0 (unsatisfiable)" in result.stderr + + +def test_final_release_floor_above_a_pre_release_is_unsatisfiable( + tmp_path: Path, +) -> None: + """0.4.0 is not satisfied by 0.4.0rc1, and the guard must say so. + + Comparing only the numeric segments made these two look identical. + """ + workspace = make_workspace( + tmp_path, + root_deps=["deepctl-core>=0.4.0rc1", "deepctl-cmd-keys>=0.1.0"], + packages={"deepctl-core": "0.4.0rc1", "deepctl-cmd-keys": "0.1.0"}, + ) + keys = workspace / "packages" / "deepctl-cmd-keys" / "pyproject.toml" + keys.write_text( + '[project]\nname = "deepctl-cmd-keys"\nversion = "0.1.0"\n' + 'dependencies = ["deepctl-core>=0.4.0"]\n', + encoding="utf-8", + ) + + result = run_guard(workspace) + assert result.returncode == 1 + assert "exceeds workspace version 0.4.0rc1 (unsatisfiable)" in result.stderr diff --git a/uv.lock b/uv.lock index 94ba75a8..b702822a 100644 --- a/uv.lock +++ b/uv.lock @@ -904,6 +904,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "mypy" }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -922,6 +923,7 @@ dev = [ { name = "build" }, { name = "deepctl-plugin-example" }, { name = "mypy" }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -930,6 +932,7 @@ dev = [ { name = "pytest-timeout" }, { name = "responses" }, { name = "ruff" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "twine" }, { name = "types-pyyaml" }, { name = "types-requests" }, @@ -942,6 +945,7 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-timeout" }, { name = "responses" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] @@ -981,6 +985,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.24.0" }, { name = "keyring", specifier = ">=24.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "packaging", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "platformdirs", specifier = ">=3.0.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -1007,6 +1012,7 @@ dev = [ { name = "build", specifier = ">=0.10.0" }, { name = "deepctl-plugin-example", editable = "packages/deepctl-plugin-example" }, { name = "mypy", specifier = ">=1.0.0" }, + { name = "packaging", specifier = ">=23.0" }, { name = "pre-commit", specifier = ">=3.0.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, @@ -1015,6 +1021,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, { name = "responses", specifier = ">=0.23.0" }, { name = "ruff", specifier = ">=0.8.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "twine", specifier = ">=7.0.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, { name = "types-requests", specifier = ">=2.32.4.20250611" }, @@ -1027,6 +1034,7 @@ testing = [ { name = "pytest-mock", specifier = ">=3.10.0" }, { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, { name = "responses", specifier = ">=0.23.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, ] [[package]] @@ -1785,7 +1793,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2024,7 +2032,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [