ocx-indexbot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ocx_indexbot-0.1.0/.claude/rules/python-packaging.md +78 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/api-surface.md +151 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/async.md +140 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/ci-gate.md +139 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/cli-contract.md +157 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/data-modelling.md +92 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/http.md +140 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/observability.md +143 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/processes.md +138 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/security.md +137 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/single-file-tools.md +136 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/testing.md +133 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality/typing.md +101 -0
- ocx_indexbot-0.1.0/.claude/rules/python-quality.md +122 -0
- ocx_indexbot-0.1.0/.claude/rules/quality-indexbot-security.md +113 -0
- ocx_indexbot-0.1.0/.claude/settings.json +5 -0
- ocx_indexbot-0.1.0/.envrc +4 -0
- ocx_indexbot-0.1.0/.gitattributes +2 -0
- ocx_indexbot-0.1.0/.github/workflows/audit.yml +27 -0
- ocx_indexbot-0.1.0/.github/workflows/ci.yml +123 -0
- ocx_indexbot-0.1.0/.github/workflows/docs.yml +73 -0
- ocx_indexbot-0.1.0/.github/workflows/mutmut.yml +40 -0
- ocx_indexbot-0.1.0/.github/workflows/release.yml +77 -0
- ocx_indexbot-0.1.0/.gitignore +43 -0
- ocx_indexbot-0.1.0/.python-version +1 -0
- ocx_indexbot-0.1.0/.taskrc.yml +2 -0
- ocx_indexbot-0.1.0/CHANGELOG.md +62 -0
- ocx_indexbot-0.1.0/CLAUDE.md +83 -0
- ocx_indexbot-0.1.0/LICENSE +201 -0
- ocx_indexbot-0.1.0/PKG-INFO +98 -0
- ocx_indexbot-0.1.0/README.md +70 -0
- ocx_indexbot-0.1.0/cliff.toml +76 -0
- ocx_indexbot-0.1.0/codecov.yml +28 -0
- ocx_indexbot-0.1.0/docs/changelog.md +9 -0
- ocx_indexbot-0.1.0/docs/contributing/releasing.md +87 -0
- ocx_indexbot-0.1.0/docs/guide/quickstart.md +139 -0
- ocx_indexbot-0.1.0/docs/index.md +57 -0
- ocx_indexbot-0.1.0/docs/reference/cli.md +162 -0
- ocx_indexbot-0.1.0/docs/reference/contracts.md +1202 -0
- ocx_indexbot-0.1.0/docs/reference/policy.md +68 -0
- ocx_indexbot-0.1.0/docs/reference/workflow-invariants.md +58 -0
- ocx_indexbot-0.1.0/grimoire.lock +37 -0
- ocx_indexbot-0.1.0/grimoire.toml +10 -0
- ocx_indexbot-0.1.0/lychee.toml +8 -0
- ocx_indexbot-0.1.0/mkdocs.yml +100 -0
- ocx_indexbot-0.1.0/ocx.lock +84 -0
- ocx_indexbot-0.1.0/ocx.toml +9 -0
- ocx_indexbot-0.1.0/pyproject.toml +140 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/__init__.py +44 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/adapters/__init__.py +6 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/adapters/github_api.py +413 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/adapters/local_files.py +74 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/adapters/registry_v2.py +443 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/adapters/system_clock.py +12 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/__init__.py +10 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/_common.py +82 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/_wiring.py +312 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/announce.py +288 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/classify_pr.py +180 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/governance_check.py +165 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/main.py +154 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/reconcile.py +294 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/render.py +169 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/seed_import.py +433 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/validate.py +403 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/cli/workflows_check.py +88 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/__init__.py +8 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/anomaly.py +75 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/backoff.py +54 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/desc.py +169 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/diff.py +128 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/maintainers.py +72 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/observe.py +169 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/policy.py +115 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/regenerate.py +111 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/registry_checks.py +61 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/render.py +177 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/validate_entry.py +675 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/verify_claims.py +180 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/version_order.py +138 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/core/workflow_invariants.py +305 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/errors.py +47 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/exit_codes.py +27 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/model.py +213 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/ports.py +254 -0
- ocx_indexbot-0.1.0/src/ocx_indexbot/py.typed +0 -0
- ocx_indexbot-0.1.0/taskfile.yml +231 -0
- ocx_indexbot-0.1.0/tests/adapters/test_local_files.py +198 -0
- ocx_indexbot-0.1.0/tests/adapters/test_registry_v2.py +700 -0
- ocx_indexbot-0.1.0/tests/adapters/test_system_clock.py +31 -0
- ocx_indexbot-0.1.0/tests/cli/__init__.py +4 -0
- ocx_indexbot-0.1.0/tests/cli/test_announce.py +1062 -0
- ocx_indexbot-0.1.0/tests/cli/test_classify_pr.py +264 -0
- ocx_indexbot-0.1.0/tests/cli/test_common.py +135 -0
- ocx_indexbot-0.1.0/tests/cli/test_governance_check.py +302 -0
- ocx_indexbot-0.1.0/tests/cli/test_main.py +101 -0
- ocx_indexbot-0.1.0/tests/cli/test_reconcile.py +519 -0
- ocx_indexbot-0.1.0/tests/cli/test_render.py +204 -0
- ocx_indexbot-0.1.0/tests/cli/test_seed_import.py +552 -0
- ocx_indexbot-0.1.0/tests/cli/test_validate.py +771 -0
- ocx_indexbot-0.1.0/tests/cli/test_wiring.py +562 -0
- ocx_indexbot-0.1.0/tests/cli/test_workflows_check.py +142 -0
- ocx_indexbot-0.1.0/tests/conftest.py +25 -0
- ocx_indexbot-0.1.0/tests/core/__init__.py +4 -0
- ocx_indexbot-0.1.0/tests/core/test_anomaly.py +113 -0
- ocx_indexbot-0.1.0/tests/core/test_backoff.py +70 -0
- ocx_indexbot-0.1.0/tests/core/test_desc.py +222 -0
- ocx_indexbot-0.1.0/tests/core/test_diff.py +194 -0
- ocx_indexbot-0.1.0/tests/core/test_maintainers.py +62 -0
- ocx_indexbot-0.1.0/tests/core/test_observe.py +323 -0
- ocx_indexbot-0.1.0/tests/core/test_policy.py +91 -0
- ocx_indexbot-0.1.0/tests/core/test_regenerate.py +173 -0
- ocx_indexbot-0.1.0/tests/core/test_render.py +497 -0
- ocx_indexbot-0.1.0/tests/core/test_reserved_tags.py +150 -0
- ocx_indexbot-0.1.0/tests/core/test_root_variants.py +224 -0
- ocx_indexbot-0.1.0/tests/core/test_serializer_golden.py +88 -0
- ocx_indexbot-0.1.0/tests/core/test_variant_names.py +54 -0
- ocx_indexbot-0.1.0/tests/core/test_verify_claims.py +261 -0
- ocx_indexbot-0.1.0/tests/core/test_version_order.py +119 -0
- ocx_indexbot-0.1.0/tests/core/test_workflow_invariants.py +369 -0
- ocx_indexbot-0.1.0/tests/fakes/__init__.py +276 -0
- ocx_indexbot-0.1.0/tests/fakes/test_fakes.py +397 -0
- ocx_indexbot-0.1.0/tests/golden/dispatch/README.md +64 -0
- ocx_indexbot-0.1.0/tests/golden/dispatch/expected_platforms.json +81 -0
- ocx_indexbot-0.1.0/tests/golden/dispatch/sha256/22af3b607beb45837d335028872801d22e77f10e65f3d68b71c63282c65a7cc3.json +1 -0
- ocx_indexbot-0.1.0/tests/golden/dispatch/sha256/2f1b78d35e78f24a9654311c66f520c041691bb0e34e2865c8717d436f69d614.json +1 -0
- ocx_indexbot-0.1.0/tests/golden/dispatch/sha256/50e02438d1d8e4968ad9a663d29185638931b2771e7e4f68cc9923926ccb5ee1.json +1 -0
- ocx_indexbot-0.1.0/tests/golden/dispatch/sha256/bce4d35fc4fec56efe1ec9b92a36852b2be5cfb81e69164572a33c76e9194f8c.json +1 -0
- ocx_indexbot-0.1.0/tests/golden/serializer/README.md +64 -0
- ocx_indexbot-0.1.0/tests/golden/serializer/root/full-fields.json +49 -0
- ocx_indexbot-0.1.0/tests/golden/serializer/root/minimal.json +20 -0
- ocx_indexbot-0.1.0/tests/golden/serializer/root/with-source.json +30 -0
- ocx_indexbot-0.1.0/tests/golden/serializer/root/with-variants.json +55 -0
- ocx_indexbot-0.1.0/tests/golden/tag_verdicts.json +91 -0
- ocx_indexbot-0.1.0/tests/integration/__init__.py +7 -0
- ocx_indexbot-0.1.0/tests/integration/conftest.py +46 -0
- ocx_indexbot-0.1.0/tests/integration/fixtures/__init__.py +2 -0
- ocx_indexbot-0.1.0/tests/integration/fixtures/canonical.py +96 -0
- ocx_indexbot-0.1.0/tests/integration/harness/__init__.py +30 -0
- ocx_indexbot-0.1.0/tests/integration/harness/_http.py +57 -0
- ocx_indexbot-0.1.0/tests/integration/harness/fake_forge.py +208 -0
- ocx_indexbot-0.1.0/tests/integration/harness/fake_ghcr.py +274 -0
- ocx_indexbot-0.1.0/tests/integration/harness/git_tree.py +143 -0
- ocx_indexbot-0.1.0/tests/integration/test_classify_flow.py +197 -0
- ocx_indexbot-0.1.0/tests/integration/test_governance_flow.py +231 -0
- ocx_indexbot-0.1.0/tests/integration/test_harness_smoke.py +124 -0
- ocx_indexbot-0.1.0/tests/integration/test_reconcile_verify_flow.py +266 -0
- ocx_indexbot-0.1.0/tests/integration/test_validate_flow.py +223 -0
- ocx_indexbot-0.1.0/tests/security/__init__.py +12 -0
- ocx_indexbot-0.1.0/tests/security/test_governance_contracts.py +660 -0
- ocx_indexbot-0.1.0/tests/security/test_threat_classes.py +388 -0
- ocx_indexbot-0.1.0/tests/test_errors.py +29 -0
- ocx_indexbot-0.1.0/tests/test_exit_codes.py +14 -0
- ocx_indexbot-0.1.0/tests/test_github_api.py +641 -0
- ocx_indexbot-0.1.0/tests/test_model.py +206 -0
- ocx_indexbot-0.1.0/tests/test_registry_checks.py +58 -0
- ocx_indexbot-0.1.0/tests/test_validate_entry.py +952 -0
- ocx_indexbot-0.1.0/tests/test_version.py +37 -0
- ocx_indexbot-0.1.0/uv.lock +1840 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/pyproject.toml"
|
|
4
|
+
- "**/uv.lock"
|
|
5
|
+
summary: Python manifests and distribution — the version floor that must actually run, dependency declaration, lockfiles, wheel contents, and publishing credentials
|
|
6
|
+
keywords: python,pyproject,packaging,uv,lockfile,requires-python,classifiers,py.typed,dependency-groups,trusted-publishing,hatchling,deptry
|
|
7
|
+
license: Apache-2.0
|
|
8
|
+
repository: https://github.com/ocx-sh/grimoire-lore
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Python Packaging
|
|
12
|
+
|
|
13
|
+
The manifest is the only place a Python project states a promise a machine
|
|
14
|
+
can check, and almost nothing checks it. Loads while editing
|
|
15
|
+
`pyproject.toml`, a lockfile, or a publish workflow.
|
|
16
|
+
|
|
17
|
+
Contents: [The Metadata That Lies](#the-metadata-that-lies) ·
|
|
18
|
+
[Dependencies](#dependencies) · [What Ships](#what-ships) ·
|
|
19
|
+
[Publishing](#publishing) · [Where Tool Config Lives](#where-tool-config-lives) ·
|
|
20
|
+
[Severity](#severity)
|
|
21
|
+
|
|
22
|
+
## The Metadata That Lies
|
|
23
|
+
|
|
24
|
+
Every key below is a claim, and each one was found false somewhere in this
|
|
25
|
+
family. A manifest claim nothing executes is documentation, not a contract.
|
|
26
|
+
|
|
27
|
+
| ID | Rule | Verification | Severity |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| PY-PKG-01 | The declared `requires-python` floor is installed in CI and the suite collects against it — a floor nothing runs is a guess, and two harnesses here declared `>=3.10` while failing collection on it. | From the project directory: `uv python install 3.10` then `uv run --python 3.10 python -m pytest --collect-only -q .`, substituting the declared floor. A collection error is the violation; clean collection is the pass. | MUST |
|
|
30
|
+
| PY-PKG-02 | Every `Programming Language :: Python ::` classifier names a version the CI matrix actually runs — a classifier is a public compatibility claim, and advertising 3.13 while testing only 3.12 ships an untested promise. | Read the classifier list, read the workflow's `matrix.python-version`, and name every classifier absent from the matrix. Each one named is the violation. | SHOULD |
|
|
31
|
+
| PY-PKG-03 | An upper bound on `requires-python` appears only with an adjacent comment naming the incompatibility — an unexplained `<4.0` makes every future interpreter a resolver conflict for every consumer. | `rg -n 'requires-python' --glob 'pyproject.toml' .` then read each hit: a `<` with no adjacent reason is the violation. Empty output means no upper bound anywhere, which is the pass. | SHOULD |
|
|
32
|
+
| PY-PKG-04 | A single source defines the version, and reading it at runtime never lets a packaging exception reach a consumer — `importlib.metadata.PackageNotFoundError` escaping the package is an internal detail becoming API. | Uninstall the distribution while keeping the source importable, then import the package. It must raise its own error type; a bare `PackageNotFoundError` is the violation. | SHOULD |
|
|
33
|
+
|
|
34
|
+
## Dependencies
|
|
35
|
+
|
|
36
|
+
| ID | Rule | Verification | Severity |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| PY-PKG-05 | The lockfile is committed and verified in CI on every change — a lockfile that drifts from the manifest means CI and a contributor resolve different code. | `uv lock --check` in each project directory. Non-zero is the violation. | SHOULD |
|
|
39
|
+
| PY-PKG-06 | Declared dependencies match imported ones, checked with the project's own configured ignores — `deptry` invoked naively reported 67 phantom findings here against a real answer of zero, so the configuration is part of the rule and not a detail. | `uvx deptry . --optional-dependencies-dev-groups dev` from the project directory, plus whatever `--exclude` the project needs to cover `tests`. A finding that survives the configured run is the violation; a naive `deptry .` is not evidence either way. | SHOULD |
|
|
40
|
+
| PY-PKG-07 | Development-only tooling lives in PEP 735 `[dependency-groups]`, not in `[project.optional-dependencies]` — an extra is installable by a consumer, so a `dev` extra offers the world a way to pull your test stack. | `rg -n 'optional-dependencies' -A 12 --glob 'pyproject.toml' .` and name any group holding a linter, a type checker or a test runner. Each is the violation; a `docs` extra is defensible and a `dev` extra is not. | SHOULD |
|
|
41
|
+
|
|
42
|
+
## What Ships
|
|
43
|
+
|
|
44
|
+
| ID | Rule | Verification | Severity |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| PY-PKG-08 | A package that ships annotations ships `py.typed` **inside the built wheel** — present in the source tree and absent from the wheel means every downstream `--strict` consumer silently sees the whole package as untyped, with no error anywhere. | `uv build --wheel` then `unzip -l dist/*.whl` and look for `<package>/py.typed`. Absent from the archive is the violation; presence in `src/` alone is not a pass. | MUST |
|
|
47
|
+
| PY-PKG-09 | The layout is `src/`, with the build backend declared and version-constrained — a flat layout lets the test suite import the working tree while the built wheel is broken, and that failure appears first in a consumer's install. | Confirm a `src/` directory holds the package, then `rg -n 'build-backend' --glob 'pyproject.toml' .` and read each hit for a version constraint on the corresponding `requires`. An unconstrained backend or a flat layout is the violation. | SHOULD |
|
|
48
|
+
|
|
49
|
+
## Publishing
|
|
50
|
+
|
|
51
|
+
| ID | Rule | Verification | Severity |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| PY-PKG-10 | Publishing authenticates through Trusted Publishing (OIDC), never a long-lived token in a repository secret — a stored token outlives the job, the contributor, and usually the memory of who minted it. | `rg -n 'PYPI_TOKEN' .github/workflows` and `rg -n 'password:' .github/workflows` as two separate commands. Any hit in a publish job is the violation; empty output from both is the pass. | SHOULD |
|
|
54
|
+
|
|
55
|
+
## Where Tool Config Lives
|
|
56
|
+
|
|
57
|
+
Not a rule, a settled decision, because a project with both files silently
|
|
58
|
+
resolves one and ignores the other.
|
|
59
|
+
|
|
60
|
+
`[tool.ruff]` belongs in `pyproject.toml` wherever a `pyproject.toml`
|
|
61
|
+
exists — one file, not two. A standalone `ruff.toml` is correct only where
|
|
62
|
+
there is no `[project]` table at all, which in this family means the
|
|
63
|
+
catalog repository itself and nothing else.
|
|
64
|
+
|
|
65
|
+
## Severity
|
|
66
|
+
|
|
67
|
+
MUST = Block: fix before it lands. SHOULD = Warn: fix, or state why not
|
|
68
|
+
in the commit body. CONSIDER = Suggest: never blocks, never re-raised
|
|
69
|
+
after a decline.
|
|
70
|
+
|
|
71
|
+
## Siblings
|
|
72
|
+
|
|
73
|
+
- **`python-quality`** — everything about the Python itself: typing,
|
|
74
|
+
subprocess and process control, async, HTTP, the CLI contract, testing,
|
|
75
|
+
security, logging, and the single-file stdlib tools. Loads on `**/*.py`.
|
|
76
|
+
**Read its `ci-gate.md` when wiring a gate**, not this file — a workflow
|
|
77
|
+
filename says nothing about its language, so nothing here globs
|
|
78
|
+
`.github/workflows/`.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Public API Surface
|
|
2
|
+
|
|
3
|
+
What a Python package promises to the outside and cannot quietly take back:
|
|
4
|
+
which names are importable, which signatures can grow, which exceptions a
|
|
5
|
+
caller may catch. Loads with the Python quality rule on any diff touching an
|
|
6
|
+
`__init__.py`, an `__all__` list, a non-underscore `def`/`class`, an exception
|
|
7
|
+
hierarchy, or a `warnings.warn` call.
|
|
8
|
+
|
|
9
|
+
Contents: [Declaring What Is Public](#declaring-what-is-public) ·
|
|
10
|
+
[Evolving It Without Breaking Callers](#evolving-it-without-breaking-callers) ·
|
|
11
|
+
[Shaping a Public Signature](#shaping-a-public-signature) ·
|
|
12
|
+
[Errors and Docstrings as Contract](#errors-and-docstrings-as-contract) ·
|
|
13
|
+
[What Agents Get Wrong](#what-agents-get-wrong-here)
|
|
14
|
+
|
|
15
|
+
**Library or application — this distinction decides half the rules below.** A
|
|
16
|
+
published library's public surface is its importable symbols, and every
|
|
17
|
+
consumer holds it to that. An application's public surface is its argv, its
|
|
18
|
+
exit codes and its files; nobody imports it, so an `__all__` audit or a
|
|
19
|
+
`griffe check` against it proves nothing. Each rule cell opens by naming which
|
|
20
|
+
shape it binds. Where the right answer genuinely differs between the two —
|
|
21
|
+
PY-SURF-07 — the rule says so rather than picking one and calling it style.
|
|
22
|
+
|
|
23
|
+
**The mechanism** is portable: the typing spec's re-export rule, one
|
|
24
|
+
deprecation gate, keyword-only growth room, a single exception root.
|
|
25
|
+
|
|
26
|
+
Severity maps onto the house tiers: MUST = Block, SHOULD = Warn,
|
|
27
|
+
CONSIDER = Suggest.
|
|
28
|
+
|
|
29
|
+
## Declaring What Is Public
|
|
30
|
+
|
|
31
|
+
Three definitions of "public" coexist in every typed package — the
|
|
32
|
+
no-underscore convention, `__all__`, and what is actually bound in the module
|
|
33
|
+
namespace. They are only equal by construction, never by accident, and a type
|
|
34
|
+
checker follows the third one. The import form alone decides it:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from .core import Ocx # NOT re-exported — imported names are private by default
|
|
38
|
+
from .core import Ocx as Ocx # re-exported — the redundant alias is the signal
|
|
39
|
+
from .core import Ocx as _ocx # private, and says so at a glance
|
|
40
|
+
|
|
41
|
+
__all__ = ["Ocx"] # re-exports, and overrides every rule above
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This is the typing spec's rule, identical for `.py` and `.pyi` — not a
|
|
45
|
+
stub-only convention, as it is commonly mistaken for. Pyright enforces the
|
|
46
|
+
private-by-default half through `reportPrivateImportUsage`, which defaults to
|
|
47
|
+
**error** in basic, standard and strict alike: a leaked name is a diagnostic
|
|
48
|
+
for every consumer, not just the careful ones.
|
|
49
|
+
|
|
50
|
+
| ID | Rule | Verification | Severity |
|
|
51
|
+
|---|---|---|---|
|
|
52
|
+
| PY-SURF-01 | **Library.** Every public module declares `__all__`, and the three definitions of public agree: a bound non-underscore name is either listed in `__all__` or imported with a redundant `as` alias, and every `__all__` entry is actually bound. An implementation-detail import that skips this becomes a name consumers can import and you can never remove — `from importlib.metadata import PackageNotFoundError` in an `__init__.py` re-exports the stdlib's exception as part of your API. Under pyright, a plain `from .x import Y` re-exports **only** via `__all__`; `from .x import Y as Y` (the redundant alias) is the other accepted form, and `from .x import Y as _y` is the correct spelling for "private". | Per public module: `python3 -c 'import importlib,sys; m=importlib.import_module(sys.argv[1]); d=set(getattr(m,"__all__",())); v=sorted({n for n in vars(m) if not n.startswith("_")}-d-{"annotations"}); u=sorted(d-set(vars(m))); [print("VIOLATION: public but absent from __all__:",n) for n in v]; [print("VIOLATION: in __all__ but not bound:",n) for n in u]; sys.exit(1 if v or u else 0)' <pkg>` — output is the finding, silence is the pass, exit 1 gates CI. `annotations` is excluded by name because `from __future__ import annotations` binds it in every module that uses it | MUST |
|
|
53
|
+
| PY-SURF-02 | **Library.** `griffe check <pkg> -s src` runs in CI **beside** PY-SURF-01, never instead of it. The two cover disjoint failure modes: griffe reads actual module reachability, so it reports a symbol that was deleted outright and stays silent when a name is dropped from `__all__` while remaining importable — which is the exact regression PY-SURF-01 exists to catch. Treating either as sufficient leaves a real break shipping green. | `griffe check <pkg> -s src -f verbose` — any output, and a non-zero exit, is the finding; silence is the pass. Watched both ways on a two-symbol package: deleting the symbol printed `Public object was removed` and exited 1; dropping only its `__all__` entry printed nothing and exited 0 while PY-SURF-01 named it | MUST |
|
|
54
|
+
|
|
55
|
+
## Evolving It Without Breaking Callers
|
|
56
|
+
|
|
57
|
+
SemVer 0.y.z promises nothing — "anything MAY change at any time" — which is
|
|
58
|
+
precisely why the gate has to be mechanical rather than stated. The failure
|
|
59
|
+
mode has real precedent: a widely used HTTP library shipped a documented
|
|
60
|
+
"deprecate in one release, remove in the next" policy, broke callers in a
|
|
61
|
+
point release anyway, and its maintainer's own post-mortem was that the policy
|
|
62
|
+
"wasn't cautious or clearly communicated enough". The intent was there; no job
|
|
63
|
+
blocked on it. Write the gate before there is anything to deprecate — the
|
|
64
|
+
first release is the cheapest moment, because there are no removals yet to
|
|
65
|
+
grandfather in.
|
|
66
|
+
|
|
67
|
+
| ID | Rule | Verification | Severity |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| PY-SURF-03 | **Library.** A public symbol leaves the surface only after shipping deprecated in at least one released version. The gate is a job, not a paragraph in CONTRIBUTING — an intended "deprecate in 0.y, remove in 0.z" window that nothing blocks on is the documented way real projects break callers in a point release. | `griffe check <pkg> -s src --against <last tag> -f verbose` — every `Public object was removed` line names a symbol. For each, `git grep -n -e deprecated <last tag> -- src` ; an empty result for a removed name is the finding (the removal shipped with no prior deprecation). Watched red on a tagged package: griffe named the removed symbol, `git grep` returned nothing | SHOULD |
|
|
70
|
+
| PY-SURF-04 | **Library.** A deprecated symbol carries PEP 702 `@deprecated`, and every `warnings.warn(..., DeprecationWarning)` passes an explicit `stacklevel=`. The default `stacklevel=1` blames the `warn()` line inside your own library, which tells the caller nothing about their code; the correct number is a property of the call chain, not of the warning — one helper frame between the public entry point and `warn()` makes it 3, not the textbook 2. `@deprecated` is the typed half and pyright understands it, but `reportDeprecated` is `none` in basic and standard mode and only `error` in strict, so the marker alone is invisible to most consumers: ship both. | `python3 -c 'import pathlib,sys; v=[(p,i+1) for p in pathlib.Path(sys.argv[1]).rglob("*.py") for t in [p.read_text(encoding="utf-8").splitlines()] for i,l in enumerate(t) if "warnings.warn(" in l and "stacklevel=" not in "".join(t[i:i+6])]; [print(f"VIOLATION: {p}:{n}: warnings.warn(...) with no explicit stacklevel=") for p,n in v]; sys.exit(1 if v else 0)' src` — output is the finding. Then `rg --files-without-match -e typeCheckingMode -e strict pyproject.toml` — the file being **listed** is the finding: no type-checking mode is configured at all, so `reportDeprecated` can never fire | SHOULD |
|
|
71
|
+
|
|
72
|
+
## Shaping a Public Signature
|
|
73
|
+
|
|
74
|
+
Every optional parameter is a promise about insertion order. Keyword-only is
|
|
75
|
+
how a signature grows for ten releases without a single breaking change, and
|
|
76
|
+
the bare `*` costs one character to add on the day the function is written
|
|
77
|
+
against zero call sites — and is a breaking change to add later.
|
|
78
|
+
|
|
79
|
+
The other half is the sentinel, where the reflex idiom cannot be typed at all:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
class _Unset(Enum): # private, single member
|
|
83
|
+
TOKEN = "unset"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
UNSET: Final = _Unset.TOKEN # public, so wrappers can pass it on
|
|
87
|
+
type MaybeTimeout = float | Literal[_Unset.TOKEN] | None # exactly three states
|
|
88
|
+
|
|
89
|
+
SENTINEL = object() # the reflex: annotates to `T | object`
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`Literal[]` accepts enum members, so `Literal[_Unset.TOKEN]` denotes exactly
|
|
93
|
+
one value. A bare `object()` denotes every object, so the checker cannot
|
|
94
|
+
narrow `is UNSET` and the third state exists only in the author's head.
|
|
95
|
+
|
|
96
|
+
| ID | Rule | Verification | Severity |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| PY-SURF-05 | **Both.** At most one optional parameter is positional; everything after it sits behind a bare `*`. With two or more optional positionals, inserting a parameter next release silently reorders every call site that passed them positionally, and no type checker sees the caller. Booleans are never positional at all — `deploy(target, True)` reads as nothing at the call site. Selecting `FBT001`/`FBT002` costs zero today on both audited codebases (5 hits, all on underscore-prefixed private helpers); this is a do-not-regress rule, not a cleanup. | `python3 -c 'import ast,pathlib,sys; v=[(p,f) for p in pathlib.Path(sys.argv[1]).rglob("*.py") for f in ast.walk(ast.parse(p.read_text(encoding="utf-8"))) if isinstance(f,(ast.FunctionDef,ast.AsyncFunctionDef)) and not f.name.startswith("_") and len(f.args.defaults)>1]; [print(f"VIOLATION: {p}:{f.lineno}: {f.name}() takes {len(f.args.defaults)} optional parameters positionally") for p,f in v]; sys.exit(1 if v else 0)' src` — output is the finding; two live hits in the audited SDK, clean on the audited application. Separately `ruff check --select FBT001,FBT002 src` — every hit on a non-underscore `def` is a finding | SHOULD |
|
|
99
|
+
| PY-SURF-06 | **Both.** A "not given" marker distinct from `None` is a private single-member `Enum`, a `Final` alias to its member, and `Literal[_Unset.TOKEN]` in the public type alias — never a bare `object()`. `SENTINEL = object()` cannot be spelled in a type expression: the parameter degrades to `T \| object`, which narrows to nothing, so every caller and the checker lose the third state the sentinel was introduced to carry. | `rg -n --glob '*.py' '=\s*object\(\)' src` — any hit is the finding; nothing but a sentinel binds a bare `object()` to a name. Watched red on a planted `SENTINEL = object()`, silent on the audited SDK, whose `_Unset`/`UNSET`/`Literal[_Unset.TOKEN]` trio is the reference form | SHOULD |
|
|
100
|
+
|
|
101
|
+
## Errors and Docstrings as Contract
|
|
102
|
+
|
|
103
|
+
The exit-code-to-exception mapping is the place two correct answers look
|
|
104
|
+
identical on the page and are not interchangeable. Which one is right is
|
|
105
|
+
decided by the direction the code has to travel, and nothing else:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# Library: an external process hands back a bare int. The reverse lookup has to live
|
|
109
|
+
# outside the classes, because a class cannot introspect which code it answers to.
|
|
110
|
+
_EXIT_CODE_ERRORS: dict[ExitCode, type[PkgProcessError]] = {ExitCode.USAGE: UsageError, ...}
|
|
111
|
+
|
|
112
|
+
# Application: the code always knows which exception it is about to raise.
|
|
113
|
+
# The forward direction is all it needs, and a class attribute cannot drift from a table.
|
|
114
|
+
class AnomalyError(AppError):
|
|
115
|
+
_exit_code = ExitCode.ANOMALY
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Port the dict onto the application and it buys an unused indirection that can
|
|
119
|
+
fall out of sync with the class list. Port the class attribute onto the
|
|
120
|
+
library and the code that classifies a raw exit status has nowhere to look it
|
|
121
|
+
up, so it grows an `if`/`elif` chain that is the same table under a worse name.
|
|
122
|
+
|
|
123
|
+
| ID | Rule | Verification | Severity |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| PY-SURF-07 | **Both, in two different shapes.** Exactly one class in the package subclasses `Exception`/`BaseException` directly; every other exception routes through it, so `except <PkgError>` is a complete catch-all. A stray `class NetworkError(Exception)` bypasses it silently and escapes every caller's handler. The exit-code mapping then follows the direction the code actually needs, and the two are not interchangeable: a **library** that receives a raw status from an external process needs a reverse lookup (code → class) living outside the classes, because a class cannot introspect which code it answers to — an `ExitCode` enum plus an explicit mapping dict, kept complete. An **application** always knows which exception it is about to raise, so the forward direction is all it needs — a `_exit_code` class attribute, no external table, no drift. Porting either shape onto the other adds an unused indirection or forces an `if`/`elif` chain wearing a different name. | `python3 -c 'import ast,pathlib,sys; v=[(p,c,b.id) for p in pathlib.Path(sys.argv[1]).rglob("*.py") for c in ast.walk(ast.parse(p.read_text(encoding="utf-8"))) if isinstance(c,ast.ClassDef) and c.name!=sys.argv[2] for b in c.bases if isinstance(b,ast.Name) and b.id in ("Exception","BaseException")]; [print(f"VIOLATION: {p}:{c.lineno}: {c.name} subclasses {n} directly, bypassing {sys.argv[2]}") for p,c,n in v]; sys.exit(1 if v else 0)' src <RootError>` — output is the finding; watched red on a planted second root, silent on both audited codebases. Where a reverse mapping exists, a second check walks the exit-code enum and prints every member with no mapped subclass; watched red by removing one entry | MUST |
|
|
126
|
+
| PY-SURF-08 | **Library.** A public callable documents every exception it can propagate, including ones raised by a helper it calls. Select `DOC501` **alone**, not the `DOC` family: `DOC502` misreads a correct generic re-raise out of a broad `except` as extraneous (40 of 122 hits on the audited SDK), and `DOC201` fights Google convention's permitted `Returns:` omission (75 more) — selecting the family fails CI on accurate documentation, which teaches the next author to suppress the whole thing. | `ruff check --preview --select DOC501 src` — every hit is the finding; the `--preview` flag is required or the selector is silently inert ("Selection `DOC` has no effect"), which reads as a pass. Watched red on a planted undocumented `raise ValueError`; 7 live hits on the audited SDK, one hand-confirmed as a genuine undocumented propagation | SHOULD |
|
|
127
|
+
|
|
128
|
+
## What Agents Get Wrong Here
|
|
129
|
+
|
|
130
|
+
1. **Adding an import to `__init__.py` to fix a name error**, with no `as`
|
|
131
|
+
alias and no `__all__` entry — the shortest edit that makes the traceback
|
|
132
|
+
go away, and it publishes a stdlib symbol as your API forever.
|
|
133
|
+
2. **Appending a new optional parameter to an existing public function**
|
|
134
|
+
because it is a one-line diff, instead of putting it behind the `*`.
|
|
135
|
+
3. **A positional boolean flag** rather than a second function or an enum,
|
|
136
|
+
when the two behaviours share almost no body.
|
|
137
|
+
4. **`SENTINEL = object()`** for "not given" — the idiom every corpus is full
|
|
138
|
+
of, and untypeable, so the annotation quietly widens to `object`.
|
|
139
|
+
5. **A new exception subclassing `Exception` directly** because that is what
|
|
140
|
+
the tutorial shows, escaping the package's own catch-all.
|
|
141
|
+
6. **`warnings.warn(msg, DeprecationWarning)` with no `stacklevel=`**, which
|
|
142
|
+
points the caller at a line inside your library.
|
|
143
|
+
7. **Deleting a public symbol in the same PR that stops using it**, with no
|
|
144
|
+
deprecation release in between — the diff looks like tidying.
|
|
145
|
+
8. **Treating `griffe check` as the whole surface gate**, so an `__all__`-only
|
|
146
|
+
regression ships green.
|
|
147
|
+
9. **Copying the exit-code mapping dict from a library into an application**
|
|
148
|
+
(or the class attribute the other way) because the two look alike on the
|
|
149
|
+
page, buying an indirection nothing reads.
|
|
150
|
+
10. **Turning on the whole `DOC` family after one useful hit**, then adding a
|
|
151
|
+
blanket suppression when 122 findings land on correct docstrings.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Async and Cancellation
|
|
2
|
+
|
|
3
|
+
asyncio rules for the one async codebase in this fleet: blocking calls inside
|
|
4
|
+
the loop, timeout scopes, structured spawning, cancellation handlers, and
|
|
5
|
+
event-loop ownership. Loads when editing any file containing `async def`,
|
|
6
|
+
`await`, or an `asyncio.` call.
|
|
7
|
+
|
|
8
|
+
Contents: [Scope](#scope-pinned) ·
|
|
9
|
+
[Blocking and Deadlines](#blocking-and-deadlines) ·
|
|
10
|
+
[Structure and Cancellation](#structure-and-cancellation) ·
|
|
11
|
+
[Entry Points and Yield Points](#entry-points-and-yield-points) ·
|
|
12
|
+
[What Agents Get Wrong](#what-agents-get-wrong-here)
|
|
13
|
+
|
|
14
|
+
## Scope (pinned)
|
|
15
|
+
|
|
16
|
+
- **`ocx-sdk-python` is the only asyncio codebase here** — 17 `async def`, 27
|
|
17
|
+
`await`. `index/bot` has zero of either, confirmed by full-tree grep. None of
|
|
18
|
+
this applies to it, and none of it gets adopted preemptively: the event that
|
|
19
|
+
adopts this file is a first `async def`, not a refactor someone scheduled.
|
|
20
|
+
- **Most of it is already true, and stays true.** `TaskGroup` is the only spawn
|
|
21
|
+
primitive in `src/`, `asyncio.gather(` has zero call sites, `RUF006` is clean,
|
|
22
|
+
and no blocking primitive appears inside an `async def`. Those rules exist to
|
|
23
|
+
stop a regression — the cheaper half of the job, and the half a green CI run
|
|
24
|
+
will not do for you.
|
|
25
|
+
- **This surface is unguarded today.** `ASYNC` is selected in no ruff config in
|
|
26
|
+
the fleet, so every blocking-call rule below currently fails open.
|
|
27
|
+
PY-ASYNC-01 is the line that turns it on.
|
|
28
|
+
|
|
29
|
+
## Blocking and Deadlines
|
|
30
|
+
|
|
31
|
+
| ID | Rule | Verification | Severity |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| PY-ASYNC-01 | Never call a blocking primitive from inside an `async def` — `time.sleep`, `subprocess.run`/`.wait()`/`.communicate()`, `open()`, `Path.read_*`/`write_*`, `input()`, a sync HTTP client. Each stalls every other task on the loop for its full duration, and the failure shows up as latency under load rather than as a test failure. Select `ASYNC` in ruff for any tree containing `async def`. Two blind spots need a read rather than a lint, because they have no lint signature at all: CPU-bound work (hashing, parsing, compression) and a slow synchronous logging handler block just as hard. | `ruff check --select ASYNC --no-fix src/` — every finding is the violation, except an `ASYNC109` site already resolved under PY-ASYNC-06. Six findings today, all `ASYNC109`; the blocking families `ASYNC210/212/220/221/222/230/240/250/251` are all clean and stay that way | MUST |
|
|
34
|
+
| PY-ASYNC-06 | A public async entry point may take `timeout:` as its contract, but exactly one `asyncio.timeout()` scope wrapping the whole operation enforces it. Never re-derive a shrinking budget at each nested layer: every layer resets its own clock, so a per-call timeout threaded downward bounds each call and no total — a server dribbling one byte per interval keeps a "10-second" call alive forever. Prefer the scope to `asyncio.wait_for()`, which since 3.12 is implemented on top of it and only ever wraps a single awaitable. | `ASYNC109` flags the public-parameter shape; the 6 hits in the SDK all resolve to one internal scope. Suppress per site with `# noqa: ASYNC109` naming that scope, never with a config-level ignore that would also hide the case where no scope exists: `rg -n --type py '# noqa: ASYNC109\s*$' src/` — a bare suppression with no rationale is the violation, and so is any `ASYNC109` in `pyproject.toml`. `rg -n --type py 'asyncio\.wait_for\(' src/` — each hit is a candidate for a scope | SHOULD |
|
|
35
|
+
|
|
36
|
+
PY-ASYNC-01 is a config change, not a review habit. Turning the family on costs
|
|
37
|
+
two edits — one in the manifest, one per pre-triaged `ASYNC109` site:
|
|
38
|
+
|
|
39
|
+
```toml
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "W", "F", "I", "B", "UP", "ANN", "RUF", "D", "ASYNC"]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
async def run_command_async(
|
|
46
|
+
argv: Sequence[str],
|
|
47
|
+
*,
|
|
48
|
+
timeout: float | None = None, # noqa: ASYNC109 - one asyncio.timeout() scope enforces it
|
|
49
|
+
) -> CommandResult: ...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The suppression goes on the parameter line, names the scope, and is never
|
|
53
|
+
hoisted into `ignore` — a blanket entry would also hide the sites where the
|
|
54
|
+
parameter is threaded downward and no scope exists at all.
|
|
55
|
+
|
|
56
|
+
## Structure and Cancellation
|
|
57
|
+
|
|
58
|
+
| ID | Rule | Verification | Severity |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| PY-ASYNC-02 | Every `asyncio.create_task()`/`ensure_future()` result is bound — to a variable, to a set held for the task's whole lifetime, or to the `TaskGroup` that owns it. The loop keeps only a **weak** reference, so an unbound task can be collected mid-execution and its work simply never finishes, with nothing raised anywhere. A bare set fixes the lifetime but not the error path: nothing ever retrieves the exception, which is why a `TaskGroup` is the form that does both. | `ruff check --select RUF006 --no-fix src/` — any finding is the violation; clean today, and enforced now since `RUF` is already selected. `TaskGroup.create_task` is correctly exempt, the group holds the reference. RUF006 is syntactic: a task bound and then dropped, or a set cleared before its tasks finish, needs a read | SHOULD |
|
|
61
|
+
| PY-ASYNC-03 | Per PY-CORE-01, only a clause catching `BaseException` sees a cancellation at all — `except asyncio.CancelledError`, `except BaseException`, bare `except:`. Each of those re-raises in its own body after its cleanup, or calls `.uncancel()` where absorbing the request is deliberate. Cancellation is a request, not an error to absorb: swallowing one turns "stop now" into a normal return that the caller reads as success. | The AST check below prints one line per offending handler; empty output is a pass. No lint covers this: `B036` fires on `except BaseException` without a re-raise, and never on `except asyncio.CancelledError` | MUST |
|
|
62
|
+
| PY-ASYNC-04 | Spawn concurrent children only inside an `asyncio.TaskGroup`. Never `asyncio.gather()`: at its default `return_exceptions=False` the first exception propagates and the siblings are **not** cancelled — they keep running with no scope watching them, past the `with` block that owned their resources. `return_exceptions=True` trades that for silence, returning failures as ordinary list elements nobody has to inspect. | `rg -n --type py 'asyncio\.gather\(' src/` — any line printed is the violation; zero call sites today, and the count staying at zero is the whole rule | SHOULD |
|
|
63
|
+
| PY-ASYNC-05 | A `CancelledError` handler contains no `await` beyond what releasing the resource strictly requires, and none at all when the release is synchronous — awaiting here delays the stop the caller already asked for. A task that spawned a child process must signal it (`terminate`/`kill`/`killpg`) inside that handler: CPython does **not** kill the child when the awaiting task is cancelled (gh-88050), so the child survives as an orphan. Cleanup that genuinely must be async belongs on the `TimeoutError`/`BaseException` path, inside its own bounded `asyncio.timeout(grace)`. | The AST check below prints every `await` inside a `CancelledError` handler, and every such handler in a module that spawns a child without signalling it. Zero lines against `ocx-sdk-python/src` today | MUST |
|
|
64
|
+
|
|
65
|
+
The check behind PY-ASYNC-03 and PY-ASYNC-05. Empty output is a pass; every
|
|
66
|
+
line printed is one handler to fix:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import ast, pathlib, sys
|
|
70
|
+
|
|
71
|
+
for f in sorted(pathlib.Path(sys.argv[1]).rglob("*.py")):
|
|
72
|
+
text = f.read_text()
|
|
73
|
+
for h in (n for n in ast.walk(ast.parse(text)) if isinstance(n, ast.ExceptHandler)):
|
|
74
|
+
caught = ast.unparse(h.type) if h.type else "bare except"
|
|
75
|
+
if h.type and "CancelledError" not in caught and "BaseException" not in caught:
|
|
76
|
+
continue
|
|
77
|
+
body = ast.unparse(h)
|
|
78
|
+
if not any(isinstance(n, ast.Raise) for n in ast.walk(h)) and "uncancel()" not in body:
|
|
79
|
+
print(f"{f}:{h.lineno}: except {caught} neither re-raises nor uncancels")
|
|
80
|
+
if "CancelledError" not in caught:
|
|
81
|
+
continue
|
|
82
|
+
for n in ast.walk(h):
|
|
83
|
+
if isinstance(n, ast.Await):
|
|
84
|
+
print(f"{f}:{n.lineno}: await inside except {caught}")
|
|
85
|
+
if "create_subprocess_" in text and "terminate" not in body and "kill" not in body:
|
|
86
|
+
print(f"{f}:{h.lineno}: cancellation handler in a subprocess module signals no child")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The handler shape all of this is aiming at — a synchronous signal, a note, a
|
|
90
|
+
re-raise, and not one `await` on the path out:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
err = bytearray()
|
|
94
|
+
try:
|
|
95
|
+
async with asyncio.timeout(timeout):
|
|
96
|
+
await _drain(proc, err)
|
|
97
|
+
except asyncio.CancelledError as cancelled:
|
|
98
|
+
# gh-88050: CPython leaves the child running when the awaiting task is
|
|
99
|
+
# cancelled. No grace wait and no awaits at all here — awaiting inside a
|
|
100
|
+
# cancellation handler is how a caller that asked to stop ends up hanging.
|
|
101
|
+
_terminate_group(proc)
|
|
102
|
+
if err:
|
|
103
|
+
cancelled.add_note(f"partial stderr before cancellation: {err!r}")
|
|
104
|
+
raise
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Entry Points and Yield Points
|
|
108
|
+
|
|
109
|
+
| ID | Rule | Verification | Severity |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| PY-ASYNC-07 | `asyncio.run()` appears once, at the process entry point. Library code never creates, sets, or closes an event loop on its caller's behalf — no `new_event_loop`, no `set_event_loop`, no `loop.close()` — and nothing calls `asyncio.get_event_loop()`; inside a coroutine or callback the answer is `get_running_loop()`. A second `asyncio.run()` reached from inside a running loop raises `RuntimeError`, and as of 3.14 `get_event_loop()` raises rather than quietly manufacturing a loop that nothing runs. | `rg -n --type py -e 'get_event_loop\(' -e 'new_event_loop\(' -e 'set_event_loop\(' -e 'loop\.close\(' src/` — any line printed is the violation; zero today. `rg -n --type py 'asyncio\.run\(' src/` — more than one hit, or one outside the entry point, is the violation | SHOULD |
|
|
112
|
+
| PY-ASYNC-08 | Do not treat `await asyncio.sleep(0)` as a guaranteed yield point. It is a scheduler implementation detail, not a language guarantee, and it does not generalise across loop implementations. Where the code must wait for something, wait on an `asyncio.Event`; keep `sleep(0)` to places where any scheduler tick will do, such as letting a task reach its first await in a test. | `rg -n --type py 'asyncio\.sleep\(0\)' src/` — each hit needs a comment naming why a scheduler tick suffices; zero in `src/` today. Nothing lints it: `ASYNC115` is trio/anyio-only and does not fire on the asyncio spelling, and `ASYNC110` catches only the `while …: await sleep(…)` busy-wait shape | SHOULD |
|
|
113
|
+
|
|
114
|
+
## What Agents Get Wrong Here
|
|
115
|
+
|
|
116
|
+
1. **`asyncio.get_event_loop()` inside a coroutine.** Pre-3.10 examples
|
|
117
|
+
dominate the corpus, and the call still "works" outside a running loop, so a
|
|
118
|
+
quick manual test does not catch it.
|
|
119
|
+
2. **`gather()` where a `TaskGroup` is meant.** `gather` predates it by a
|
|
120
|
+
decade; the two read as interchangeable and differ exactly where it matters
|
|
121
|
+
— what happens to the siblings of a task that failed.
|
|
122
|
+
3. **A defensive `except BaseException:` added for symmetry, with no `raise`**
|
|
123
|
+
— the one edit that converts a working cancellation into a silent success.
|
|
124
|
+
4. **`wait_for()` reached for reflexively**, because every pre-2022 tutorial
|
|
125
|
+
uses it, where the call site actually wants a scope over several statements.
|
|
126
|
+
5. **Porting a sync function by adding `async` to the signature** and leaving
|
|
127
|
+
the blocking body in place. It compiles, the tests pass, and the loop
|
|
128
|
+
starves under load. The single most likely defect here.
|
|
129
|
+
6. **`asyncio.run()` inside a helper already running under `asyncio.run()`** —
|
|
130
|
+
a convenience wrapper written without tracking whether the call site is
|
|
131
|
+
already inside a loop. `RuntimeError`, far from the wrapper that caused it.
|
|
132
|
+
7. **A coroutine constructed and never awaited** (`result = fetch()`). Nothing
|
|
133
|
+
raises at the call site; CPython emits a `RuntimeWarning` at GC time.
|
|
134
|
+
`filterwarnings = ["error::RuntimeWarning"]` turns that into a test failure.
|
|
135
|
+
8. **Awaiting inside a cancellation handler "to clean up properly"** — the one
|
|
136
|
+
place in the codebase where more awaiting is strictly worse.
|
|
137
|
+
9. **Assuming a cancelled task takes its subprocess with it.** It does not, and
|
|
138
|
+
has not since the bug was filed in 2021.
|
|
139
|
+
10. **`await asyncio.sleep(0)` written as a fairness guarantee** in a polling
|
|
140
|
+
loop, where an `Event` is what the code actually wanted.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# The CI Gate
|
|
2
|
+
|
|
3
|
+
What makes a rule enforced rather than merely written, and the order in which
|
|
4
|
+
to turn one on without ever landing red. Loads while adding a lint or type
|
|
5
|
+
gate, editing a workflow, or wiring an existing suite into CI.
|
|
6
|
+
|
|
7
|
+
Contents: [What Binds](#what-binds) · [Turning a Gate On](#turning-a-gate-on) ·
|
|
8
|
+
[Workflow Hardening](#workflow-hardening) ·
|
|
9
|
+
[What Agents Get Wrong](#what-agents-get-wrong-here)
|
|
10
|
+
|
|
11
|
+
Under agent-primary authorship, with no human on most diffs, the usual answer
|
|
12
|
+
changes. A pre-commit hook plus a lighter CI check is a reasonable pairing when
|
|
13
|
+
a person reviews the pull request; here the hook contributes approximately
|
|
14
|
+
nothing, and the whole file is built around that.
|
|
15
|
+
|
|
16
|
+
## What Binds
|
|
17
|
+
|
|
18
|
+
| Mechanism | Binds a human | Binds an unattended agent |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Required, blocking CI status check | Yes | **Yes** — a red pull request cannot merge and there is no local step to skip; the only bypass is an explicit grant, itself a deliberate and logged act |
|
|
21
|
+
| Pre-commit hook | Weakly, by habit | **No** — needs a manual `pre-commit install` in that specific clone, and `--no-verify` or `SKIP=<hook-id>` bypasses it for one keystroke. An agent working in a fresh worktree has no reason to know it exists |
|
|
22
|
+
| Diff-scoped "no new violations" check | Only as a blocking check | Same as the blocking check — this is that mechanism with a different baseline, not a fourth one |
|
|
23
|
+
| Periodically-reviewed count | No | **No** — needs an attentive human on a cadence, which is exactly the resource agent authorship removed. A count nobody reads is a number, not a gate |
|
|
24
|
+
|
|
25
|
+
| ID | Rule | Verification | Severity |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| PY-GATE-01 | A subject gets exactly one contributor-and-CI command (`task verify` or equivalent) **before** any lint or type rule is turned on for it. Turn a rule on first and the two invocations drift the moment someone runs the tool directly — which is then discovered as a CI failure nobody can reproduce locally. | `rg -n --glob 'taskfile.yml' --glob 'Taskfile.yml' --glob 'Makefile' -e 'verify:' -e 'ci:' <project-dir>` — empty output is the finding: no single command exists yet, so no gate may be added to this subject until one does. | MUST |
|
|
28
|
+
| PY-GATE-02 | Every gate is a required, blocking status check on the merge path — never a pre-commit hook as the primary mechanism, and never an advisory comment. A gate that an author can skip by choosing not to run it is documentation. It also has to be able to go red: see the softened-job check below. | `gh api repos/OWNER/REPO/rules/branches/main --jq '.[].type'` — the gate job's absence from a `required_status_checks` rule is the finding, and empty output means nothing gates the branch at all. Watched red on a live repository whose `main` had no ruleset. `git commit --no-verify` succeeding locally is expected and proves nothing either way. | MUST |
|
|
29
|
+
| PY-GATE-07 | A suite that is configured and passes but that no workflow invokes gets wired in during the same cycle that discovers it, not deferred. Wiring in a suite that is already green costs one line; leaving it costs a silent drift nobody sees until the day it is finally run and everything has broken at once. | `rg -l --glob 'pyproject.toml' 'tool.pytest.ini_options' <repo-root>` lists every project with a configured suite. For each one's directory name D: `rg -n --glob '*.yml' --glob '*.yaml' -e 'D/' -e 'D:' .github/workflows` — empty output for that name is the violation. Search the compound form, never the bare word: a bare directory name collides with unrelated workflow text and reports a false pass. | SHOULD |
|
|
30
|
+
|
|
31
|
+
A required check that cannot fail is worse than no check: it occupies the slot,
|
|
32
|
+
shows a green tick, and reports nothing. Two spellings soften a job into that
|
|
33
|
+
state, and both look like ordinary workflow hygiene in a diff —
|
|
34
|
+
`continue-on-error: true` on the job or the step, and a trailing `|| true` on
|
|
35
|
+
the `run:` line. Watched red on both, plus a bare `exit 0`:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
rg -n --glob '*.yml' --glob '*.yaml' -e 'continue-on-error: true' -e 'run: .*\|\| true' -e 'exit 0$' .github/workflows
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every hit on a job named as a required check is the violation; empty output is
|
|
42
|
+
the pass. A deliberately advisory job — a trend report, an informational scan —
|
|
43
|
+
is a legitimate hit, and belongs nowhere in the required-checks list.
|
|
44
|
+
|
|
45
|
+
"Configured but unenforced" is a different finding from "never configured", and
|
|
46
|
+
only the first belongs here. A tree with no lint config is a decision someone
|
|
47
|
+
can make; a tree with a lint config nothing runs is a gate that reads as
|
|
48
|
+
present and is not.
|
|
49
|
+
|
|
50
|
+
Building that check is itself the cautionary tale. Three separate bugs made it
|
|
51
|
+
report a false pass before it was trusted: a `grep -q` under `pipefail`, where
|
|
52
|
+
the match closed the pipe, the upstream write took `SIGPIPE`, and the pipeline
|
|
53
|
+
reported failure *because* the search succeeded; and then two rounds of a token
|
|
54
|
+
search colliding with unrelated text — first a workflow whose job key happened
|
|
55
|
+
to be the searched word, then a doc comment mentioning a different directory of
|
|
56
|
+
the same name. Each fix was confirmed against both a known-guilty and a
|
|
57
|
+
known-clean subject, because the second bug's fix turned one guilty subject red
|
|
58
|
+
and left the other silently passing for a third, unrelated reason. **A check
|
|
59
|
+
that passes its first red test is not proven; it is proven once it has also
|
|
60
|
+
been run against everything it is supposed to leave green.**
|
|
61
|
+
|
|
62
|
+
## Turning a Gate On
|
|
63
|
+
|
|
64
|
+
Lint and type-check ride in the same job as the tests they gate, on every push
|
|
65
|
+
— they cost less than the run-to-run variance of the suite beside them. There
|
|
66
|
+
is no timing argument for deferring either to a nightly run or a merge queue,
|
|
67
|
+
and "we will add it later, for timing" should be named as the non-objection it
|
|
68
|
+
is when it is raised.
|
|
69
|
+
|
|
70
|
+
The sequence, in order, where every step leaves the repository green:
|
|
71
|
+
|
|
72
|
+
1. **Add the command, wire nothing.** `task lint` / `task types` / `task
|
|
73
|
+
verify` exist; no workflow calls them. Zero violations possible.
|
|
74
|
+
2. **Land the config, safe autofix only.** Still not blocking.
|
|
75
|
+
3. **Fix the largest remaining bucket at its call sites**, with targeted
|
|
76
|
+
suppressions naming the rule code — not a config-level ignore, which would
|
|
77
|
+
also hide the one genuine instance living outside that bucket.
|
|
78
|
+
4. **Triage the named remainder, one pull request per rule code.**
|
|
79
|
+
5. **Turn the job blocking.** This is the only step that gates; everything
|
|
80
|
+
before it was preparation that could not land red.
|
|
81
|
+
6. **Add the type check as a second blocking job**, scoped to the source tree
|
|
82
|
+
first. Widening it to the test tree is its own project, not part of this one.
|
|
83
|
+
|
|
84
|
+
Step 3 is where the baseline argument is actually won. A large remainder is
|
|
85
|
+
never an undifferentiated pile — decompose it by rule code first and it turns
|
|
86
|
+
into a handful of independently landable slices, each with a different verdict:
|
|
87
|
+
one is a lint blind spot needing two targeted suppressions at named call sites,
|
|
88
|
+
one is real complexity to refactor, one is cosmetic and safe to fix in bulk,
|
|
89
|
+
one is worth a security read before touching. A baseline file exempts all of
|
|
90
|
+
them at once and erases exactly the structure that made them tractable.
|
|
91
|
+
|
|
92
|
+
The buckets also decide the *shape* of the suppression. A config-level ignore
|
|
93
|
+
for the largest bucket is tempting because it is one line — and it also hides
|
|
94
|
+
the one genuine instance of that rule living outside the bucket, which is
|
|
95
|
+
usually the only one that mattered.
|
|
96
|
+
|
|
97
|
+
| ID | Rule | Verification | Severity |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| PY-GATE-03 | A lint adoption with more than roughly 200 violations left after safe autofix lands as named, bounded buckets per rule code — never a generated baseline, and never a directory-wide suppression. A baseline flattens the one structure that makes a large remainder tractable, and nothing ever forces it to shrink: every violation inside it is exempt forever, and "clean up the baseline" competes with every other backlog item indefinitely. | `rg -n --glob 'ruff.toml' --glob '.ruff.toml' --glob 'pyproject.toml' '"ALL"' <repo-root>` — a hit under `per-file-ignores` is the violation, empty is the pass; a hit under `select` is a different and legitimate choice. | MUST |
|
|
100
|
+
| PY-GATE-04 | Tools resolve through the project pin, never through `$PATH`. A globally installed linter shadowing the pinned one is not hypothetical — measured on a live development machine, `$PATH` resolved to 0.16.1 while the project pin was 0.16.3: two different linters, same machine, same moment, and the contributor sees a clean tree CI rejects. | `rg -n --glob '*.yml' --glob '*.yaml' -e 'run: ruff ' -e 'run: pyright' -e 'run: pytest' -e 'run: mypy' .github/workflows` — every hit invokes a bare tool name and is the violation; the pinned forms are `uv run …` or the task. Then run `ruff --version` and `uv run ruff --version` in the project directory: two different numbers is the violation. | MUST |
|
|
101
|
+
| PY-GATE-08 | A `# noqa` or `# type: ignore` always names its code. One line of config, not a paragraph: select ruff's `PGH` group, which denies both bare forms. Any new hit is an agent taking the fast path to green. | `ruff check --select PGH <repo-root>` — `PGH003` and `PGH004` findings are the violation. Watched red on a bare `# noqa` and a bare `# type: ignore`, silent on `# noqa: F401` and `# type: ignore[assignment]`. | SHOULD |
|
|
102
|
+
|
|
103
|
+
## Workflow Hardening
|
|
104
|
+
|
|
105
|
+
Not every workflow finding is worth the same attention. Interpolation into a
|
|
106
|
+
`run:` block is the one that converts data into script, and it is the one to
|
|
107
|
+
fix first even when the trigger is maintainer-only. Floating action refs are
|
|
108
|
+
next, and they cluster in the release workflow — the file whose pinning
|
|
109
|
+
discipline usually lapsed precisely because it is edited least. Missing
|
|
110
|
+
`persist-credentials: false` is high-volume, low-severity, and auto-fixable in
|
|
111
|
+
bulk; a flagged trigger class on a workflow that checks the untrusted tree out
|
|
112
|
+
as data, executes nothing from it, and runs at zero default permissions is a
|
|
113
|
+
tool correctly raising a flag that a correct implementation survives — document
|
|
114
|
+
the reasoning next to the trigger rather than suppressing the rule.
|
|
115
|
+
|
|
116
|
+
| ID | Rule | Verification | Severity |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| PY-GATE-05 | `${{ }}` never appears directly inside a `run:` block; every value flows through an `env:`-declared intermediate variable first. A branch name, tag or input interpolated straight into a shell line is substituted before the shell parses it, so the value becomes script rather than data. | `rg -n --glob '*.yml' --glob '*.yaml' 'run: .*\$\{\{' .github/workflows` — every hit is the violation, and an `env:` intermediate is correctly not matched. That grep provably misses the multi-line block form: watched silent on an interpolation two lines below a `run:` key. So the check that binds is `zizmor --format plain .github/workflows` reporting zero `template-injection` findings; the grep is the zero-install partial, not a substitute. | MUST |
|
|
119
|
+
| PY-GATE-06 | Every third-party action is pinned by commit SHA with a version comment, in *every* workflow — the release one included. The convention usually holds everywhere except the file that ships the artifact, which is the highest-stakes place for it to lapse. | `rg -n --glob '*.yml' --glob '*.yaml' -e 'uses: [^@]+@v[0-9]' -e 'uses: [^@]+@main' -e 'uses: [^@]+@master' .github/workflows` — every hit is a floating ref and the violation; empty is the pass. A SHA pin and a local `uses: ./…` are both correctly unmatched. | SHOULD |
|
|
120
|
+
|
|
121
|
+
## What Agents Get Wrong Here
|
|
122
|
+
|
|
123
|
+
1. **Adding a pre-commit hook and calling the gate done.** It looks like
|
|
124
|
+
enforcement, it is one flag away from nothing, and the agent that adds it is
|
|
125
|
+
the same agent that will not run it.
|
|
126
|
+
2. **Generating a baseline to make a large adoption land in one pull
|
|
127
|
+
request.** The diff looks decisive; the remainder never shrinks again.
|
|
128
|
+
3. **Landing the whole remainder in one pull request** by suppressing what the
|
|
129
|
+
safe autofix left, rather than in slices anyone could review.
|
|
130
|
+
4. **Suppressing a whole directory to clear the last bucket** instead of the
|
|
131
|
+
handful of call sites that actually need it.
|
|
132
|
+
5. **Invoking a bare tool name in a workflow** because it works locally —
|
|
133
|
+
silently gating against whatever version the runner image happens to ship.
|
|
134
|
+
6. **Interpolating a ref or input straight into `run:`** while correctly using
|
|
135
|
+
an `env:` intermediate three lines above, in the same file.
|
|
136
|
+
7. **Softening a required job with `continue-on-error` or a trailing truthy
|
|
137
|
+
command** to unblock a merge, leaving a check that can only ever be green.
|
|
138
|
+
8. **Turning a gate on before the single command exists**, so the contributor's
|
|
139
|
+
invocation and CI's diverge from the first day rather than the hundredth.
|