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 +1 -1
- constricter/cli/command.py +73 -38
- constricter/cli/hints.py +1 -1
- constricter/cli/options.py +34 -2
- constricter/cli/report.py +5 -2
- constricter/fix/core/fixes.py +26 -1
- constricter/fix/core/imports.py +51 -2
- constricter/fix/core/known.py +41 -4
- constricter/fix/index/modules.py +15 -9
- constricter/fix/index/project.py +5 -9
- constricter/fix/tables/source.json +1 -1
- constricter/fix/values/aliased.py +9 -4
- constricter/fix/values/doubts.py +14 -3
- constricter/fix/values/fills.py +4 -3
- constricter/fix/values/inference.py +4 -2
- constricter/fix/values/members.py +12 -1
- constricter/fix/values/shapes.py +107 -22
- constricter/fix/values/unchecked.py +119 -3
- constricter/offences.py +74 -1
- constricter/recording.py +16 -4
- constricter/rules/binding.py +3 -2
- constricter/rules/checker.py +5 -47
- constricter/rules/late.py +12 -5
- constricter/rules/quoted.py +21 -0
- constricter/rules/scope.py +28 -27
- {python_constricter-0.3.5.dist-info → python_constricter-0.3.6.dist-info}/METADATA +7 -5
- {python_constricter-0.3.5.dist-info → python_constricter-0.3.6.dist-info}/RECORD +30 -30
- {python_constricter-0.3.5.dist-info → python_constricter-0.3.6.dist-info}/WHEEL +0 -0
- {python_constricter-0.3.5.dist-info → python_constricter-0.3.6.dist-info}/entry_points.txt +0 -0
- {python_constricter-0.3.5.dist-info → python_constricter-0.3.6.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
constricter/cli/command.py
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
382
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
constricter/cli/options.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}]"
|
constricter/fix/core/fixes.py
CHANGED
|
@@ -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
|
|
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
|
|
constricter/fix/core/imports.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
constricter/fix/core/known.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
constricter/fix/index/modules.py
CHANGED
|
@@ -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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
for
|
|
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]:
|
constricter/fix/index/project.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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;
|
|
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
|
-
|
|
123
|
-
|
|
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,
|
constricter/fix/values/doubts.py
CHANGED
|
@@ -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;
|
|
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
|
}:
|