diff --git a/.agents/skills/python-codestyle/references/profiles.md b/.agents/skills/python-codestyle/references/profiles.md index 0a8236e1..315cdf78 100644 --- a/.agents/skills/python-codestyle/references/profiles.md +++ b/.agents/skills/python-codestyle/references/profiles.md @@ -33,7 +33,9 @@ whether the Python has third-party runtime dependencies, which shows up structur repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + - `uv run `, so the lockfile pins tool versions. + `uv run `, so the lockfile pins tool versions. A `pyproject.toml` beside a + `requirements*.txt` is this profile too, installed with pip, whether or not it carries a + `[project]` table. - **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index 05968c5f..fb555798 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -7fb092673fc04b77 +258f0a702efc7fb2 diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md b/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md index 0a8236e1..315cdf78 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md @@ -33,7 +33,9 @@ whether the Python has third-party runtime dependencies, which shows up structur repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + - `uv run `, so the lockfile pins tool versions. + `uv run `, so the lockfile pins tool versions. A `pyproject.toml` beside a + `requirements*.txt` is this profile too, installed with pip, whether or not it carries a + `[project]` table. - **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and diff --git a/.github/skills/python-codestyle/references/profiles.md b/.github/skills/python-codestyle/references/profiles.md index 0a8236e1..315cdf78 100644 --- a/.github/skills/python-codestyle/references/profiles.md +++ b/.github/skills/python-codestyle/references/profiles.md @@ -33,7 +33,9 @@ whether the Python has third-party runtime dependencies, which shows up structur repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + - `uv run `, so the lockfile pins tool versions. + `uv run `, so the lockfile pins tool versions. A `pyproject.toml` beside a + `requirements*.txt` is this profile too, installed with pip, whether or not it carries a + `[project]` table. - **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 516ddc13..aa0e901d 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -286,9 +286,10 @@ jobs: fi done <<< "$PYTHON_PROJECTS" - # Mypy takes precedence where a pyproject declares both sections, matching every repo that runs mypy in CI today. - # A pyright-only pyproject, with no [tool.mypy] section, falls back to pyright instead. - # A declared directory's standalone mypy.ini or pyrightconfig.json counts too, the placement a Home Assistant integration keeps, while the undeclared root reads only the pyproject sections it always did. + # Mypy takes precedence where a config declares both, matching every repo that runs mypy in CI today. + # A pyright-only config, with no mypy section, falls back to pyright instead. + # A declared directory's standalone mypy.ini or .mypy.ini, setup.cfg [mypy] section, or pyrightconfig.json counts too, the placement a Home Assistant integration keeps, while the undeclared root reads only the pyproject sections it always did. + # A declared subdirectory configuring neither falls back to the repository root's config and runs the checker from the root, the way a monorepo keeping one config there runs it. # A repo that genuinely wants both enforced adds the second as its own validate hook step. - name: Type check Python step if: steps.python.outputs.any == 'true' @@ -297,39 +298,74 @@ jobs: DECLARED: ${{ steps.python.outputs.declared }} run: | set -Eeuo pipefail + has_section() { + [ -f "$1" ] && grep -Eq "^[[:space:]]*\[$2\]" "$1" + } + find_checker() { + checker= + if has_section "$1/pyproject.toml" 'tool\.mypy' || { [ "$2" = true ] && { [ -f "$1/mypy.ini" ] || [ -f "$1/.mypy.ini" ] || has_section "$1/setup.cfg" mypy; }; }; then + checker=mypy + elif has_section "$1/pyproject.toml" 'tool\.pyright' || { [ "$2" = true ] && [ -f "$1/pyrightconfig.json" ]; }; then + checker=pyright + fi + } + # Reads python_version only from the one config file mypy loads, and only from its mypy section. + pins_python_version() { + local file section + if [ -f "$1/mypy.ini" ]; then + file="$1/mypy.ini" section=mypy + elif [ -f "$1/.mypy.ini" ]; then + file="$1/.mypy.ini" section=mypy + elif has_section "$1/pyproject.toml" 'tool\.mypy'; then + file="$1/pyproject.toml" section=tool.mypy + elif has_section "$1/setup.cfg" mypy; then + file="$1/setup.cfg" section=mypy + else + return 1 + fi + awk -v want="[$section]" '/^[[:space:]]*\[/ { h = $0; sub(/[#;].*/, "", h); gsub(/[[:space:]]/, "", h); on = (h == want); next } on && /^[[:space:]]*python_version[[:space:]]*[=:]/ { found = 1 } END { exit !found }' "$file" + } while IFS=$'\t' read -r kind dir; do [ -n "$dir" ] || continue - standalone_mypy=false - standalone_pyright=false - if [ "$DECLARED" = "true" ]; then - if [ -f "$dir/mypy.ini" ] || [ -f "$dir/.mypy.ini" ]; then - standalone_mypy=true - fi - if [ -f "$dir/pyrightconfig.json" ]; then - standalone_pyright=true - fi + find_checker "$dir" "$DECLARED" + config_dir="$dir" + if [ -z "$checker" ] && [ "$DECLARED" = "true" ] && [ "$dir" != . ]; then + find_checker . true + config_dir=. fi - if grep -Eq '^[[:space:]]*\[tool\.mypy\]' "$dir/pyproject.toml" || [ "$standalone_mypy" = true ]; then - checker=mypy - elif grep -Eq '^[[:space:]]*\[tool\.pyright\]' "$dir/pyproject.toml" || [ "$standalone_pyright" = true ]; then - checker=pyright - elif [ "$DECLARED" = "true" ]; then - echo "::error::The declared Python directory $dir configures neither mypy nor pyright, in its pyproject.toml or in a mypy.ini or pyrightconfig.json beside it. Every Python directory owes a type check." + if [ -z "$checker" ] && [ "$DECLARED" = "true" ]; then + echo "::error::The declared Python directory $dir configures neither mypy nor pyright, in its pyproject.toml, in a mypy.ini, .mypy.ini, setup.cfg, or pyrightconfig.json beside it, or in the same files at the repository root. Every Python directory owes a type check." exit 1 - else + elif [ -z "$checker" ]; then echo "no [tool.mypy] or [tool.pyright] section in $dir/pyproject.toml, skipping" continue fi - echo "$checker in $dir" + echo "$checker in $dir, configured in $config_dir" + project="$(cd "$dir" && pwd)" + # Run from the root, a checker reads the root config's own file selection, so the directory is named to keep it the one checked. + targets=() + if [ "$config_dir" != "$dir" ]; then + targets=("$dir") + fi + venv_python="$project/.venv/bin/python" if [ "$kind" = uv ]; then - (cd "$dir" && uv run "$checker") < /dev/null + (cd "$config_dir" && uv run --project "$project" "$checker" "${targets[@]}") < /dev/null elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ] && [ "$checker" = mypy ]; then - # Left alone, mypy targets the interpreter it runs under, so the venv's version is passed with it. - (cd "$dir" && uvx mypy@latest --python-executable .venv/bin/python --python-version "$(.venv/bin/python -c 'import sys; print("%d.%d" % sys.version_info[:2])')") < /dev/null + if "$venv_python" -c 'import importlib.util, sys; sys.exit(importlib.util.find_spec("mypy") is None)'; then + # The venv's own mypy imports the plugins installed beside it and targets its interpreter already. + (cd "$config_dir" && "$venv_python" -m mypy "${targets[@]}") < /dev/null + else + # Left alone, mypy under uvx targets the interpreter it runs under, so the venv's version is passed unless the config pins its own, which the flag would override. + version_args=() + if ! pins_python_version "$config_dir"; then + version_args=(--python-version "$("$venv_python" -c 'import sys; print("%d.%d" % sys.version_info[:2])')") + fi + (cd "$config_dir" && uvx mypy@latest --python-executable "$venv_python" "${version_args[@]}" "${targets[@]}") < /dev/null + fi elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ]; then - (cd "$dir" && uvx pyright@latest --pythonpath .venv/bin/python) < /dev/null + (cd "$config_dir" && uvx pyright@latest --pythonpath "$venv_python" "${targets[@]}") < /dev/null else - (cd "$dir" && uvx "$checker@latest") < /dev/null + (cd "$config_dir" && uvx "$checker@latest" "${targets[@]}") < /dev/null fi done <<< "$PYTHON_PROJECTS" @@ -525,7 +561,12 @@ jobs: .venv/bin/python -m pytest --cov-report=xml ;; lint-only) - uvx coverage@latest run -m unittest discover -s tests + status=0 + uvx coverage@latest run -m unittest discover -s tests || status=$? + if [ "$status" -eq 5 ]; then + echo "::error::unittest found no tests in $dir/tests. Discovery collects only unittest.TestCase subclasses, so a pytest-style plain test function does not count, it reads only files named test*.py, and it skips a nested test directory that has no __init__.py." + fi + [ "$status" -eq 0 ] || exit "$status" uvx coverage@latest xml -o coverage.xml ;; esac diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 93853586..a482a5a8 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -417,7 +417,7 @@ A repo whose Python lives below the root names each project directory in `python Tools ``` -Both Python jobs run in each named directory. The `lint` job runs ruff and the type check there, and the `unit-test` job runs that directory's `tests/` under coverage and uploads each report. A directory with its own `uv.lock` or `requirements*.txt` runs `pytest`. One with neither, whose `pyproject.toml` holds no `[project]` or `[build-system]` table, is the `lint-only` profile and runs `unittest` under `coverage`. A named directory that is neither, or has no `tests/`, or configures neither mypy nor pyright, fails its job, since every Python directory owes a suite and a type check. A uv workspace member carries no lock of its own, since its lock belongs to the workspace root, so a workspace is named by its root rather than by its members. A caller naming nothing keeps the root, where a root `pyproject.toml` is tracked, and naming a subdirectory drops the root unless `.` is named beside it. A root named that way sets `testpaths` in its own pytest configuration, since pytest otherwise collects the subdirectory's suite from the root too. The declaration rather than discovery decides what is gated, so the `lint` job only warns about a tracked `.py` file no named directory covers. The fleet audit compares the input against the registry's `pythonDirectories` and reports that file as a finding. +Both Python jobs run in each named directory. The `lint` job runs ruff and the type check there, and the `unit-test` job runs that directory's `tests/` under coverage and uploads each report. A directory with its own `uv.lock` or `requirements*.txt` runs `pytest`. One with neither, whose `pyproject.toml` holds no `[project]` or `[build-system]` table, is the `lint-only` profile and runs `unittest` under `coverage`. A named directory that is neither, or has no `tests/`, or configures neither mypy nor pyright, fails its job, since every Python directory owes a suite and a type check. A named subdirectory with no type-checker config of its own uses the root's, running the checker from the root. A uv workspace member carries no lock of its own, since its lock belongs to the workspace root, so a workspace is named by its root rather than by its members. A caller naming nothing keeps the root, where a root `pyproject.toml` is tracked, and naming a subdirectory drops the root unless `.` is named beside it. A root named that way sets `testpaths` in its own pytest configuration, since pytest otherwise collects the subdirectory's suite from the root too. The declaration rather than discovery decides what is gated, so the `lint` job only warns about a tracked `.py` file no named directory covers. The fleet audit compares the input against the registry's `pythonDirectories` and reports that file as a finding. A repo whose package claims more than one interpreter names them all in `python-versions`, a JSON array the `unit-test` job reads through `fromJSON` as its matrix, one leg per entry: diff --git a/spec/audit.py b/spec/audit.py index 98fca319..8a0e8011 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2110,16 +2110,45 @@ def python_directories_caller_findings(path, text, entry): Scoped to PYTHON_DIRECTORIES_CALLERS, and only where the job's own code (comments excluded, per _code_view()) actually names validate-task.yml, since a caller with no validate job, or one that has not adopted the reusable gate yet, states nothing this can compare against; that absence is already - check_interface()'s finding, not this one's. + check_interface()'s finding, not this one's. A shape this cannot read is reported rather than skipped + or misread: validate-task.yml called from a job under another key, and a flow-mapping `with:`. """ if path not in PYTHON_DIRECTORIES_CALLERS: return [] - validate_job = split_jobs(text).get("validate", "") + jobs = split_jobs(text) + validate_job = jobs.get("validate", "") + others = sorted( + k for k, body in jobs.items() if k != "validate" and "validate-task.yml" in _code_view(body) + ) + unread = ( + [ + ( + "DRIFT", + ( + f"python-directories: {path} calls validate-task.yml from " + f"{'job' if len(others) == 1 else 'jobs'} {', '.join(others)}, and only the job " + "keyed 'validate' has its python-directories input compared against the registry." + ), + ) + ] + if others + else [] + ) if "validate-task.yml" not in _code_view(validate_job): - return [] + return unread + if re.search(r"^[ \t]*with:[ \t]*\{", validate_job, re.MULTILINE): + return unread + [ + ( + "DRIFT", + ( + f"python-directories: {path} writes its validate job's with: inputs as a flow " + "mapping, which the audit cannot read. Write a block mapping, one input per line." + ), + ) + ] # A folded scalar's value depends on YAML's indentation rules, so it is refused rather than guessed. if re.search(r"^[ \t]*python-directories:[ \t]*>", validate_job, re.MULTILINE): - return [ + return unread + [ ( "DRIFT", ( @@ -2138,8 +2167,8 @@ def python_directories_caller_findings(path, text, entry): ) registry = sorted(python_directories_of(entry)) if declared == registry: - return [] - return [ + return unread + return unread + [ ( "DRIFT", ( @@ -5938,6 +5967,10 @@ def _selftest(): "the carried hook helper sits outside every directory", ) ) + python_directory_cases += [ + ({"types": "python"}, {"tools/x.py"}, 0, "a string types value reads as untyped"), + ({"types": None}, {"tools/x.py"}, 0, "a null types value reads as untyped"), + ] for entry_in, tree_in, expected, label in python_directory_cases: got_findings = python_directory_coverage_findings( {"types": ["python"], **entry_in}, tree_in @@ -6021,6 +6054,27 @@ def _selftest(): 0, "validate job never reaches validate-task.yml", ), + ( + py_caller_path, + py_caller_block.replace(" validate:\n", " check:\n"), + {"pythonDirectories": ["Tools"]}, + 1, + "a renamed job is reported, not skipped", + ), + ( + py_caller_path, + "jobs:\n validate:\n" + py_caller_uses + " with: { python-directories: Tools }\n", + {}, + 1, + "a flow-mapping with: is reported, not misread as declaring nothing", + ), + ( + py_caller_path, + py_caller_block + " validate-extra:\n" + py_caller_uses, + {"pythonDirectories": ["Tools"]}, + 1, + "a second job calling validate-task.yml is reported beside a matching validate", + ), ] py_caller_root = py_caller_plain.replace("python-directories: Tools", "python-directories: .") python_caller_cases.append( @@ -6104,6 +6158,20 @@ def _selftest(): "an untyped repo gets only the type advisory", ) ) + undeclared_root_cases += [ + ( + {"types": "python"}, + {"pyproject.toml", "tests/test_a.py"}, + 0, + "a string types value reads as untyped", + ), + ( + {"types": None}, + {"pyproject.toml", "tests/test_a.py"}, + 0, + "a null types value reads as untyped", + ), + ] for entry_in, tree_in, expected, label in undeclared_root_cases: got = len(python_undeclared_root_findings({"types": ["python"], **entry_in}, tree_in)) if got != expected: diff --git a/spec/project-types.json b/spec/project-types.json index 1fa51890..11c06702 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -37,7 +37,7 @@ "canonicalPlacement": "pyproject.toml", "profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies or as the repo's deliverable - a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --frozen + uv run in CI). The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by whether the Python has third-party runtime dependencies, which the audit detects structurally from pyproject.toml (see python.profile.detect) rather than by inspecting imports. The shape [project]+deps/[build-system] + uv.lock is build, and tool-config-only with no [project]/[build-system], no uv.lock, and no requirements*.txt is lint-only. The declared profile corresponds to this shape, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile). The profile decides only how the tools are installed, never what is owed. Every Python directory owes lint, format, a type check, a tests/ suite, and coverage, and a repository declares each in the hub validator's python-directories input, which the registry's pythonDirectories mirrors (python.directories.declared). Tracked .py files other than the catalog's carried hub-fetch-run.py hook helper, not a config file, are what make a repository Python.", "checks": [ - { "id": "python.profile.detect", "verdict": "letter", "assert": "The declared profile corresponds to the pyproject.toml shape. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject carrying only [tool.*] config with no [project]/[build-system], no uv.lock, and no requirements*.txt is the lint-only profile (the SCRIPTS shape). A lint-only subtree must not carry a uv.lock or project/build metadata, which would misrepresent it as a shippable package, and a build one must. A lint-only subtree carries no requirements*.txt either. A virtual uv workspace root, whose root pyproject.toml carries [tool.uv.workspace] with no [project] or [build-system] and commits a uv.lock, is the build shape, its [project] tables living in the workspace members under subtrees.", "intentRef": "CODESTYLE.md" }, + { "id": "python.profile.detect", "verdict": "letter", "assert": "The declared profile corresponds to the pyproject.toml shape. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject.toml beside a requirements*.txt is the build profile too, installed with pip, whether or not it carries a [project] table. A pyproject carrying only [tool.*] config with no [project]/[build-system], no uv.lock, and no requirements*.txt is the lint-only profile (the SCRIPTS shape). A lint-only subtree must not carry a uv.lock or project/build metadata, which would misrepresent it as a shippable package, and a build one must carry one of those or a requirements*.txt. A lint-only subtree carries no requirements*.txt either. A virtual uv workspace root, whose root pyproject.toml carries [tool.uv.workspace] with no [project] or [build-system] and commits a uv.lock, is the build shape, its [project] tables living in the workspace members under subtrees.", "intentRef": "CODESTYLE.md" }, { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present (pyproject.toml [tool.ruff]). Both profiles.", "intentRef": "CODESTYLE.md" }, { "id": "python.pyright.config", "verdict": "intent", "assert": "Build profile: pyright is configured and runs strict on first-party code (src or the integration package) - the strong typing baseline. Third-party strictness is relaxed only where a dependency has no usable types. N/A for the lint-only profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md", "minProfile": "build" }, { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, diff --git a/tests/test_release_guards.py b/tests/test_release_guards.py index 8536cf03..72535ef6 100755 --- a/tests/test_release_guards.py +++ b/tests/test_release_guards.py @@ -899,12 +899,22 @@ def run_python_tests_step( stubs.mkdir() # Each stub writes the report its real tool would, so the step's own report check is what decides. # The venv interpreter answers a version query with a version no host carries, so a test can tell it was asked. - python_stub = '#!/usr/bin/env bash\nif [ "$1" = -c ]; then echo 3.99; else echo x > coverage.xml; fi\n' + # It holds mypy only where VENV_MYPY is set, and logs a mypy run the way the uv stubs log theirs. + python_stub = ( + "#!/usr/bin/env bash\n" + 'case "$1 $2" in\n' + '"-c "*find_spec*) [ -n "${VENV_MYPY:-}" ] ;;\n' + '"-c "*) echo 3.99 ;;\n' + '"-m mypy") echo "python $*" >> "$STUB_LOG" ;;\n' + "*) echo x > coverage.xml ;;\n" + "esac\n" + ) stub = ( "#!/usr/bin/env bash\n" 'echo "${0##*/} $*${UV_PYTHON:+ [UV_PYTHON=$UV_PYTHON]}" >> "$STUB_LOG"\n' 'case " $* " in\n' '*" venv "*) mkdir -p .venv/bin && printf %s "$PYTHON_STUB" > .venv/bin/python && chmod +x .venv/bin/python ;;\n' + '*" unittest "*) exit "${UNITTEST_EXIT:-0}" ;;\n' '*" pytest "*|*" xml "*) echo x > coverage.xml ;;\n' "esac\n" ) @@ -1018,49 +1028,137 @@ def test_type_check_picks_its_checker_and_environment(self) -> None: ".venv/bin/python": "", } standalone = {"pyproject.toml": "[tool.ruff]\n", "pyrightconfig.json": "{}"} + pinned = {**mypy_section, "pyproject.toml": '[tool.mypy]\npython_version = "3.12"\n'} + pinned_elsewhere = { + **mypy_section, + "pyproject.toml": '[tool.mypy]\n[tool.other]\npython_version = "3.12"\n', + } + pinned_unread = {**pinned, "mypy.ini": "[mypy]\n"} + pinned_ini = {**mypy_section, "mypy.ini": "[mypy] ; note\npython_version: 3.12\n"} + root_config = {"pyproject.toml": "[tool.mypy]\n", "sub/pyproject.toml": "[tool.ruff]\n"} + in_sub = {"PYTHON_PROJECTS": "lint-only\tsub\n"} + venv_mypy = {"VENV_MYPY": "1"} cases = { "declared pip mypy reads the venv": ( "pip", mypy_section, True, + None, + 0, + r"uvx mypy@latest --python-executable /\S+/\.venv/bin/python --python-version 3\.99\n", + ), + "declared pip mypy keeps a pinned python_version": ( + "pip", + pinned, + True, + None, + 0, + r"uvx mypy@latest --python-executable /\S+/\.venv/bin/python\n", + ), + "a python_version outside the mypy section is no pin": ( + "pip", + pinned_elsewhere, + True, + None, + 0, + r"--python-version 3\.99\n", + ), + "a pin in a config mypy does not load is no pin": ( + "pip", + pinned_unread, + True, + None, 0, - "uvx mypy@latest --python-executable .venv/bin/python --python-version 3.99", + r"--python-version 3\.99\n", + ), + "an INI pin written with a colon still pins": ( + "pip", + pinned_ini, + True, + None, + 0, + r"uvx mypy@latest --python-executable /\S+/\.venv/bin/python\n", + ), + "declared pip mypy installed in the venv runs there": ( + "pip", + mypy_section, + True, + venv_mypy, + 0, + r"^python -m mypy\n", ), "undeclared pip mypy keeps the old call": ( "pip", mypy_section, False, + None, 0, - "uvx mypy@latest\n", + r"uvx mypy@latest\n", ), "declared standalone pyright counts": ( "lint-only", standalone, True, + None, + 0, + r"uvx pyright@latest\n", + ), + "declared setup.cfg mypy counts": ( + "lint-only", + {"pyproject.toml": "[tool.ruff]\n", "setup.cfg": "[mypy]\n"}, + True, + None, + 0, + r"uvx mypy@latest\n", + ), + "declared subdirectory falls back to the root config": ( + "lint-only", + root_config, + True, + in_sub, 0, - "uvx pyright@latest\n", + r"uvx mypy@latest sub\n", + ), + "undeclared standalone pyright is not read": ( + "lint-only", + standalone, + False, + None, + 0, + None, + ), + "declared with no checker fails": ( + "lint-only", + {"pyproject.toml": ""}, + True, + None, + 1, + None, ), - "undeclared standalone pyright is not read": ("lint-only", standalone, False, 0, None), - "declared with no checker fails": ("lint-only", {"pyproject.toml": ""}, True, 1, None), "uv runs its locked checker": ( "uv", {"pyproject.toml": "[tool.mypy]\n"}, True, + None, 0, - "uv run mypy", + r"uv run --project /\S+ mypy\n", ), } - for label, (kind, tree, declared, code_expected, call) in cases.items(): + for label, (kind, tree, declared, env, code_expected, call) in cases.items(): with self.subTest(label): - code, _, written = self.run_python_tests_step( - kind, tree, declared, step_name="Type check Python step" + code, stdout, written = self.run_python_tests_step( + kind, tree, declared, step_name="Type check Python step", extra_env=env ) self.assertEqual(code_expected, code) if call is None: self.assertNotIn("mypy", written.replace("[tool.mypy]", "")) self.assertNotIn("pyright", written) else: - self.assertIn(call, written) + self.assertRegex(written, re.compile(call, re.MULTILINE)) + if env is in_sub: + self.assertIn("mypy in sub, configured in .", stdout) + if env is venv_mypy: + self.assertNotIn("uvx", written) @unittest.skipUnless( shutil.which("bash") and os.name == "posix", @@ -1091,6 +1189,16 @@ def test_a_declared_directory_owes_a_runnable_suite(self) -> None: code, stdout, _ = self.run_python_tests_step(kind, tree, declared=True) self.assertEqual(1, code) self.assertIn(message, stdout) + for status, explained in ((5, True), (1, False)): + with self.subTest(f"unittest exit {status}"): + code, stdout, _ = self.run_python_tests_step( + "lint-only", + {"pyproject.toml", "tests/test_a.py"}, + declared=True, + extra_env={"UNITTEST_EXIT": str(status)}, + ) + self.assertEqual(status, code) + self.assertEqual(explained, "unittest found no tests" in stdout) def test_validator_python_leg_reaches_a_pip_dependency_repo(self) -> None: """WORKFLOW.md D1.6 owes coverage to every Python directory with tests, uv-managed or not.