python-constricter 0.2.8__py3-none-any.whl → 0.3.1__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/schedule.py +7 -1
- constricter/fix/atoms.py +407 -0
- constricter/fix/classnames.py +107 -0
- constricter/fix/declared.py +426 -0
- constricter/fix/guesses.py +6 -2
- constricter/fix/inference.py +12 -2
- constricter/fix/installed.py +62 -4
- constricter/fix/known.py +24 -1
- constricter/fix/library.py +66 -3
- constricter/fix/modules.py +36 -0
- constricter/fix/overloads.py +354 -41
- constricter/fix/project.py +34 -13
- constricter/fix/signatures.py +102 -0
- constricter/fix/stdlib.py +46 -52
- constricter/fix/stubbed.py +990 -0
- constricter/fix/tables/scalar_members.json +16 -0
- constricter/fix/tables/scalars.json +2702 -0
- constricter/fix/tables/source.json +19 -1
- constricter/rules/checker.py +5 -0
- {python_constricter-0.2.8.dist-info → python_constricter-0.3.1.dist-info}/METADATA +8 -9
- {python_constricter-0.2.8.dist-info → python_constricter-0.3.1.dist-info}/RECORD +88 -81
- {python_constricter-0.2.8.dist-info → python_constricter-0.3.1.dist-info}/WHEEL +1 -1
- python_constricter-0.3.1.dist-info/entry_points.txt +5 -0
- python_constricter-0.2.8.dist-info/entry_points.txt +0 -6
- {python_constricter-0.2.8.dist-info → python_constricter-0.3.1.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
constricter/cli/schedule.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import Final
|
|
|
13
13
|
|
|
14
14
|
from constricter.cli.runs import CoverageRun, FileRun
|
|
15
15
|
from constricter.cli.workers import Checking, Workers, check_share, first_done
|
|
16
|
-
from constricter.fix import callers, order, project
|
|
16
|
+
from constricter.fix import callers, order, project, stubbed
|
|
17
17
|
from constricter.fix.known import Hints, Outside
|
|
18
18
|
|
|
19
19
|
CYCLE_ROUNDS: Final = 3 # how many times to check again files calling each other's functions
|
|
@@ -27,6 +27,7 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
|
|
|
27
27
|
|
|
28
28
|
"""
|
|
29
29
|
imported: project.Imported = project.imported(modules, path)
|
|
30
|
+
methods: stubbed.Methods = stubbed.methods(modules, path, imported.guarded)
|
|
30
31
|
return Outside(
|
|
31
32
|
imported.calls,
|
|
32
33
|
imported.classes,
|
|
@@ -37,6 +38,11 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
|
|
|
37
38
|
imported.generics,
|
|
38
39
|
callers.callees(modules, path),
|
|
39
40
|
callers.own_parameters(modules, path),
|
|
41
|
+
{**stubbed.overloaded(modules, path), **methods.signatures},
|
|
42
|
+
stubbed.classes(modules, path),
|
|
43
|
+
methods.parameters,
|
|
44
|
+
methods.lineage,
|
|
45
|
+
methods.aliases,
|
|
40
46
|
)
|
|
41
47
|
|
|
42
48
|
|
constricter/fix/atoms.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""What an installed package's annotations are made of, and which arguments each part takes.
|
|
3
|
+
|
|
4
|
+
An annotation is read (by `constricter.fix.stubbed`) into its union's members (`Atom`s): `None`, a
|
|
5
|
+
`Literal`'s values, a type variable (with its bound's members), a `type[...]`, a class, or something
|
|
6
|
+
unread. Each takes a builtin scalar or container argument, or a class passed as one, by the verdicts
|
|
7
|
+
here: a standard-library class by the `scalars` table, an installed protocol by its members, and no
|
|
8
|
+
other installed class.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
13
|
+
from types import EllipsisType
|
|
14
|
+
from typing import Final, NamedTuple, TypeAlias
|
|
15
|
+
|
|
16
|
+
from constricter.fix import stdlib
|
|
17
|
+
from constricter.fix.declared import Alias, Declarations, Protocol, Variable
|
|
18
|
+
from constricter.fix.known import Origin
|
|
19
|
+
from constricter.fix.modules import Module
|
|
20
|
+
from constricter.fix.overloads import CONTAINERS, SCALARS
|
|
21
|
+
from constricter.fix.signatures import Constant
|
|
22
|
+
|
|
23
|
+
YES: Final = "y"
|
|
24
|
+
NO: Final = "n"
|
|
25
|
+
MAYBE: Final = "?"
|
|
26
|
+
NONE: Final = "None"
|
|
27
|
+
LITERAL_STRING: Final = "LiteralString"
|
|
28
|
+
VAR: Final = "var"
|
|
29
|
+
TYPE: Final = "type"
|
|
30
|
+
CLASS: Final = "class"
|
|
31
|
+
UNKNOWN_ATOM: Final = "unknown"
|
|
32
|
+
STR: Final = "str" # `project.definition`'s kind for a function `Declarations` has
|
|
33
|
+
BUILTINS_MODULE: Final = "builtins"
|
|
34
|
+
TYPING: Final = frozenset({"typing", "typing_extensions"})
|
|
35
|
+
ANYTHING: Final = frozenset(
|
|
36
|
+
{"typing.Any", "typing_extensions.Any", "_typeshed.Incomplete", "builtins.object"},
|
|
37
|
+
)
|
|
38
|
+
TYPES: Final = frozenset({"builtins.type", "typing.Type", "typing_extensions.Type"})
|
|
39
|
+
REFUSING: Final = frozenset({"Callable", "Type"}) # no scalar is a callable or a class
|
|
40
|
+
PARAM_SPEC: Final = "*" # a `ParamSpec`'s or `TypeVarTuple`'s bound (see `declared`)
|
|
41
|
+
BUILTIN_TYPES: Final = (*SCALARS, *CONTAINERS) # the `scalars` table's columns
|
|
42
|
+
ELEMENT_OF: Final = frozenset({"list", "set", "frozenset"}) # one type argument: their elements'
|
|
43
|
+
TUPLE: Final = "tuple"
|
|
44
|
+
HOMOGENEOUS: Final = 2 # `tuple[int, ...]`'s arguments
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Scope(NamedTuple):
|
|
48
|
+
"""Where an annotation is read: its module, and the type parameters in scope.
|
|
49
|
+
|
|
50
|
+
Each parameter is its bound's text (`None`: unbounded; `"*"`: a `ParamSpec`'s), or an
|
|
51
|
+
argument an alias was subscripted with: its annotation and the scope that's read in.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
module: Module
|
|
55
|
+
params: Mapping[str, "str | tuple[ast.expr, Scope] | None"]
|
|
56
|
+
owner: str # the module whose function is read: only its type variables are bound
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Atom(NamedTuple):
|
|
60
|
+
"""One member of an annotation's union.
|
|
61
|
+
|
|
62
|
+
`kind`: `none`, `literal` (`values`), `any`, `var` (a type variable `name`, bounded by `bound`
|
|
63
|
+
read in `scope`), `type` (a `type[...]`, whose own atoms are `inner`), `class` (`origin`, and
|
|
64
|
+
the installed module defining it, if one does), or `unknown`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
kind: str
|
|
68
|
+
values: tuple[Constant, ...] = ()
|
|
69
|
+
name: str = ""
|
|
70
|
+
bound: str | None = None
|
|
71
|
+
scope: Scope | None = None
|
|
72
|
+
inner: tuple["Atom", ...] = ()
|
|
73
|
+
origin: Origin = ("", None)
|
|
74
|
+
module: Module | None = None
|
|
75
|
+
subscripted: bool = False
|
|
76
|
+
args: tuple[ast.expr, ...] = () # a class's type arguments, read in `scope`
|
|
77
|
+
constrained: bool = False # a type variable's `bound` is its constraints: it binds none of them
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
UNKNOWN: Final = Atom(UNKNOWN_ATOM)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Whether an atom takes a container argument, and what that container's elements must fit, if it says.
|
|
84
|
+
Taken: TypeAlias = tuple[str, tuple[Atom, ...] | None]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def atom_path(atom: Atom) -> str:
|
|
88
|
+
"""Name a class atom by its dotted path (`typing.SupportsIndex`).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
It.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
return f"{atom.origin[0]}.{atom.origin[1]}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def owned(atom: Atom, owner: str) -> bool:
|
|
98
|
+
"""Check that a type variable is the function's own: one of its type parameters, or its module's.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Whether it is.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
return atom.scope is not None and atom.scope.module.name == owner
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def argument_of(atom: Atom) -> tuple[ast.expr, Scope]:
|
|
108
|
+
"""Read back the argument an alias's parameter was bound to (an `arg` atom).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Its annotation, and the scope it's read in.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
scope: Scope | None = atom.scope
|
|
115
|
+
assert scope is not None # an `arg` atom always has one # ruff: ignore[assert]
|
|
116
|
+
return parse_text(atom.name), scope
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def bound_alias(alias: Alias, where: Scope, args: Sequence[ast.expr], caller: Scope) -> Scope:
|
|
120
|
+
"""Scope an alias's value: its type parameters bound to the arguments it's subscripted with.
|
|
121
|
+
|
|
122
|
+
Its own (`type X[T] = ...`), or else the type variables its value names, in order; one without
|
|
123
|
+
an argument stays unbound (any type).
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The scope.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
params: tuple[str, ...] = alias.params or free_variables(alias.value, where)
|
|
130
|
+
bound: dict[str, str | tuple[ast.expr, Scope] | None] = dict.fromkeys(params)
|
|
131
|
+
name: str
|
|
132
|
+
arg: ast.expr
|
|
133
|
+
for name, arg in zip(params, args, strict=False):
|
|
134
|
+
bound[name] = (arg, caller)
|
|
135
|
+
return Scope(where.module, bound, caller.owner)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def free_variables(value: str, scope: Scope) -> tuple[str, ...]:
|
|
139
|
+
"""Name the type variables an old-style alias's value names (its parameters), in order.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Them.
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
declared: Declarations | None = scope.module.declared
|
|
146
|
+
variables: Mapping[str, Variable] = {} if declared is None else declared.variables
|
|
147
|
+
return tuple(
|
|
148
|
+
dict.fromkeys(
|
|
149
|
+
node.id
|
|
150
|
+
for node in ast.walk(parse_text(value))
|
|
151
|
+
if isinstance(node, ast.Name) and (node.id in variables or node.id in scope.module.type_vars)
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def verdict_of(atoms: Sequence[Atom], scalar: str, *, constant: bool) -> str:
|
|
157
|
+
"""Decide whether an annotation's atoms take an argument of type `scalar` (a literal one if `constant`).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
`y` if one certainly does, `n` if none does, else `?`.
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
return combined({scalar_verdict(atom, scalar, constant=constant) for atom in atoms})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def combined(verdicts: set[str]) -> str:
|
|
167
|
+
"""Combine the verdicts of a union's members: `y` if one takes it, `n` if none does, else `?`.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The verdict.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
if YES in verdicts:
|
|
174
|
+
return YES
|
|
175
|
+
return NO if verdicts == {NO} else MAYBE
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def scalar_verdict(atom: Atom, scalar: str, *, constant: bool) -> str:
|
|
179
|
+
"""Decide whether one atom takes an argument of type `scalar`.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The verdict.
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
match atom.kind:
|
|
186
|
+
case "none":
|
|
187
|
+
return YES if scalar == NONE else NO
|
|
188
|
+
case "literal":
|
|
189
|
+
types: set[str] = {type(value).__name__ if value is not None else NONE for value in atom.values}
|
|
190
|
+
matches: bool = scalar in types or (scalar == LITERAL_STRING and STR in types)
|
|
191
|
+
return NO if constant or not matches else MAYBE
|
|
192
|
+
case "var":
|
|
193
|
+
return bound_verdict(atom, scalar)
|
|
194
|
+
case "type":
|
|
195
|
+
return NO
|
|
196
|
+
case "class":
|
|
197
|
+
return class_takes(atom, scalar)
|
|
198
|
+
case _:
|
|
199
|
+
return MAYBE
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def bound_verdict(atom: Atom, scalar: str) -> str:
|
|
203
|
+
"""Decide whether a type variable takes an argument of type `scalar`, by its bound (its `inner` atoms).
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The verdict (`y` without one).
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
if atom.bound is None:
|
|
210
|
+
return YES
|
|
211
|
+
return MAYBE if atom.bound == PARAM_SPEC else verdict_of(atom.inner, scalar, constant=False)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def class_takes(atom: Atom, scalar: str) -> str:
|
|
215
|
+
"""Decide whether a class takes an argument of type `scalar`.
|
|
216
|
+
|
|
217
|
+
A standard-library one by the `scalars` table; an installed protocol by its members (a generic
|
|
218
|
+
one's parameters unread); no other installed class.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The verdict.
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
path: str = atom_path(atom)
|
|
225
|
+
if atom.module is None:
|
|
226
|
+
return external_takes(path, scalar)
|
|
227
|
+
declared: Declarations | None = atom.module.declared
|
|
228
|
+
protocol: Protocol | None = None if declared is None else declared.protocols.get(path.rpartition(".")[2])
|
|
229
|
+
members: frozenset[str] | None = stdlib.scalar_members(scalar)
|
|
230
|
+
if protocol is None or (members is not None and not protocol.members <= members):
|
|
231
|
+
return NO
|
|
232
|
+
return (
|
|
233
|
+
MAYBE
|
|
234
|
+
if members is None or atom.subscripted or path.rpartition(".")[2] in atom.module.generics
|
|
235
|
+
else YES
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def element_of(container: str, args: Sequence[ast.expr]) -> ast.expr | None:
|
|
240
|
+
"""Find what a builtin container's type arguments say its elements are (`list[int]`, `tuple[int, ...]`).
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Their annotation, or `None` for any other (`tuple[int, str]`, a `dict`'s, none at all).
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
if container in ELEMENT_OF and len(args) == 1:
|
|
247
|
+
return args[0]
|
|
248
|
+
ellipsis: bool = (
|
|
249
|
+
len(args) == HOMOGENEOUS
|
|
250
|
+
and isinstance(args[1], ast.Constant)
|
|
251
|
+
and isinstance(args[1].value, EllipsisType)
|
|
252
|
+
)
|
|
253
|
+
return args[0] if container == TUPLE and ellipsis else None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def external_takes(path: str, scalar: str) -> str:
|
|
257
|
+
"""Decide whether a class outside the installed packages (`typing.SupportsIndex`) takes a `scalar`.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
The verdict: the `scalars` table's, or `?` where it hasn't one.
|
|
261
|
+
|
|
262
|
+
"""
|
|
263
|
+
module: str
|
|
264
|
+
name: str
|
|
265
|
+
module, _, name = path.rpartition(".")
|
|
266
|
+
verdicts: str | None = stdlib.scalar_verdicts(path)
|
|
267
|
+
if path in ANYTHING:
|
|
268
|
+
verdicts = YES * len(BUILTIN_TYPES)
|
|
269
|
+
elif module in TYPING and name == LITERAL_STRING:
|
|
270
|
+
verdicts = "".join(YES if each == LITERAL_STRING else NO for each in BUILTIN_TYPES)
|
|
271
|
+
elif module in TYPING and name in REFUSING:
|
|
272
|
+
verdicts = NO * len(BUILTIN_TYPES)
|
|
273
|
+
return MAYBE if verdicts is None else verdicts[BUILTIN_TYPES.index(scalar)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def class_verdict(atom: Atom) -> str:
|
|
277
|
+
"""Decide whether one atom takes a class passed as an argument (`dtype=np.float64`).
|
|
278
|
+
|
|
279
|
+
A class is an instance of `type` alone: `type[...]`, `type` and `object` take it; `None`, a
|
|
280
|
+
literal, any other class, or a protocol with an attribute or property (which a class has as a
|
|
281
|
+
descriptor, not a value) don't.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The verdict.
|
|
285
|
+
|
|
286
|
+
"""
|
|
287
|
+
found: str = MAYBE
|
|
288
|
+
match atom.kind:
|
|
289
|
+
case "none" | "literal":
|
|
290
|
+
found = NO
|
|
291
|
+
case "type": # a `type[T]` takes any class (bounds unchecked: a call outside one is an error)
|
|
292
|
+
found = YES if all(inner.kind == VAR or is_anything(inner) for inner in atom.inner) else MAYBE
|
|
293
|
+
case "var" if atom.bound is None:
|
|
294
|
+
found = YES
|
|
295
|
+
case "var" if atom.bound != PARAM_SPEC:
|
|
296
|
+
found = combined({class_verdict(inner) for inner in atom.inner})
|
|
297
|
+
case "class":
|
|
298
|
+
found = class_takes_class(atom)
|
|
299
|
+
case _:
|
|
300
|
+
pass
|
|
301
|
+
return found
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def is_anything(atom: Atom) -> bool:
|
|
305
|
+
"""Check whether an atom is `Any`, `object` or `typeshed`'s `Incomplete`.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Whether it is.
|
|
309
|
+
|
|
310
|
+
"""
|
|
311
|
+
return atom.kind == CLASS and atom_path(atom) in ANYTHING
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def class_takes_class(atom: Atom) -> str:
|
|
315
|
+
"""Decide whether a class atom takes a class passed as an argument (see `class_verdict`).
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The verdict.
|
|
319
|
+
|
|
320
|
+
"""
|
|
321
|
+
origin: Origin = atom.origin
|
|
322
|
+
if atom_path(atom) in ANYTHING | TYPES:
|
|
323
|
+
return YES
|
|
324
|
+
if atom.module is None:
|
|
325
|
+
return NO if origin[0] == BUILTINS_MODULE else MAYBE
|
|
326
|
+
declared: Declarations | None = atom.module.declared
|
|
327
|
+
protocol: Protocol | None = None if declared is None else declared.protocols.get(origin[1] or "")
|
|
328
|
+
return NO if protocol is None or protocol.properties else MAYBE
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def scalar_binds(atoms: Sequence[Atom], scalar: str, owner: str) -> tuple[str, str] | None:
|
|
332
|
+
"""Find the type variable an argument of type `scalar` binds: the one member that takes it.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Its name, and the argument's type (a literal's widened to `str`); or `None`.
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
taking: list[Atom] = [atom for atom in atoms if scalar_verdict(atom, scalar, constant=False) != NO]
|
|
339
|
+
if len(taking) != 1 or taking[0].kind != VAR or taking[0].constrained or not owned(taking[0], owner):
|
|
340
|
+
return None
|
|
341
|
+
if scalar_verdict(taking[0], scalar, constant=False) != YES:
|
|
342
|
+
return None
|
|
343
|
+
return taking[0].name, STR if scalar == LITERAL_STRING else scalar
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def class_binds(atoms: Sequence[Atom], owner: str) -> str | None:
|
|
347
|
+
"""Find the type variable a class argument binds: a `type[T]`, all else refusing a class.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Its name, or `None`.
|
|
351
|
+
|
|
352
|
+
"""
|
|
353
|
+
types: list[Atom] = [atom for atom in atoms if atom.kind == TYPE]
|
|
354
|
+
if len(types) != 1 or any(class_verdict(atom) != NO for atom in atoms if atom.kind != TYPE):
|
|
355
|
+
return None
|
|
356
|
+
inner: tuple[Atom, ...] = types[0].inner
|
|
357
|
+
variable: Atom | None = inner[0] if len(inner) == 1 and inner[0].kind == VAR else None
|
|
358
|
+
return None if variable is None or variable.constrained or not owned(variable, owner) else variable.name
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def literal_values(args: Sequence[ast.expr]) -> Iterator[Constant]:
|
|
362
|
+
"""Read a `Literal[...]`'s values (a name, which isn't one, left out).
|
|
363
|
+
|
|
364
|
+
Yields:
|
|
365
|
+
Each.
|
|
366
|
+
|
|
367
|
+
"""
|
|
368
|
+
arg: ast.expr
|
|
369
|
+
for arg in args:
|
|
370
|
+
if isinstance(arg, ast.Constant) and isinstance(
|
|
371
|
+
arg.value,
|
|
372
|
+
bool | int | float | complex | str | bytes | None,
|
|
373
|
+
):
|
|
374
|
+
yield arg.value
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def joined(separator: str, parts: Sequence[str | None]) -> str | None:
|
|
378
|
+
"""Join a template's parts, none of which may be missing.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
The text, or `None` if a part is.
|
|
382
|
+
|
|
383
|
+
"""
|
|
384
|
+
return None if None in parts else separator.join(part for part in parts if part is not None)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def parse_text(text: str) -> ast.expr:
|
|
388
|
+
"""Parse an annotation's text; one that doesn't parse (`"int["`) names nothing.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Its tree.
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
return ast.parse(text, mode="eval").body
|
|
396
|
+
except SyntaxError:
|
|
397
|
+
return ast.Constant(value=...) # read as nothing it names
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def private_name(name: str | None) -> bool:
|
|
401
|
+
"""Check whether a dotted name (`numpy._typing`) has a private part, or is missing.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Whether it has, or is.
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
return name is None or any(part.startswith("_") for part in name.split("."))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The installed classes and aliases a module names, and the methods a class's lineage declares.
|
|
3
|
+
|
|
4
|
+
For `constricter.fix.stubbed`, which reads their methods' signatures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
9
|
+
|
|
10
|
+
from constricter.fix import project
|
|
11
|
+
from constricter.fix.known import Origin
|
|
12
|
+
from constricter.fix.modules import Module
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from constricter.fix.declared import Class, Declarations
|
|
16
|
+
|
|
17
|
+
# A package's classes (or aliases): each as written after the package's name, and where it's defined.
|
|
18
|
+
Packaged: TypeAlias = tuple[tuple[str, Origin], ...]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def method_names(modules: Mapping[str, Module], lineage: Sequence[str]) -> frozenset[str]:
|
|
22
|
+
"""Name the methods a class or any of its installed ancestors (its lineage) declares.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Them.
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
found: set[str] = set()
|
|
29
|
+
path: str
|
|
30
|
+
for path in lineage:
|
|
31
|
+
module: str
|
|
32
|
+
name: str
|
|
33
|
+
module, _, name = path.rpartition(".")
|
|
34
|
+
declared: Declarations | None = None if module not in modules else modules[module].declared
|
|
35
|
+
klass: Class | None = None if declared is None else declared.classes.get(name)
|
|
36
|
+
found.update(() if klass is None else klass.methods)
|
|
37
|
+
return frozenset(found)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classes(
|
|
41
|
+
modules: Mapping[str, Module],
|
|
42
|
+
names: Mapping[str, Origin],
|
|
43
|
+
packaged: dict[tuple[str, bool], "Packaged"],
|
|
44
|
+
*,
|
|
45
|
+
aliases: bool = False,
|
|
46
|
+
) -> Iterator[tuple[str, Origin]]:
|
|
47
|
+
"""Find the installed classes `names` (a module's) name: imported (`ndarray`), or through a package.
|
|
48
|
+
|
|
49
|
+
Through a package, what any of its modules defines or re-exports (`numpy.ndarray`), the package
|
|
50
|
+
imported from another module too (`from pandas._typing import npt`). With
|
|
51
|
+
`aliases`, the installed aliases it names instead (`npt.NDArray`). `packaged`: each package's, as
|
|
52
|
+
read so far.
|
|
53
|
+
|
|
54
|
+
Yields:
|
|
55
|
+
Each as the module writes it, and where it's defined.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
local: str
|
|
59
|
+
origin: Origin
|
|
60
|
+
for local, origin in names.items():
|
|
61
|
+
where: Origin = project.canonical_origin(modules, origin)
|
|
62
|
+
if where[1] is not None:
|
|
63
|
+
if declares(modules, where, aliases=aliases):
|
|
64
|
+
yield local, where
|
|
65
|
+
continue
|
|
66
|
+
if (where[0], aliases) not in packaged:
|
|
67
|
+
packaged[where[0], aliases] = tuple(_package_classes(modules, where[0], aliases=aliases))
|
|
68
|
+
suffix: str
|
|
69
|
+
found: Origin
|
|
70
|
+
for suffix, found in packaged[where[0], aliases]:
|
|
71
|
+
yield f"{local}{suffix}", found
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _package_classes(
|
|
75
|
+
modules: Mapping[str, Module],
|
|
76
|
+
package: str,
|
|
77
|
+
*,
|
|
78
|
+
aliases: bool,
|
|
79
|
+
) -> Iterator[tuple[str, Origin]]:
|
|
80
|
+
"""Find the installed classes (or aliases) a package's modules define or re-export.
|
|
81
|
+
|
|
82
|
+
Yields:
|
|
83
|
+
Each as written after the package's name (`.ndarray`, `.linalg.LinAlgError`), and where it's defined.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
name: str
|
|
87
|
+
other: Module
|
|
88
|
+
for name, other in modules.items():
|
|
89
|
+
if other.installed and (name == package or name.startswith(f"{package}.")):
|
|
90
|
+
bound: str
|
|
91
|
+
found: Origin
|
|
92
|
+
for bound, found in other.names.items():
|
|
93
|
+
where: Origin = project.canonical_origin(modules, found)
|
|
94
|
+
if declares(modules, where, aliases=aliases):
|
|
95
|
+
yield f"{name.removeprefix(package)}.{bound}", where
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def declares(modules: Mapping[str, Module], origin: Origin, *, aliases: bool = False) -> bool:
|
|
99
|
+
"""Check whether an installed module declares a class (or an alias) under `origin`'s name.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Whether it does.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
module: Module | None = modules.get(origin[0])
|
|
106
|
+
declared: Declarations | None = None if module is None or not module.installed else module.declared
|
|
107
|
+
return declared is not None and origin[1] in (declared.aliases if aliases else declared.classes)
|