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 CHANGED
@@ -25,7 +25,7 @@ from constricter.offences import (
25
25
  )
26
26
  from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
27
27
 
28
- __version__ = "0.2.8"
28
+ __version__ = "0.3.1"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -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
 
@@ -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)