python-constricter 0.3.5__py3-none-any.whl → 0.3.6__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.3.5"
28
+ __version__ = "0.3.6"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -31,6 +31,7 @@ from constricter.offences import (
31
31
  DEFAULT_CHECKS,
32
32
  Checks,
33
33
  Edit,
34
+ Fix,
34
35
  Offence,
35
36
  )
36
37
  from constricter.rules import parsed
@@ -184,7 +185,10 @@ def fix_file(path: Path, offences: Sequence[Offence]) -> int:
184
185
  return count
185
186
 
186
187
 
187
- HINT_ROUNDS: Final = 4 # with `--fix --infer-with`: how many times each file is fixed, at most
188
+ HINT_ROUNDS: Final = 4 # with `--fix`: how many times a file is fixed, at most (see `_settled`)
189
+ # The fix kinds a scope's check comes to late, once the scope is read: what was bound to such a
190
+ # name's value before had no type yet, and has one once the fix is written.
191
+ _LATE_KINDS: Final = frozenset({"optional", "filled"})
188
192
 
189
193
 
190
194
  def _shown_lines(raw: str, name: Path) -> dict[tuple[int | None, int], str]:
@@ -288,9 +292,8 @@ def _handled(path: Path, text: tuple[str, Path], offences: list[Offence], option
288
292
  baselined: int
289
293
  offences, baselined = options.filter.unbaselined(name, offences)
290
294
  results: list[Result] = _results(raw, name, offences, options)
291
- unsafe: bool = options.unsafe_fixes
292
295
  fixing: list[Offence] = [
293
- r.offence for r in results if r.offence.edit is not None and (unsafe or not r.offence.unsafe)
296
+ r.offence for r in results if r.offence.edit is not None and options.guesses.applies(r.offence)
294
297
  ]
295
298
  if options.mode is Mode.DIFF:
296
299
  return CheckRun(text=_diff(raw, name, fixing))
@@ -378,9 +381,8 @@ def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
378
381
 
379
382
  With `--infer-with`, the type checker's server runs throughout: it's asked for every file's
380
383
  hints first, and with `--fix`, each file a round changed is asked again and checked again, as its
381
- new annotations change what the checker infers, until a round changes nothing (at most
382
- `HINT_ROUNDS`). A baseline records offences, and coverage counts annotations: hints change
383
- neither, only what `--fix` offers.
384
+ new annotations change what the checker infers (see `_settled`). A baseline records offences,
385
+ and coverage counts annotations: hints change neither, only what `--fix` offers.
384
386
 
385
387
  Returns:
386
388
  The names, and what each file found.
@@ -398,7 +400,7 @@ def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
398
400
  if options.mode in {Mode.COVERAGE, Mode.WRITE_BASELINE}:
399
401
  return names, _checked_all(paths, check, options)[0]
400
402
  if not options.infer_with:
401
- return names, _called_again(paths, _checked_all(paths, check, options), check, options)[0]
403
+ return names, _settled(paths, check, options, None)
402
404
  session: hints.Session
403
405
  # Imported only for `--infer-with`: it's slow to import, for every run's start.
404
406
  with cast("type[hints.Session]", importlib.import_module("constricter.cli.hints").Session)(
@@ -407,36 +409,7 @@ def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
407
409
  options.jobs,
408
410
  options.infer_memory,
409
411
  ) as session:
410
- runs: list[FileRun]
411
- modules: project.Index
412
- runs, modules = _called_again(
413
- paths,
414
- _checked_all(paths, check, options, session),
415
- check,
416
- options,
417
- session,
418
- )
419
- # The files the last round changed: only their hints can have changed.
420
- again: list[int] = [
421
- index
422
- for index, run in enumerate(runs)
423
- if options.mode is Mode.FIX and isinstance(run, CheckRun) and run.fixed and paths[index] != STDIN
424
- ]
425
- for _ in range(HINT_ROUNDS - 1):
426
- if not again:
427
- break
428
- redone: list[FileRun] = _checked_all(
429
- [paths[i] for i in again],
430
- check,
431
- options,
432
- session,
433
- modules,
434
- )[0]
435
- index: int
436
- run: FileRun
437
- for index, run in zip(again, redone, strict=True):
438
- runs[index] = merged(runs[index], run)
439
- again = [index for index, run in zip(again, redone, strict=True) if cast("CheckRun", run).fixed]
412
+ runs: list[FileRun] = _settled(paths, check, options, session)
440
413
  checker: str
441
414
  path: Path
442
415
  for checker, path in session.abandoned:
@@ -445,6 +418,65 @@ def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
445
418
  return names, runs
446
419
 
447
420
 
421
+ def _settled(
422
+ paths: Sequence[Path],
423
+ check: Callable[[Path, Outside], FileRun],
424
+ options: Options,
425
+ session: "hints.Session | None",
426
+ ) -> list[FileRun]:
427
+ """Check every file, then again each one `--fix` changed in a way a second check would see.
428
+
429
+ Until a round changes nothing, `HINT_ROUNDS` at most: a second pass is then left nothing.
430
+
431
+ Returns:
432
+ What each file found, a file's rounds joined.
433
+
434
+ """
435
+ runs: list[FileRun]
436
+ modules: project.Index
437
+ runs, modules = _called_again(
438
+ paths,
439
+ _checked_all(paths, check, options, session),
440
+ check,
441
+ options,
442
+ session,
443
+ )
444
+ again: list[int] = [
445
+ index
446
+ for index, run in enumerate(runs)
447
+ if options.mode is Mode.FIX and paths[index] != STDIN and _unsettled(run, session)
448
+ ]
449
+ for _ in range(HINT_ROUNDS - 1):
450
+ if not again:
451
+ break
452
+ redone: list[FileRun] = _checked_all([paths[i] for i in again], check, options, session, modules)[0]
453
+ index: int
454
+ run: FileRun
455
+ for index, run in zip(again, redone, strict=True):
456
+ runs[index] = merged(runs[index], run)
457
+ again = [index for index, run in zip(again, redone, strict=True) if _unsettled(run, session)]
458
+ return runs
459
+
460
+
461
+ def _unsettled(run: FileRun, session: "hints.Session | None") -> bool:
462
+ """Check whether a round of `--fix` changed a file in a way that changes what's inferred there.
463
+
464
+ Returns:
465
+ Whether it fixed anything, with a type checker's hints: its new annotations change them.
466
+ Without, whether it made a fix the scope's check comes to late (`_LATE_KINDS`): what's bound
467
+ to such a name's value was read before its type was known.
468
+
469
+ """
470
+ checked: CheckRun = cast("CheckRun", run)
471
+ return bool(checked.fixed) and (
472
+ session is not None
473
+ or any(
474
+ isinstance(made.offence.edit, Fix) and made.offence.edit.kinds & _LATE_KINDS
475
+ for made in checked.made
476
+ )
477
+ )
478
+
479
+
448
480
  def _called_again(
449
481
  paths: Sequence[Path],
450
482
  first: tuple[list[FileRun], project.Index],
@@ -618,7 +650,10 @@ def _report(options: Options, runs: Sequence[FileRun], files: int) -> int:
618
650
  parts.append(f"fixed {sum(run.fixed for run in checked)}")
619
651
  guesses: int
620
652
  if guesses := sum(r.offence.unsafe for r in results):
621
- parts.append(f"{guesses} more with --unsafe-fixes")
653
+ likely: int = sum(r.offence.likely for r in results)
654
+ parts.append(
655
+ f"{guesses} more with --unsafe-fixes" + f" ({likely} with --likely)" * bool(likely),
656
+ )
622
657
  if options.filter.baseline_file is not None:
623
658
  parts.append(f"{sum(run.baselined for run in checked)} baselined")
624
659
  _ = sys.stdout.write("; ".join(parts) + ".\n")
constricter/cli/hints.py CHANGED
@@ -816,6 +816,6 @@ class Connection:
816
816
  cast("IO[bytes]", self.process.stdin),
817
817
  f"Content-Length: {len(body)}\r\n\r\n".encode() + body,
818
818
  )
819
- except OSError as error:
819
+ except (OSError, ValueError) as error: # it has gone, or its input was closed to stop it
820
820
  failure: str = f"{self.name} exited: {error}"
821
821
  raise HintError(failure) from error
@@ -332,6 +332,11 @@ def _parser() -> argparse.ArgumentParser:
332
332
  action="store_true",
333
333
  help="with --fix or --diff: also apply guesses (a call to a class that may be generic)",
334
334
  )
335
+ _ = parser.add_argument(
336
+ "--likely",
337
+ action="store_true",
338
+ help="with --fix or --diff: also apply the guesses measured to hold (see docs/LIKELY.md)",
339
+ )
335
340
  _ = parser.add_argument(
336
341
  "--fix-select",
337
342
  type=_fix_kinds,
@@ -563,13 +568,40 @@ class Output:
563
568
  output_file: Path | None = None
564
569
 
565
570
 
571
+ class Guesses(Enum):
572
+ """Which guesses `--fix` and `--diff` apply: none, `--likely`'s, or every one (`--unsafe-fixes`)."""
573
+
574
+ NONE = "none"
575
+ LIKELY = "likely"
576
+ ALL = "all"
577
+
578
+ @classmethod
579
+ def of(cls, *, unsafe: bool, likely: bool) -> "Guesses":
580
+ """Read the two flags.
581
+
582
+ Returns:
583
+ `ALL` with `--unsafe-fixes`, whatever `--likely` says.
584
+
585
+ """
586
+ return cls.ALL if unsafe else cls.LIKELY if likely else cls.NONE
587
+
588
+ def applies(self, offence: Offence) -> bool:
589
+ """Check whether `offence`'s fix is one to apply.
590
+
591
+ Returns:
592
+ Whether it is: a certain one always.
593
+
594
+ """
595
+ return self is Guesses.ALL or not offence.unsafe or (self is Guesses.LIKELY and offence.likely)
596
+
597
+
566
598
  @dataclass(frozen=True)
567
599
  class Options:
568
600
  """The command's parsed options."""
569
601
 
570
602
  input: Input
571
603
  checks: Checks
572
- unsafe_fixes: bool
604
+ guesses: Guesses
573
605
  filter: Filter
574
606
  output: Output
575
607
  mode: Mode
@@ -626,7 +658,7 @@ class Options:
626
658
  min_python=cast("tuple[int, int] | None", args.min_python),
627
659
  plain_bases=(*PLAIN_BASES, *cast("list[str]", args.fix_plain_bases)),
628
660
  ),
629
- unsafe_fixes=cast("bool", args.unsafe_fixes),
661
+ guesses=Guesses.of(unsafe=cast("bool", args.unsafe_fixes), likely=cast("bool", args.likely)),
630
662
  filter=_filter(parser, args, mode),
631
663
  output=Output(
632
664
  fmt=cast("Format", args.format),
constricter/cli/report.py CHANGED
@@ -216,7 +216,9 @@ def _json(results: Sequence[Result]) -> Iterator[str]:
216
216
  "annotation": r.offence.fix,
217
217
  "reason": r.offence.reason,
218
218
  "unsafe": r.offence.unsafe,
219
+ "likely": r.offence.likely,
219
220
  "kinds": sorted(r.offence.edit.kinds if r.offence.edit else ()),
221
+ "imports": [*r.offence.edit.imports, *r.offence.edit.guarded] if r.offence.edit else [],
220
222
  },
221
223
  }
222
224
  for r in results
@@ -366,7 +368,7 @@ def fix_reasons(results: Sequence[Result]) -> Iterator[str]:
366
368
 
367
369
  Yields:
368
370
  A line each: where, the name (`fixed`, for a fix `--fix` made), the annotation and its reason;
369
- a guess left unmade says it needs `--unsafe-fixes`.
371
+ a guess left unmade says it needs `--unsafe-fixes`, or `--likely` where that applies it.
370
372
 
371
373
  """
372
374
  r: Result
@@ -374,7 +376,8 @@ def fix_reasons(results: Sequence[Result]) -> Iterator[str]:
374
376
  if r.offence.fix is not None:
375
377
  cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
376
378
  where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
377
- guess: str = " (a guess: --unsafe-fixes)" if r.offence.unsafe and not r.fixed else ""
379
+ flag: str = "--likely" if r.offence.likely else "--unsafe-fixes"
380
+ guess: str = f" (a guess: {flag})" if r.offence.unsafe and not r.fixed else ""
378
381
  kinds: str = ", ".join(sorted(r.offence.edit.kinds if r.offence.edit else ()))
379
382
  written: str = f"`{r.offence.fix}`" if r.offence.fix else "drop its annotation"
380
383
  decided: str = f"{written}, from {r.offence.reason} [{kinds}]"
@@ -3,6 +3,7 @@
3
3
 
4
4
  import contextlib
5
5
  import re
6
+ import unicodedata
6
7
  from collections.abc import Sequence
7
8
  from operator import itemgetter
8
9
  from typing import Final, NamedTuple, TypeAlias
@@ -14,6 +15,8 @@ _HEADER: Final = 2 # a file's shebang and coding lines come first, if it has th
14
15
  _Present: TypeAlias = tuple[Sequence[str], int, frozenset[str]]
15
16
  _PRESENT: Final[dict[str | None, _Present]] = {}
16
17
  _HEADER_LINE: Final = re.compile(r"#!|#.*coding[:=]")
18
+ _IDENTIFIER: Final = re.compile(r"\w+")
19
+ _NORMAL: Final = "NFKC" # how Python normalises an identifier: `ast` has a full-width `w` as `w`
17
20
  # Lines to insert, and the number of lines before them.
18
21
  _Run: TypeAlias = tuple[int, list[str]]
19
22
  # What orders the edits: a line, what's made there first (`_IMPORTS`, then an edit in the line,
@@ -74,13 +77,35 @@ def replacement(lines: Sequence[str], offence: Offence) -> Replacement | None:
74
77
  mark: str = f" {MARK}" if fix.kinds & WIDEN_KINDS else ""
75
78
  return Replacement(fix.span[0], "", "", f"{indent}{offence.name}: {fix.annotation}{mark}{ending}")
76
79
  raw: bytes = lines[offence.line - 1].encode()
77
- start: int = fix.span[0] if fix.edit is Edit.REPLACE else offence.col + len(offence.name.encode())
80
+ start: int = fix.span[0] if fix.edit is Edit.REPLACE else _name_end(raw, offence)
78
81
  end: int = fix.span[1] if fix.edit is Edit.REPLACE else start
79
82
  written: str = fix.annotation if fix.edit is Edit.REPLACE else f": {fix.annotation}"
80
83
  return Replacement(offence.line, raw[:start].decode(), raw[start:end].decode(), written)
81
84
  return None
82
85
 
83
86
 
87
+ def _name_end(raw: bytes, offence: Offence) -> int:
88
+ """Find where the offence's name ends in its line `raw`, as the source spells it.
89
+
90
+ The source may spell it longer than `ast` has it, which normalises an identifier (a full-width
91
+ `w` is `w`).
92
+
93
+ Returns:
94
+ The column after it.
95
+
96
+ Raises:
97
+ IndexError: The line doesn't have the name there.
98
+
99
+ """
100
+ rest: str = raw[offence.col :].decode()
101
+ if rest.startswith(offence.name):
102
+ return offence.col + len(offence.name.encode())
103
+ spelled: re.Match[str] | None = _IDENTIFIER.match(rest)
104
+ if spelled is None or unicodedata.normalize(_NORMAL, spelled.group()) != offence.name:
105
+ raise IndexError(offence.col)
106
+ return offence.col + len(spelled.group().encode())
107
+
108
+
84
109
  def dropped(lines: Sequence[str], offence: Offence) -> Replacement | None:
85
110
  """Turn a fix's `drop` into a text edit: deleting those columns of its statement's line.
86
111
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  import ast
5
5
  import bisect
6
+ import sys
6
7
  from collections.abc import Iterator, Mapping, Sequence
7
8
  from itertools import accumulate
8
9
  from typing import Final, TypeAlias, cast
@@ -11,9 +12,10 @@ from weakref import WeakKeyDictionary
11
12
  from constricter.fix.core.known import Checking, Guarded, ImportPlan, Origin, Returns
12
13
  from constricter.rules.annotations import roots
13
14
  from constricter.rules.syntax import FunctionDef, Start, import_bindings
14
- from constricter.rules.walked import of_type
15
+ from constricter.rules.walked import classes, of_type
15
16
 
16
17
  _TYPE_CHECKING: Final = "TYPE_CHECKING"
18
+ _STDLIB: Final = sys.stdlib_module_names
17
19
  _FUTURE: Final = "__future__"
18
20
  _ANNOTATIONS: Final = "annotations" # `from __future__ import annotations` postpones them all
19
21
  # Each module's names, for as long as its tree lives: the index reads them, then the check.
@@ -52,11 +54,12 @@ def plan(tree: ast.Module) -> ImportPlan:
52
54
  values: frozenset[str]
53
55
  taken, values = taken_names(tree)
54
56
  bound: dict[str, str] = _bound(tree)
57
+ defined: dict[str, int] = _defined(tree)
55
58
  return ImportPlan(
56
59
  bound,
57
60
  taken,
58
61
  _after(tree),
59
- _defined(tree),
62
+ defined,
60
63
  checking=Checking(
61
64
  _block(tree),
62
65
  _lazy(tree, bound),
@@ -72,6 +75,52 @@ def plan(tree: ast.Module) -> ImportPlan:
72
75
  )
73
76
 
74
77
 
78
+ def nested_classes(tree: ast.Module) -> frozenset[str]:
79
+ """Name the classes defined in another class's body: bare, names of that body alone.
80
+
81
+ Returns:
82
+ Them.
83
+
84
+ """
85
+ return frozenset(
86
+ stmt.name for node in classes(tree) for stmt in node.body if isinstance(stmt, ast.ClassDef)
87
+ )
88
+
89
+
90
+ def _private(statement: str) -> bool:
91
+ """Check whether an import to add takes a name its module keeps to itself (`from m import _T`).
92
+
93
+ Not the standard library's, whose stubs name such classes in what they declare
94
+ (`unittest.case._AssertRaisesContext`).
95
+
96
+ Returns:
97
+ Whether it does: one underscore before it, not a dunder's two.
98
+
99
+ """
100
+ module: str
101
+ name: str
102
+ module, _, name = statement.removeprefix("from ").rpartition(" import ")
103
+ return name.startswith("_") and not name.startswith("__") and module.partition(".")[0] not in _STDLIB
104
+
105
+
106
+ def unwritable(annotation: str, planned: ImportPlan, nested: frozenset[str]) -> bool:
107
+ """Check whether the module can't write `annotation`, whatever it imports.
108
+
109
+ It names a class of `nested` (see `nested_classes`) the module binds nowhere else, which no
110
+ function names bare; or an import it needs would take another module's private name.
111
+
112
+ Returns:
113
+ Whether it can't.
114
+
115
+ """
116
+ if roots(annotation) & nested - planned.bound.keys() - planned.defined.keys():
117
+ return True
118
+ return any(
119
+ _private(statement)
120
+ for statement in (*guarded_imports(annotation, planned), *imports_of(annotation, planned))
121
+ )
122
+
123
+
75
124
  def added_origin(statement: str) -> Origin:
76
125
  """Read what an import `ImportPlan.spell` added binds: `from m import T`, or `import m`.
77
126
 
@@ -31,6 +31,30 @@ else:
31
31
 
32
32
  _BUILTINS: Final = frozenset(dir(builtins))
33
33
  _DOT: Final = "."
34
+ # The standard library's modules only some platforms have: a checker on another finds nothing in one.
35
+ _PLATFORM: Final = frozenset(
36
+ {
37
+ "_winapi",
38
+ "crypt",
39
+ "fcntl",
40
+ "grp",
41
+ "msvcrt",
42
+ "nis",
43
+ "nt",
44
+ "ossaudiodev",
45
+ "posix",
46
+ "pty",
47
+ "pwd",
48
+ "readline",
49
+ "resource",
50
+ "spwd",
51
+ "syslog",
52
+ "termios",
53
+ "tty",
54
+ "winreg",
55
+ "winsound",
56
+ },
57
+ )
34
58
  _NAME: Final = re.compile(r"[A-Za-z_]\w*") # each name in a return's template
35
59
  # What a name refers to: a module and an attribute of it (`None`: the module itself).
36
60
  Origin: TypeAlias = tuple[str, str | None]
@@ -177,26 +201,39 @@ class ImportPlan:
177
201
  postponed: bool = False
178
202
  values: frozenset[str] = frozenset() # names it binds as values somewhere (see `imports.taken_names`)
179
203
 
180
- def spell(self, qualified: str) -> str | None:
204
+ def type_checking(self) -> str | None:
205
+ """Name `typing.TYPE_CHECKING` for a new block, by an import that runs: the module reads it.
206
+
207
+ Returns:
208
+ The name, or `None` if every way to write it is taken.
209
+
210
+ """
211
+ return self.spell("typing.TYPE_CHECKING", runs=True)
212
+
213
+ def spell(self, qualified: str, *, runs: bool = False) -> str | None:
181
214
  """Name `qualified` (`io.BufferedReader`) in this module, adding an import if it has to.
182
215
 
183
216
  Through an import it has (see `named`), for type checking alone too (`checking`, or one
184
217
  `guarded` already), else a new `from io import BufferedReader`, else a new `import io`, but
185
218
  only binding a name nothing in the module binds. Under `if TYPE_CHECKING:` for a module in
186
- `checking.lazy`.
219
+ `checking.lazy`, and not at all for one only some platforms have. With `runs`, only through
220
+ an import that runs: the name is read as the module is imported (`TYPE_CHECKING` itself).
187
221
 
188
222
  Returns:
189
223
  The name, or `None` if every way to write it is taken.
190
224
 
191
225
  """
192
226
  found: str | None
193
- if (found := self.named(qualified) or self._checked(qualified)) is not None:
227
+ if (found := self.named(qualified) or (None if runs else self._checked(qualified))) is not None:
194
228
  return found
195
229
  module: str
196
230
  name: str
197
231
  module, _, name = qualified.rpartition(".")
198
232
  statement: str = f"from {module} import {name}"
199
- if module.partition(_DOT)[0] in self.checking.lazy:
233
+ top: str = module.partition(_DOT)[0]
234
+ if not runs and top in self.checking.lazy:
235
+ if top in _PLATFORM:
236
+ return None
200
237
  return name if self.guard(name, (module, name), statement) else None
201
238
  if self._free(name, statement):
202
239
  self.added[name] = statement
@@ -291,20 +291,26 @@ def _guarded(tree: ast.Module, name: str, *, is_package: bool) -> dict[str, Orig
291
291
  """Map the names module `name` imports under a top-level `if` or `try` (`if TYPE_CHECKING:`).
292
292
 
293
293
  Not what it binds at run time (`_names`): a type variable imported only for the checker is a type
294
- variable all the same.
294
+ variable all the same. Not a name two of them bind to different things (one in each branch of an
295
+ `if`): which it is, another file can't say.
295
296
 
296
297
  Returns:
297
298
  What each refers to.
298
299
 
299
300
  """
300
- return {
301
- bound: origin
302
- for stmt in tree.body
303
- if isinstance(stmt, ast.If | ast.Try | ast.TryStar)
304
- for node in ast.walk(stmt)
305
- if isinstance(node, ast.Import | ast.ImportFrom)
306
- for bound, origin in _imported(node, name, is_package=is_package).items()
307
- }
301
+ found: dict[str, Origin] = {}
302
+ either: set[str] = set()
303
+ node: ast.AST
304
+ bound: str
305
+ origin: Origin
306
+ for node in (
307
+ n for stmt in tree.body if isinstance(stmt, ast.If | ast.Try | ast.TryStar) for n in ast.walk(stmt)
308
+ ):
309
+ if isinstance(node, ast.Import | ast.ImportFrom):
310
+ for bound, origin in _imported(node, name, is_package=is_package).items():
311
+ if found.setdefault(bound, origin) != origin:
312
+ either.add(bound)
313
+ return {bound: origin for bound, origin in found.items() if bound not in either}
308
314
 
309
315
 
310
316
  def _bound(stmt: ast.stmt) -> Iterator[str]:
@@ -910,6 +910,7 @@ def _spelled_classes(
910
910
  spelled: list[tuple[str, Origin]] = []
911
911
  if origin[1] is not None and origin[0] != target.name:
912
912
  spelled = [(local, origin)]
913
+ generics.update([local] * _is_generic(catalog.modules, origin))
913
914
  elif origin[1] is None:
914
915
  package: Module | None = catalog.modules.get(origin[0])
915
916
  spelled = [
@@ -934,7 +935,8 @@ def _spelled_classes(
934
935
  def _reexported_generics(modules: Mapping[str, Module], local: str, name: str) -> Iterator[str]:
935
936
  """Spell the generic classes module `name` re-exports (`from ._c import OrderedSet`) as `local.C`.
936
937
 
937
- Only its own names, not its submodules': a package's `__init__` is where they're re-exported.
938
+ Only its own names, not its submodules': a package's `__init__` is where they're re-exported,
939
+ for type checking alone too, where it imports them only when they're asked for (pydantic's).
938
940
 
939
941
  Yields:
940
942
  Each spelling.
@@ -943,14 +945,8 @@ def _reexported_generics(modules: Mapping[str, Module], local: str, name: str) -
943
945
  module: Module | None = modules.get(name)
944
946
  reexported: str
945
947
  origin: Origin
946
- for reexported, origin in ({} if module is None else module.names).items():
947
- defined: tuple[Module, str] | None
948
- if (
949
- origin[1] is not None
950
- and origin[0] != name
951
- and (defined := definition(modules, origin, CLASS)) is not None
952
- and defined[1] in defined[0].generics
953
- ):
948
+ for reexported, origin in ({} if module is None else {**module.guarded, **module.names}).items():
949
+ if origin[1] is not None and origin[0] != name and _is_generic(modules, origin):
954
950
  yield f"{local}.{reexported}"
955
951
 
956
952
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "basedpyright": "1.40.1",
3
- "digest": "831ff4b6298124e889d624018c974797d696b66243a1269bc73eea81b9124523",
3
+ "digest": "5dda9baa09ec382c49b7aaa4208c9416110efc7e054e3affe61bc4a6d5c93495",
4
4
  "tables": [
5
5
  "aliases",
6
6
  "attributes",
@@ -11,7 +11,8 @@ binds again, which is a variable to a type checker.
11
11
 
12
12
  `TypeAlias` is named as the module's imports can, else imported from `typing`, which has it from
13
13
  Python 3.10: certain where every Python the module runs on has it there (`min-python`), or the
14
- module imports the name already; else a guess.
14
+ module imports the name already; a guess where `min-python` isn't known, and no fix where it says
15
+ some Python lacks it.
15
16
  """
16
17
 
17
18
  import ast
@@ -111,6 +112,9 @@ def declared(
111
112
  aliases: frozenset[str]
112
113
  min_python: tuple[int, int] | None
113
114
  aliases, min_python = scope
115
+ older: bool = min_python is not None and min_python < _SINCE
116
+ if older and not _ALWAYS.intersection(plan.bound.values()):
117
+ return None
114
118
  copied: bool = isinstance(value, ast.Name) and value.id in aliases
115
119
  named: str | None = (
116
120
  hinted.type_alias(known, target.lineno)
@@ -119,9 +123,10 @@ def declared(
119
123
  )
120
124
  if named is None:
121
125
  return None
122
- certain: bool = (min_python is not None and min_python >= _SINCE) or (
123
- plan.bound.get(named.partition(".")[0]) in _ALWAYS
124
- )
126
+ always: bool = plan.bound.get(named.partition(".")[0]) in _ALWAYS
127
+ if older and not always:
128
+ return None
129
+ certain: bool = min_python is not None or always
125
130
  return (
126
131
  Inference(named, _REASON, frozenset({KIND})),
127
132
  not certain,
@@ -22,7 +22,7 @@ from functools import lru_cache
22
22
  from types import MappingProxyType
23
23
  from typing import Final, NamedTuple, TypeAlias, cast
24
24
 
25
- from constricter.fix.core.imports import checking, inner_imports, rebound_names
25
+ from constricter.fix.core.imports import checking, inner_imports, nested_classes, rebound_names
26
26
  from constricter.fix.core.known import ImportPlan, Inference, Passed
27
27
  from constricter.fix.values.narrowed import Regions, regions
28
28
  from constricter.rules.annotations import generic_classes, node_name, roots
@@ -41,6 +41,7 @@ _READS: Final[dict[_Read, str]] = {
41
41
  # Where a function tests a value, a type checker may narrow it (`isinstance`, `is None`, a `TypeGuard`).
42
42
  _TESTS: Final = (ast.If, ast.While, ast.Assert, ast.IfExp, ast.comprehension, ast.Match)
43
43
  _SELF: Final = "Self"
44
+ _STDLIB: Final = "stdlib" # the fix kind of what typeshed declares
44
45
  _COMPREHENSION: Final = "comprehension"
45
46
  _LITERAL: Final = "literal"
46
47
  _NONE: Final = "None"
@@ -96,6 +97,9 @@ class Facts(NamedTuple):
96
97
  attributed: frozenset[str] = frozenset()
97
98
  # Its functions' parameters' types, where a `Callable` can list them (see `callables.signatures`).
98
99
  positional: Mapping[str, tuple[str, ...]] = MappingProxyType({})
100
+ nested: frozenset[str] = (
101
+ frozenset()
102
+ ) # the classes defined in a class's body (see `imports.nested_classes`)
99
103
 
100
104
 
101
105
  def facts(
@@ -127,6 +131,7 @@ def facts(
127
131
  _bound_vars(tree, type_vars),
128
132
  rebound_names(tree),
129
133
  [(stmt.lineno, stmt.col_offset) for stmt in inner_imports(tree)],
134
+ nested=nested_classes(tree),
130
135
  )
131
136
 
132
137
 
@@ -524,8 +529,8 @@ def _selfish(value: ast.expr, found: Inference, owner: Owner) -> str | None:
524
529
 
525
530
  `self`, or a `Self` method called on `self`, `cls` or `type(self)`; `type(self)` or
526
531
  `self.__class__`, a `type[Self]`; what `cls()`, either of those called, or `__new__` given one
527
- constructs; and a
528
- conditional of two of them (`self if inplace else self.copy()`).
532
+ constructs; a standard-library function's call given `self` that returns its class
533
+ (`copy.copy(self)`); and a conditional of two of them (`self if inplace else self.copy()`).
529
534
 
530
535
  Returns:
531
536
  Its annotation, `{}` standing for `Self`; or `None` if it isn't one.
@@ -534,6 +539,7 @@ def _selfish(value: ast.expr, found: Inference, owner: Owner) -> str | None:
534
539
  receiver: ast.expr
535
540
  method: str
536
541
  func: ast.expr
542
+ args: list[ast.expr]
537
543
  template: str | None = None
538
544
  match value:
539
545
  case ast.Name() | ast.Call(func=ast.Name()) | ast.Attribute() if (
@@ -548,6 +554,11 @@ def _selfish(value: ast.expr, found: Inference, owner: Owner) -> str | None:
548
554
  method in owner.selfish and _own(receiver, owner.first) is not None
549
555
  ):
550
556
  template = _SELF_ITSELF
557
+ # The class only by a type variable its argument binds: the standard library names no other's.
558
+ case ast.Call(args=[*args]) if _STDLIB in found.kinds and any(
559
+ _own(arg, owner.first) == _SELF_ITSELF for arg in args
560
+ ):
561
+ template = _SELF_ITSELF
551
562
  case ast.IfExp() if {_selfish(side, found, owner) for side in (value.body, value.orelse)} == {
552
563
  _SELF_ITSELF,
553
564
  }: