python-constricter 0.2.3__tar.gz → 0.2.4__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.4/PKG-INFO +378 -0
- python_constricter-0.2.4/README.md +350 -0
- {python_constricter-0.2.3/src → python_constricter-0.2.4}/constricter/__init__.py +17 -6
- {python_constricter-0.2.3/src → python_constricter-0.2.4}/constricter/__main__.py +1 -1
- python_constricter-0.2.4/constricter/cli/__init__.py +2 -0
- {python_constricter-0.2.3/src/constricter → python_constricter-0.2.4/constricter/cli}/baseline.py +1 -1
- python_constricter-0.2.4/constricter/cli/command.py +598 -0
- {python_constricter-0.2.3/src/constricter → python_constricter-0.2.4/constricter/cli}/config.py +62 -2
- {python_constricter-0.2.3/src/constricter → python_constricter-0.2.4/constricter/cli}/explain.py +38 -3
- python_constricter-0.2.4/constricter/cli/guard.py +129 -0
- python_constricter-0.2.4/constricter/cli/hints.py +789 -0
- python_constricter-0.2.4/constricter/cli/options.py +601 -0
- python_constricter-0.2.4/constricter/cli/paths.py +51 -0
- {python_constricter-0.2.3/src/constricter → python_constricter-0.2.4/constricter/cli}/report.py +156 -16
- python_constricter-0.2.4/constricter/fix/__init__.py +2 -0
- python_constricter-0.2.4/constricter/fix/fills.py +226 -0
- python_constricter-0.2.4/constricter/fix/fixes.py +192 -0
- python_constricter-0.2.4/constricter/fix/guesses.py +164 -0
- python_constricter-0.2.4/constricter/fix/hinted.py +242 -0
- python_constricter-0.2.4/constricter/fix/imports.py +174 -0
- python_constricter-0.2.4/constricter/fix/inference.py +756 -0
- python_constricter-0.2.4/constricter/fix/known.py +177 -0
- python_constricter-0.2.4/constricter/fix/opened.py +74 -0
- {python_constricter-0.2.3/src/constricter → python_constricter-0.2.4/constricter/fix}/project.py +139 -23
- python_constricter-0.2.4/constricter/fix/returned.py +217 -0
- python_constricter-0.2.4/constricter/fix/returns.py +200 -0
- python_constricter-0.2.4/constricter/fix/stdlib.py +255 -0
- python_constricter-0.2.4/constricter/fix/targets.py +128 -0
- {python_constricter-0.2.3/src → python_constricter-0.2.4}/constricter/noqa.py +1 -1
- {python_constricter-0.2.3/src → python_constricter-0.2.4}/constricter/notebook.py +4 -3
- python_constricter-0.2.4/constricter/offences.py +245 -0
- python_constricter-0.2.4/constricter/plugins/__init__.py +2 -0
- python_constricter-0.2.3/src/constricter/flake8_plugin.py → python_constricter-0.2.4/constricter/plugins/flake8.py +35 -2
- python_constricter-0.2.3/src/constricter/pylint_plugin.py → python_constricter-0.2.4/constricter/plugins/pylint.py +49 -7
- python_constricter-0.2.4/constricter/rules/__init__.py +2 -0
- python_constricter-0.2.4/constricter/rules/annotations.py +592 -0
- python_constricter-0.2.4/constricter/rules/checker.py +745 -0
- python_constricter-0.2.4/constricter/rules/flow.py +481 -0
- python_constricter-0.2.4/constricter/rules/narrowing.py +51 -0
- python_constricter-0.2.4/constricter/rules/redundant.py +166 -0
- python_constricter-0.2.4/constricter/rules/scope.py +608 -0
- python_constricter-0.2.4/constricter/rules/syntax.py +239 -0
- python_constricter-0.2.4/constricter/rules/walked.py +57 -0
- {python_constricter-0.2.3 → python_constricter-0.2.4}/pyproject.toml +51 -15
- python_constricter-0.2.3/PKG-INFO +0 -606
- python_constricter-0.2.3/README.md +0 -585
- python_constricter-0.2.3/src/constricter/annotations.py +0 -653
- python_constricter-0.2.3/src/constricter/checker.py +0 -824
- python_constricter-0.2.3/src/constricter/cli.py +0 -857
- python_constricter-0.2.3/src/constricter/fixes.py +0 -28
- {python_constricter-0.2.3 → python_constricter-0.2.4}/LICENSE.md +0 -0
- {python_constricter-0.2.3/src → python_constricter-0.2.4}/constricter/jsonc.py +0 -0
- {python_constricter-0.2.3/src → python_constricter-0.2.4}/constricter/py.typed +0 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-constricter
|
|
3
|
+
Version: 0.2.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
|
+
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: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
License-File: LICENSE.md
|
|
22
|
+
Requires-Dist: flake8>=7 ; extra == "flake8"
|
|
23
|
+
Requires-Dist: pylint>=3 ; extra == "pylint"
|
|
24
|
+
Provides-Extra: flake8
|
|
25
|
+
Provides-Extra: pylint
|
|
26
|
+
Import-Name: constricter
|
|
27
|
+
|
|
28
|
+
# python-con`strict`er
|
|
29
|
+
|
|
30
|
+
> EXPERIMENTAL UNTIL v1.0.0
|
|
31
|
+
|
|
32
|
+
**I want all of my python code typed.**
|
|
33
|
+
|
|
34
|
+
[](https://github.com/ivylikethevine/python-constricter/actions/workflows/ci.yml)
|
|
35
|
+
[](https://github.com/ivylikethevine/python-constricter/actions/workflows/security.yml)
|
|
36
|
+
[](https://pypi.org/project/python-constricter/)
|
|
37
|
+
[](https://pypi.org/project/python-constricter/)
|
|
38
|
+
[](https://scorecard.dev/viewer/?uri=github.com/ivylikethevine/python-constricter)
|
|
39
|
+
[](pyproject.toml)
|
|
40
|
+
[](#annotation-coverage)
|
|
41
|
+
[](LICENSE.md)
|
|
42
|
+
|
|
43
|
+
Lint rules: every local variable is typed where it's first bound. Ships as a flake8 plugin, a pylint
|
|
44
|
+
plugin and a standalone command (for ruff, which loads no plugins).
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/^\/^\
|
|
48
|
+
_|__| O|
|
|
49
|
+
\/ /~ \_/ \
|
|
50
|
+
\____|__________/ \
|
|
51
|
+
\_______ \
|
|
52
|
+
`\ \ \
|
|
53
|
+
| | \
|
|
54
|
+
/ / \
|
|
55
|
+
/ / \\
|
|
56
|
+
/ / \ \
|
|
57
|
+
/ / \ \
|
|
58
|
+
/ / _----_ \ \
|
|
59
|
+
/ / _-~ ~-_ | |
|
|
60
|
+
( ( _-~ _--_ ~-_ _/ |
|
|
61
|
+
\ ~-____-~ _-~ ~-_ ~-_-~ /
|
|
62
|
+
~-_ _-~ ~-_ _-~
|
|
63
|
+
~--______-~ ~-___-~
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
def total(items: list[int]) -> int:
|
|
70
|
+
count = 0 # LVA001
|
|
71
|
+
result: int = 0 # ok
|
|
72
|
+
first, *rest = items # LVA001 twice
|
|
73
|
+
head: int
|
|
74
|
+
tail: list[int]
|
|
75
|
+
head, *tail = items # ok: declared first
|
|
76
|
+
if (n := len(items)) > 3: # LVA001
|
|
77
|
+
result = n # ok: rebinding
|
|
78
|
+
for item in items: # LVA002
|
|
79
|
+
result += item
|
|
80
|
+
for other in items: # type: int # LVA003
|
|
81
|
+
result += other
|
|
82
|
+
value: int
|
|
83
|
+
for value in items: # ok: declared first
|
|
84
|
+
result += value
|
|
85
|
+
return result
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Rules
|
|
89
|
+
|
|
90
|
+
Checked per function body, including methods and nested functions; with `all-scopes`, module and
|
|
91
|
+
class bodies too. Statements are read in source order, and only a name's first binding counts
|
|
92
|
+
(`LVA008`–`LVA010` aside, which read every one).
|
|
93
|
+
|
|
94
|
+
| Code | Reports | Fix |
|
|
95
|
+
| -------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
96
|
+
| `LVA001` | `=`, unpacking, `:=` or `with ... as` in a function without annotation | `name: T = ...`, or `name: T` first |
|
|
97
|
+
| `LVA002` | an untyped `for` target or `match` capture | `name: T` first (or a type comment) |
|
|
98
|
+
| `LVA003` | a `for` target typed only by `# type: T` | `name: T` first |
|
|
99
|
+
| `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
|
|
100
|
+
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters | name the real type |
|
|
101
|
+
| `LVA006` | an annotation nested `nesting` deep (3 by default) | a `type` alias for a part of it |
|
|
102
|
+
| `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
|
|
103
|
+
| `LVA008` | with every value the name ever holds known: an annotation that could narrow to them | narrow it (`total: int`) |
|
|
104
|
+
| `LVA009` | a value, anywhere in the name's lifetime, whose type doesn't fit its annotation | fix the value, or widen the annotation |
|
|
105
|
+
| `LVA010` | with every value known: a union member no value is | drop the member |
|
|
106
|
+
| `LVA011` | an annotation listing a fixed-length tuple of more than `max-length` types (4 by default) | name the fields (a `NamedTuple`, a dataclass) |
|
|
107
|
+
| `LVA012` | opt-in: a local bound once, outside any loop, and never rebound | `name: Final = ...` |
|
|
108
|
+
|
|
109
|
+
Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
|
|
110
|
+
`global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
|
|
111
|
+
enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
|
|
112
|
+
or `Flag`).
|
|
113
|
+
|
|
114
|
+
A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
|
|
115
|
+
`type-comments`, or automatically in a module written to run on Python 2: one that imports
|
|
116
|
+
`print_function`, `unicode_literals`, `absolute_import`, `division`, `with_statement`, `generators`
|
|
117
|
+
or `nested_scopes` from `__future__`.
|
|
118
|
+
|
|
119
|
+
`LVA007` compares a block on its own: an `if`'s body and its `orelse`, a `try`'s body and its
|
|
120
|
+
`except`s, and the like, are different blocks, since they don't both run in the same pass.
|
|
121
|
+
|
|
122
|
+
`LVA009` checks every binding of an annotated name (or parameter) in the scope, wherever and in
|
|
123
|
+
whatever order they run, but only one whose value's type `--fix` would infer with certainty, and
|
|
124
|
+
only against types whose every subclass is known: builtins, and classes the module defines on such
|
|
125
|
+
bases. An imported class, a protocol or an alias is never compared, builtin containers are compared
|
|
126
|
+
by the container alone (`flags: tuple[str, ...] = ("-q",)` fits), a copy of a union-typed name is
|
|
127
|
+
skipped (an `is None` check may have narrowed it), and so is a name annotated only under
|
|
128
|
+
`if TYPE_CHECKING:`. It has no `--fix`.
|
|
129
|
+
|
|
130
|
+
`LVA008` and `LVA010` also need every binding's value known (one unknown call, loop target or
|
|
131
|
+
unpacking and the name could hold anything), and are only claimed for a function's own names: a
|
|
132
|
+
module or class variable is state other code rebinds out of sight (`mod.X = ...`, `self.x = ...`,
|
|
133
|
+
`monkeypatch`), and a `nonlocal` write from a nested function counts as unknown too. Neither has a
|
|
134
|
+
`--fix`: `--fix` adds annotations, it doesn't rewrite them.
|
|
135
|
+
|
|
136
|
+
## Levels
|
|
137
|
+
|
|
138
|
+
Each level makes one more code an error. The rest are warnings: the CLI prints them (as `::warning`
|
|
139
|
+
or SARIF `warning` in those formats) but exits 0; flake8 and pylint report errors only.
|
|
140
|
+
|
|
141
|
+
| Level | Errors | Warnings |
|
|
142
|
+
| ----------------- | -------------------------------------- | --------------------------------------------------------- |
|
|
143
|
+
| `relaxed` / `0` | none | `LVA001`–`LVA004`, `LVA007`, `LVA009` |
|
|
144
|
+
| `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`–`LVA007`, `LVA009`, `LVA011` |
|
|
145
|
+
| `constrict` / `2` | `LVA001`, `LVA004`, `LVA002`, `LVA009` | `LVA003`, `LVA005`–`LVA008`, `LVA010`, `LVA011` |
|
|
146
|
+
| `suffocate` / `3` | all | none |
|
|
147
|
+
|
|
148
|
+
`LVA005`, `LVA006` and `LVA011` aren't reported at `relaxed`; `LVA008` and `LVA010` only from
|
|
149
|
+
`constrict`. `LVA012` is opt-in, reported only when selected by its full code
|
|
150
|
+
(`--extend-select LVA012`, flake8's `extend-select = LVA012`, pylint's `enable = could-be-final`),
|
|
151
|
+
at every level, and an error only at `suffocate`: on the corpus, 45–70% of every codebase's first
|
|
152
|
+
bindings qualify, so it suits a codebase that wants `Final` everywhere it can go, not a default.
|
|
153
|
+
|
|
154
|
+
Python 3.11+, no runtime dependencies.
|
|
155
|
+
|
|
156
|
+
## Use
|
|
157
|
+
|
|
158
|
+
| Tool | Setup | Reports | Suppress |
|
|
159
|
+
| ------ | ----------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
|
|
160
|
+
| CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA012` | `# noqa: LVA001` |
|
|
161
|
+
| flake8 | install it (on by default) | `LVA001`–`LVA012` | `# noqa: LVA001` |
|
|
162
|
+
| pylint | `load-plugins = ["constricter.plugins.pylint"]` | `C9101`–`C9112` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
|
|
163
|
+
| ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA012` | `# noqa: LVA001` |
|
|
164
|
+
|
|
165
|
+
pylint symbols: `unannotated-local-variable`, `untyped-for-or-match-variable`,
|
|
166
|
+
`comment-typed-for-variable`, `unannotated-module-or-class-variable`, `vague-annotation`,
|
|
167
|
+
`deeply-nested-annotation`, `redundant-annotation`, `narrowable-annotation`,
|
|
168
|
+
`mismatched-value-type`, `unused-union-member`, `long-tuple-annotation`, `could-be-final` (off until
|
|
169
|
+
enabled).
|
|
170
|
+
|
|
171
|
+
Options:
|
|
172
|
+
|
|
173
|
+
| Option | CLI | `[tool.constricter]` | flake8 (CLI or config) | pylint |
|
|
174
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------- | --------------------------------- |
|
|
175
|
+
| level | `--level` | `level` | `--constricter-level` | `constricter-level` |
|
|
176
|
+
| type comments | `--type-comments` | `type-comments` | `--constricter-type-comments` | `constricter-type-comments = yes` |
|
|
177
|
+
| all scopes | `--all-scopes` | `all-scopes` | `--constricter-all-scopes` | `constricter-all-scopes = yes` |
|
|
178
|
+
| nesting | `--nesting N` | `nesting` | `--constricter-nesting` | `constricter-nesting` |
|
|
179
|
+
| max length | `--max-length N` (LVA011) | `max-length` | `--constricter-max-length` | `constricter-max-length` |
|
|
180
|
+
| type hierarchy | - | `narrower` (a table) | `--constricter-narrower` | `constricter-narrower` |
|
|
181
|
+
| fix | `--fix` (`--unsafe-fixes` for guesses), `--diff` to preview | - | - | - |
|
|
182
|
+
| infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`, both: inferred types, as guesses) | `infer-with` | - | - |
|
|
183
|
+
| show fixes | `--show-fixes` (each fix and how it was decided, text) | - | - | - |
|
|
184
|
+
| fix levels | `--fix-select`, `--fix-ignore`, `--unsafe-fix-select` (mechanisms: [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md#fix-levels)) | `fix-select`, `fix-ignore`, `unsafe-fix-select` | - | - |
|
|
185
|
+
| select | `--select CODES` (codes or prefixes) | `select` | flake8's own `select` | pylint's own `enable` |
|
|
186
|
+
| extend select | `--extend-select CODES` (also report these; an opt-in code by its full code) | `extend-select` | flake8's own `extend-select` | pylint's own `enable` |
|
|
187
|
+
| ignore | `--ignore CODES` | `ignore` | flake8's own `extend-ignore` | pylint's own `disable` |
|
|
188
|
+
| exclude | `--exclude GLOB` (repeatable) | `exclude` | flake8's own `exclude` | pylint's own `ignore-paths` |
|
|
189
|
+
| format | `--format`: `text`, `full` (with source), `json`, `github`, `sarif`, `gitlab`, `junit`, `rdjson` | - | - | - |
|
|
190
|
+
| statistics | `--statistics` (counts per code, text format) | - | - | - |
|
|
191
|
+
| jobs | `--jobs N` (`-j`; 0: one per CPU) | `jobs` | flake8's own `--jobs` | pylint's own `--jobs` |
|
|
192
|
+
| baseline | `--baseline FILE`; `--write-baseline` records it | `baseline` | - | - |
|
|
193
|
+
| coverage | `--coverage`, `--fail-under PCT` | - | - | - |
|
|
194
|
+
| per-path levels | - | `per-path-levels` | - | - |
|
|
195
|
+
| per-file ignores | - | `per-file-ignores` | flake8's own `per-file-ignores` | - |
|
|
196
|
+
| stdin | `-` as the path, `--stdin-filename PATH` | - | flake8's own `-` | - |
|
|
197
|
+
| exit status | `--exit-zero` | - | flake8's own `--exit-zero` | pylint's own `--exit-zero` |
|
|
198
|
+
| output file | `--output-file FILE` | - | flake8's own `--output-file` | pylint's own `--output` |
|
|
199
|
+
|
|
200
|
+
`constricter --explain LVA002` prints a code's rationale, its fix, and the levels that report it.
|
|
201
|
+
|
|
202
|
+
The CLI reads `[tool.constricter]` from the nearest `pyproject.toml` above the current directory;
|
|
203
|
+
its flags override it, and `--exclude` adds to it. An unknown key or a bad value exits 2.
|
|
204
|
+
|
|
205
|
+
```toml
|
|
206
|
+
[tool.constricter]
|
|
207
|
+
level = "constrict" # or 2
|
|
208
|
+
exclude = ["tests/fixtures/*"]
|
|
209
|
+
type-comments = false
|
|
210
|
+
all-scopes = true
|
|
211
|
+
nesting = 3
|
|
212
|
+
max-length = 4
|
|
213
|
+
jobs = 0
|
|
214
|
+
baseline = "constricter-baseline.json" # the default; relative to this pyproject.toml
|
|
215
|
+
|
|
216
|
+
# The first glob a file matches sets its level; other files get `level`.
|
|
217
|
+
[tool.constricter.per-path-levels]
|
|
218
|
+
"tests/*" = "strict"
|
|
219
|
+
|
|
220
|
+
# Codes (or prefixes) to drop for files matching a glob.
|
|
221
|
+
[tool.constricter.per-file-ignores]
|
|
222
|
+
"tests/fixtures/*" = ["LVA005", "LVA006"]
|
|
223
|
+
|
|
224
|
+
# Your own type hierarchy for LVA008–LVA010: each type, and the types it's narrower than.
|
|
225
|
+
[tool.constricter.narrower]
|
|
226
|
+
UserId = ["str"] # an imported NewType the rules then compare
|
|
227
|
+
int = [] # an `int` no longer fits `float`
|
|
228
|
+
select = ["LVA00"]
|
|
229
|
+
ignore = ["LVA003"]
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`--fix` adds the annotation where the value decides it: a literal (`count = 0` becomes
|
|
233
|
+
`count: int = 0`), a container whose elements agree, a constructor or a function that declares its
|
|
234
|
+
return type (in another checked file too), a copy, subscript, attribute or method call of a local
|
|
235
|
+
whose type is known, and values computed from those; a loop's target or an unpacking's names get a
|
|
236
|
+
declaration on the line before. `--unsafe-fixes` adds guesses, and `--show-fixes` lists each fix and
|
|
237
|
+
how its value decided it. `--infer-with basedpyright` (or `ty`, or `basedpyright,ty`, installed
|
|
238
|
+
alongside) asks those type checkers for what `--fix` can't type itself, as guesses. The full list is
|
|
239
|
+
in [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
|
|
240
|
+
|
|
241
|
+
### Installing and running
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
pip install python-constricter # into the project's environment
|
|
245
|
+
uvx --from python-constricter constricter # or run it without installing: uv's tool runner
|
|
246
|
+
pipx run --spec python-constricter constricter # or pipx's
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Tools that run flake8 or pylint (VS Code's extensions, python-lsp-server, prospector, MegaLinter,
|
|
250
|
+
Trunk) pick the plugin up once it's installed alongside them.
|
|
251
|
+
|
|
252
|
+
Without `lint.external`, ruff flags `# noqa: LVA00x` (RUF102) and `--fix` deletes it.
|
|
253
|
+
|
|
254
|
+
The CLI defaults to `.`, checks `*.py` and `*.ipynb`, and skips hidden dirs, `__pycache__`, `venv`,
|
|
255
|
+
`site-packages`, `build`, `dist` and `node_modules` by directory name; `--exclude` adds more
|
|
256
|
+
directory names (or globs) to skip the same way, on top of matching whole paths and file names. Exit
|
|
257
|
+
codes: `0` no errors, `1` errors, `2` an unreadable or unparsable file, a `--fix` its encoding can't
|
|
258
|
+
hold, or a bad `pyproject.toml`. A module is read in its PEP 263 declaration's encoding.
|
|
259
|
+
|
|
260
|
+
pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
|
|
261
|
+
|
|
262
|
+
```yaml
|
|
263
|
+
- repo: https://github.com/ivylikethevine/python-constricter
|
|
264
|
+
rev: v0.2.3
|
|
265
|
+
hooks:
|
|
266
|
+
- id: constricter
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
pre-commit.ci, tox, nox, Bazel, Pants and the GitHub Action (PR annotations, a summary table and a
|
|
270
|
+
SARIF log) are in
|
|
271
|
+
[docs/INTEGRATIONS.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/INTEGRATIONS.md);
|
|
272
|
+
settings for VS Code, Zed and Neovim are in
|
|
273
|
+
[docs/editors/](https://github.com/ivylikethevine/python-constricter/blob/main/docs/editors/README.md).
|
|
274
|
+
|
|
275
|
+
### Baselines
|
|
276
|
+
|
|
277
|
+
To adopt constricter on a codebase that already has offences, record them, then report only new
|
|
278
|
+
ones:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
constricter --write-baseline src # writes constricter-baseline.json next to pyproject.toml
|
|
282
|
+
constricter src # reports only offences the baseline doesn't cover
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
A baseline counts each file's offences by code and variable name, not line number, so it survives
|
|
286
|
+
code moving around; another offence for a name it covers is still reported. Paths in it are relative
|
|
287
|
+
to it. It's JSON, and like every JSON file constricter reads it may have `//` and `/* */` comments
|
|
288
|
+
and trailing commas. `--baseline FILE` or `baseline` in `[tool.constricter]` names another file; the
|
|
289
|
+
default one is used only if it exists.
|
|
290
|
+
|
|
291
|
+
### Annotation coverage
|
|
292
|
+
|
|
293
|
+
`constricter --coverage src` prints the share of first bindings that are typed, per file and in
|
|
294
|
+
total; the bindings are the ones the rules cover (with `--all-scopes`, module and class bodies too),
|
|
295
|
+
and `# noqa` doesn't make one typed. `--fail-under PCT` (which implies `--coverage`) exits 1 below
|
|
296
|
+
PCT, so CI can hold a codebase to a share. With `--format=json` it prints
|
|
297
|
+
`{"typed", "total", "percent", "files"}`, which a badge can read: publish that JSON somewhere (a
|
|
298
|
+
gist, a release asset) and point
|
|
299
|
+
[shields.io's dynamic JSON badge](https://shields.io/badges/dynamic-json-badge) at it with the query
|
|
300
|
+
`$.percent`. This project keeps its own share at 100% in CI, so its badge is static.
|
|
301
|
+
|
|
302
|
+
### Notebooks
|
|
303
|
+
|
|
304
|
+
`.ipynb` files are checked too (directories include them): their code cells are read as one module,
|
|
305
|
+
IPython-only lines (`%magic`, `!shell`, `obj?`, `%%cell` magics) are skipped, and each offence is
|
|
306
|
+
reported at its cell and line (`analysis.ipynb:cell 3:2:5`). JSON output has a `cell` field; GitHub
|
|
307
|
+
and SARIF output point at the file and put the cell in the message. `--fix` and `--diff` edit the
|
|
308
|
+
cells, keeping the notebook's formatting.
|
|
309
|
+
|
|
310
|
+
### Adopting it on an existing codebase
|
|
311
|
+
|
|
312
|
+
1. See the scale: `constricter --statistics src` counts offences per code.
|
|
313
|
+
2. Record them: `constricter --write-baseline src`, and commit `constricter-baseline.json`.
|
|
314
|
+
3. Enforce it for new code: add the pre-commit hook or the GitHub Action; the baseline keeps old
|
|
315
|
+
offences quiet, and `--diff` / `--fix` clear the easy ones.
|
|
316
|
+
4. Burn it down: fix a file or package at a time, then `--write-baseline` again to shrink the file.
|
|
317
|
+
5. Tighten: raise `level` (or `per-path-levels` for the parts that are clean), then turn on
|
|
318
|
+
`all-scopes`.
|
|
319
|
+
|
|
320
|
+
### Output formats
|
|
321
|
+
|
|
322
|
+
`--format` is `text` (the default), `json`, `github` (workflow annotations), `sarif` (below),
|
|
323
|
+
`gitlab` (Code Climate JSON, for GitLab's merge-request Code Quality widget: pass the file as a
|
|
324
|
+
`codequality` report artifact), `junit` (a test suite per file, a failed test case per offence, for
|
|
325
|
+
Jenkins, Azure Pipelines, CircleCI or GitLab's test reports) or `rdjson` (for
|
|
326
|
+
[reviewdog](https://github.com/reviewdog/reviewdog), with each certain fix as a suggestion).
|
|
327
|
+
`--output-file FILE` writes the report there, and `--exit-zero` exits 0 even when there are errors
|
|
328
|
+
(not when a file can't be read).
|
|
329
|
+
|
|
330
|
+
### Editors (standard input)
|
|
331
|
+
|
|
332
|
+
`constricter - --stdin-filename path/to/file.py` checks standard input, reported as that path (which
|
|
333
|
+
also picks its per-path level and baseline entry; a `.ipynb` name reads a notebook). With `--fix` it
|
|
334
|
+
prints the fixed source instead of a report, and `--diff` diffs it. That's what editors that lint
|
|
335
|
+
unsaved buffers through a command need (none-ls, nvim-lint, flycheck, ALE, efm-langserver, Helix).
|
|
336
|
+
|
|
337
|
+
### SARIF (code scanning)
|
|
338
|
+
|
|
339
|
+
`--format=sarif` writes SARIF 2.1.0, with each result's level (`error` or `warning`) set by
|
|
340
|
+
`--level`. Each rule has its help text and a link to [Rules](#rules), and each certain fix is a
|
|
341
|
+
SARIF fix (columns count characters: `columnKind` is `unicodeCodePoints`). In GitHub Actions, upload
|
|
342
|
+
it to code scanning (the job needs `security-events: write`; the
|
|
343
|
+
[GitHub Action](https://github.com/ivylikethevine/python-constricter/blob/main/docs/INTEGRATIONS.md#github-actions)'s
|
|
344
|
+
`sarif-file` output does the same):
|
|
345
|
+
|
|
346
|
+
```yaml
|
|
347
|
+
- run: constricter --format=sarif src > constricter.sarif
|
|
348
|
+
- if: ${{ !cancelled() }} # upload the findings even when the step above failed on them
|
|
349
|
+
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
|
350
|
+
with:
|
|
351
|
+
sarif_file: constricter.sarif
|
|
352
|
+
category: constricter
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
SonarQube and SonarCloud import it with `sonar.sarifReportPaths=constricter.sarif`; any other tool
|
|
356
|
+
that reads SARIF 2.1.0 takes the same file.
|
|
357
|
+
|
|
358
|
+
## Development
|
|
359
|
+
|
|
360
|
+
Setting up, the checks CI runs, the corpus runs and the rules this project's own linters leave off
|
|
361
|
+
are in
|
|
362
|
+
[docs/CONTRIBUTING.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/CONTRIBUTING.md).
|
|
363
|
+
|
|
364
|
+
## Roadmap
|
|
365
|
+
|
|
366
|
+
What's done and what's next, by scope:
|
|
367
|
+
[docs/ROADMAP.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/ROADMAP.md).
|
|
368
|
+
|
|
369
|
+
## AI usage
|
|
370
|
+
|
|
371
|
+
Heavily inspired by
|
|
372
|
+
[Dictionarry/Profilarr's AI Transparency Statement](https://v2.dictionarry.dev/ai-transparency).
|
|
373
|
+
|
|
374
|
+
I have used generative AI to write large parts of this code. All of the code here is my
|
|
375
|
+
_responsibility_ regardless: AI is a tool, not an owner of a project. I have personally understood,
|
|
376
|
+
reviewed, and approved all of the AI-generated code in this repository, and **mainline releases**
|
|
377
|
+
carry the same accountability to me as anything I write and publish myself.
|
|
378
|
+
|