python-constricter 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl
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.
- constricter/__init__.py +1 -1
- constricter/cli/command.py +137 -9
- constricter/fix/doubts.py +288 -0
- constricter/fix/guesses.py +105 -58
- constricter/fix/inference.py +137 -145
- constricter/fix/known.py +13 -1
- constricter/fix/members.py +197 -0
- constricter/fix/project.py +152 -31
- constricter/fix/returned.py +177 -10
- constricter/fix/returns.py +22 -29
- constricter/fix/stdlib.json +15351 -0
- constricter/fix/stdlib.py +126 -195
- constricter/fix/targets.py +64 -8
- constricter/offences.py +1 -0
- constricter/rules/annotations.py +75 -10
- constricter/rules/checker.py +133 -70
- constricter/rules/flow.py +6 -2
- constricter/rules/parsed.py +84 -0
- constricter/rules/rebinding.py +140 -0
- constricter/rules/scope.py +152 -30
- constricter/rules/syntax.py +1 -1
- constricter/rules/walked.py +36 -18
- {python_constricter-0.2.4.dist-info → python_constricter-0.2.5.dist-info}/METADATA +1 -1
- {python_constricter-0.2.4.dist-info → python_constricter-0.2.5.dist-info}/RECORD +27 -22
- {python_constricter-0.2.4.dist-info → python_constricter-0.2.5.dist-info}/WHEEL +0 -0
- {python_constricter-0.2.4.dist-info → python_constricter-0.2.5.dist-info}/entry_points.txt +0 -0
- {python_constricter-0.2.4.dist-info → python_constricter-0.2.5.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
constricter/cli/command.py
CHANGED
|
@@ -10,11 +10,11 @@ import json
|
|
|
10
10
|
import sys
|
|
11
11
|
import tokenize
|
|
12
12
|
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
-
from concurrent.futures import ProcessPoolExecutor
|
|
13
|
+
from concurrent.futures import Future, ProcessPoolExecutor
|
|
14
14
|
from dataclasses import dataclass, field, replace
|
|
15
15
|
from functools import partial
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Final, NamedTuple, TextIO, TypeAlias, cast
|
|
17
|
+
from typing import Final, NamedTuple, Self, TextIO, TypeAlias, cast
|
|
18
18
|
|
|
19
19
|
from constricter import notebook
|
|
20
20
|
from constricter.cli import baseline, hints
|
|
@@ -30,6 +30,7 @@ from constricter.offences import (
|
|
|
30
30
|
Edit,
|
|
31
31
|
Offence,
|
|
32
32
|
)
|
|
33
|
+
from constricter.rules import parsed
|
|
33
34
|
from constricter.rules.checker import Coverage, annotation_coverage, check_source
|
|
34
35
|
|
|
35
36
|
EXIT_CLEAN: Final = 0
|
|
@@ -203,6 +204,10 @@ class _CoverageRun:
|
|
|
203
204
|
|
|
204
205
|
|
|
205
206
|
_FileRun: TypeAlias = _CheckRun | _BaselineRun | _CoverageRun
|
|
207
|
+
_Check: TypeAlias = Callable[[Path, Outside], _FileRun] # check (or count, or baseline) one file
|
|
208
|
+
# A worker's answers, one per file of its share: what it read (to index), and what it found.
|
|
209
|
+
_Reading: TypeAlias = Future[list[project.Module | None]]
|
|
210
|
+
_Checking: TypeAlias = Future[list[_FileRun]]
|
|
206
211
|
HINT_ROUNDS: Final = 4 # with `--fix --infer-with`: how many times each file is fixed, at most
|
|
207
212
|
|
|
208
213
|
|
|
@@ -412,14 +417,135 @@ def _checked_all(
|
|
|
412
417
|
return list(
|
|
413
418
|
itertools.starmap(check, zip(paths, _outside(modules, paths, hinted), strict=True)),
|
|
414
419
|
), modules
|
|
415
|
-
|
|
416
|
-
with
|
|
420
|
+
workers: _Workers
|
|
421
|
+
with _Workers(paths, options.jobs) as workers:
|
|
417
422
|
if modules is None:
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
423
|
+
modules = project.Index({}, []) if coverage else workers.index()
|
|
424
|
+
return workers.check(check, _outside(modules, paths, hinted)), modules
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class _Workers:
|
|
428
|
+
"""Worker processes, each with its own share of the files, which it indexes and then checks.
|
|
429
|
+
|
|
430
|
+
The trees a worker parsed to index its files are still there to check (`parsed.keep`), within its
|
|
431
|
+
part of the budget: sharing the files out anew for the check (a pool's way) would parse them again.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
def __init__(self, paths: Sequence[Path], jobs: int) -> None:
|
|
435
|
+
"""Share `paths` out among up to `jobs` workers (see `_shares`)."""
|
|
436
|
+
self.paths: Sequence[Path] = paths
|
|
437
|
+
self.shares: list[list[int]] = _shares(paths, jobs)
|
|
438
|
+
self.stack: contextlib.ExitStack = contextlib.ExitStack()
|
|
439
|
+
self.pools: list[ProcessPoolExecutor] = []
|
|
440
|
+
|
|
441
|
+
def __enter__(self) -> Self:
|
|
442
|
+
"""Start the workers, one process each, each with its part of the budget.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
Them.
|
|
446
|
+
|
|
447
|
+
"""
|
|
448
|
+
self.pools = [
|
|
449
|
+
self.stack.enter_context(
|
|
450
|
+
ProcessPoolExecutor(max_workers=1, initializer=parsed.budget, initargs=(len(self.shares),)),
|
|
421
451
|
)
|
|
422
|
-
|
|
452
|
+
for _ in self.shares
|
|
453
|
+
]
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
def __exit__(self, *_details: object) -> None:
|
|
457
|
+
"""Stop the workers."""
|
|
458
|
+
self.stack.close()
|
|
459
|
+
|
|
460
|
+
def index(self) -> project.Index:
|
|
461
|
+
"""Index every file, each worker its share.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
The index, as one process would build it.
|
|
465
|
+
|
|
466
|
+
"""
|
|
467
|
+
read: list[_Reading] = [
|
|
468
|
+
pool.submit(_read_share, [self.paths[at] for at in share])
|
|
469
|
+
for pool, share in zip(self.pools, self.shares, strict=True)
|
|
470
|
+
]
|
|
471
|
+
found: dict[int, project.Module | None] = {}
|
|
472
|
+
share: list[int]
|
|
473
|
+
future: _Reading
|
|
474
|
+
for share, future in zip(self.shares, read, strict=True):
|
|
475
|
+
found.update(zip(share, future.result(), strict=True))
|
|
476
|
+
return project.indexed(found[at] for at in range(len(self.paths))) # in their order, as one at a time
|
|
477
|
+
|
|
478
|
+
def check(self, check: _Check, outside: Sequence[Outside]) -> list[_FileRun]:
|
|
479
|
+
"""Check every file, each worker the share it indexed.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
What each file found, in their order.
|
|
483
|
+
|
|
484
|
+
"""
|
|
485
|
+
checked: list[_Checking] = [
|
|
486
|
+
pool.submit(_check_share, check, [(self.paths[at], outside[at]) for at in share])
|
|
487
|
+
for pool, share in zip(self.pools, self.shares, strict=True)
|
|
488
|
+
]
|
|
489
|
+
found: dict[int, _FileRun] = {}
|
|
490
|
+
share: list[int]
|
|
491
|
+
future: _Checking
|
|
492
|
+
for share, future in zip(self.shares, checked, strict=True):
|
|
493
|
+
found.update(zip(share, future.result(), strict=True))
|
|
494
|
+
return [found[at] for at in range(len(self.paths))]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _shares(paths: Sequence[Path], workers: int) -> list[list[int]]:
|
|
498
|
+
"""Share the files among the workers, the biggest first to the least loaded: a check's time goes by size.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Each worker's files, by their place in `paths` (no more workers than files).
|
|
502
|
+
|
|
503
|
+
"""
|
|
504
|
+
loads: list[int] = [0] * min(workers, len(paths))
|
|
505
|
+
shares: list[list[int]] = [[] for _ in loads]
|
|
506
|
+
sizes: list[int] = [_size(path) for path in paths]
|
|
507
|
+
index: int
|
|
508
|
+
for index in sorted(range(len(paths)), key=lambda at: -sizes[at]):
|
|
509
|
+
least: int = loads.index(min(loads))
|
|
510
|
+
shares[least].append(index)
|
|
511
|
+
loads[least] += sizes[index] or 1
|
|
512
|
+
return [sorted(share) for share in shares if share]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _size(path: Path) -> int:
|
|
516
|
+
"""Find a file's size, to balance the workers by.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
It, or 0 if it can't be read (checking it reports that).
|
|
520
|
+
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
return path.stat().st_size
|
|
524
|
+
except OSError:
|
|
525
|
+
return 0
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _read_share(paths: Sequence[Path]) -> list[project.Module | None]:
|
|
529
|
+
"""Index a worker's share of the files (see `project.read`).
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Each file's module, or `None`.
|
|
533
|
+
|
|
534
|
+
"""
|
|
535
|
+
return [project.read(path) for path in paths]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _check_share(
|
|
539
|
+
check: _Check,
|
|
540
|
+
files: Sequence[tuple[Path, Outside]],
|
|
541
|
+
) -> list[_FileRun]:
|
|
542
|
+
"""Check a worker's share of the files.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
What each found.
|
|
546
|
+
|
|
547
|
+
"""
|
|
548
|
+
return list(itertools.starmap(check, files))
|
|
423
549
|
|
|
424
550
|
|
|
425
551
|
def _merged(before: _CheckRun, after: _CheckRun) -> _CheckRun:
|
|
@@ -466,7 +592,9 @@ def _outside(
|
|
|
466
592
|
path: Path
|
|
467
593
|
for path in paths:
|
|
468
594
|
imported: project.Imported = project.imported(modules, path)
|
|
469
|
-
found.append(
|
|
595
|
+
found.append(
|
|
596
|
+
Outside(imported.calls, imported.classes, hinted.get(path, ()), project.type_vars(modules, path)),
|
|
597
|
+
)
|
|
470
598
|
return found
|
|
471
599
|
|
|
472
600
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Where a type checker sees a value otherwise than `--fix` infers it, and what `--fix` does then.
|
|
3
|
+
|
|
4
|
+
- A copy, attribute or subscript of a union may be narrowed where it's read (`if x is not None:`,
|
|
5
|
+
`isinstance`), which a type checker sees and `--fix` doesn't; so may a comprehension's elements, by
|
|
6
|
+
its condition (`[c for c in cs if isinstance(c, Column)]`). Their fixes are guesses.
|
|
7
|
+
- An ALL_CAPS module-level name is a constant to pyright, which keeps its literal's type
|
|
8
|
+
(`Literal["r"]`) where `str` would widen it: a guess too, where the module passes it to a call
|
|
9
|
+
or a default (see `passed`), for a parameter that may take only some values.
|
|
10
|
+
- `self`, and a method declared to return `Self` called on `self` or `cls`, are `Self`, not the class
|
|
11
|
+
(a subclass's `self` isn't its base): written as the module already spells `Self`, or not at all.
|
|
12
|
+
- A generic class the module defines, written bare (`list[Mapper]`, as `self` makes one), is missing
|
|
13
|
+
its type arguments: not offered.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from types import MappingProxyType
|
|
20
|
+
from typing import Final, NamedTuple, TypeAlias, cast
|
|
21
|
+
|
|
22
|
+
from constricter.fix.known import ImportPlan, Inference
|
|
23
|
+
from constricter.rules.annotations import node_name
|
|
24
|
+
from constricter.rules.flow import members
|
|
25
|
+
from constricter.rules.syntax import FunctionDef
|
|
26
|
+
from constricter.rules.walked import of_type
|
|
27
|
+
|
|
28
|
+
# What a copy, an attribute and a subscript of a narrowable union rest on, as guesses.
|
|
29
|
+
_Read: TypeAlias = type[ast.expr]
|
|
30
|
+
_READS: Final[dict[_Read, str]] = {
|
|
31
|
+
ast.Name: "copy",
|
|
32
|
+
ast.Attribute: "attribute",
|
|
33
|
+
ast.Subscript: "subscript",
|
|
34
|
+
}
|
|
35
|
+
# Where a function tests a value, a type checker may narrow it (`isinstance`, `is None`, a `TypeGuard`).
|
|
36
|
+
_TESTS: Final = (ast.If, ast.While, ast.Assert, ast.IfExp, ast.comprehension, ast.Match)
|
|
37
|
+
_SELF: Final = "Self"
|
|
38
|
+
_COMPREHENSION: Final = "comprehension"
|
|
39
|
+
_LITERAL: Final = "literal"
|
|
40
|
+
_COMPREHENSIONS: Final = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
41
|
+
_UNIONS: Final = frozenset({"Optional", "Union"})
|
|
42
|
+
_SELF_ORIGINS: Final = frozenset({"typing.Self", "typing_extensions.Self"})
|
|
43
|
+
_TYPING: Final = frozenset({"typing", "typing_extensions"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Facts(NamedTuple):
|
|
47
|
+
"""What of a module decides what a type checker sees otherwise than `--fix` infers.
|
|
48
|
+
|
|
49
|
+
Each class's methods declared to return a bare `Self` (`self_returns`), its generic classes
|
|
50
|
+
(`generic_classes`), and the names it passes to a call or a default (`passed`).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
selfish: Mapping[str, frozenset[str]] = MappingProxyType({})
|
|
54
|
+
generics: frozenset[str] = frozenset()
|
|
55
|
+
passed: frozenset[str] = frozenset()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Owner(NamedTuple):
|
|
59
|
+
"""The class a method belongs to, the name its instance (or class) goes by, and its `Self` methods."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
first: str # `self`, or a classmethod's first parameter
|
|
63
|
+
selfish: frozenset[str] # its methods declared to return a bare `Self` (see `self_returns`)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def doubts(value: ast.expr, found: Inference, *, constant: bool, narrowed: bool) -> frozenset[str]:
|
|
67
|
+
"""Find what makes `found`, a certain inference of `value`, a guess (see the module docstring).
|
|
68
|
+
|
|
69
|
+
`constant`: whether the value is bound to an ALL_CAPS module-level name; `narrowed`: whether the
|
|
70
|
+
function tests the value itself somewhere (`isinstance(x, C)`, `x is None`, `is_c(x)`), where a
|
|
71
|
+
type checker narrows it, whatever its type.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The guessing mechanisms (`FIX_KINDS`) it then rests on; none if it stays certain.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
kind: str | None = _READS.get(type(value))
|
|
78
|
+
if kind is not None and (narrowed or len(members(found.annotation) or ()) > 1):
|
|
79
|
+
return frozenset({kind})
|
|
80
|
+
if isinstance(value, _COMPREHENSIONS) and any(g.ifs for g in value.generators) and _has_union(found):
|
|
81
|
+
return frozenset({_COMPREHENSION})
|
|
82
|
+
if constant and _literal(value):
|
|
83
|
+
return frozenset({_LITERAL})
|
|
84
|
+
return frozenset()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@lru_cache(maxsize=64) # asked by each assignment in the function's scope
|
|
88
|
+
def tested(function: FunctionDef) -> frozenset[str]:
|
|
89
|
+
"""List what a function tests (see `_TESTS`), where a type checker may narrow it: `x`, `self.x`, `d[k]`.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Each name, attribute and subscript in a test, as source text.
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
return frozenset(
|
|
96
|
+
ast.unparse(read)
|
|
97
|
+
for node in ast.walk(function)
|
|
98
|
+
if isinstance(node, _TESTS)
|
|
99
|
+
for test in _tested_parts(node)
|
|
100
|
+
for read in ast.walk(test)
|
|
101
|
+
if isinstance(read, ast.Name | ast.Attribute | ast.Subscript)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tested_parts(node: ast.AST) -> list[ast.expr]:
|
|
106
|
+
"""Find what one of `_TESTS` tests: an `if`'s condition, a comprehension's, a `match`'s subject.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Them.
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
test: ast.expr
|
|
113
|
+
ifs: list[ast.expr]
|
|
114
|
+
match node:
|
|
115
|
+
case ast.If(test=test) | ast.While(test=test) | ast.Assert(test=test) | ast.IfExp(test=test):
|
|
116
|
+
return [test]
|
|
117
|
+
case ast.comprehension(ifs=ifs):
|
|
118
|
+
return list(ifs)
|
|
119
|
+
case _: # a `match` (see `_TESTS`)
|
|
120
|
+
return [cast("ast.Match", node).subject]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def says_self(function: FunctionDef) -> bool:
|
|
124
|
+
"""Check whether a function's signature mentions `Self` (`-> Self`, `other: Self`, `list[Self]`).
|
|
125
|
+
|
|
126
|
+
In any other method, mypy takes `self` for its class, not `Self`.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Whether it does.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
args: ast.arguments = function.args
|
|
133
|
+
annotations: list[ast.expr | None] = [
|
|
134
|
+
function.returns,
|
|
135
|
+
*(
|
|
136
|
+
arg.annotation
|
|
137
|
+
for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg)
|
|
138
|
+
if arg
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
return any(
|
|
142
|
+
node_name(node) == _SELF
|
|
143
|
+
for annotation in annotations
|
|
144
|
+
if annotation is not None
|
|
145
|
+
for node in ast.walk(annotation)
|
|
146
|
+
if isinstance(node, ast.Name | ast.Attribute)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def passed(tree: ast.Module) -> frozenset[str]:
|
|
151
|
+
"""Find the names a module passes to a call (`f(X)`, `f(code=X)`) or gives a parameter as its default.
|
|
152
|
+
|
|
153
|
+
Where a constant's `Literal` type matters: a parameter may take only some values
|
|
154
|
+
(`code: Literal["invalid-type", ...]`), which `str` doesn't say.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Them.
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
names: set[str] = set()
|
|
161
|
+
node: ast.AST
|
|
162
|
+
for node in of_type(tree, ast.Call, ast.arguments):
|
|
163
|
+
values: list[ast.expr | None] = (
|
|
164
|
+
[*node.args, *(keyword.value for keyword in node.keywords)]
|
|
165
|
+
if isinstance(node, ast.Call)
|
|
166
|
+
else [*cast("ast.arguments", node).defaults, *cast("ast.arguments", node).kw_defaults]
|
|
167
|
+
)
|
|
168
|
+
names.update(value.id for value in values if isinstance(value, ast.Name))
|
|
169
|
+
return frozenset(names)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_constant(name: str) -> bool:
|
|
173
|
+
"""Check whether pyright takes a name for a constant: letters all capitals (`MAX`, `_DEFAULT_MODE`).
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Whether it does.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
return name.upper() == name and any(char.isalpha() for char in name)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def corrected(
|
|
183
|
+
value: ast.expr,
|
|
184
|
+
found: Inference,
|
|
185
|
+
owner: Owner | None,
|
|
186
|
+
generics: frozenset[str],
|
|
187
|
+
plan: ImportPlan | None,
|
|
188
|
+
) -> Inference | None:
|
|
189
|
+
"""Write `found` as a type checker sees it: `Self` where the value is one; nothing for a bare generic.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The inference to offer, or `None` if there's none to.
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
if owner is not None and _selfish(value, found, owner):
|
|
196
|
+
spelled: str | None = None if plan is None else spelled_self(plan)
|
|
197
|
+
return (
|
|
198
|
+
None if spelled is None else found._replace(annotation=spelled, reason=f"{found.reason}: `Self`")
|
|
199
|
+
)
|
|
200
|
+
return None if _bare(found.annotation, generics) else found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _selfish(value: ast.expr, found: Inference, owner: Owner) -> bool:
|
|
204
|
+
"""Check whether `value` is `self`, or a `Self` method called on `self` or `cls`, typed as the class.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Whether it is.
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
name: str
|
|
211
|
+
method: str
|
|
212
|
+
match value:
|
|
213
|
+
case ast.Name(id=name) if name == owner.first:
|
|
214
|
+
return found.annotation == owner.name
|
|
215
|
+
case ast.Call(func=ast.Attribute(value=ast.Name(id=name), attr=method)) if name == owner.first:
|
|
216
|
+
return method in owner.selfish and found.annotation == owner.name
|
|
217
|
+
case _:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def spelled_self(plan: ImportPlan) -> str | None:
|
|
222
|
+
"""Name `Self` as the module already imports it (`Self`, `typing.Self`, `te.Self`).
|
|
223
|
+
|
|
224
|
+
Not an import added for it: `typing.Self` is Python 3.11's, and the module may run on older ones.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The name, or `None` if it doesn't import it.
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
bound: str
|
|
231
|
+
origin: str
|
|
232
|
+
for bound, origin in plan.bound.items():
|
|
233
|
+
if origin in _SELF_ORIGINS:
|
|
234
|
+
return bound
|
|
235
|
+
for bound, origin in plan.bound.items():
|
|
236
|
+
if origin in _TYPING:
|
|
237
|
+
return f"{bound}.Self"
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _bare(annotation: str, generics: frozenset[str]) -> bool:
|
|
242
|
+
"""Check whether an annotation names one of `generics` without subscripting it.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Whether it does.
|
|
246
|
+
|
|
247
|
+
"""
|
|
248
|
+
if not generics:
|
|
249
|
+
return False
|
|
250
|
+
# `annotation` is always `ast.unparse`'s own output, so it's always valid Python to parse back.
|
|
251
|
+
tree: ast.expr = ast.parse(annotation, mode="eval").body
|
|
252
|
+
subscripted: set[int] = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Subscript)}
|
|
253
|
+
return any(
|
|
254
|
+
isinstance(node, ast.Name) and node.id in generics and id(node) not in subscripted
|
|
255
|
+
for node in ast.walk(tree)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _has_union(found: Inference) -> bool:
|
|
260
|
+
"""Check whether an annotation has a union anywhere in it (`list[int | None]`, `Optional[str]`).
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Whether it has.
|
|
264
|
+
|
|
265
|
+
"""
|
|
266
|
+
# `found.annotation` is always `ast.unparse`'s own output, so it's always valid Python to parse back.
|
|
267
|
+
return any(
|
|
268
|
+
(isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr))
|
|
269
|
+
or (isinstance(node, ast.Name) and node.id in _UNIONS)
|
|
270
|
+
or (isinstance(node, ast.Attribute) and node.attr in _UNIONS)
|
|
271
|
+
for node in ast.walk(ast.parse(found.annotation, mode="eval"))
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _literal(value: ast.expr) -> bool:
|
|
276
|
+
"""Check whether `value` is a literal whose own type a `Literal` can be (a string, a number, a `bool`).
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Whether it is.
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
match value:
|
|
283
|
+
case ast.Constant(value=str() | bytes() | int()): # `bool` is an `int`
|
|
284
|
+
return True
|
|
285
|
+
case ast.UnaryOp(op=ast.USub(), operand=ast.Constant(value=int())):
|
|
286
|
+
return True
|
|
287
|
+
case _:
|
|
288
|
+
return False
|