python-constricter 0.2.2__tar.gz → 0.2.3__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.
- {python_constricter-0.2.2 → python_constricter-0.2.3}/PKG-INFO +176 -26
- {python_constricter-0.2.2 → python_constricter-0.2.3}/README.md +175 -25
- {python_constricter-0.2.2 → python_constricter-0.2.3}/pyproject.toml +14 -2
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/__init__.py +3 -1
- python_constricter-0.2.3/src/constricter/annotations.py +653 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/baseline.py +1 -5
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/checker.py +220 -47
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/cli.py +122 -56
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/config.py +19 -9
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/explain.py +7 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/fixes.py +7 -1
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/jsonc.py +22 -3
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/notebook.py +7 -2
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/project.py +35 -10
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/pylint_plugin.py +5 -2
- python_constricter-0.2.2/src/constricter/annotations.py +0 -290
- {python_constricter-0.2.2 → python_constricter-0.2.3}/LICENSE.md +0 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/__main__.py +0 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/flake8_plugin.py +0 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/noqa.py +0 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/py.typed +0 -0
- {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-constricter
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Lint rules: every local variable is typed where it's first bound. A flake8 plugin, a pylint plugin and a standalone CLI.
|
|
5
5
|
Author: Ivy Duggan
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -51,6 +51,8 @@ Import-Name: constricter
|
|
|
51
51
|
|
|
52
52
|
[snake](https://www.asciiart.eu/art/595284d82d1f8d6d)
|
|
53
53
|
|
|
54
|
+
<https://pypi.org/project/python-constricter/>
|
|
55
|
+
|
|
54
56
|
---
|
|
55
57
|
|
|
56
58
|
Lint rules: every local variable is typed where it's first bound. Ships as a flake8 plugin, a pylint
|
|
@@ -89,27 +91,32 @@ class bodies too. Statements are read in source order, and only a name's first b
|
|
|
89
91
|
| `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
|
|
90
92
|
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters | name the real type |
|
|
91
93
|
| `LVA006` | an annotation nested `nesting` deep (5 by default) | a `type` alias for a part of it |
|
|
94
|
+
| `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
|
|
92
95
|
|
|
93
96
|
Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
|
|
94
97
|
`global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
|
|
95
|
-
enum members (a
|
|
98
|
+
enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
|
|
99
|
+
or `Flag`).
|
|
96
100
|
|
|
97
101
|
A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
|
|
98
102
|
`type-comments`, or automatically in a module written to run on Python 2: one that imports
|
|
99
103
|
`print_function`, `unicode_literals`, `absolute_import`, `division`, `with_statement`, `generators`
|
|
100
104
|
or `nested_scopes` from `__future__`.
|
|
101
105
|
|
|
106
|
+
`LVA007` compares a block on its own: an `if`'s body and its `orelse`, a `try`'s body and its
|
|
107
|
+
`except`s, and the like, are different blocks, since they don't both run in the same pass.
|
|
108
|
+
|
|
102
109
|
## Levels
|
|
103
110
|
|
|
104
111
|
Each level makes one more code an error. The rest are warnings: the CLI prints them (as `::warning`
|
|
105
112
|
or SARIF `warning` in those formats) but exits 0; flake8 and pylint report errors only.
|
|
106
113
|
|
|
107
|
-
| Level | Errors | Warnings
|
|
108
|
-
| ----------------- | -------------------------------- |
|
|
109
|
-
| `relaxed` / `0` | none | `LVA001`–`LVA004`
|
|
110
|
-
| `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005
|
|
111
|
-
| `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005
|
|
112
|
-
| `suffocate` / `3` | all | none
|
|
114
|
+
| Level | Errors | Warnings |
|
|
115
|
+
| ----------------- | -------------------------------- | ------------------------------------- |
|
|
116
|
+
| `relaxed` / `0` | none | `LVA001`–`LVA004`, `LVA007` |
|
|
117
|
+
| `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`–`LVA007` |
|
|
118
|
+
| `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`–`LVA007` |
|
|
119
|
+
| `suffocate` / `3` | all | none |
|
|
113
120
|
|
|
114
121
|
`LVA005` and `LVA006` aren't reported at `relaxed`.
|
|
115
122
|
|
|
@@ -119,14 +126,14 @@ Python 3.11+, no runtime dependencies.
|
|
|
119
126
|
|
|
120
127
|
| Tool | Setup | Reports | Suppress |
|
|
121
128
|
| ------ | ----------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
|
|
122
|
-
| CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`
|
|
123
|
-
| flake8 | install it (on by default) | `LVA001`–`
|
|
124
|
-
| pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`
|
|
125
|
-
| ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`
|
|
129
|
+
| CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
|
|
130
|
+
| flake8 | install it (on by default) | `LVA001`–`LVA007` | `# noqa: LVA001` |
|
|
131
|
+
| pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9107` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
|
|
132
|
+
| ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
|
|
126
133
|
|
|
127
134
|
pylint symbols: `unannotated-local-variable`, `untyped-for-or-match-variable`,
|
|
128
135
|
`comment-typed-for-variable`, `unannotated-module-or-class-variable`, `vague-annotation`,
|
|
129
|
-
`deeply-nested-annotation`.
|
|
136
|
+
`deeply-nested-annotation`, `redundant-annotation`.
|
|
130
137
|
|
|
131
138
|
Options:
|
|
132
139
|
|
|
@@ -277,8 +284,9 @@ Trunk) pick the plugin up once it's installed alongside them.
|
|
|
277
284
|
Without `lint.external`, ruff flags `# noqa: LVA00x` (RUF102) and `--fix` deletes it.
|
|
278
285
|
|
|
279
286
|
The CLI defaults to `.`, checks `*.py` and `*.ipynb`, and skips hidden dirs, `__pycache__`, `venv`,
|
|
280
|
-
`site-packages`, `build`, `dist` and `node_modules
|
|
281
|
-
|
|
287
|
+
`site-packages`, `build`, `dist` and `node_modules` by directory name; `--exclude` adds more
|
|
288
|
+
directory names (or globs) to skip the same way, on top of matching whole paths and file names. Exit
|
|
289
|
+
codes: `0` no errors, `1` errors, `2` an unreadable or unparsable file, or a bad `pyproject.toml`.
|
|
282
290
|
|
|
283
291
|
```bash
|
|
284
292
|
pip install python-constricter # once the first release is out; until then:
|
|
@@ -326,6 +334,7 @@ With [uv](https://docs.astral.sh/uv/) installed (CI pins 0.12.17):
|
|
|
326
334
|
|
|
327
335
|
```bash
|
|
328
336
|
export UV_PROJECT_ENVIRONMENT=local/.venv
|
|
337
|
+
uv venv --prompt constricter local/.venv # the prompt name; uv sync reuses this venv
|
|
329
338
|
uv sync --locked --no-install-project --no-build # the dev group: hash-checked wheels from uv.lock
|
|
330
339
|
uv pip install --python local/.venv --no-deps --no-build-isolation -e .
|
|
331
340
|
```
|
|
@@ -337,16 +346,18 @@ Checks (as CI runs them): `ruff check .` (every rule, preview included), `ruff f
|
|
|
337
346
|
`constricter --coverage --all-scopes --fail-under=100 src tests`, `pytest --cov` (100% branch
|
|
338
347
|
coverage). Everything generated goes in `local/`. Python is indented with 4 spaces.
|
|
339
348
|
|
|
340
|
-
After editing
|
|
341
|
-
the npm lock and the actions weekly.
|
|
349
|
+
After editing a dependency group, run `uv lock` (CI fails until you do). Dependabot updates
|
|
350
|
+
`uv.lock`, the npm lock and the actions weekly.
|
|
342
351
|
|
|
343
352
|
Fuzzing (`tests/test_fuzz.py`) runs with the tests: hypothesmith generates valid Python, which must
|
|
344
353
|
never crash the checker and must stay valid after `--fix`. For a large real codebase, run
|
|
345
354
|
`local/.venv/bin/python tests/corpus.py [PATH]` by hand: it checks PATH (default: this Python's
|
|
346
|
-
standard library, about
|
|
355
|
+
standard library, about 730 files in a few seconds) at `suffocate` and prints the time, the offences
|
|
347
356
|
per code, and any crash. `local/.venv/bin/python tests/corpus_fix.py [PATH]` runs
|
|
348
357
|
`--fix --unsafe-fixes` on a copy of it (in `local/corpus-fix/`) and checks every file still compiles
|
|
349
|
-
and a second pass has nothing left to fix
|
|
358
|
+
and a second pass has nothing left to fix. CI's Corpus job runs both against the standard library
|
|
359
|
+
and, from the pinned `corpus` dependency group (`requests`, `flask`, `django`, `sqlalchemy` — a tiny
|
|
360
|
+
HTTP client, two web frameworks and an ORM), the same way.
|
|
350
361
|
|
|
351
362
|
CI also runs the tests on PyPy 3.11 and free-threaded Python 3.14, which install only the `test`
|
|
352
363
|
dependency group: every dev tool doesn't have wheels for them, and the tests don't need them all.
|
|
@@ -371,7 +382,7 @@ Everything else is on. Some of these may be revisited.
|
|
|
371
382
|
| typos | the word `astroid` | A real package name. |
|
|
372
383
|
| harden-runner | `egress-policy: audit` on macOS and Windows, in the release jobs (release.yml, build.yml), and in the weekly external-link check | harden-runner supports only audit on GitHub's macOS and Windows runners; the release jobs haven't run yet; external links can go anywhere. |
|
|
373
384
|
| reuse | `reuse lint` not run (the files still comply: `REUSE.toml` covers them) | No recent release ships a wheel for Python 3.11+, so installing it builds from source with an unpinned `poetry-core`. |
|
|
374
|
-
| zizmor | `self-repository`
|
|
385
|
+
| zizmor | `self-repository` (`.github/zizmor.yml`) | Scorecard reads the `$/` form it wants as an unpinned third-party action, so local actions stay `./`. |
|
|
375
386
|
|
|
376
387
|
To apply the rulesets in `.github/rulesets/` (repo admin):
|
|
377
388
|
|
|
@@ -392,10 +403,8 @@ Done:
|
|
|
392
403
|
(Docs), pytest on Linux, macOS and Windows × Python 3.11–3.14 (Test), and the sdist and wheel,
|
|
393
404
|
`twine check` and a wheel smoke test (Build).
|
|
394
405
|
- **security.yml** runs on pushes, PRs and weekly: CodeQL (Python and Actions), zizmor (pedantic),
|
|
395
|
-
actionlint
|
|
396
|
-
|
|
397
|
-
- **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly. Its pin check misreads the `$/`
|
|
398
|
-
references as unpinned actions, so it flags them.
|
|
406
|
+
actionlint, pip-audit on the lock, and dependency review on PRs.
|
|
407
|
+
- **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly.
|
|
399
408
|
- **release.yml** runs on `v*` tags: CI, then **build.yml** (a reusable workflow) builds the dists,
|
|
400
409
|
checks the tag matches the version, and attests their provenance (SLSA v1 Build Level 3, as the
|
|
401
410
|
build and attestation run in a reusable workflow), then PyPI (trusted publishing), then a GitHub
|
|
@@ -423,7 +432,7 @@ Done:
|
|
|
423
432
|
badges this project's CI keeps true.
|
|
424
433
|
- **Baselines**, a **smarter `--fix`** (containers, same-module return types), **notebooks**, and
|
|
425
434
|
JSON with comments and trailing commas wherever constricter reads JSON.
|
|
426
|
-
- **Fuzzing**, a
|
|
435
|
+
- **Fuzzing**, a **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
|
|
427
436
|
notebooks**, and **SLSA Build Level 3 provenance** (GitHub's artifact attestations, from a
|
|
428
437
|
reusable build workflow) on each release.
|
|
429
438
|
- **Python 3.11+**, the oldest version still maintained after 3.10's end of life in October 2026.
|
|
@@ -434,8 +443,107 @@ Done:
|
|
|
434
443
|
Test jobs and Scorecard included.
|
|
435
444
|
- **Stdin**, **`gitlab`, `junit` and `rdjson` output**, **safe and `--unsafe-fixes`**, **per-file
|
|
436
445
|
ignores**, **`--exit-zero`** and **`--output-file`**, **tox and nox** snippets, **PyPy 3.11 and
|
|
437
|
-
free-threaded 3.14** in CI, and a
|
|
446
|
+
free-threaded 3.14** in CI, and a **`--fix` corpus run** (`tests/corpus_fix.py`).
|
|
438
447
|
- **Cross-module `--fix`** in the CLI (the flake8 and pylint plugins see one file at a time).
|
|
448
|
+
- **CI's Corpus job** runs `tests/corpus.py` and `tests/corpus_fix.py` against the runner's Python
|
|
449
|
+
standard library on every push and PR.
|
|
450
|
+
- **`project.Index`** sorts modules by name so `calls` finds a module/submodule import by prefix
|
|
451
|
+
(`bisect`) instead of scanning every indexed module.
|
|
452
|
+
- **Enum bases and factory calls resolve by import origin** (`annotations.factories`,
|
|
453
|
+
`checker._is_enum`'s `imported_from` check), so an aliased or re-exported `Enum`/`NamedTuple`/...
|
|
454
|
+
is still recognised; the bare-name lists remain a fallback for one imported some other way.
|
|
455
|
+
- **A general path-exclusion mechanism**: `--exclude` globs also match a directory name during a
|
|
456
|
+
directory walk, folding the built-in skip list (`__pycache__`, `node_modules`, hidden dirs, ...)
|
|
457
|
+
into the same mechanism instead of a separate hardcoded check.
|
|
458
|
+
- **`_FileRun` split** into `_CheckRun`, `_BaselineRun` and `_CoverageRun` (one per mode-group,
|
|
459
|
+
instead of one struct with fields only some modes populate), and `_check_path` and
|
|
460
|
+
`_baseline_path` share a `_read_checked` read-and-report-errors wrapper.
|
|
461
|
+
- **LVA007: duplicate/redundant typing.** A name annotated again with the type it already has, in
|
|
462
|
+
the same straight-line block; a warning at every level, an error at `suffocate`.
|
|
463
|
+
- **`--fix` infers more**: `not x` (always a real `bool`, unlike a comparison, which sqlalchemy's
|
|
464
|
+
own corpus data proves isn't safe to assume — it overloads `<`/`==` to build query expressions); a
|
|
465
|
+
table of builtins with a fixed, un-overloadable return type (`len`→`int`,
|
|
466
|
+
`isinstance`/`hasattr`/`callable`/`issubclass`→`bool`, `str`/`repr`/`chr`→`str`, `int`/`float`
|
|
467
|
+
→themselves, ...); and copying an already-known local's type for a plain `x = y` (from its own
|
|
468
|
+
annotation, an earlier fix in the same scope, or an annotated parameter) — a guessed source's type
|
|
469
|
+
copies too, marked just as guessed, so a chain of copies still converges in one `--fix` pass
|
|
470
|
+
instead of needing a second. Verified on the eighteen-codebase corpus below: fixed rose from
|
|
471
|
+
12,104 to 13,608 of the same 94,523 found (12.8% → 14.4%), no crashes, nothing left to fix on a
|
|
472
|
+
second pass anywhere, and `requests`' own test suite (not just its compile check) passed
|
|
473
|
+
identically — 617 passed, 15 skipped, 1 xfailed — before and after `--fix --unsafe-fixes` on its
|
|
474
|
+
source.
|
|
475
|
+
- **`--fix` infers subscripts and attributes** of an already-typed local: `container[key]` (its
|
|
476
|
+
element type from a `list`, `dict` or homogeneous `tuple[T, ...]`; the same `list`/`str`/`bytes`
|
|
477
|
+
type back for a slice; nothing for a fixed-length heterogeneous tuple, since the element varies
|
|
478
|
+
with the index) and `obj.attr` (a class-level annotated attribute of a class defined in the same
|
|
479
|
+
module — not one only assigned in `__init__`, which would need dataflow across methods to see).
|
|
480
|
+
Both build on `_Scope.types`, so a guessed source's uncertainty carries through automatically, the
|
|
481
|
+
same as a plain copy. Re-verified on the corpus: no crashes, still converges in one `--fix` pass,
|
|
482
|
+
`requests`' test suite still passes identically, and fixed rose further (e.g. standard library
|
|
483
|
+
4,431 → 4,450, mypy 1,939 → 1,996, django 2,408 → 2,414, sqlalchemy 830 → 848, pydantic 434 →
|
|
484
|
+
447).
|
|
485
|
+
- **A permanent, pinned corpus.** By hand (`tests/corpus.py`/`corpus_fix.py`,
|
|
486
|
+
`--unsafe-fixes --all-scopes`), against eighteen real packages, to choose it — OpenCV's Python
|
|
487
|
+
bindings (the original idea) turned out to be a poor fit, since they're mostly thin C bindings,
|
|
488
|
+
not hand-annotated Python:
|
|
489
|
+
|
|
490
|
+
| Codebase | Version | Files | Left un-typed | Fixed | LVA006 @5 | LVA007 |
|
|
491
|
+
| ---------------- | ------- | ----: | ------------: | ---------: | --------: | -----: |
|
|
492
|
+
| standard library | 3.11.16 | 732 | 24,173 | 3,828 | 0 | 0 |
|
|
493
|
+
| mypy | 2.3.1 | 195 | 9,853 | 1,777 | 0 | 0 |
|
|
494
|
+
| pylint | 4.0.8 | 178 | 3,609 | 486 | 0 | 0 |
|
|
495
|
+
| libcst | 1.9.0 | 297 | 3,407 | 1,042 | 201 | 0 |
|
|
496
|
+
| uiautomator2 | 3.7.0 | 32 | 888 | 86 | 0 | 0 |
|
|
497
|
+
| requests | 2.34.2 | 19 | 372 | 45 | 0 | 0 |
|
|
498
|
+
| flask | 3.1.3 | 24 | 410 | 29 | 0 | 0 |
|
|
499
|
+
| click | 8.5.0 | 17 | 567 | 86 | 0 | 0 |
|
|
500
|
+
| praw | 8.0.3 | 89 | 602 | 114 | 0 | 0 |
|
|
501
|
+
| boto3 | 1.43.99 | 39 | 483 | 93 | 0 | 0 |
|
|
502
|
+
| django | 6.1.1 | 907 | 15,648 | 2,248 | 0 | 0 |
|
|
503
|
+
| pydantic | 2.13.5 | 105 | 2,998 | 367 | 0 | 0 |
|
|
504
|
+
| attrs | 26.1.0 | 13 | 336 | 60 | 0 | 0 |
|
|
505
|
+
| aiohttp | 3.14.3 | 55 | 1,590 | 217 | 0 | 0 |
|
|
506
|
+
| paramiko | 5.0.0 | 41 | 1,272 | 241 | 0 | 0 |
|
|
507
|
+
| scrapy | 2.19.0 | 179 | 1,814 | 389 | 1 | 0 |
|
|
508
|
+
| sqlalchemy | 2.0.54 | 257 | 12,556 | 643 | 7 | 0 |
|
|
509
|
+
| rich | 15.0.0 | 100 | 1,841 | 353 | 0 | 0 |
|
|
510
|
+
| **Total** | | | **82,419** | **12,104** | | |
|
|
511
|
+
|
|
512
|
+
No crashes on any of them, and `--unsafe-fixes` left nothing broken or nothing unfixed on a second
|
|
513
|
+
pass, on any of them; `--nesting`'s default (5) never fires on fourteen of the eighteen, and
|
|
514
|
+
LVA007 found nothing on any of them, at any nesting — strong evidence it isn't noisy
|
|
515
|
+
(`--nesting`'s default is still worth revisiting some day: `libcst`, deeply nested CST types, is
|
|
516
|
+
by far the most affected, `sqlalchemy` and `scrapy` are the only other two to hit it at all at the
|
|
517
|
+
default, and 3 already reported 124 times on sqlalchemy, 45 on mypy). Chose four to run
|
|
518
|
+
permanently in CI (see the Corpus job): **`requests`** (tiny, so a fast check; extremely stable
|
|
519
|
+
and widely known; the canonical "makes external API calls" library; unlike the dev tools,
|
|
520
|
+
representative of typical, lightly-typed real-world code), **`flask`** and **`django`** (two web
|
|
521
|
+
frameworks, more decorator/class-heavy than `requests`; `django` pinned to the 5.2 LTS, since 6.x
|
|
522
|
+
needs Python 3.12+) and **`sqlalchemy`** (an ORM, and the corpus most likely to exercise
|
|
523
|
+
`LVA006`). Each runs as its own Corpus (`package`) matrix job, from a new `corpus` dependency
|
|
524
|
+
group.
|
|
525
|
+
|
|
526
|
+
Also validated `requests` specifically: cloned `v2.34.2` (its source checkout, with its own test
|
|
527
|
+
suite, not just the installed wheel), ran `--fix --unsafe-fixes --all-scopes` on `src/requests/`,
|
|
528
|
+
and ran its own test suite before and after. Identical both times: 617 passed, 15 skipped, 1
|
|
529
|
+
xfailed — the inferred types changed nothing about its runtime behaviour. One found a real, if
|
|
530
|
+
inert, mistake in the "guessed" heuristic: `internetSettings = winreg.OpenKey(...)` (Windows-only,
|
|
531
|
+
guarded by `sys.platform == "win32"`, so untested by this run) got annotated
|
|
532
|
+
`internetSettings: winreg.OpenKey = ...` — `winreg.OpenKey` is a _function_, not a class, but its
|
|
533
|
+
PascalCase name (a Windows API convention, not Python's) fools the capitalised-name "constructs a
|
|
534
|
+
class" heuristic (`annotations._constructs`).
|
|
535
|
+
|
|
536
|
+
- **`--fix` infers `self.attr` and `str`/`bytes` method calls.** `classes` (used for `obj.attr`) now
|
|
537
|
+
also collects `self.x: T = ...` from anywhere in a method's body, not just class-level
|
|
538
|
+
annotations; a method whose first parameter is literally named `self` has it typed as its class
|
|
539
|
+
(`_owners`, by the method's `id()`, not by name — a same-named method on an unrelated class isn't
|
|
540
|
+
confused with it), so `self.attr` resolves the same way `obj.attr` already did. Separately, a
|
|
541
|
+
fixed table of `str`/`bytes` methods whose return type doesn't depend on their arguments (`strip`,
|
|
542
|
+
`split`, `startswith`, `encode`, `decode`, ...) makes `some_str.strip()` on an already-typed local
|
|
543
|
+
as certain as a builtin function call — not a guess, unlike an arbitrary method call, which stays
|
|
544
|
+
guessed. Re-verified on the corpus: no crashes, still converges in one `--fix` pass on all six
|
|
545
|
+
re-checked (standard library, mypy, `requests`, `flask`, `django`, `sqlalchemy`), and fixed rose
|
|
546
|
+
further still (e.g. mypy 1,996 → 2,074, sqlalchemy 848 → 1,039, `requests` 52 → 57).
|
|
439
547
|
|
|
440
548
|
Next:
|
|
441
549
|
|
|
@@ -443,6 +551,48 @@ Next:
|
|
|
443
551
|
CPython 3.10 one).
|
|
444
552
|
2. Revisit the [disabled rules](#disabled-rules) as tools change (last checked 2026-09-22: COM812,
|
|
445
553
|
one-line DOC201/DOC402 and `max-args` came back on; the rest can't go yet).
|
|
554
|
+
3. **Raise `--fix`'s auto-fix rate further.** Attributes, subscripts, `self.attr` and `str`/`bytes`
|
|
555
|
+
method calls are done (see Done, above); what's left: a method call on an arbitrary class's
|
|
556
|
+
instance (`x = obj.method()`, unlike `str`/`bytes` isn't resolvable without following the
|
|
557
|
+
method's own return annotation, the same as `classes` already does for a field, extended to
|
|
558
|
+
methods) and `list`/`dict` methods whose return is the receiver's own element type (`list.pop`,
|
|
559
|
+
`dict.pop`, ...), which need the same element-type parsing `_subscripted` already does, just
|
|
560
|
+
reached from a method call instead of a subscript. `BinOp` (`a + b`, ~5% of the original sample)
|
|
561
|
+
would need operand types plus knowing the operator isn't overloaded to something else — riskier,
|
|
562
|
+
lower value, likely skip.
|
|
563
|
+
4. **LVA008: a type that could narrow.** A warning at `constrict`, an error at `suffocate`: a
|
|
564
|
+
declared type that every value assigned to the name (across its lifetime, not just its first
|
|
565
|
+
binding) is consistent with a strictly narrower one, e.g. a `str` only ever assigned `"0"` or
|
|
566
|
+
`"1"` (could be `bool`), or a `float` only ever incremented, never divided (could be `int`).
|
|
567
|
+
Needs whole-variable value-flow analysis across every reassignment in a scope, not just a first
|
|
568
|
+
binding, which is a different (and much bigger) kind of check than `LVA001`–`LVA007`; wants its
|
|
569
|
+
own design pass (what counts as "consistent with" a type, how far to follow calls and mutation,
|
|
570
|
+
false-positive risk on a codebase this analysis can't fully see) before it's worth building.
|
|
571
|
+
5. **LVA009: a reassignment that changes the type.** An error: `count: int = 0` later reassigned
|
|
572
|
+
`count = "done"` in the same scope. A real, common bug class (mypy already treats this as a type
|
|
573
|
+
error by default), but needs the same value-flow machinery as `LVA008` (infer every
|
|
574
|
+
reassignment's type with `annotations.inferred`, not just the first binding's), plus real
|
|
575
|
+
subtyping awareness to avoid noise `LVA008` doesn't have to worry about: a declared `X | None`
|
|
576
|
+
later assigned a plain `X` is normal Optional narrowing, not a bug, so the check needs to know
|
|
577
|
+
that's consistent rather than comparing annotation text like `LVA007` does; a name reused for
|
|
578
|
+
genuinely unrelated purposes (a sentinel, a generic helper handling more than one type by design)
|
|
579
|
+
is a real, if rarer, source of false positives to design around. Depends on `LVA008`'s design
|
|
580
|
+
work (same value-flow pass could likely serve both checks).
|
|
581
|
+
6. **LVA010: a declared union only one branch ever uses.** A warning: `x: int | str = 0` where every
|
|
582
|
+
value ever assigned across `x`'s lifetime is consistent with only `int`, never `str` — the union
|
|
583
|
+
is wider than the code actually exercises, and could narrow to `int`. The complement of `LVA008`
|
|
584
|
+
(inferring a narrower type from values with no declared type to compare against) and `LVA009` (a
|
|
585
|
+
reassignment that breaks a declared type, not just widens what's already declared as a union);
|
|
586
|
+
shares the same value-flow machinery and design questions as both.
|
|
587
|
+
7. **Show how each fix was inferred.** `annotations.inferred` now decides a fix through one of
|
|
588
|
+
several mechanisms (a literal, a container of literals, a same/cross-module function's declared
|
|
589
|
+
return type, a fixed-return builtin, a class it constructs, a copy of an already-typed local, a
|
|
590
|
+
subscript or an attribute of one), but `Offence.fix` keeps only the resulting annotation text,
|
|
591
|
+
not which one produced it. Surfacing that (`--diff`, or a verbose/explain mode) would help trust
|
|
592
|
+
and debug a fix, especially a guessed one. Needs every inference helper (`_scalar`, `_container`,
|
|
593
|
+
`_called`, `_subscripted`, the copy and attribute checks in `inferred` itself) to report a reason
|
|
594
|
+
alongside the type, not just the type — a real (if mechanical) change through most of
|
|
595
|
+
`annotations.py`'s inference path, not a one-line addition.
|
|
446
596
|
|
|
447
597
|
After the first release (these need it on PyPI, or a published tag):
|
|
448
598
|
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
[snake](https://www.asciiart.eu/art/595284d82d1f8d6d)
|
|
33
33
|
|
|
34
|
+
<https://pypi.org/project/python-constricter/>
|
|
35
|
+
|
|
34
36
|
---
|
|
35
37
|
|
|
36
38
|
Lint rules: every local variable is typed where it's first bound. Ships as a flake8 plugin, a pylint
|
|
@@ -69,27 +71,32 @@ class bodies too. Statements are read in source order, and only a name's first b
|
|
|
69
71
|
| `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
|
|
70
72
|
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters | name the real type |
|
|
71
73
|
| `LVA006` | an annotation nested `nesting` deep (5 by default) | a `type` alias for a part of it |
|
|
74
|
+
| `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
|
|
72
75
|
|
|
73
76
|
Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
|
|
74
77
|
`global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
|
|
75
|
-
enum members (a
|
|
78
|
+
enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
|
|
79
|
+
or `Flag`).
|
|
76
80
|
|
|
77
81
|
A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
|
|
78
82
|
`type-comments`, or automatically in a module written to run on Python 2: one that imports
|
|
79
83
|
`print_function`, `unicode_literals`, `absolute_import`, `division`, `with_statement`, `generators`
|
|
80
84
|
or `nested_scopes` from `__future__`.
|
|
81
85
|
|
|
86
|
+
`LVA007` compares a block on its own: an `if`'s body and its `orelse`, a `try`'s body and its
|
|
87
|
+
`except`s, and the like, are different blocks, since they don't both run in the same pass.
|
|
88
|
+
|
|
82
89
|
## Levels
|
|
83
90
|
|
|
84
91
|
Each level makes one more code an error. The rest are warnings: the CLI prints them (as `::warning`
|
|
85
92
|
or SARIF `warning` in those formats) but exits 0; flake8 and pylint report errors only.
|
|
86
93
|
|
|
87
|
-
| Level | Errors | Warnings
|
|
88
|
-
| ----------------- | -------------------------------- |
|
|
89
|
-
| `relaxed` / `0` | none | `LVA001`–`LVA004`
|
|
90
|
-
| `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005
|
|
91
|
-
| `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005
|
|
92
|
-
| `suffocate` / `3` | all | none
|
|
94
|
+
| Level | Errors | Warnings |
|
|
95
|
+
| ----------------- | -------------------------------- | ------------------------------------- |
|
|
96
|
+
| `relaxed` / `0` | none | `LVA001`–`LVA004`, `LVA007` |
|
|
97
|
+
| `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`–`LVA007` |
|
|
98
|
+
| `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`–`LVA007` |
|
|
99
|
+
| `suffocate` / `3` | all | none |
|
|
93
100
|
|
|
94
101
|
`LVA005` and `LVA006` aren't reported at `relaxed`.
|
|
95
102
|
|
|
@@ -99,14 +106,14 @@ Python 3.11+, no runtime dependencies.
|
|
|
99
106
|
|
|
100
107
|
| Tool | Setup | Reports | Suppress |
|
|
101
108
|
| ------ | ----------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
|
|
102
|
-
| CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`
|
|
103
|
-
| flake8 | install it (on by default) | `LVA001`–`
|
|
104
|
-
| pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`
|
|
105
|
-
| ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`
|
|
109
|
+
| CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
|
|
110
|
+
| flake8 | install it (on by default) | `LVA001`–`LVA007` | `# noqa: LVA001` |
|
|
111
|
+
| pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9107` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
|
|
112
|
+
| ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
|
|
106
113
|
|
|
107
114
|
pylint symbols: `unannotated-local-variable`, `untyped-for-or-match-variable`,
|
|
108
115
|
`comment-typed-for-variable`, `unannotated-module-or-class-variable`, `vague-annotation`,
|
|
109
|
-
`deeply-nested-annotation`.
|
|
116
|
+
`deeply-nested-annotation`, `redundant-annotation`.
|
|
110
117
|
|
|
111
118
|
Options:
|
|
112
119
|
|
|
@@ -257,8 +264,9 @@ Trunk) pick the plugin up once it's installed alongside them.
|
|
|
257
264
|
Without `lint.external`, ruff flags `# noqa: LVA00x` (RUF102) and `--fix` deletes it.
|
|
258
265
|
|
|
259
266
|
The CLI defaults to `.`, checks `*.py` and `*.ipynb`, and skips hidden dirs, `__pycache__`, `venv`,
|
|
260
|
-
`site-packages`, `build`, `dist` and `node_modules
|
|
261
|
-
|
|
267
|
+
`site-packages`, `build`, `dist` and `node_modules` by directory name; `--exclude` adds more
|
|
268
|
+
directory names (or globs) to skip the same way, on top of matching whole paths and file names. Exit
|
|
269
|
+
codes: `0` no errors, `1` errors, `2` an unreadable or unparsable file, or a bad `pyproject.toml`.
|
|
262
270
|
|
|
263
271
|
```bash
|
|
264
272
|
pip install python-constricter # once the first release is out; until then:
|
|
@@ -306,6 +314,7 @@ With [uv](https://docs.astral.sh/uv/) installed (CI pins 0.12.17):
|
|
|
306
314
|
|
|
307
315
|
```bash
|
|
308
316
|
export UV_PROJECT_ENVIRONMENT=local/.venv
|
|
317
|
+
uv venv --prompt constricter local/.venv # the prompt name; uv sync reuses this venv
|
|
309
318
|
uv sync --locked --no-install-project --no-build # the dev group: hash-checked wheels from uv.lock
|
|
310
319
|
uv pip install --python local/.venv --no-deps --no-build-isolation -e .
|
|
311
320
|
```
|
|
@@ -317,16 +326,18 @@ Checks (as CI runs them): `ruff check .` (every rule, preview included), `ruff f
|
|
|
317
326
|
`constricter --coverage --all-scopes --fail-under=100 src tests`, `pytest --cov` (100% branch
|
|
318
327
|
coverage). Everything generated goes in `local/`. Python is indented with 4 spaces.
|
|
319
328
|
|
|
320
|
-
After editing
|
|
321
|
-
the npm lock and the actions weekly.
|
|
329
|
+
After editing a dependency group, run `uv lock` (CI fails until you do). Dependabot updates
|
|
330
|
+
`uv.lock`, the npm lock and the actions weekly.
|
|
322
331
|
|
|
323
332
|
Fuzzing (`tests/test_fuzz.py`) runs with the tests: hypothesmith generates valid Python, which must
|
|
324
333
|
never crash the checker and must stay valid after `--fix`. For a large real codebase, run
|
|
325
334
|
`local/.venv/bin/python tests/corpus.py [PATH]` by hand: it checks PATH (default: this Python's
|
|
326
|
-
standard library, about
|
|
335
|
+
standard library, about 730 files in a few seconds) at `suffocate` and prints the time, the offences
|
|
327
336
|
per code, and any crash. `local/.venv/bin/python tests/corpus_fix.py [PATH]` runs
|
|
328
337
|
`--fix --unsafe-fixes` on a copy of it (in `local/corpus-fix/`) and checks every file still compiles
|
|
329
|
-
and a second pass has nothing left to fix
|
|
338
|
+
and a second pass has nothing left to fix. CI's Corpus job runs both against the standard library
|
|
339
|
+
and, from the pinned `corpus` dependency group (`requests`, `flask`, `django`, `sqlalchemy` — a tiny
|
|
340
|
+
HTTP client, two web frameworks and an ORM), the same way.
|
|
330
341
|
|
|
331
342
|
CI also runs the tests on PyPy 3.11 and free-threaded Python 3.14, which install only the `test`
|
|
332
343
|
dependency group: every dev tool doesn't have wheels for them, and the tests don't need them all.
|
|
@@ -351,7 +362,7 @@ Everything else is on. Some of these may be revisited.
|
|
|
351
362
|
| typos | the word `astroid` | A real package name. |
|
|
352
363
|
| harden-runner | `egress-policy: audit` on macOS and Windows, in the release jobs (release.yml, build.yml), and in the weekly external-link check | harden-runner supports only audit on GitHub's macOS and Windows runners; the release jobs haven't run yet; external links can go anywhere. |
|
|
353
364
|
| reuse | `reuse lint` not run (the files still comply: `REUSE.toml` covers them) | No recent release ships a wheel for Python 3.11+, so installing it builds from source with an unpinned `poetry-core`. |
|
|
354
|
-
| zizmor | `self-repository`
|
|
365
|
+
| zizmor | `self-repository` (`.github/zizmor.yml`) | Scorecard reads the `$/` form it wants as an unpinned third-party action, so local actions stay `./`. |
|
|
355
366
|
|
|
356
367
|
To apply the rulesets in `.github/rulesets/` (repo admin):
|
|
357
368
|
|
|
@@ -372,10 +383,8 @@ Done:
|
|
|
372
383
|
(Docs), pytest on Linux, macOS and Windows × Python 3.11–3.14 (Test), and the sdist and wheel,
|
|
373
384
|
`twine check` and a wheel smoke test (Build).
|
|
374
385
|
- **security.yml** runs on pushes, PRs and weekly: CodeQL (Python and Actions), zizmor (pedantic),
|
|
375
|
-
actionlint
|
|
376
|
-
|
|
377
|
-
- **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly. Its pin check misreads the `$/`
|
|
378
|
-
references as unpinned actions, so it flags them.
|
|
386
|
+
actionlint, pip-audit on the lock, and dependency review on PRs.
|
|
387
|
+
- **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly.
|
|
379
388
|
- **release.yml** runs on `v*` tags: CI, then **build.yml** (a reusable workflow) builds the dists,
|
|
380
389
|
checks the tag matches the version, and attests their provenance (SLSA v1 Build Level 3, as the
|
|
381
390
|
build and attestation run in a reusable workflow), then PyPI (trusted publishing), then a GitHub
|
|
@@ -403,7 +412,7 @@ Done:
|
|
|
403
412
|
badges this project's CI keeps true.
|
|
404
413
|
- **Baselines**, a **smarter `--fix`** (containers, same-module return types), **notebooks**, and
|
|
405
414
|
JSON with comments and trailing commas wherever constricter reads JSON.
|
|
406
|
-
- **Fuzzing**, a
|
|
415
|
+
- **Fuzzing**, a **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
|
|
407
416
|
notebooks**, and **SLSA Build Level 3 provenance** (GitHub's artifact attestations, from a
|
|
408
417
|
reusable build workflow) on each release.
|
|
409
418
|
- **Python 3.11+**, the oldest version still maintained after 3.10's end of life in October 2026.
|
|
@@ -414,8 +423,107 @@ Done:
|
|
|
414
423
|
Test jobs and Scorecard included.
|
|
415
424
|
- **Stdin**, **`gitlab`, `junit` and `rdjson` output**, **safe and `--unsafe-fixes`**, **per-file
|
|
416
425
|
ignores**, **`--exit-zero`** and **`--output-file`**, **tox and nox** snippets, **PyPy 3.11 and
|
|
417
|
-
free-threaded 3.14** in CI, and a
|
|
426
|
+
free-threaded 3.14** in CI, and a **`--fix` corpus run** (`tests/corpus_fix.py`).
|
|
418
427
|
- **Cross-module `--fix`** in the CLI (the flake8 and pylint plugins see one file at a time).
|
|
428
|
+
- **CI's Corpus job** runs `tests/corpus.py` and `tests/corpus_fix.py` against the runner's Python
|
|
429
|
+
standard library on every push and PR.
|
|
430
|
+
- **`project.Index`** sorts modules by name so `calls` finds a module/submodule import by prefix
|
|
431
|
+
(`bisect`) instead of scanning every indexed module.
|
|
432
|
+
- **Enum bases and factory calls resolve by import origin** (`annotations.factories`,
|
|
433
|
+
`checker._is_enum`'s `imported_from` check), so an aliased or re-exported `Enum`/`NamedTuple`/...
|
|
434
|
+
is still recognised; the bare-name lists remain a fallback for one imported some other way.
|
|
435
|
+
- **A general path-exclusion mechanism**: `--exclude` globs also match a directory name during a
|
|
436
|
+
directory walk, folding the built-in skip list (`__pycache__`, `node_modules`, hidden dirs, ...)
|
|
437
|
+
into the same mechanism instead of a separate hardcoded check.
|
|
438
|
+
- **`_FileRun` split** into `_CheckRun`, `_BaselineRun` and `_CoverageRun` (one per mode-group,
|
|
439
|
+
instead of one struct with fields only some modes populate), and `_check_path` and
|
|
440
|
+
`_baseline_path` share a `_read_checked` read-and-report-errors wrapper.
|
|
441
|
+
- **LVA007: duplicate/redundant typing.** A name annotated again with the type it already has, in
|
|
442
|
+
the same straight-line block; a warning at every level, an error at `suffocate`.
|
|
443
|
+
- **`--fix` infers more**: `not x` (always a real `bool`, unlike a comparison, which sqlalchemy's
|
|
444
|
+
own corpus data proves isn't safe to assume — it overloads `<`/`==` to build query expressions); a
|
|
445
|
+
table of builtins with a fixed, un-overloadable return type (`len`→`int`,
|
|
446
|
+
`isinstance`/`hasattr`/`callable`/`issubclass`→`bool`, `str`/`repr`/`chr`→`str`, `int`/`float`
|
|
447
|
+
→themselves, ...); and copying an already-known local's type for a plain `x = y` (from its own
|
|
448
|
+
annotation, an earlier fix in the same scope, or an annotated parameter) — a guessed source's type
|
|
449
|
+
copies too, marked just as guessed, so a chain of copies still converges in one `--fix` pass
|
|
450
|
+
instead of needing a second. Verified on the eighteen-codebase corpus below: fixed rose from
|
|
451
|
+
12,104 to 13,608 of the same 94,523 found (12.8% → 14.4%), no crashes, nothing left to fix on a
|
|
452
|
+
second pass anywhere, and `requests`' own test suite (not just its compile check) passed
|
|
453
|
+
identically — 617 passed, 15 skipped, 1 xfailed — before and after `--fix --unsafe-fixes` on its
|
|
454
|
+
source.
|
|
455
|
+
- **`--fix` infers subscripts and attributes** of an already-typed local: `container[key]` (its
|
|
456
|
+
element type from a `list`, `dict` or homogeneous `tuple[T, ...]`; the same `list`/`str`/`bytes`
|
|
457
|
+
type back for a slice; nothing for a fixed-length heterogeneous tuple, since the element varies
|
|
458
|
+
with the index) and `obj.attr` (a class-level annotated attribute of a class defined in the same
|
|
459
|
+
module — not one only assigned in `__init__`, which would need dataflow across methods to see).
|
|
460
|
+
Both build on `_Scope.types`, so a guessed source's uncertainty carries through automatically, the
|
|
461
|
+
same as a plain copy. Re-verified on the corpus: no crashes, still converges in one `--fix` pass,
|
|
462
|
+
`requests`' test suite still passes identically, and fixed rose further (e.g. standard library
|
|
463
|
+
4,431 → 4,450, mypy 1,939 → 1,996, django 2,408 → 2,414, sqlalchemy 830 → 848, pydantic 434 →
|
|
464
|
+
447).
|
|
465
|
+
- **A permanent, pinned corpus.** By hand (`tests/corpus.py`/`corpus_fix.py`,
|
|
466
|
+
`--unsafe-fixes --all-scopes`), against eighteen real packages, to choose it — OpenCV's Python
|
|
467
|
+
bindings (the original idea) turned out to be a poor fit, since they're mostly thin C bindings,
|
|
468
|
+
not hand-annotated Python:
|
|
469
|
+
|
|
470
|
+
| Codebase | Version | Files | Left un-typed | Fixed | LVA006 @5 | LVA007 |
|
|
471
|
+
| ---------------- | ------- | ----: | ------------: | ---------: | --------: | -----: |
|
|
472
|
+
| standard library | 3.11.16 | 732 | 24,173 | 3,828 | 0 | 0 |
|
|
473
|
+
| mypy | 2.3.1 | 195 | 9,853 | 1,777 | 0 | 0 |
|
|
474
|
+
| pylint | 4.0.8 | 178 | 3,609 | 486 | 0 | 0 |
|
|
475
|
+
| libcst | 1.9.0 | 297 | 3,407 | 1,042 | 201 | 0 |
|
|
476
|
+
| uiautomator2 | 3.7.0 | 32 | 888 | 86 | 0 | 0 |
|
|
477
|
+
| requests | 2.34.2 | 19 | 372 | 45 | 0 | 0 |
|
|
478
|
+
| flask | 3.1.3 | 24 | 410 | 29 | 0 | 0 |
|
|
479
|
+
| click | 8.5.0 | 17 | 567 | 86 | 0 | 0 |
|
|
480
|
+
| praw | 8.0.3 | 89 | 602 | 114 | 0 | 0 |
|
|
481
|
+
| boto3 | 1.43.99 | 39 | 483 | 93 | 0 | 0 |
|
|
482
|
+
| django | 6.1.1 | 907 | 15,648 | 2,248 | 0 | 0 |
|
|
483
|
+
| pydantic | 2.13.5 | 105 | 2,998 | 367 | 0 | 0 |
|
|
484
|
+
| attrs | 26.1.0 | 13 | 336 | 60 | 0 | 0 |
|
|
485
|
+
| aiohttp | 3.14.3 | 55 | 1,590 | 217 | 0 | 0 |
|
|
486
|
+
| paramiko | 5.0.0 | 41 | 1,272 | 241 | 0 | 0 |
|
|
487
|
+
| scrapy | 2.19.0 | 179 | 1,814 | 389 | 1 | 0 |
|
|
488
|
+
| sqlalchemy | 2.0.54 | 257 | 12,556 | 643 | 7 | 0 |
|
|
489
|
+
| rich | 15.0.0 | 100 | 1,841 | 353 | 0 | 0 |
|
|
490
|
+
| **Total** | | | **82,419** | **12,104** | | |
|
|
491
|
+
|
|
492
|
+
No crashes on any of them, and `--unsafe-fixes` left nothing broken or nothing unfixed on a second
|
|
493
|
+
pass, on any of them; `--nesting`'s default (5) never fires on fourteen of the eighteen, and
|
|
494
|
+
LVA007 found nothing on any of them, at any nesting — strong evidence it isn't noisy
|
|
495
|
+
(`--nesting`'s default is still worth revisiting some day: `libcst`, deeply nested CST types, is
|
|
496
|
+
by far the most affected, `sqlalchemy` and `scrapy` are the only other two to hit it at all at the
|
|
497
|
+
default, and 3 already reported 124 times on sqlalchemy, 45 on mypy). Chose four to run
|
|
498
|
+
permanently in CI (see the Corpus job): **`requests`** (tiny, so a fast check; extremely stable
|
|
499
|
+
and widely known; the canonical "makes external API calls" library; unlike the dev tools,
|
|
500
|
+
representative of typical, lightly-typed real-world code), **`flask`** and **`django`** (two web
|
|
501
|
+
frameworks, more decorator/class-heavy than `requests`; `django` pinned to the 5.2 LTS, since 6.x
|
|
502
|
+
needs Python 3.12+) and **`sqlalchemy`** (an ORM, and the corpus most likely to exercise
|
|
503
|
+
`LVA006`). Each runs as its own Corpus (`package`) matrix job, from a new `corpus` dependency
|
|
504
|
+
group.
|
|
505
|
+
|
|
506
|
+
Also validated `requests` specifically: cloned `v2.34.2` (its source checkout, with its own test
|
|
507
|
+
suite, not just the installed wheel), ran `--fix --unsafe-fixes --all-scopes` on `src/requests/`,
|
|
508
|
+
and ran its own test suite before and after. Identical both times: 617 passed, 15 skipped, 1
|
|
509
|
+
xfailed — the inferred types changed nothing about its runtime behaviour. One found a real, if
|
|
510
|
+
inert, mistake in the "guessed" heuristic: `internetSettings = winreg.OpenKey(...)` (Windows-only,
|
|
511
|
+
guarded by `sys.platform == "win32"`, so untested by this run) got annotated
|
|
512
|
+
`internetSettings: winreg.OpenKey = ...` — `winreg.OpenKey` is a _function_, not a class, but its
|
|
513
|
+
PascalCase name (a Windows API convention, not Python's) fools the capitalised-name "constructs a
|
|
514
|
+
class" heuristic (`annotations._constructs`).
|
|
515
|
+
|
|
516
|
+
- **`--fix` infers `self.attr` and `str`/`bytes` method calls.** `classes` (used for `obj.attr`) now
|
|
517
|
+
also collects `self.x: T = ...` from anywhere in a method's body, not just class-level
|
|
518
|
+
annotations; a method whose first parameter is literally named `self` has it typed as its class
|
|
519
|
+
(`_owners`, by the method's `id()`, not by name — a same-named method on an unrelated class isn't
|
|
520
|
+
confused with it), so `self.attr` resolves the same way `obj.attr` already did. Separately, a
|
|
521
|
+
fixed table of `str`/`bytes` methods whose return type doesn't depend on their arguments (`strip`,
|
|
522
|
+
`split`, `startswith`, `encode`, `decode`, ...) makes `some_str.strip()` on an already-typed local
|
|
523
|
+
as certain as a builtin function call — not a guess, unlike an arbitrary method call, which stays
|
|
524
|
+
guessed. Re-verified on the corpus: no crashes, still converges in one `--fix` pass on all six
|
|
525
|
+
re-checked (standard library, mypy, `requests`, `flask`, `django`, `sqlalchemy`), and fixed rose
|
|
526
|
+
further still (e.g. mypy 1,996 → 2,074, sqlalchemy 848 → 1,039, `requests` 52 → 57).
|
|
419
527
|
|
|
420
528
|
Next:
|
|
421
529
|
|
|
@@ -423,6 +531,48 @@ Next:
|
|
|
423
531
|
CPython 3.10 one).
|
|
424
532
|
2. Revisit the [disabled rules](#disabled-rules) as tools change (last checked 2026-09-22: COM812,
|
|
425
533
|
one-line DOC201/DOC402 and `max-args` came back on; the rest can't go yet).
|
|
534
|
+
3. **Raise `--fix`'s auto-fix rate further.** Attributes, subscripts, `self.attr` and `str`/`bytes`
|
|
535
|
+
method calls are done (see Done, above); what's left: a method call on an arbitrary class's
|
|
536
|
+
instance (`x = obj.method()`, unlike `str`/`bytes` isn't resolvable without following the
|
|
537
|
+
method's own return annotation, the same as `classes` already does for a field, extended to
|
|
538
|
+
methods) and `list`/`dict` methods whose return is the receiver's own element type (`list.pop`,
|
|
539
|
+
`dict.pop`, ...), which need the same element-type parsing `_subscripted` already does, just
|
|
540
|
+
reached from a method call instead of a subscript. `BinOp` (`a + b`, ~5% of the original sample)
|
|
541
|
+
would need operand types plus knowing the operator isn't overloaded to something else — riskier,
|
|
542
|
+
lower value, likely skip.
|
|
543
|
+
4. **LVA008: a type that could narrow.** A warning at `constrict`, an error at `suffocate`: a
|
|
544
|
+
declared type that every value assigned to the name (across its lifetime, not just its first
|
|
545
|
+
binding) is consistent with a strictly narrower one, e.g. a `str` only ever assigned `"0"` or
|
|
546
|
+
`"1"` (could be `bool`), or a `float` only ever incremented, never divided (could be `int`).
|
|
547
|
+
Needs whole-variable value-flow analysis across every reassignment in a scope, not just a first
|
|
548
|
+
binding, which is a different (and much bigger) kind of check than `LVA001`–`LVA007`; wants its
|
|
549
|
+
own design pass (what counts as "consistent with" a type, how far to follow calls and mutation,
|
|
550
|
+
false-positive risk on a codebase this analysis can't fully see) before it's worth building.
|
|
551
|
+
5. **LVA009: a reassignment that changes the type.** An error: `count: int = 0` later reassigned
|
|
552
|
+
`count = "done"` in the same scope. A real, common bug class (mypy already treats this as a type
|
|
553
|
+
error by default), but needs the same value-flow machinery as `LVA008` (infer every
|
|
554
|
+
reassignment's type with `annotations.inferred`, not just the first binding's), plus real
|
|
555
|
+
subtyping awareness to avoid noise `LVA008` doesn't have to worry about: a declared `X | None`
|
|
556
|
+
later assigned a plain `X` is normal Optional narrowing, not a bug, so the check needs to know
|
|
557
|
+
that's consistent rather than comparing annotation text like `LVA007` does; a name reused for
|
|
558
|
+
genuinely unrelated purposes (a sentinel, a generic helper handling more than one type by design)
|
|
559
|
+
is a real, if rarer, source of false positives to design around. Depends on `LVA008`'s design
|
|
560
|
+
work (same value-flow pass could likely serve both checks).
|
|
561
|
+
6. **LVA010: a declared union only one branch ever uses.** A warning: `x: int | str = 0` where every
|
|
562
|
+
value ever assigned across `x`'s lifetime is consistent with only `int`, never `str` — the union
|
|
563
|
+
is wider than the code actually exercises, and could narrow to `int`. The complement of `LVA008`
|
|
564
|
+
(inferring a narrower type from values with no declared type to compare against) and `LVA009` (a
|
|
565
|
+
reassignment that breaks a declared type, not just widens what's already declared as a union);
|
|
566
|
+
shares the same value-flow machinery and design questions as both.
|
|
567
|
+
7. **Show how each fix was inferred.** `annotations.inferred` now decides a fix through one of
|
|
568
|
+
several mechanisms (a literal, a container of literals, a same/cross-module function's declared
|
|
569
|
+
return type, a fixed-return builtin, a class it constructs, a copy of an already-typed local, a
|
|
570
|
+
subscript or an attribute of one), but `Offence.fix` keeps only the resulting annotation text,
|
|
571
|
+
not which one produced it. Surfacing that (`--diff`, or a verbose/explain mode) would help trust
|
|
572
|
+
and debug a fix, especially a guessed one. Needs every inference helper (`_scalar`, `_container`,
|
|
573
|
+
`_called`, `_subscripted`, the copy and attribute checks in `inferred` itself) to report a reason
|
|
574
|
+
alongside the type, not just the type — a real (if mechanical) change through most of
|
|
575
|
+
`annotations.py`'s inference path, not a one-line addition.
|
|
426
576
|
|
|
427
577
|
After the first release (these need it on PyPI, or a published tag):
|
|
428
578
|
|