python-constricter 0.3.1__tar.gz → 0.3.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.3.1 → python_constricter-0.3.2}/.gitignore +26 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/PKG-INFO +16 -8
- {python_constricter-0.3.1 → python_constricter-0.3.2}/README.md +15 -7
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/__init__.py +1 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/command.py +46 -13
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/config.py +64 -11
- python_constricter-0.3.2/constricter/cli/edits.py +188 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/explain.py +2 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/hints.py +122 -29
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/options.py +60 -3
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/protocol.py +6 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/report.py +1 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/runs.py +1 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/schedule.py +19 -7
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/workers.py +2 -2
- python_constricter-0.3.2/constricter/fix/core/__init__.py +2 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/core}/fixes.py +27 -21
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/core}/imports.py +58 -4
- python_constricter-0.3.2/constricter/fix/core/inherited.py +197 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/core}/known.py +145 -26
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/core}/signatures.py +7 -7
- python_constricter-0.3.2/constricter/fix/index/__init__.py +2 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/atoms.py +7 -7
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/callers.py +3 -3
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/classnames.py +5 -5
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/declared.py +1 -1
- python_constricter-0.3.2/constricter/fix/index/decorated.py +119 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/installed.py +2 -2
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/modules.py +156 -10
- python_constricter-0.3.2/constricter/fix/index/offers.py +135 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/order.py +2 -2
- python_constricter-0.3.2/constricter/fix/index/plain.py +120 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/project.py +268 -81
- python_constricter-0.3.2/constricter/fix/index/sides.py +65 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/index}/stubbed.py +25 -21
- python_constricter-0.3.2/constricter/fix/index/tuples.py +86 -0
- python_constricter-0.3.2/constricter/fix/libraries/__init__.py +2 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/libraries}/library.py +40 -32
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/libraries}/opened.py +2 -2
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/libraries}/overloads.py +3 -3
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/libraries}/stdlib.py +90 -8
- python_constricter-0.3.2/constricter/fix/tables/functions.json +44 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/method_overloads.json +96 -35
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/method_signatures.json +52 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/methods.json +157 -85
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/overloads.json +18 -5
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/returns.json +34 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/source.json +2 -1
- python_constricter-0.3.2/constricter/fix/values/__init__.py +2 -0
- python_constricter-0.3.2/constricter/fix/values/aliased.py +182 -0
- python_constricter-0.3.2/constricter/fix/values/called.py +108 -0
- python_constricter-0.3.2/constricter/fix/values/classvars.py +365 -0
- python_constricter-0.3.2/constricter/fix/values/decided.py +333 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/doubts.py +175 -27
- python_constricter-0.3.2/constricter/fix/values/entered.py +152 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/fills.py +46 -18
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/guesses.py +74 -41
- python_constricter-0.3.2/constricter/fix/values/hinted.py +558 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/inference.py +298 -106
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/members.py +73 -10
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/returned.py +18 -15
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/returns.py +1 -1
- python_constricter-0.3.2/constricter/fix/values/shapes.py +279 -0
- python_constricter-0.3.2/constricter/fix/values/targets.py +350 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/notebook.py +1 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/offences.py +27 -3
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/plugins/flake8.py +11 -1
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/plugins/pylint.py +11 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/annotations.py +298 -74
- python_constricter-0.3.2/constricter/rules/binding.py +457 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/calls.py +79 -27
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/checker.py +121 -98
- python_constricter-0.3.2/constricter/rules/decorators.py +209 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/flow.py +18 -3
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/late.py +59 -24
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/parsed.py +2 -2
- python_constricter-0.3.2/constricter/rules/quoted.py +88 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/rebinding.py +1 -1
- python_constricter-0.3.2/constricter/rules/recorded.py +124 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/scope.py +231 -78
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/syntax.py +123 -5
- python_constricter-0.3.2/constricter/rules/tables.py +99 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/pyproject.toml +2 -2
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/generate.py +74 -15
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/overloads.py +24 -3
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/partial.json +25 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/reading.py +41 -4
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/stubs.py +11 -4
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/templates.py +36 -6
- python_constricter-0.3.1/constricter/fix/hinted.py +0 -242
- python_constricter-0.3.1/constricter/fix/targets.py +0 -184
- python_constricter-0.3.1/constricter/rules/binding.py +0 -251
- {python_constricter-0.3.1 → python_constricter-0.3.2}/LICENSE.md +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/__main__.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/__init__.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/baseline.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/collecting.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/guard.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/cli/paths.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/__init__.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/aliases.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/attributes.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/bases.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/classes.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/generic_attributes.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/scalar_members.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/scalars.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/subscriptable.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/type_parameters.json +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/fix/tables/variables.json +0 -0
- {python_constricter-0.3.1/constricter/fix → python_constricter-0.3.2/constricter/fix/values}/narrowed.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/jsonc.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/noqa.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/plugins/__init__.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/py.typed +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/__init__.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/narrowing.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/redundant.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/constricter/rules/walked.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/hatch_build.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/__init__.py +0 -0
- {python_constricter-0.3.1 → python_constricter-0.3.2}/stdlib_tables/__main__.py +0 -0
|
@@ -17,5 +17,31 @@ stdlib_tables/partial.json
|
|
|
17
17
|
# Claude Code's agent worktrees: other checkouts of this repository.
|
|
18
18
|
.claude/worktrees/
|
|
19
19
|
|
|
20
|
+
# Claude Code's sandbox bind-mounts /dev/null over these denied paths while a
|
|
21
|
+
# session runs; none is a file of this repo
|
|
22
|
+
/.bash_profile
|
|
23
|
+
/.bashrc
|
|
24
|
+
/.gitconfig
|
|
25
|
+
/.gitmodules
|
|
26
|
+
/.idea
|
|
27
|
+
/.mcp.json
|
|
28
|
+
/.profile
|
|
29
|
+
/.ripgreprc
|
|
30
|
+
/.zprofile
|
|
31
|
+
/.zshrc
|
|
32
|
+
/.claude/agents
|
|
33
|
+
/.claude/commands
|
|
34
|
+
/.claude/hooks
|
|
35
|
+
/.claude/launch.json
|
|
36
|
+
/.claude/loop.md
|
|
37
|
+
/.claude/output-styles
|
|
38
|
+
/.claude/routines
|
|
39
|
+
/.claude/scheduled_tasks.json
|
|
40
|
+
/.claude/settings.json
|
|
41
|
+
/.claude/settings.local.json
|
|
42
|
+
/.claude/skills
|
|
43
|
+
/.claude/workflows
|
|
44
|
+
/.claude/.cc-writes
|
|
45
|
+
|
|
20
46
|
# Backups merge tools leave next to a conflicted file.
|
|
21
47
|
*.orig
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-constricter
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Import-Name: constricter
|
|
5
5
|
Summary: Lint rules: every local variable is typed where it's first bound. A flake8 plugin, a pylint plugin and a standalone CLI.
|
|
6
6
|
Author: Ivy Duggan
|
|
@@ -97,7 +97,7 @@ class bodies too. Statements are read in source order, and only a name's first b
|
|
|
97
97
|
| `LVA002` | an untyped `for` target or `match` capture | `name: T` first (or a type comment) |
|
|
98
98
|
| `LVA003` | a `for` target typed only by `# type: T` | `name: T` first |
|
|
99
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
|
|
100
|
+
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters, past `vague` | name the real type |
|
|
101
101
|
| `LVA006` | an annotation nested `nesting` deep (3 by default) | a `type` alias for a part of it |
|
|
102
102
|
| `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
|
|
103
103
|
| `LVA008` | with every value the name ever holds known: an annotation that could narrow to them | narrow it (`total: int`) |
|
|
@@ -109,7 +109,10 @@ class bodies too. Statements are read in source order, and only a name's first b
|
|
|
109
109
|
Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
|
|
110
110
|
`global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
|
|
111
111
|
enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
|
|
112
|
-
or `Flag`).
|
|
112
|
+
or `Flag`). And what makes a type, not a value, which an annotation would turn into a variable to a
|
|
113
|
+
type checker: `T = TypeVar("T")`, a `ParamSpec`, a `TypeVarTuple`, a `NewType`, and a functional
|
|
114
|
+
`NamedTuple`, `TypedDict`, `Enum` or `collections.namedtuple`, however `typing`,
|
|
115
|
+
`typing_extensions`, `enum` or `collections` is imported.
|
|
113
116
|
|
|
114
117
|
A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
|
|
115
118
|
`type-comments`, or automatically in a module written to run on Python 2: one that imports
|
|
@@ -174,16 +177,19 @@ Options:
|
|
|
174
177
|
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------- | --------------------------------- |
|
|
175
178
|
| level | `--level` | `level` | `--constricter-level` | `constricter-level` |
|
|
176
179
|
| strictest | `--max`: `--level=suffocate` for every path, `--all-scopes`, and the opt-in `LVA012` | — | — | — |
|
|
177
|
-
| every fix | `--max-fix`: `--max`, `--fix --unsafe-fixes`, and `--infer-with` each of basedpyright and
|
|
180
|
+
| every fix | `--max-fix`: `--max`, `--fix --unsafe-fixes`, and `--infer-with` each of basedpyright, ty and pyrefly that's installed and runs | — | — | — |
|
|
178
181
|
| type comments | `--type-comments` | `type-comments` | `--constricter-type-comments` | `constricter-type-comments = yes` |
|
|
179
182
|
| all scopes | `--all-scopes` | `all-scopes` | `--constricter-all-scopes` | `constricter-all-scopes = yes` |
|
|
180
183
|
| nesting | `--nesting N` | `nesting` | `--constricter-nesting` | `constricter-nesting` |
|
|
181
184
|
| max length | `--max-length N` (LVA011) | `max-length` | `--constricter-max-length` | `constricter-max-length` |
|
|
185
|
+
| vague | `--vague LEVEL` (LVA005, and the fixes offered): -1 none (the default), 0 one `Any` inside a type that says the rest (`tuple[str, Any]`), N from 1 N + 1, or one alone | `vague` | `--constricter-vague` | `constricter-vague` |
|
|
186
|
+
| min python | `--min-python VERSION` (the oldest Python the code runs on, whose syntax `--fix` writes; default: `requires-python`'s) | `min-python` | - | - |
|
|
182
187
|
| type hierarchy | - | `narrower` (a table) | `--constricter-narrower` | `constricter-narrower` |
|
|
183
188
|
| fix | `--fix` (`--unsafe-fixes` for guesses), `--diff` to preview | - | - | - |
|
|
184
|
-
| infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`,
|
|
189
|
+
| infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`, `pyrefly`, several: inferred types, as guesses) | `infer-with` | - | - |
|
|
185
190
|
| show fixes | `--show-fixes` (each fix and how it was decided, text) | - | - | - |
|
|
186
191
|
| 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` | - | - |
|
|
192
|
+
| fix plain bases | `--fix-plain-bases BASES` (frameworks' classes whose subclasses' bodies `--fix` annotates; django's are built in) | `fix-plain-bases` | - | - |
|
|
187
193
|
| select | `--select CODES` (codes or prefixes) | `select` | flake8's own `select` | pylint's own `enable` |
|
|
188
194
|
| 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` |
|
|
189
195
|
| ignore | `--ignore CODES` | `ignore` | flake8's own `extend-ignore` | pylint's own `disable` |
|
|
@@ -212,6 +218,8 @@ type-comments = false
|
|
|
212
218
|
all-scopes = true
|
|
213
219
|
nesting = 3
|
|
214
220
|
max-length = 4
|
|
221
|
+
vague = -1 # the default: no `Any`; 0 lets `dict[str, Any]` through, 1 `Any` itself
|
|
222
|
+
min-python = "3.11" # the default: your `requires-python`'s lower bound
|
|
215
223
|
jobs = 0
|
|
216
224
|
baseline = "constricter-baseline.json" # the default; relative to this pyproject.toml
|
|
217
225
|
|
|
@@ -237,8 +245,8 @@ return type or whose `return`s agree (in another checked file too), a copy, subs
|
|
|
237
245
|
method call of a local whose type is known, and values computed from those; a loop's target or an
|
|
238
246
|
unpacking's names get a declaration on the line before. `--unsafe-fixes` adds guesses, and
|
|
239
247
|
`--show-fixes` lists each fix and how its value decided it. `--infer-with basedpyright` (or `ty`, or
|
|
240
|
-
`basedpyright,ty
|
|
241
|
-
as guesses. The full list is in
|
|
248
|
+
`pyrefly`, or several: `basedpyright,ty`; each installed alongside) asks those type checkers for
|
|
249
|
+
what `--fix` can't type itself, as guesses. The full list is in
|
|
242
250
|
[docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
|
|
243
251
|
|
|
244
252
|
### Installing and running
|
|
@@ -264,7 +272,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
|
|
|
264
272
|
|
|
265
273
|
```yaml
|
|
266
274
|
- repo: https://github.com/ivylikethevine/python-constricter
|
|
267
|
-
rev: v0.3.
|
|
275
|
+
rev: v0.3.2
|
|
268
276
|
hooks:
|
|
269
277
|
- id: constricter
|
|
270
278
|
```
|
|
@@ -70,7 +70,7 @@ class bodies too. Statements are read in source order, and only a name's first b
|
|
|
70
70
|
| `LVA002` | an untyped `for` target or `match` capture | `name: T` first (or a type comment) |
|
|
71
71
|
| `LVA003` | a `for` target typed only by `# type: T` | `name: T` first |
|
|
72
72
|
| `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
|
|
73
|
-
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters
|
|
73
|
+
| `LVA005` | an annotation with `Any`, `object` or a generic without its parameters, past `vague` | name the real type |
|
|
74
74
|
| `LVA006` | an annotation nested `nesting` deep (3 by default) | a `type` alias for a part of it |
|
|
75
75
|
| `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
|
|
76
76
|
| `LVA008` | with every value the name ever holds known: an annotation that could narrow to them | narrow it (`total: int`) |
|
|
@@ -82,7 +82,10 @@ class bodies too. Statements are read in source order, and only a name's first b
|
|
|
82
82
|
Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
|
|
83
83
|
`global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
|
|
84
84
|
enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
|
|
85
|
-
or `Flag`).
|
|
85
|
+
or `Flag`). And what makes a type, not a value, which an annotation would turn into a variable to a
|
|
86
|
+
type checker: `T = TypeVar("T")`, a `ParamSpec`, a `TypeVarTuple`, a `NewType`, and a functional
|
|
87
|
+
`NamedTuple`, `TypedDict`, `Enum` or `collections.namedtuple`, however `typing`,
|
|
88
|
+
`typing_extensions`, `enum` or `collections` is imported.
|
|
86
89
|
|
|
87
90
|
A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
|
|
88
91
|
`type-comments`, or automatically in a module written to run on Python 2: one that imports
|
|
@@ -147,16 +150,19 @@ Options:
|
|
|
147
150
|
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------- | --------------------------------- |
|
|
148
151
|
| level | `--level` | `level` | `--constricter-level` | `constricter-level` |
|
|
149
152
|
| strictest | `--max`: `--level=suffocate` for every path, `--all-scopes`, and the opt-in `LVA012` | — | — | — |
|
|
150
|
-
| every fix | `--max-fix`: `--max`, `--fix --unsafe-fixes`, and `--infer-with` each of basedpyright and
|
|
153
|
+
| every fix | `--max-fix`: `--max`, `--fix --unsafe-fixes`, and `--infer-with` each of basedpyright, ty and pyrefly that's installed and runs | — | — | — |
|
|
151
154
|
| type comments | `--type-comments` | `type-comments` | `--constricter-type-comments` | `constricter-type-comments = yes` |
|
|
152
155
|
| all scopes | `--all-scopes` | `all-scopes` | `--constricter-all-scopes` | `constricter-all-scopes = yes` |
|
|
153
156
|
| nesting | `--nesting N` | `nesting` | `--constricter-nesting` | `constricter-nesting` |
|
|
154
157
|
| max length | `--max-length N` (LVA011) | `max-length` | `--constricter-max-length` | `constricter-max-length` |
|
|
158
|
+
| vague | `--vague LEVEL` (LVA005, and the fixes offered): -1 none (the default), 0 one `Any` inside a type that says the rest (`tuple[str, Any]`), N from 1 N + 1, or one alone | `vague` | `--constricter-vague` | `constricter-vague` |
|
|
159
|
+
| min python | `--min-python VERSION` (the oldest Python the code runs on, whose syntax `--fix` writes; default: `requires-python`'s) | `min-python` | - | - |
|
|
155
160
|
| type hierarchy | - | `narrower` (a table) | `--constricter-narrower` | `constricter-narrower` |
|
|
156
161
|
| fix | `--fix` (`--unsafe-fixes` for guesses), `--diff` to preview | - | - | - |
|
|
157
|
-
| infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`,
|
|
162
|
+
| infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`, `pyrefly`, several: inferred types, as guesses) | `infer-with` | - | - |
|
|
158
163
|
| show fixes | `--show-fixes` (each fix and how it was decided, text) | - | - | - |
|
|
159
164
|
| 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` | - | - |
|
|
165
|
+
| fix plain bases | `--fix-plain-bases BASES` (frameworks' classes whose subclasses' bodies `--fix` annotates; django's are built in) | `fix-plain-bases` | - | - |
|
|
160
166
|
| select | `--select CODES` (codes or prefixes) | `select` | flake8's own `select` | pylint's own `enable` |
|
|
161
167
|
| 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` |
|
|
162
168
|
| ignore | `--ignore CODES` | `ignore` | flake8's own `extend-ignore` | pylint's own `disable` |
|
|
@@ -185,6 +191,8 @@ type-comments = false
|
|
|
185
191
|
all-scopes = true
|
|
186
192
|
nesting = 3
|
|
187
193
|
max-length = 4
|
|
194
|
+
vague = -1 # the default: no `Any`; 0 lets `dict[str, Any]` through, 1 `Any` itself
|
|
195
|
+
min-python = "3.11" # the default: your `requires-python`'s lower bound
|
|
188
196
|
jobs = 0
|
|
189
197
|
baseline = "constricter-baseline.json" # the default; relative to this pyproject.toml
|
|
190
198
|
|
|
@@ -210,8 +218,8 @@ return type or whose `return`s agree (in another checked file too), a copy, subs
|
|
|
210
218
|
method call of a local whose type is known, and values computed from those; a loop's target or an
|
|
211
219
|
unpacking's names get a declaration on the line before. `--unsafe-fixes` adds guesses, and
|
|
212
220
|
`--show-fixes` lists each fix and how its value decided it. `--infer-with basedpyright` (or `ty`, or
|
|
213
|
-
`basedpyright,ty
|
|
214
|
-
as guesses. The full list is in
|
|
221
|
+
`pyrefly`, or several: `basedpyright,ty`; each installed alongside) asks those type checkers for
|
|
222
|
+
what `--fix` can't type itself, as guesses. The full list is in
|
|
215
223
|
[docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
|
|
216
224
|
|
|
217
225
|
### Installing and running
|
|
@@ -237,7 +245,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
|
|
|
237
245
|
|
|
238
246
|
```yaml
|
|
239
247
|
- repo: https://github.com/ivylikethevine/python-constricter
|
|
240
|
-
rev: v0.3.
|
|
248
|
+
rev: v0.3.2
|
|
241
249
|
hooks:
|
|
242
250
|
- id: constricter
|
|
243
251
|
```
|
|
@@ -23,8 +23,9 @@ from constricter.cli.protocol import HintError
|
|
|
23
23
|
from constricter.cli.report import Format, Result, fix_reasons, render, statistics
|
|
24
24
|
from constricter.cli.runs import BaselineRun, CheckRun, CoverageRun, FileRun
|
|
25
25
|
from constricter.cli.workers import Workers
|
|
26
|
-
from constricter.fix import
|
|
27
|
-
from constricter.fix.known import Callee, Hints, Outside, Returns
|
|
26
|
+
from constricter.fix.core import fixes
|
|
27
|
+
from constricter.fix.core.known import Callee, Hints, Outside, Returns
|
|
28
|
+
from constricter.fix.index import callers, decorated, installed, plain, project
|
|
28
29
|
from constricter.noqa import lines, unsuppressed
|
|
29
30
|
from constricter.offences import (
|
|
30
31
|
DEFAULT_CHECKS,
|
|
@@ -397,6 +398,11 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
397
398
|
for index, run in zip(again, redone, strict=True):
|
|
398
399
|
runs[index] = _merged(runs[index], run)
|
|
399
400
|
again = [index for index, run in zip(again, redone, strict=True) if cast("CheckRun", run).fixed]
|
|
401
|
+
checker: str
|
|
402
|
+
path: Path
|
|
403
|
+
for checker, path in session.abandoned:
|
|
404
|
+
name: Path = options.input.name(path)
|
|
405
|
+
_ = sys.stderr.write(f"constricter: warning: {checker} hung on {name} twice: no hints for it\n")
|
|
400
406
|
return names, runs
|
|
401
407
|
|
|
402
408
|
|
|
@@ -430,16 +436,40 @@ def _called_again(
|
|
|
430
436
|
if not (again := [at for at, name in enumerate(names) if name in found and names.count(name) == 1]):
|
|
431
437
|
return runs, modules
|
|
432
438
|
calling: list[int] = _calling(runs, found, again)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
at
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
439
|
+
before: project.Index = callers.with_parameters(modules, found)
|
|
440
|
+
modules = _checked_more(paths, runs, again, (check, options, session), before)
|
|
441
|
+
# A caller is checked again only if a function it imports now returns something else.
|
|
442
|
+
calling = [
|
|
443
|
+
at
|
|
444
|
+
for at in calling
|
|
445
|
+
if project.returned(modules, paths[at], {}) != project.returned(before, paths[at], {})
|
|
446
|
+
]
|
|
447
|
+
return runs, _checked_more(paths, runs, calling, (check, options, session), modules)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _checked_more(
|
|
451
|
+
paths: Sequence[Path],
|
|
452
|
+
runs: list[FileRun],
|
|
453
|
+
which: Sequence[int],
|
|
454
|
+
how: "tuple[Callable[[Path, Outside], FileRun], Options, hints.Session | None]",
|
|
455
|
+
modules: project.Index,
|
|
456
|
+
) -> project.Index:
|
|
457
|
+
"""Check the files of `paths` at `which` again, knowing `modules`, into `runs`.
|
|
458
|
+
|
|
459
|
+
`how`: the check, the options and the type checkers' session. With `--fix` a file's rounds are
|
|
460
|
+
joined; otherwise the later one stands.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
The index, with what their functions now return.
|
|
464
|
+
|
|
465
|
+
"""
|
|
466
|
+
redone: list[FileRun]
|
|
467
|
+
redone, modules = _checked_all([paths[at] for at in which], how[0], how[1], how[2], modules)
|
|
468
|
+
at: int
|
|
469
|
+
run: FileRun
|
|
470
|
+
for at, run in zip(which, redone, strict=True):
|
|
471
|
+
runs[at] = _merged(runs[at], run) if how[1].mode is Mode.FIX else run
|
|
472
|
+
return modules
|
|
443
473
|
|
|
444
474
|
|
|
445
475
|
def _calling(
|
|
@@ -487,7 +517,10 @@ def _checked_all(
|
|
|
487
517
|
modules = project.Index({}, []) if coverage else project.index(paths)
|
|
488
518
|
collecting.indexed()
|
|
489
519
|
if not coverage:
|
|
490
|
-
modules =
|
|
520
|
+
modules = plain.settled(
|
|
521
|
+
decorated.passed(installed.with_installed(modules, installed.search_path())),
|
|
522
|
+
options.checks.plain_bases,
|
|
523
|
+
)
|
|
491
524
|
return schedule.checked(
|
|
492
525
|
paths,
|
|
493
526
|
check,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
"""The command's defaults, from the nearest `pyproject.toml`'s `[tool.constricter]` table."""
|
|
3
3
|
|
|
4
|
+
import re
|
|
4
5
|
import tomllib
|
|
5
6
|
from collections.abc import Callable, Sequence
|
|
6
7
|
from functools import partial
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from typing import TYPE_CHECKING, Final, TypeAlias
|
|
9
|
+
from typing import TYPE_CHECKING, Final, TypeAlias, cast
|
|
9
10
|
|
|
10
11
|
from constricter.cli.protocol import SERVERS
|
|
11
12
|
from constricter.jsonc import is_int
|
|
@@ -30,6 +31,10 @@ def unknown_codes(codes: Sequence[str]) -> list[str]:
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
DEFAULT_BASELINE: Final = "constricter-baseline.json"
|
|
34
|
+
_MIN_PYTHON: Final = "min_python"
|
|
35
|
+
_VERSION: Final = re.compile(r"\d+\.\d+") # `min-python`'s: `3.11`
|
|
36
|
+
# A lower bound among `requires-python`'s specifiers: `>=3.11`, `~=3.11`, `==3.11.*`, `>3.10`.
|
|
37
|
+
_LOWER_BOUND: Final = re.compile(r"(?:>=|~=|==|>)\s*(\d+)(?:\.(\d+))?")
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
def project_root(start: Path) -> Path:
|
|
@@ -58,36 +63,65 @@ def _pyproject(start: Path) -> Path | None:
|
|
|
58
63
|
return None
|
|
59
64
|
|
|
60
65
|
|
|
61
|
-
def
|
|
62
|
-
"""
|
|
66
|
+
def _document(path: Path) -> dict[str, _Toml]:
|
|
67
|
+
"""Read `path`, a `pyproject.toml`.
|
|
63
68
|
|
|
64
69
|
Returns:
|
|
65
|
-
|
|
70
|
+
Its tables, as TOML parsed them.
|
|
66
71
|
|
|
67
72
|
Raises:
|
|
68
|
-
ValueError: The file isn't TOML
|
|
73
|
+
ValueError: The file isn't TOML.
|
|
69
74
|
|
|
70
75
|
"""
|
|
71
76
|
file: BufferedReader
|
|
72
|
-
document: dict[str, _Toml]
|
|
73
|
-
message: str
|
|
74
77
|
with path.open("rb") as file:
|
|
75
78
|
try:
|
|
76
|
-
|
|
79
|
+
return tomllib.load(file)
|
|
77
80
|
except tomllib.TOMLDecodeError as error:
|
|
78
|
-
message = f"{path}: {error}"
|
|
81
|
+
message: str = f"{path}: {error}"
|
|
79
82
|
raise ValueError(message) from error
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _table(path: Path, document: dict[str, _Toml]) -> dict[str, _Toml]:
|
|
86
|
+
"""Return the `[tool.constricter]` table of `document` (`path`'s), or an empty one.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The table's keys and values, as TOML parsed them.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: `tool.constricter` isn't a table.
|
|
93
|
+
|
|
94
|
+
"""
|
|
80
95
|
table: dict[str, _Toml]
|
|
81
96
|
match document.get("tool"):
|
|
82
97
|
case {"constricter": dict() as table}:
|
|
83
98
|
return table
|
|
84
99
|
case {"constricter": _}:
|
|
85
|
-
message = f"{path}: [tool.constricter] isn't a table"
|
|
100
|
+
message: str = f"{path}: [tool.constricter] isn't a table"
|
|
86
101
|
raise ValueError(message)
|
|
87
102
|
case _:
|
|
88
103
|
return {}
|
|
89
104
|
|
|
90
105
|
|
|
106
|
+
def _required(document: dict[str, _Toml]) -> str | None:
|
|
107
|
+
"""Read the oldest Python a project's `requires-python` allows: its highest lower bound.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
It (`3.11`, for `>=3.11,<4`), or `None` if the project doesn't say.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
required: str
|
|
114
|
+
match document.get("project"):
|
|
115
|
+
case {"requires-python": str() as required}:
|
|
116
|
+
pass
|
|
117
|
+
case _:
|
|
118
|
+
return None
|
|
119
|
+
found: list[tuple[str, str]] = cast("list[tuple[str, str]]", _LOWER_BOUND.findall(required))
|
|
120
|
+
bounds: list[tuple[int, int]] = [(int(major), int(minor or 0)) for major, minor in found]
|
|
121
|
+
oldest: tuple[int, int] | None = max(bounds, default=None)
|
|
122
|
+
return None if oldest is None else f"{oldest[0]}.{oldest[1]}"
|
|
123
|
+
|
|
124
|
+
|
|
91
125
|
def _level(value: _Toml) -> str | None:
|
|
92
126
|
"""Read a level's name or number as the options take it.
|
|
93
127
|
|
|
@@ -135,6 +169,16 @@ def _flag(value: _Toml) -> bool | None:
|
|
|
135
169
|
return value if isinstance(value, bool) else None
|
|
136
170
|
|
|
137
171
|
|
|
172
|
+
def _version(value: _Toml) -> str | None:
|
|
173
|
+
"""Read `min-python`: a Python version, as text (`"3.11"`).
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
It, or `None` for anything else.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
return value if isinstance(value, str) and _VERSION.fullmatch(value) else None
|
|
180
|
+
|
|
181
|
+
|
|
138
182
|
def _gigabytes(value: _Toml) -> float | None:
|
|
139
183
|
"""Read `infer-memory`: a positive number of gigabytes.
|
|
140
184
|
|
|
@@ -193,6 +237,7 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
|
|
|
193
237
|
"level": _level,
|
|
194
238
|
"nesting": partial(_whole, minimum=1),
|
|
195
239
|
"max-length": partial(_whole, minimum=1),
|
|
240
|
+
"vague": partial(_whole, minimum=-1),
|
|
196
241
|
"jobs": partial(_whole, minimum=0),
|
|
197
242
|
"exclude": _strings,
|
|
198
243
|
"select": _codes,
|
|
@@ -201,6 +246,7 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
|
|
|
201
246
|
"fix-select": _fix_kinds,
|
|
202
247
|
"fix-ignore": _fix_kinds,
|
|
203
248
|
"unsafe-fix-select": _fix_kinds,
|
|
249
|
+
"fix-plain-bases": _strings,
|
|
204
250
|
"type-comments": _flag,
|
|
205
251
|
"all-scopes": _flag,
|
|
206
252
|
"per-path-levels": _levels,
|
|
@@ -208,12 +254,15 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
|
|
|
208
254
|
"narrower": partial(_lists, read=_strings), # a type hierarchy: each type, and those it's narrower than
|
|
209
255
|
"infer-with": _checkers,
|
|
210
256
|
"infer-memory": _gigabytes,
|
|
257
|
+
"min-python": _version,
|
|
211
258
|
}
|
|
212
259
|
|
|
213
260
|
|
|
214
261
|
def config_defaults(start: Path) -> dict[str, Default]:
|
|
215
262
|
"""Return the option defaults in the nearest `pyproject.toml`'s `[tool.constricter]`.
|
|
216
263
|
|
|
264
|
+
And `min-python`'s from the project's `requires-python`, where the table doesn't set it.
|
|
265
|
+
|
|
217
266
|
Returns:
|
|
218
267
|
Each option's default, keyed by its `argparse` name (`type_comments`, not `type-comments`).
|
|
219
268
|
|
|
@@ -231,10 +280,14 @@ def config_defaults(start: Path) -> dict[str, Default]:
|
|
|
231
280
|
defaults: dict[str, Default] = {}
|
|
232
281
|
key: str
|
|
233
282
|
value: _Toml
|
|
234
|
-
|
|
283
|
+
document: dict[str, _Toml] = _document(path)
|
|
284
|
+
for key, value in _table(path, document).items():
|
|
235
285
|
default: Default | None
|
|
236
286
|
if key not in readers or (default := readers[key](value)) is None:
|
|
237
287
|
message: str = f"{path}: [tool.constricter] has an invalid {key} = {value!r}"
|
|
238
288
|
raise ValueError(message)
|
|
239
289
|
defaults[key.replace("-", "_")] = default
|
|
290
|
+
required: str | None
|
|
291
|
+
if _MIN_PYTHON not in defaults and (required := _required(document)) is not None:
|
|
292
|
+
defaults[_MIN_PYTHON] = required
|
|
240
293
|
return defaults
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""`--infer-with`: what a hint's own edits would write (see `Offered`).
|
|
3
|
+
|
|
4
|
+
An editor that accepts an inlay hint applies its `textEdits`: the annotation, spelled as the file can
|
|
5
|
+
write it, and the imports its names take. An import comes as a statement to add
|
|
6
|
+
(`from shapes import Shape`), or as names inserted into a `from` import the file has (`, Shape`).
|
|
7
|
+
|
|
8
|
+
A hint with no edits (pyrefly's, for a loop's or an unpacking's names) may say where each class in
|
|
9
|
+
its label is defined instead: the import is from the module that file is.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Final, TypeAlias, cast
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
from urllib.request import url2pathname
|
|
19
|
+
|
|
20
|
+
from constricter.cli.protocol import Json, Object
|
|
21
|
+
from constricter.fix.core.known import Offered
|
|
22
|
+
from constricter.fix.index.modules import SUFFIX, module_name
|
|
23
|
+
from constricter.rules import parsed
|
|
24
|
+
|
|
25
|
+
_Where: TypeAlias = tuple[int, int] # a line (from 1) and a UTF-8 byte column, as `ast` counts
|
|
26
|
+
_Locate: TypeAlias = Callable[[Object], _Where | None] # a position's, or `None` past the file's end
|
|
27
|
+
_SITE: Final = "site-packages"
|
|
28
|
+
_BUNDLED: Final = "pyrefly_bundled_typeshed" # how the folders pyrefly unpacks its stubs into start
|
|
29
|
+
_STUBS: Final = "-stubs" # a stub package's folder, after the package's name
|
|
30
|
+
_PACKAGE: Final = "__init__"
|
|
31
|
+
_BUILTINS: Final = "builtins"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FromImports:
|
|
36
|
+
"""A file's `from` imports (`found`), read from its `lines` when one is first looked for."""
|
|
37
|
+
|
|
38
|
+
lines: Sequence[str]
|
|
39
|
+
found: list[ast.ImportFrom] | None = None
|
|
40
|
+
|
|
41
|
+
def at(self, where: _Where) -> ast.ImportFrom | None:
|
|
42
|
+
"""Find the `from` import `where` is in.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
It, or `None` (always, for a file that doesn't parse).
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
if self.found is None:
|
|
49
|
+
self.found = _from_imports(self.lines)
|
|
50
|
+
return next(
|
|
51
|
+
(
|
|
52
|
+
node
|
|
53
|
+
for node in self.found
|
|
54
|
+
if (node.lineno, node.col_offset)
|
|
55
|
+
<= where
|
|
56
|
+
<= (node.end_lineno or node.lineno, node.end_col_offset or 0)
|
|
57
|
+
),
|
|
58
|
+
None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _from_imports(lines: Sequence[str]) -> list[ast.ImportFrom]:
|
|
63
|
+
"""Read a file's `from` imports, wherever they are.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Them; none for a file that doesn't parse.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
tree: ast.Module = parsed.parse("\n".join(lines))
|
|
71
|
+
except (SyntaxError, ValueError): # a null byte is a `ValueError` before Python 3.12
|
|
72
|
+
return []
|
|
73
|
+
return [node for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def offered(hint: Object, label: str, locate: _Locate, existing: FromImports) -> Offered | None:
|
|
77
|
+
"""Read what a variable-type hint (showing `label`) would write: its annotation and its imports.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Them; `None` for a hint whose edits say no more than its label, one with an edit that is
|
|
81
|
+
neither, or one with no edits whose label doesn't say where a class in it is defined.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
edits: list[Json]
|
|
85
|
+
if not (edits := cast("list[Json]", hint.get("textEdits") or [])):
|
|
86
|
+
located: tuple[str, ...] = _located(hint.get("label"))
|
|
87
|
+
return Offered(label, located) if located else None
|
|
88
|
+
text: str = ""
|
|
89
|
+
imports: list[str] = []
|
|
90
|
+
edit: Json
|
|
91
|
+
for edit in edits:
|
|
92
|
+
new: str = str(cast("Object", edit).get("newText", ""))
|
|
93
|
+
start: Object = cast("Object", cast("Object", cast("Object", edit)["range"])["start"])
|
|
94
|
+
if start == hint["position"] and new.startswith(":"):
|
|
95
|
+
text = new[1:].strip()
|
|
96
|
+
continue
|
|
97
|
+
found: list[str] | None
|
|
98
|
+
if (found := _imports(new.strip(), start, locate, existing)) is None:
|
|
99
|
+
return None
|
|
100
|
+
imports += found
|
|
101
|
+
return Offered(text, tuple(imports)) if text and (imports or text != label) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _located(label: Json) -> tuple[str, ...]:
|
|
105
|
+
"""Read the imports a label's parts take: each class a part names, from the module its file is.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A statement for each class whose defining file's module can be named, once; not a builtin's.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
found: dict[str, None] = {}
|
|
112
|
+
part: Json
|
|
113
|
+
for part in label if isinstance(label, list) else []:
|
|
114
|
+
name: str = str(cast("Object", part).get("value", ""))
|
|
115
|
+
location: Json = cast("Object", part).get("location")
|
|
116
|
+
if location is None or not name.isidentifier():
|
|
117
|
+
continue
|
|
118
|
+
uri: str = str(cast("Object", location)["uri"])
|
|
119
|
+
module: str | None = _defined_in(Path(url2pathname(urlsplit(uri).path)))
|
|
120
|
+
if module and module != _BUILTINS:
|
|
121
|
+
found[f"from {module} import {name}"] = None
|
|
122
|
+
return tuple(found)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _defined_in(path: Path) -> str | None:
|
|
126
|
+
"""Name the module a file is: an installed one, one of pyrefly's own stubs, or a project's.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
It, by the folders after `site-packages` or pyrefly's stubs' folder, else by its package
|
|
130
|
+
folders as the project index names it; `None` for a stub anywhere else, or no file at all.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
if not path.name: # a location that isn't a file's (`untitled:`)
|
|
134
|
+
return None
|
|
135
|
+
parts: tuple[str, ...] = path.with_suffix("").parts
|
|
136
|
+
index: int
|
|
137
|
+
part: str
|
|
138
|
+
for index, part in enumerate(parts[:-1]):
|
|
139
|
+
if part == _SITE or part.startswith(_BUNDLED):
|
|
140
|
+
after: int = index + 2
|
|
141
|
+
inside: list[str] = [parts[index + 1].removesuffix(_STUBS), *parts[after:]]
|
|
142
|
+
return ".".join(inside[:-1] if inside[-1] == _PACKAGE and len(inside) > 1 else inside)
|
|
143
|
+
return module_name(path) if path.suffix == SUFFIX else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _imports(new: str, start: Object, locate: _Locate, existing: FromImports) -> list[str] | None:
|
|
147
|
+
"""Read an edit that adds imports: whole statements, or names into a `from` import at `start`.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Each name's import, as a statement of its own; `None` for any other edit.
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
body: list[ast.stmt] = ast.parse(new).body
|
|
155
|
+
except SyntaxError: # `, Shape`: names for an import the file has
|
|
156
|
+
body = []
|
|
157
|
+
if body and all(isinstance(stmt, ast.Import | ast.ImportFrom) for stmt in body):
|
|
158
|
+
return [statement for stmt in body for statement in _statements(stmt)]
|
|
159
|
+
where: _Where | None = locate(start)
|
|
160
|
+
node: ast.ImportFrom | None = None if where is None else existing.at(where)
|
|
161
|
+
names: list[str] = [name.strip() for name in new.split(",") if name.strip()]
|
|
162
|
+
if node is None or not names or not all(name.isidentifier() for name in names):
|
|
163
|
+
return None
|
|
164
|
+
return [f"from {_module(node)} import {name}" for name in names]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _statements(stmt: ast.stmt) -> list[str]:
|
|
168
|
+
"""Write an import as one statement per name it binds.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Them.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
prefix: str = f"from {_module(stmt)} import " if isinstance(stmt, ast.ImportFrom) else "import "
|
|
175
|
+
return [
|
|
176
|
+
prefix + alias.name + (f" as {alias.asname}" if alias.asname else "")
|
|
177
|
+
for alias in cast("ast.Import | ast.ImportFrom", stmt).names
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _module(node: ast.ImportFrom) -> str:
|
|
182
|
+
"""Write the module a `from` import names.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
It, a relative one with its dots.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
return "." * node.level + (node.module or "")
|
|
@@ -43,7 +43,8 @@ _WHY: Final = {
|
|
|
43
43
|
),
|
|
44
44
|
VAGUE_TYPE: (
|
|
45
45
|
"`Any`, `object` and generics without their parameters (`list`, `dict`) say almost nothing\n"
|
|
46
|
-
"about the value. Name the real type: `list[str]`, a `TypedDict`, a union."
|
|
46
|
+
"about the value. Name the real type: `list[str]`, a `TypedDict`, a union. `vague` lets some\n"
|
|
47
|
+
"through: at 0 one inside a type that says the rest (`tuple[str, Any]`), at N N+1 or one alone."
|
|
47
48
|
),
|
|
48
49
|
NESTED_TYPE: (
|
|
49
50
|
"An annotation nested `nesting` deep (3 by default) is hard to read and to change. Name a part\n"
|