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 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.4"
28
+ __version__ = "0.2.5"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -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
- pool: ProcessPoolExecutor
416
- with ProcessPoolExecutor(max_workers=options.jobs) as pool:
420
+ workers: _Workers
421
+ with _Workers(paths, options.jobs) as workers:
417
422
  if modules is None:
418
- # The index, as the checks, read one file per task.
419
- modules = (
420
- project.Index({}, []) if coverage else project.index(paths, partial(pool.map, chunksize=16))
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
- return list(pool.map(check, paths, _outside(modules, paths, hinted))), modules
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(Outside(imported.calls, imported.classes, hinted.get(path, ())))
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