python-constricter 0.2.2__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/LICENSE.md +21 -0
- python_constricter-0.2.2/PKG-INFO +456 -0
- python_constricter-0.2.2/README.md +435 -0
- python_constricter-0.2.2/pyproject.toml +176 -0
- python_constricter-0.2.2/src/constricter/__init__.py +42 -0
- python_constricter-0.2.2/src/constricter/__main__.py +9 -0
- python_constricter-0.2.2/src/constricter/annotations.py +290 -0
- python_constricter-0.2.2/src/constricter/baseline.py +111 -0
- python_constricter-0.2.2/src/constricter/checker.py +651 -0
- python_constricter-0.2.2/src/constricter/cli.py +791 -0
- python_constricter-0.2.2/src/constricter/config.py +177 -0
- python_constricter-0.2.2/src/constricter/explain.py +61 -0
- python_constricter-0.2.2/src/constricter/fixes.py +22 -0
- python_constricter-0.2.2/src/constricter/flake8_plugin.py +90 -0
- python_constricter-0.2.2/src/constricter/jsonc.py +31 -0
- python_constricter-0.2.2/src/constricter/noqa.py +47 -0
- python_constricter-0.2.2/src/constricter/notebook.py +111 -0
- python_constricter-0.2.2/src/constricter/project.py +229 -0
- python_constricter-0.2.2/src/constricter/py.typed +0 -0
- python_constricter-0.2.2/src/constricter/pylint_plugin.py +127 -0
- python_constricter-0.2.2/src/constricter/report.py +252 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivy Duggan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-constricter
|
|
3
|
+
Version: 0.2.2
|
|
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
|
+
Author: Ivy Duggan
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Framework :: Flake8
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
License-File: LICENSE.md
|
|
15
|
+
Requires-Dist: flake8>=7 ; extra == "flake8"
|
|
16
|
+
Requires-Dist: pylint>=3 ; extra == "pylint"
|
|
17
|
+
Provides-Extra: flake8
|
|
18
|
+
Provides-Extra: pylint
|
|
19
|
+
Import-Name: constricter
|
|
20
|
+
|
|
21
|
+
# python-con`strict`er
|
|
22
|
+
|
|
23
|
+
[](https://github.com/ivylikethevine/python-constricter/actions/workflows/ci.yml)
|
|
24
|
+
[](https://github.com/ivylikethevine/python-constricter/actions/workflows/security.yml)
|
|
25
|
+
[](https://scorecard.dev/viewer/?uri=github.com/ivylikethevine/python-constricter)
|
|
26
|
+
[](pyproject.toml)
|
|
27
|
+
[](#annotation-coverage)
|
|
28
|
+
[](LICENSE.md)
|
|
29
|
+
|
|
30
|
+
> I want **all** of my python code typed.
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
/^\/^\
|
|
34
|
+
_|__| O|
|
|
35
|
+
\/ /~ \_/ \
|
|
36
|
+
\____|__________/ \
|
|
37
|
+
\_______ \
|
|
38
|
+
`\ \ \
|
|
39
|
+
| | \
|
|
40
|
+
/ / \
|
|
41
|
+
/ / \\
|
|
42
|
+
/ / \ \
|
|
43
|
+
/ / \ \
|
|
44
|
+
/ / _----_ \ \
|
|
45
|
+
/ / _-~ ~-_ | |
|
|
46
|
+
( ( _-~ _--_ ~-_ _/ |
|
|
47
|
+
\ ~-____-~ _-~ ~-_ ~-_-~ /
|
|
48
|
+
~-_ _-~ ~-_ _-~
|
|
49
|
+
~--______-~ ~-___-~
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
[snake](https://www.asciiart.eu/art/595284d82d1f8d6d)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
Lint rules: every local variable is typed where it's first bound. Ships as a flake8 plugin, a pylint
|
|
57
|
+
plugin and a standalone command (for ruff, which loads no plugins).
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
def total(items: list[int]) -> int:
|
|
61
|
+
count = 0 # LVA001
|
|
62
|
+
result: int = 0 # ok
|
|
63
|
+
first, *rest = items # LVA001 twice
|
|
64
|
+
head: int
|
|
65
|
+
tail: list[int]
|
|
66
|
+
head, *tail = items # ok: declared first
|
|
67
|
+
if (n := len(items)) > 3: # LVA001
|
|
68
|
+
result = n # ok: rebinding
|
|
69
|
+
for item in items: # LVA002
|
|
70
|
+
result += item
|
|
71
|
+
for other in items: # type: int # LVA003
|
|
72
|
+
result += other
|
|
73
|
+
value: int
|
|
74
|
+
for value in items: # ok: declared first
|
|
75
|
+
result += value
|
|
76
|
+
return result
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Rules
|
|
80
|
+
|
|
81
|
+
Checked per function body, including methods and nested functions; with `all-scopes`, module and
|
|
82
|
+
class bodies too. Statements are read in source order, and only a name's first binding counts.
|
|
83
|
+
|
|
84
|
+
| Code | Reports | Fix |
|
|
85
|
+
| -------- | ---------------------------------------------------------------------- | ---------------------------------------------- |
|
|
86
|
+
| `LVA001` | `=`, unpacking, `:=` or `with ... as` in a function without annotation | `name: T = ...`, or `name: T` first |
|
|
87
|
+
| `LVA002` | an untyped `for` target or `match` capture | `name: T` first (or a type comment) |
|
|
88
|
+
| `LVA003` | a `for` target typed only by `# type: T` | `name: T` first |
|
|
89
|
+
| `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
|
|
90
|
+
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters | name the real type |
|
|
91
|
+
| `LVA006` | an annotation nested `nesting` deep (5 by default) | a `type` alias for a part of it |
|
|
92
|
+
|
|
93
|
+
Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
|
|
94
|
+
`global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
|
|
95
|
+
enum members (a class whose base's name ends in `Enum` or `Flag`).
|
|
96
|
+
|
|
97
|
+
A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
|
|
98
|
+
`type-comments`, or automatically in a module written to run on Python 2: one that imports
|
|
99
|
+
`print_function`, `unicode_literals`, `absolute_import`, `division`, `with_statement`, `generators`
|
|
100
|
+
or `nested_scopes` from `__future__`.
|
|
101
|
+
|
|
102
|
+
## Levels
|
|
103
|
+
|
|
104
|
+
Each level makes one more code an error. The rest are warnings: the CLI prints them (as `::warning`
|
|
105
|
+
or SARIF `warning` in those formats) but exits 0; flake8 and pylint report errors only.
|
|
106
|
+
|
|
107
|
+
| Level | Errors | Warnings |
|
|
108
|
+
| ----------------- | -------------------------------- | -------------------------------------- |
|
|
109
|
+
| `relaxed` / `0` | none | `LVA001`–`LVA004` |
|
|
110
|
+
| `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`, `LVA006` |
|
|
111
|
+
| `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`, `LVA006` |
|
|
112
|
+
| `suffocate` / `3` | all | none |
|
|
113
|
+
|
|
114
|
+
`LVA005` and `LVA006` aren't reported at `relaxed`.
|
|
115
|
+
|
|
116
|
+
Python 3.11+, no runtime dependencies.
|
|
117
|
+
|
|
118
|
+
## Use
|
|
119
|
+
|
|
120
|
+
| Tool | Setup | Reports | Suppress |
|
|
121
|
+
| ------ | ----------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
|
|
122
|
+
| CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA006` | `# noqa: LVA001` |
|
|
123
|
+
| flake8 | install it (on by default) | `LVA001`–`LVA006` | `# noqa: LVA001` |
|
|
124
|
+
| pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9106` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
|
|
125
|
+
| ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA006` | `# noqa: LVA001` |
|
|
126
|
+
|
|
127
|
+
pylint symbols: `unannotated-local-variable`, `untyped-for-or-match-variable`,
|
|
128
|
+
`comment-typed-for-variable`, `unannotated-module-or-class-variable`, `vague-annotation`,
|
|
129
|
+
`deeply-nested-annotation`.
|
|
130
|
+
|
|
131
|
+
Options:
|
|
132
|
+
|
|
133
|
+
| Option | CLI | `[tool.constricter]` | flake8 (CLI or config) | pylint |
|
|
134
|
+
| ---------------- | ----------------------------------------------------------- | -------------------- | ------------------------------- | --------------------------------- |
|
|
135
|
+
| level | `--level` | `level` | `--constricter-level` | `constricter-level` |
|
|
136
|
+
| type comments | `--type-comments` | `type-comments` | `--constricter-type-comments` | `constricter-type-comments = yes` |
|
|
137
|
+
| all scopes | `--all-scopes` | `all-scopes` | `--constricter-all-scopes` | `constricter-all-scopes = yes` |
|
|
138
|
+
| nesting | `--nesting N` | `nesting` | `--constricter-nesting` | `constricter-nesting` |
|
|
139
|
+
| fix | `--fix` (`--unsafe-fixes` for guesses), `--diff` to preview | - | - | - |
|
|
140
|
+
| select | `--select CODES` (codes or prefixes) | `select` | flake8's own `select` | pylint's own `enable` |
|
|
141
|
+
| ignore | `--ignore CODES` | `ignore` | flake8's own `extend-ignore` | pylint's own `disable` |
|
|
142
|
+
| exclude | `--exclude GLOB` (repeatable) | `exclude` | flake8's own `exclude` | pylint's own `ignore-paths` |
|
|
143
|
+
| format | `--format`: `text`, `json`, `github`, `sarif` | - | - | - |
|
|
144
|
+
| statistics | `--statistics` (counts per code, text format) | - | - | - |
|
|
145
|
+
| jobs | `--jobs N` (`-j`; 0: one per CPU) | `jobs` | flake8's own `--jobs` | pylint's own `--jobs` |
|
|
146
|
+
| baseline | `--baseline FILE`; `--write-baseline` records it | `baseline` | - | - |
|
|
147
|
+
| coverage | `--coverage`, `--fail-under PCT` | - | - | - |
|
|
148
|
+
| per-path levels | - | `per-path-levels` | - | - |
|
|
149
|
+
| per-file ignores | - | `per-file-ignores` | flake8's own `per-file-ignores` | - |
|
|
150
|
+
| stdin | `-` as the path, `--stdin-filename PATH` | - | flake8's own `-` | - |
|
|
151
|
+
| exit status | `--exit-zero` | - | flake8's own `--exit-zero` | pylint's own `--exit-zero` |
|
|
152
|
+
| output file | `--output-file FILE` | - | flake8's own `--output-file` | pylint's own `--output` |
|
|
153
|
+
|
|
154
|
+
`constricter --explain LVA002` prints a code's rationale, its fix, and the levels that report it.
|
|
155
|
+
|
|
156
|
+
The CLI reads `[tool.constricter]` from the nearest `pyproject.toml` above the current directory;
|
|
157
|
+
its flags override it, and `--exclude` adds to it. An unknown key or a bad value exits 2.
|
|
158
|
+
|
|
159
|
+
```toml
|
|
160
|
+
[tool.constricter]
|
|
161
|
+
level = "constrict" # or 2
|
|
162
|
+
exclude = ["tests/fixtures/*"]
|
|
163
|
+
type-comments = false
|
|
164
|
+
all-scopes = true
|
|
165
|
+
nesting = 5
|
|
166
|
+
jobs = 0
|
|
167
|
+
baseline = "constricter-baseline.json" # the default; relative to this pyproject.toml
|
|
168
|
+
|
|
169
|
+
# The first glob a file matches sets its level; other files get `level`.
|
|
170
|
+
[tool.constricter.per-path-levels]
|
|
171
|
+
"tests/*" = "strict"
|
|
172
|
+
|
|
173
|
+
# Codes (or prefixes) to drop for files matching a glob.
|
|
174
|
+
[tool.constricter.per-file-ignores]
|
|
175
|
+
"tests/fixtures/*" = ["LVA005", "LVA006"]
|
|
176
|
+
select = ["LVA00"]
|
|
177
|
+
ignore = ["LVA003"]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`--fix` adds the annotation where the value decides it, for a plain `name = value` in a function or
|
|
181
|
+
module body:
|
|
182
|
+
|
|
183
|
+
- a literal: `count = 0` becomes `count: int = 0`;
|
|
184
|
+
- a container whose elements agree: `[1, 2]` gives `list[int]`, `{"a": (1, "b")}` gives
|
|
185
|
+
`dict[str, tuple[int, str]]`;
|
|
186
|
+
- a call to a capitalised name (`path = Path(...)` gives `Path`), or to a plain function that
|
|
187
|
+
declares its return type (not a decorated, generic, async or redefined one, and not a return of
|
|
188
|
+
`None`, `Any` or one that uses a `TypeVar`), in the same module or, with the CLI, in another file
|
|
189
|
+
it's checking: `from pkg.util import f`, `import pkg.util as u` then `u.f()`, relative imports and
|
|
190
|
+
re-exports all work, as long as every name in the type already means the same thing in the file.
|
|
191
|
+
|
|
192
|
+
It never touches class bodies (a dataclass would gain a field) or unpacking, and it leaves what it
|
|
193
|
+
can't fix reported. The standard library and third-party packages are out of reach.
|
|
194
|
+
|
|
195
|
+
### Baselines
|
|
196
|
+
|
|
197
|
+
To adopt constricter on a codebase that already has offences, record them, then report only new
|
|
198
|
+
ones:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
constricter --write-baseline src # writes constricter-baseline.json next to pyproject.toml
|
|
202
|
+
constricter src # reports only offences the baseline doesn't cover
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A baseline counts each file's offences by code and variable name, not line number, so it survives
|
|
206
|
+
code moving around; another offence for a name it covers is still reported. Paths in it are relative
|
|
207
|
+
to it. It's JSON, and like every JSON file constricter reads it may have `//` and `/* */` comments
|
|
208
|
+
and trailing commas. `--baseline FILE` or `baseline` in `[tool.constricter]` names another file; the
|
|
209
|
+
default one is used only if it exists.
|
|
210
|
+
|
|
211
|
+
### Annotation coverage
|
|
212
|
+
|
|
213
|
+
`constricter --coverage src` prints the share of first bindings that are typed, per file and in
|
|
214
|
+
total; the bindings are the ones the rules cover (with `--all-scopes`, module and class bodies too),
|
|
215
|
+
and `# noqa` doesn't make one typed. `--fail-under PCT` (which implies `--coverage`) exits 1 below
|
|
216
|
+
PCT, so CI can hold a codebase to a share. With `--format=json` it prints
|
|
217
|
+
`{"typed", "total", "percent", "files"}`, which a badge can read: publish that JSON somewhere (a
|
|
218
|
+
gist, a release asset) and point
|
|
219
|
+
[shields.io's dynamic JSON badge](https://shields.io/badges/dynamic-json-badge) at it with the query
|
|
220
|
+
`$.percent`. This project keeps its own share at 100% in CI, so its badge is static.
|
|
221
|
+
|
|
222
|
+
### Notebooks
|
|
223
|
+
|
|
224
|
+
`.ipynb` files are checked too (directories include them): their code cells are read as one module,
|
|
225
|
+
IPython-only lines (`%magic`, `!shell`, `obj?`, `%%cell` magics) are skipped, and each offence is
|
|
226
|
+
reported at its cell and line (`analysis.ipynb:cell 3:2:5`). JSON output has a `cell` field; GitHub
|
|
227
|
+
and SARIF output point at the file and put the cell in the message. `--fix` and `--diff` edit the
|
|
228
|
+
cells, keeping the notebook's formatting.
|
|
229
|
+
|
|
230
|
+
### Adopting it on an existing codebase
|
|
231
|
+
|
|
232
|
+
1. See the scale: `constricter --statistics src` counts offences per code.
|
|
233
|
+
2. Record them: `constricter --write-baseline src`, and commit `constricter-baseline.json`.
|
|
234
|
+
3. Enforce it for new code: add the pre-commit hook or the GitHub Action; the baseline keeps old
|
|
235
|
+
offences quiet, and `--diff` / `--fix` clear the easy ones.
|
|
236
|
+
4. Burn it down: fix a file or package at a time, then `--write-baseline` again to shrink the file.
|
|
237
|
+
5. Tighten: raise `level` (or `per-path-levels` for the parts that are clean), then turn on
|
|
238
|
+
`all-scopes`.
|
|
239
|
+
|
|
240
|
+
### Output formats
|
|
241
|
+
|
|
242
|
+
`--format` is `text` (the default), `json`, `github` (workflow annotations), `sarif` (below),
|
|
243
|
+
`gitlab` (Code Climate JSON, for GitLab's merge-request Code Quality widget: pass the file as a
|
|
244
|
+
`codequality` report artifact), `junit` (a test suite per file, a failed test case per offence, for
|
|
245
|
+
Jenkins, Azure Pipelines, CircleCI or GitLab's test reports) or `rdjson` (for
|
|
246
|
+
[reviewdog](https://github.com/reviewdog/reviewdog), with each certain fix as a suggestion).
|
|
247
|
+
`--output-file FILE` writes the report there, and `--exit-zero` exits 0 even when there are errors
|
|
248
|
+
(not when a file can't be read).
|
|
249
|
+
|
|
250
|
+
### Editors (standard input)
|
|
251
|
+
|
|
252
|
+
`constricter - --stdin-filename path/to/file.py` checks standard input, reported as that path (which
|
|
253
|
+
also picks its per-path level and baseline entry; a `.ipynb` name reads a notebook). With `--fix` it
|
|
254
|
+
prints the fixed source instead of a report, and `--diff` diffs it. That's what editors that lint
|
|
255
|
+
unsaved buffers through a command need (none-ls, nvim-lint, flycheck, ALE, efm-langserver, Helix).
|
|
256
|
+
|
|
257
|
+
### SARIF (code scanning)
|
|
258
|
+
|
|
259
|
+
`--format=sarif` writes SARIF 2.1.0, with each result's level (`error` or `warning`) set by
|
|
260
|
+
`--level`. In GitHub Actions, upload it to code scanning (the job needs `security-events: write`):
|
|
261
|
+
|
|
262
|
+
```yaml
|
|
263
|
+
- run: constricter --format=sarif src > constricter.sarif
|
|
264
|
+
- if: ${{ !cancelled() }} # upload the findings even when the step above failed on them
|
|
265
|
+
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
|
266
|
+
with:
|
|
267
|
+
sarif_file: constricter.sarif
|
|
268
|
+
category: constricter
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
SonarQube and SonarCloud import it with `sonar.sarifReportPaths=constricter.sarif`; any other tool
|
|
272
|
+
that reads SARIF 2.1.0 takes the same file.
|
|
273
|
+
|
|
274
|
+
Tools that run flake8 or pylint (VS Code's extensions, python-lsp-server, prospector, MegaLinter,
|
|
275
|
+
Trunk) pick the plugin up once it's installed alongside them.
|
|
276
|
+
|
|
277
|
+
Without `lint.external`, ruff flags `# noqa: LVA00x` (RUF102) and `--fix` deletes it.
|
|
278
|
+
|
|
279
|
+
The CLI defaults to `.`, checks `*.py` and `*.ipynb`, and skips hidden dirs, `__pycache__`, `venv`,
|
|
280
|
+
`site-packages`, `build`, `dist` and `node_modules`. Exit codes: `0` no errors, `1` errors, `2` an
|
|
281
|
+
unreadable or unparsable file, or a bad `pyproject.toml`.
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
pip install python-constricter # once the first release is out; until then:
|
|
285
|
+
pip install "python-constricter @ git+https://github.com/ivylikethevine/python-constricter@v0.2.0"
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
|
|
289
|
+
|
|
290
|
+
```yaml
|
|
291
|
+
- repo: https://github.com/ivylikethevine/python-constricter
|
|
292
|
+
rev: v0.2.0
|
|
293
|
+
hooks:
|
|
294
|
+
- id: constricter
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
tox and nox, with it in the environment's dependencies:
|
|
298
|
+
|
|
299
|
+
```ini
|
|
300
|
+
# tox.ini
|
|
301
|
+
[testenv:types]
|
|
302
|
+
deps = python-constricter
|
|
303
|
+
commands = constricter --level=constrict src
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
# noxfile.py
|
|
308
|
+
@nox.session
|
|
309
|
+
def types(session: nox.Session) -> None:
|
|
310
|
+
session.install("python-constricter")
|
|
311
|
+
session.run("constricter", "--level=constrict", "src")
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
GitHub Actions, as PR annotations (it installs from the action's own tag, not PyPI):
|
|
315
|
+
|
|
316
|
+
```yaml
|
|
317
|
+
- uses: ivylikethevine/python-constricter@v0.2.0
|
|
318
|
+
with:
|
|
319
|
+
args: --format=github src tests # the default is `--format=github` on `.`
|
|
320
|
+
python-version: "3.13" # 3.11 or later
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Development
|
|
324
|
+
|
|
325
|
+
With [uv](https://docs.astral.sh/uv/) installed (CI pins 0.12.17):
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
export UV_PROJECT_ENVIRONMENT=local/.venv
|
|
329
|
+
uv sync --locked --no-install-project --no-build # the dev group: hash-checked wheels from uv.lock
|
|
330
|
+
uv pip install --python local/.venv --no-deps --no-build-isolation -e .
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Checks (as CI runs them): `ruff check .` (every rule, preview included), `ruff format --check .`,
|
|
334
|
+
`basedpyright` (all), `mypy` (strict), `pylint src tests` (every extension), `flake8 src tests`,
|
|
335
|
+
`typos`, `validate-pyproject pyproject.toml`, `uv lock --check`,
|
|
336
|
+
`constricter --level=suffocate --all-scopes src tests`,
|
|
337
|
+
`constricter --coverage --all-scopes --fail-under=100 src tests`, `pytest --cov` (100% branch
|
|
338
|
+
coverage). Everything generated goes in `local/`. Python is indented with 4 spaces.
|
|
339
|
+
|
|
340
|
+
After editing the `dev` group, run `uv lock` (CI fails until you do). Dependabot updates `uv.lock`,
|
|
341
|
+
the npm lock and the actions weekly.
|
|
342
|
+
|
|
343
|
+
Fuzzing (`tests/test_fuzz.py`) runs with the tests: hypothesmith generates valid Python, which must
|
|
344
|
+
never crash the checker and must stay valid after `--fix`. For a large real codebase, run
|
|
345
|
+
`local/.venv/bin/python tests/corpus.py [PATH]` by hand: it checks PATH (default: this Python's
|
|
346
|
+
standard library, about 660 files in two seconds) at `suffocate` and prints the time, the offences
|
|
347
|
+
per code, and any crash. `local/.venv/bin/python tests/corpus_fix.py [PATH]` runs
|
|
348
|
+
`--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 (the standard library: about 3,800 fixes, none breaking).
|
|
350
|
+
|
|
351
|
+
CI also runs the tests on PyPy 3.11 and free-threaded Python 3.14, which install only the `test`
|
|
352
|
+
dependency group: every dev tool doesn't have wheels for them, and the tests don't need them all.
|
|
353
|
+
|
|
354
|
+
Markdown (markdownlint-cli2 and prettier, locked in `.github/package-lock.json`):
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
npm ci --prefix .github
|
|
358
|
+
git ls-files -z '*.md' | xargs -0 .github/node_modules/.bin/markdownlint-cli2
|
|
359
|
+
git ls-files -z '*.md' | xargs -0 .github/node_modules/.bin/prettier --check
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Disabled rules
|
|
363
|
+
|
|
364
|
+
Everything else is on. Some of these may be revisited.
|
|
365
|
+
|
|
366
|
+
| Tool | Rule | Why |
|
|
367
|
+
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
368
|
+
| ruff | `incorrect-blank-line-before-class`, `multi-line-summary-second-line` (D203/D213) | Each contradicts a rule that stays on (D211/D212); one of each pair has to go. |
|
|
369
|
+
| ruff (`tests/`) | `assert` (S101) | pytest works through `assert`. |
|
|
370
|
+
| mypy, basedpyright | astroid's untyped calls and missing stubs | astroid (pylint's parser) ships no type information. |
|
|
371
|
+
| typos | the word `astroid` | A real package name. |
|
|
372
|
+
| 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
|
+
| 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` on the CI job that runs the repository's root action | zizmor wants `$/`, and actionlint rejects a bare `$/` (it has no path), so that one line uses `./`. |
|
|
375
|
+
|
|
376
|
+
To apply the rulesets in `.github/rulesets/` (repo admin):
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
gh api repos/ivylikethevine/python-constricter/rulesets --method POST --input .github/rulesets/main.json
|
|
380
|
+
gh api repos/ivylikethevine/python-constricter/rulesets --method POST --input .github/rulesets/tags.json
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
To publish, add a trusted publisher on PyPI (repository `ivylikethevine/python-constricter`,
|
|
384
|
+
workflow `release.yml`, environment `pypi`) and a `pypi` environment in the repo settings, then push
|
|
385
|
+
a `v*` tag.
|
|
386
|
+
|
|
387
|
+
## Roadmap
|
|
388
|
+
|
|
389
|
+
Done:
|
|
390
|
+
|
|
391
|
+
- **ci.yml** runs on pushes and PRs: the checks above and the pre-commit hook (Lint), Markdown
|
|
392
|
+
(Docs), pytest on Linux, macOS and Windows × Python 3.11–3.14 (Test), and the sdist and wheel,
|
|
393
|
+
`twine check` and a wheel smoke test (Build).
|
|
394
|
+
- **security.yml** runs on pushes, PRs and weekly: CodeQL (Python and Actions), zizmor (pedantic),
|
|
395
|
+
actionlint (kjanat's fork, which reads the `$/` self-repository syntax the workflows use),
|
|
396
|
+
pip-audit on the lock, and dependency review on PRs.
|
|
397
|
+
- **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly. Its pin check misreads the `$/`
|
|
398
|
+
references as unpinned actions, so it flags them.
|
|
399
|
+
- **release.yml** runs on `v*` tags: CI, then **build.yml** (a reusable workflow) builds the dists,
|
|
400
|
+
checks the tag matches the version, and attests their provenance (SLSA v1 Build Level 3, as the
|
|
401
|
+
build and attestation run in a reusable workflow), then PyPI (trusted publishing), then a GitHub
|
|
402
|
+
release with the dists and the attestation bundle. Verify a download with
|
|
403
|
+
`gh attestation verify FILE --repo ivylikethevine/python-constricter --signer-workflow ivylikethevine/python-constricter/.github/workflows/build.yml`.
|
|
404
|
+
- **Pinning:** actions by SHA, Python dependencies by hash (`uv.lock`), npm by lockfile, actionlint
|
|
405
|
+
and uv by version. Dependabot updates all but the last two; `uv lock --check` fails CI on drift.
|
|
406
|
+
- **harden-runner** blocks all but the observed hosts in every Linux job that has run.
|
|
407
|
+
- **Rulesets:** `.github/rulesets/` requires every check on `main` and protects `v*` tags.
|
|
408
|
+
- **Settings** from `[tool.constricter]` in `pyproject.toml`.
|
|
409
|
+
- **Python 2 code:** type comments count automatically in modules that import Python 2 `__future__`
|
|
410
|
+
features.
|
|
411
|
+
- **All scopes:** `all-scopes` checks module and class bodies (`LVA004`).
|
|
412
|
+
- **Suffocate:** `src/` and `tests/` pass at `--level=suffocate --all-scopes` in CI.
|
|
413
|
+
- **More checks:** gitleaks over the whole history (Security), lychee on the Markdown links (offline
|
|
414
|
+
in Docs, external ones weekly), validate-pyproject and check-wheel-contents.
|
|
415
|
+
- **SARIF docs**, and `--explain`, `--select` / `--ignore`, `--diff` and `--statistics`.
|
|
416
|
+
- **Scorecard** blocks all but the hosts it was seen to use.
|
|
417
|
+
- **Project files:** a `constricter-fix` pre-commit hook, a CHANGELOG (release notes grouped by
|
|
418
|
+
`.github/release.yml`), badges, issue and PR templates, CODEOWNERS and CONTRIBUTING.
|
|
419
|
+
- **Per-path levels**, **`--jobs`** for parallel checking, and a **GitHub Action** (`action.yml`)
|
|
420
|
+
that CI runs on the project itself.
|
|
421
|
+
- **LVA005, LVA006 and `--fix`.**
|
|
422
|
+
- **`--coverage`** (and `--fail-under`) for annotation coverage, with test- and annotation-coverage
|
|
423
|
+
badges this project's CI keeps true.
|
|
424
|
+
- **Baselines**, a **smarter `--fix`** (containers, same-module return types), **notebooks**, and
|
|
425
|
+
JSON with comments and trailing commas wherever constricter reads JSON.
|
|
426
|
+
- **Fuzzing**, a manual **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
|
|
427
|
+
notebooks**, and **SLSA Build Level 3 provenance** (GitHub's artifact attestations, from a
|
|
428
|
+
reusable build workflow) on each release.
|
|
429
|
+
- **Python 3.11+**, the oldest version still maintained after 3.10's end of life in October 2026.
|
|
430
|
+
Older Pythons aren't planned: 3.10 would add a runtime dependency (`tomli`) for a month, and
|
|
431
|
+
3.6–3.9 would mean dropping `match` from the checker and keeping a second CI setup with older
|
|
432
|
+
tools. Code written for any Python 3 version can still be checked.
|
|
433
|
+
- **harden-runner** blocks all but the observed hosts in every Linux job that has run, the Linux
|
|
434
|
+
Test jobs and Scorecard included.
|
|
435
|
+
- **Stdin**, **`gitlab`, `junit` and `rdjson` output**, **safe and `--unsafe-fixes`**, **per-file
|
|
436
|
+
ignores**, **`--exit-zero`** and **`--output-file`**, **tox and nox** snippets, **PyPy 3.11 and
|
|
437
|
+
free-threaded 3.14** in CI, and a manual **`--fix` corpus run** (`tests/corpus_fix.py`).
|
|
438
|
+
- **Cross-module `--fix`** in the CLI (the flake8 and pylint plugins see one file at a time).
|
|
439
|
+
|
|
440
|
+
Next:
|
|
441
|
+
|
|
442
|
+
1. **Restore `reuse lint`** once `reuse` ships a wheel for Python 3.11+ (6.2.0 still has only a
|
|
443
|
+
CPython 3.10 one).
|
|
444
|
+
2. Revisit the [disabled rules](#disabled-rules) as tools change (last checked 2026-09-22: COM812,
|
|
445
|
+
one-line DOC201/DOC402 and `max-args` came back on; the rest can't go yet).
|
|
446
|
+
|
|
447
|
+
After the first release (these need it on PyPI, or a published tag):
|
|
448
|
+
|
|
449
|
+
1. **Switch the release jobs to `block`** with the hosts the first release run shows (PyPI upload,
|
|
450
|
+
Sigstore, GitHub releases).
|
|
451
|
+
2. **A PyPI badge**, and `pip install python-constricter` as the documented install.
|
|
452
|
+
3. **The GitHub Action on the Marketplace**, so `uses: ivylikethevine/python-constricter@v1` is
|
|
453
|
+
listed (it already works from any tag).
|
|
454
|
+
4. **Trunk and MegaLinter plugin definitions**, submitted upstream.
|
|
455
|
+
5. **A conda-forge recipe.**
|
|
456
|
+
|