Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/python-codestyle/references/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>`, so the lockfile pins tool versions.
`uv run <tool>`, 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
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7fb092673fc04b77
258f0a702efc7fb2
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>`, so the lockfile pins tool versions.
`uv run <tool>`, 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
Expand Down
4 changes: 3 additions & 1 deletion .github/skills/python-codestyle/references/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>`, so the lockfile pins tool versions.
`uv run <tool>`, 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
Expand Down
93 changes: 67 additions & 26 deletions .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reusable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
80 changes: 74 additions & 6 deletions spec/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
(
Expand All @@ -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",
(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading