python-constricter 0.2.7__py3-none-any.whl → 0.2.8__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 +82 -6
- constricter/cli/hints.py +51 -24
- constricter/cli/options.py +36 -2
- constricter/cli/protocol.py +57 -0
- constricter/cli/runs.py +2 -1
- constricter/cli/schedule.py +3 -1
- constricter/fix/callers.py +153 -0
- constricter/fix/doubts.py +9 -6
- constricter/fix/imports.py +14 -1
- constricter/fix/inference.py +74 -3
- constricter/fix/installed.py +237 -0
- constricter/fix/known.py +44 -2
- constricter/fix/library.py +21 -0
- constricter/fix/members.py +9 -2
- constricter/fix/modules.py +96 -9
- constricter/fix/overloads.py +138 -18
- constricter/fix/project.py +148 -29
- constricter/fix/returned.py +26 -1
- constricter/fix/stdlib.py +83 -16
- constricter/fix/tables/generic_attributes.json +55 -0
- constricter/fix/tables/method_signatures.json +149 -149
- constricter/fix/tables/overloads.json +251 -125
- constricter/fix/tables/subscriptable.json +76 -0
- constricter/fix/tables/variables.json +2938 -0
- constricter/offences.py +3 -1
- constricter/rules/binding.py +13 -3
- constricter/rules/calls.py +211 -0
- constricter/rules/checker.py +17 -11
- constricter/rules/late.py +3 -2
- constricter/rules/scope.py +52 -18
- {python_constricter-0.2.7.dist-info → python_constricter-0.2.8.dist-info}/METADATA +4 -2
- {python_constricter-0.2.7.dist-info → python_constricter-0.2.8.dist-info}/RECORD +36 -30
- {python_constricter-0.2.7.dist-info → python_constricter-0.2.8.dist-info}/WHEEL +0 -0
- {python_constricter-0.2.7.dist-info → python_constricter-0.2.8.dist-info}/entry_points.txt +0 -0
- {python_constricter-0.2.7.dist-info → python_constricter-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
constricter/cli/command.py
CHANGED
|
@@ -9,7 +9,7 @@ import io
|
|
|
9
9
|
import json
|
|
10
10
|
import sys
|
|
11
11
|
import tokenize
|
|
12
|
-
from collections.abc import Callable, Sequence
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
13
|
from dataclasses import replace
|
|
14
14
|
from functools import partial
|
|
15
15
|
from pathlib import Path
|
|
@@ -23,8 +23,8 @@ from constricter.cli.protocol import HintError
|
|
|
23
23
|
from constricter.cli.report import Format, Result, fix_reasons, render, statistics
|
|
24
24
|
from constricter.cli.runs import BaselineRun, CheckRun, CoverageRun, FileRun
|
|
25
25
|
from constricter.cli.workers import Workers
|
|
26
|
-
from constricter.fix import fixes, project
|
|
27
|
-
from constricter.fix.known import Hints, Outside, Returns
|
|
26
|
+
from constricter.fix import callers, fixes, installed, project
|
|
27
|
+
from constricter.fix.known import Callee, Hints, Outside, Returns
|
|
28
28
|
from constricter.noqa import lines, unsuppressed
|
|
29
29
|
from constricter.offences import (
|
|
30
30
|
DEFAULT_CHECKS,
|
|
@@ -41,6 +41,7 @@ EXIT_CLEAN: Final = 0
|
|
|
41
41
|
EXIT_FOUND: Final = 1
|
|
42
42
|
EXIT_ERROR: Final = 2
|
|
43
43
|
_ALL: Final = 100 # percent
|
|
44
|
+
_PY: Final = ".py" # a module the index names (not a notebook)
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def _encoding(path: Path, data: bytes) -> str:
|
|
@@ -264,7 +265,11 @@ def _check_path(path: Path, outside: Outside, options: Options) -> CheckRun:
|
|
|
264
265
|
raw, checked, error = _read_checked(path, name, options.checks, outside)
|
|
265
266
|
if error:
|
|
266
267
|
return CheckRun(error=error)
|
|
267
|
-
return replace(
|
|
268
|
+
return replace(
|
|
269
|
+
_handled(path, (raw, name), checked.offences, options),
|
|
270
|
+
returned=checked.returned,
|
|
271
|
+
calls=checked.calls,
|
|
272
|
+
)
|
|
268
273
|
|
|
269
274
|
|
|
270
275
|
def _handled(path: Path, text: tuple[str, Path], offences: list[Offence], options: Options) -> CheckRun:
|
|
@@ -350,8 +355,10 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
350
355
|
else:
|
|
351
356
|
check = partial(_check_path, options=options)
|
|
352
357
|
names: list[Path] = [options.input.name(path) for path in paths]
|
|
353
|
-
if
|
|
358
|
+
if options.mode in {Mode.COVERAGE, Mode.WRITE_BASELINE}:
|
|
354
359
|
return names, _checked_all(paths, check, options)[0]
|
|
360
|
+
if not options.infer_with:
|
|
361
|
+
return names, _called_again(paths, _checked_all(paths, check, options), check, options)[0]
|
|
355
362
|
session: hints.Session
|
|
356
363
|
# Imported only for `--infer-with`: it's slow to import, for every run's start.
|
|
357
364
|
with cast("type[hints.Session]", importlib.import_module("constricter.cli.hints").Session)(
|
|
@@ -362,7 +369,13 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
362
369
|
) as session:
|
|
363
370
|
runs: list[FileRun]
|
|
364
371
|
modules: project.Index
|
|
365
|
-
runs, modules =
|
|
372
|
+
runs, modules = _called_again(
|
|
373
|
+
paths,
|
|
374
|
+
_checked_all(paths, check, options, session),
|
|
375
|
+
check,
|
|
376
|
+
options,
|
|
377
|
+
session,
|
|
378
|
+
)
|
|
366
379
|
# The files the last round changed: only their hints can have changed.
|
|
367
380
|
again: list[int] = [
|
|
368
381
|
index
|
|
@@ -387,6 +400,67 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
|
|
|
387
400
|
return names, runs
|
|
388
401
|
|
|
389
402
|
|
|
403
|
+
def _called_again(
|
|
404
|
+
paths: Sequence[Path],
|
|
405
|
+
first: tuple[list[FileRun], project.Index],
|
|
406
|
+
check: Callable[[Path, Outside], FileRun],
|
|
407
|
+
options: Options,
|
|
408
|
+
session: "hints.Session | None" = None,
|
|
409
|
+
) -> tuple[list[FileRun], project.Index]:
|
|
410
|
+
"""Check again the files whose functions' unannotated parameters every call passes one type.
|
|
411
|
+
|
|
412
|
+
Knowing those types (see `callers`), as guesses; a file among several of one module name isn't
|
|
413
|
+
(which its callers call can't be told). Then the other files calling those functions, knowing
|
|
414
|
+
what they now return. With `--fix` a file's rounds are joined; otherwise the last, knowing more,
|
|
415
|
+
stands.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
What each file found, and the index, with the parameters' types.
|
|
419
|
+
|
|
420
|
+
"""
|
|
421
|
+
runs: list[FileRun]
|
|
422
|
+
modules: project.Index
|
|
423
|
+
runs, modules = first
|
|
424
|
+
found: dict[str, callers.Parameters] = callers.parameters(
|
|
425
|
+
(run.calls for run in runs if isinstance(run, CheckRun)),
|
|
426
|
+
modules,
|
|
427
|
+
)
|
|
428
|
+
names: list[str | None] = [project.module_name(path) if path.suffix == _PY else None for path in paths]
|
|
429
|
+
again: list[int]
|
|
430
|
+
if not (again := [at for at, name in enumerate(names) if name in found and names.count(name) == 1]):
|
|
431
|
+
return runs, modules
|
|
432
|
+
calling: list[int] = _calling(runs, found, again)
|
|
433
|
+
modules = callers.with_parameters(modules, found)
|
|
434
|
+
which: list[int]
|
|
435
|
+
for which in (again, calling):
|
|
436
|
+
redone: list[FileRun]
|
|
437
|
+
redone, modules = _checked_all([paths[at] for at in which], check, options, session, modules)
|
|
438
|
+
at: int
|
|
439
|
+
run: FileRun
|
|
440
|
+
for at, run in zip(which, redone, strict=True):
|
|
441
|
+
runs[at] = _merged(runs[at], run) if options.mode is Mode.FIX else run
|
|
442
|
+
return runs, modules
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _calling(
|
|
446
|
+
runs: Sequence[FileRun],
|
|
447
|
+
found: Mapping[str, callers.Parameters],
|
|
448
|
+
again: Sequence[int],
|
|
449
|
+
) -> list[int]:
|
|
450
|
+
"""Find the files (not among `again`) calling a function whose parameters `found` types.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Their indexes.
|
|
454
|
+
|
|
455
|
+
"""
|
|
456
|
+
typed: set[Callee] = {(module, function) for module, functions in found.items() for function in functions}
|
|
457
|
+
return [
|
|
458
|
+
at
|
|
459
|
+
for at, run in enumerate(runs)
|
|
460
|
+
if at not in again and isinstance(run, CheckRun) and typed & run.calls.calls.keys()
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
|
|
390
464
|
def _checked_all(
|
|
391
465
|
paths: Sequence[Path],
|
|
392
466
|
check: Callable[[Path, Outside], FileRun],
|
|
@@ -412,6 +486,8 @@ def _checked_all(
|
|
|
412
486
|
elif modules is None:
|
|
413
487
|
modules = project.Index({}, []) if coverage else project.index(paths)
|
|
414
488
|
collecting.indexed()
|
|
489
|
+
if not coverage:
|
|
490
|
+
modules = installed.with_installed(modules, installed.search_path())
|
|
415
491
|
return schedule.checked(
|
|
416
492
|
paths,
|
|
417
493
|
check,
|
constricter/cli/hints.py
CHANGED
|
@@ -59,6 +59,12 @@ _LINE_BREAK: Final = re.compile(r"\r\n|\r|\n") # the lines positions count, as
|
|
|
59
59
|
_UTF16: Final = "utf-16"
|
|
60
60
|
_UTF8: Final = "utf-8"
|
|
61
61
|
_METHOD_NOT_FOUND: Final = -32601
|
|
62
|
+
# A request the server dropped because a document changed under it (ty, as later files open): the
|
|
63
|
+
# client is to ask again.
|
|
64
|
+
_CONTENT_MODIFIED: Final = -32801
|
|
65
|
+
_ASK_AGAIN: Final = 5 # how many times a hint request is asked again after that, at most
|
|
66
|
+
_HINTS: Final = "textDocument/inlayHint"
|
|
67
|
+
_MODIFIED: Final[_Object] = {} # a hint request's answer, when it's to be asked again (by identity)
|
|
62
68
|
_ID: Final = "id"
|
|
63
69
|
_METHOD: Final = "method"
|
|
64
70
|
_ERROR: Final = "error"
|
|
@@ -325,14 +331,14 @@ def command(checker: str) -> list[str]:
|
|
|
325
331
|
|
|
326
332
|
"""
|
|
327
333
|
server: Server = SERVERS[checker]
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
found: str | None
|
|
335
|
+
if (found := protocol.executable(checker)) is None:
|
|
336
|
+
install: str = f"`pip install {checker}`"
|
|
331
337
|
message: str = (
|
|
332
|
-
f"--infer-with={checker} needs `{
|
|
338
|
+
f"--infer-with={checker} needs `{server.executable}`, which isn't installed ({install})"
|
|
333
339
|
)
|
|
334
340
|
raise HintError(message)
|
|
335
|
-
return [
|
|
341
|
+
return [found, *server.args]
|
|
336
342
|
|
|
337
343
|
|
|
338
344
|
class Connection:
|
|
@@ -450,32 +456,51 @@ class Connection:
|
|
|
450
456
|
text: str
|
|
451
457
|
for path, text in files.items():
|
|
452
458
|
self._opened(path.resolve().as_uri(), text)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
459
|
+
return _Asked({self._ask_hints(path, lines[path]): path for path in files}, lines)
|
|
460
|
+
|
|
461
|
+
def _ask_hints(self, path: Path, lines: Sequence[str]) -> int:
|
|
462
|
+
"""Ask for an open file's hints over the whole of it, without waiting for them.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
The request's number.
|
|
466
|
+
|
|
467
|
+
"""
|
|
468
|
+
return self._ask(
|
|
469
|
+
_HINTS,
|
|
470
|
+
{
|
|
471
|
+
"textDocument": {"uri": path.resolve().as_uri()},
|
|
472
|
+
"range": {"start": {"line": 0, "character": 0}, "end": {"line": len(lines), "character": 0}},
|
|
473
|
+
},
|
|
474
|
+
)
|
|
467
475
|
|
|
468
476
|
def answers(self, asked: "_Asked") -> _Found:
|
|
469
|
-
"""Wait for the answers to hint requests `ask` sent.
|
|
477
|
+
"""Wait for the answers to hint requests `ask` sent, asking again for those the server dropped.
|
|
470
478
|
|
|
471
479
|
Returns:
|
|
472
480
|
Each file's variable-type hints' texts, by where the name each types ends.
|
|
473
481
|
|
|
482
|
+
Raises:
|
|
483
|
+
HintError: The server dropped one `_ASK_AGAIN` times more (see `_CONTENT_MODIFIED`).
|
|
484
|
+
|
|
474
485
|
"""
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
486
|
+
found: _Found = {}
|
|
487
|
+
waiting: dict[int, Path] = asked.numbers
|
|
488
|
+
for _ in range(_ASK_AGAIN + 1):
|
|
489
|
+
answers: dict[int, _Json] = self._collect(set(waiting), _HINTS)
|
|
490
|
+
found.update(
|
|
491
|
+
(path, self._found(answers[number], asked.lines[path]))
|
|
492
|
+
for number, path in waiting.items()
|
|
493
|
+
if answers[number] is not _MODIFIED
|
|
494
|
+
)
|
|
495
|
+
waiting = {
|
|
496
|
+
self._ask_hints(path, asked.lines[path]): path
|
|
497
|
+
for number, path in waiting.items()
|
|
498
|
+
if answers[number] is _MODIFIED
|
|
499
|
+
}
|
|
500
|
+
if not waiting:
|
|
501
|
+
return found
|
|
502
|
+
error: str = f"{self.name} failed `{_HINTS}`: content modified, {_ASK_AGAIN + 1} times"
|
|
503
|
+
raise HintError(error)
|
|
479
504
|
|
|
480
505
|
def _opened(self, uri: str, text: str) -> None:
|
|
481
506
|
"""Send a file's text: opening it, or (open already) replacing its whole text, if it's changed."""
|
|
@@ -586,6 +611,8 @@ class Connection:
|
|
|
586
611
|
)
|
|
587
612
|
if _ERROR in answer:
|
|
588
613
|
failure: _Object = cast("_Object", answer[_ERROR])
|
|
614
|
+
if method == _HINTS and failure.get("code") == _CONTENT_MODIFIED:
|
|
615
|
+
return cast("int", answer[_ID]), _MODIFIED
|
|
589
616
|
error: str = f"{self.name} failed `{method}`: {failure.get('message')}"
|
|
590
617
|
raise HintError(error)
|
|
591
618
|
return cast("int", answer[_ID]), answer.get("result")
|
constricter/cli/options.py
CHANGED
|
@@ -21,7 +21,7 @@ from constricter.cli.config import (
|
|
|
21
21
|
)
|
|
22
22
|
from constricter.cli.explain import explain
|
|
23
23
|
from constricter.cli.paths import STDIN, excluded
|
|
24
|
-
from constricter.cli.protocol import SERVERS
|
|
24
|
+
from constricter.cli.protocol import SERVERS, runs
|
|
25
25
|
from constricter.cli.report import Format, Result
|
|
26
26
|
from constricter.offences import (
|
|
27
27
|
LEVELS,
|
|
@@ -193,6 +193,16 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
193
193
|
default="strict",
|
|
194
194
|
help="which codes are errors rather than warnings (default: strict)",
|
|
195
195
|
)
|
|
196
|
+
_ = parser.add_argument(
|
|
197
|
+
"--max",
|
|
198
|
+
action="store_true",
|
|
199
|
+
help="the strictest check: suffocate everywhere, all scopes, the opt-in codes (command line only)",
|
|
200
|
+
)
|
|
201
|
+
_ = parser.add_argument(
|
|
202
|
+
"--max-fix",
|
|
203
|
+
action="store_true",
|
|
204
|
+
help="--max, --fix --unsafe-fixes, and every installed checker's hints (command line only)",
|
|
205
|
+
)
|
|
196
206
|
_ = parser.add_argument(
|
|
197
207
|
"--format",
|
|
198
208
|
type=Format,
|
|
@@ -492,7 +502,7 @@ class Options:
|
|
|
492
502
|
parser.set_defaults(**config_defaults(Path.cwd()))
|
|
493
503
|
except ValueError as error:
|
|
494
504
|
parser.error(str(error))
|
|
495
|
-
args: argparse.Namespace = parser.parse_args(argv)
|
|
505
|
+
args: argparse.Namespace = _maximal(parser.parse_args(argv))
|
|
496
506
|
code: str | None
|
|
497
507
|
if (code := cast("str | None", args.explain)) is not None:
|
|
498
508
|
_ = sys.stdout.write(explain(code))
|
|
@@ -538,6 +548,30 @@ class Options:
|
|
|
538
548
|
)
|
|
539
549
|
|
|
540
550
|
|
|
551
|
+
def _maximal(args: argparse.Namespace) -> argparse.Namespace:
|
|
552
|
+
"""Apply `--max-fix` (`--max`, every fix, every installed checker) and `--max`.
|
|
553
|
+
|
|
554
|
+
`--max`: `suffocate` for every path (over `per-path-levels`), all scopes, the opt-in codes.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
The arguments.
|
|
558
|
+
|
|
559
|
+
"""
|
|
560
|
+
if cast("bool", args.max_fix):
|
|
561
|
+
args.max = args.fix = args.unsafe_fixes = True
|
|
562
|
+
chosen: list[str] = cast("list[str]", args.infer_with)
|
|
563
|
+
args.infer_with = [
|
|
564
|
+
*chosen,
|
|
565
|
+
*(checker for checker in SERVERS if checker not in chosen and runs(checker)),
|
|
566
|
+
]
|
|
567
|
+
if cast("bool", args.max):
|
|
568
|
+
args.level = Level.SUFFOCATE.name.lower()
|
|
569
|
+
args.per_path_levels = {}
|
|
570
|
+
args.all_scopes = True
|
|
571
|
+
args.extend_select = [*cast("list[str]", args.extend_select), *sorted(OPT_IN)]
|
|
572
|
+
return args
|
|
573
|
+
|
|
574
|
+
|
|
541
575
|
def _filter(parser: argparse.ArgumentParser, args: argparse.Namespace, mode: Mode) -> Filter:
|
|
542
576
|
"""Build the filter the options set, reading the baseline (the default one, if it exists).
|
|
543
577
|
|
constricter/cli/protocol.py
CHANGED
|
@@ -6,6 +6,10 @@ type is its label after its `:`; basedpyright is asked for variable types only.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess # asks a checker whether it runs
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
9
13
|
from typing import IO, Final, NamedTuple, TypeAlias, cast
|
|
10
14
|
|
|
11
15
|
Json: TypeAlias = dict[str, "Json"] | list["Json"] | str | int | float | bool | None
|
|
@@ -44,6 +48,59 @@ SERVERS: Final = {
|
|
|
44
48
|
# Parallel already: more servers only repeat its work (sqlalchemy's: 0.9s with one, 0.7s with four).
|
|
45
49
|
"ty": Server("ty", ("server",), 1),
|
|
46
50
|
}
|
|
51
|
+
_PROBE: Final = "--version" # quick for both; basedpyright-langserver's exits 1 all the same
|
|
52
|
+
_PROBE_TIMEOUT: Final = 10.0 # seconds it may take
|
|
53
|
+
_CANT_RUN: Final = frozenset({126, 127}) # the shell's "can't execute" and "not found": a broken shim's
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def executable(checker: str) -> str | None:
|
|
57
|
+
"""Find `checker`'s language server: on `PATH`, or beside this Python (an unactivated venv).
|
|
58
|
+
|
|
59
|
+
The first that runs (a version manager's shim can be found, and not run), else the first found.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Its path, or `None` if it isn't installed.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
name: str = SERVERS[checker].executable
|
|
66
|
+
found: list[str] = list(
|
|
67
|
+
dict.fromkeys(
|
|
68
|
+
path
|
|
69
|
+
for path in (shutil.which(name), shutil.which(name, path=str(Path(sys.executable).parent)))
|
|
70
|
+
if path is not None
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
return next((path for path in found if _runs(path)), found[0] if found else None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def runs(checker: str) -> bool:
|
|
77
|
+
"""Check that `checker` is installed, and runs.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Whether it does.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
found: str | None = executable(checker)
|
|
84
|
+
return found is not None and _runs(found)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _runs(path: str) -> bool:
|
|
88
|
+
"""Check that an executable runs: its `--version` starts, whatever it answers.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Whether it does.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
done: subprocess.CompletedProcess[bytes] = subprocess.run(
|
|
96
|
+
[path, _PROBE],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
timeout=_PROBE_TIMEOUT,
|
|
99
|
+
check=False,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
102
|
+
return False
|
|
103
|
+
return done.returncode not in _CANT_RUN
|
|
47
104
|
|
|
48
105
|
|
|
49
106
|
class HintError(Exception):
|
constricter/cli/runs.py
CHANGED
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
|
|
|
5
5
|
from typing import TypeAlias
|
|
6
6
|
|
|
7
7
|
from constricter.cli.report import Result
|
|
8
|
-
from constricter.fix.known import Returns
|
|
8
|
+
from constricter.fix.known import Observed, Returns
|
|
9
9
|
from constricter.offences import Offence
|
|
10
10
|
from constricter.rules.checker import Coverage
|
|
11
11
|
|
|
@@ -20,6 +20,7 @@ class CheckRun:
|
|
|
20
20
|
text: str = "" # --diff: the diff; --fix on standard input: the fixed source
|
|
21
21
|
error: str = ""
|
|
22
22
|
returned: Returns = field(default_factory=Returns) # what its unannotated functions return
|
|
23
|
+
calls: Observed = field(default_factory=Observed) # what it passes checked files' functions
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
@dataclass(frozen=True)
|
constricter/cli/schedule.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import Final
|
|
|
13
13
|
|
|
14
14
|
from constricter.cli.runs import CoverageRun, FileRun
|
|
15
15
|
from constricter.cli.workers import Checking, Workers, check_share, first_done
|
|
16
|
-
from constricter.fix import order, project
|
|
16
|
+
from constricter.fix import callers, order, project
|
|
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
|
|
@@ -35,6 +35,8 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
|
|
|
35
35
|
imported.returned,
|
|
36
36
|
imported.guarded,
|
|
37
37
|
imported.generics,
|
|
38
|
+
callers.callees(modules, path),
|
|
39
|
+
callers.own_parameters(modules, path),
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Unannotated parameters typed by their callers: what every call in the checked files passes each one.
|
|
3
|
+
|
|
4
|
+
Once every file is checked, each module's `Observed` calls to the checked files' open functions (see
|
|
5
|
+
`modules.open_functions`) are matched to their parameters, as Python binds them. A parameter left
|
|
6
|
+
unannotated is typed when every call passes it, each an argument of the same known type
|
|
7
|
+
(`rules.calls` says which count); not if a call leaves it to its default, can't be matched, or
|
|
8
|
+
unpacks its arguments, nor if the function escapes (a callback), and never for a function no file
|
|
9
|
+
calls. The files defining them are then checked again knowing those types, as guesses (`callers`):
|
|
10
|
+
a caller outside the checked files may pass anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Final, TypeAlias
|
|
16
|
+
|
|
17
|
+
from constricter.fix import project
|
|
18
|
+
from constricter.fix.known import Call, Callee, Observed, Origin, Passed, Seeds
|
|
19
|
+
from constricter.fix.modules import SUFFIX, Index, Param
|
|
20
|
+
|
|
21
|
+
KIND: Final = "callers" # the guessing mechanism (`FIX_KINDS`)
|
|
22
|
+
# A module's functions' typed parameters: each one's type and what it rests on, by name, by function.
|
|
23
|
+
Parameters: TypeAlias = dict[str, dict[str, Passed]]
|
|
24
|
+
_POSITIONAL: Final = frozenset({"p", "e"})
|
|
25
|
+
_KEYWORD: Final = frozenset({"e", "k"})
|
|
26
|
+
_UNPASSED: Final = "" # a parameter a call leaves to its default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parameters(observations: Iterable[Observed], catalog: Index) -> dict[str, Parameters]:
|
|
30
|
+
"""Type the checked files' functions' unannotated parameters by every call to them (see the docstring).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Each typed parameter's type, by its function and module.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
calls: dict[Callee, list[Call]] = {}
|
|
37
|
+
escaped: set[Callee] = set()
|
|
38
|
+
observed: Observed
|
|
39
|
+
for observed in observations:
|
|
40
|
+
escaped.update(observed.escaped)
|
|
41
|
+
callee: Callee
|
|
42
|
+
found: tuple[Call, ...]
|
|
43
|
+
for callee, found in observed.calls.items():
|
|
44
|
+
calls.setdefault(callee, []).extend(found)
|
|
45
|
+
typed: dict[str, Parameters] = {}
|
|
46
|
+
each: list[Call]
|
|
47
|
+
for callee, each in calls.items():
|
|
48
|
+
module: str
|
|
49
|
+
function: str
|
|
50
|
+
module, function = callee
|
|
51
|
+
params: tuple[Param, ...] | None = (
|
|
52
|
+
None
|
|
53
|
+
if callee in escaped or module not in catalog.modules
|
|
54
|
+
else catalog.modules[module].open.get(function)
|
|
55
|
+
)
|
|
56
|
+
types: dict[str, Passed]
|
|
57
|
+
if types := {} if params is None else _typed(params, each):
|
|
58
|
+
typed.setdefault(module, {})[function] = types
|
|
59
|
+
return typed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _typed(params: Sequence[Param], calls: Sequence[Call]) -> dict[str, Passed]:
|
|
63
|
+
"""Type a function's unannotated parameters by `calls`: each one's, if every call passes it the same.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Each typed parameter's type, and what its arguments' types rest on together.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
bound: list[dict[str, Passed | None] | None] = [_bound(params, call) for call in calls]
|
|
70
|
+
found: dict[str, Passed] = {}
|
|
71
|
+
param: Param
|
|
72
|
+
for param in params:
|
|
73
|
+
name: str = param[0]
|
|
74
|
+
passed: list[Passed | None] = [
|
|
75
|
+
None if one is None else one.get(name, (_UNPASSED, frozenset())) for one in bound
|
|
76
|
+
]
|
|
77
|
+
types: set[str | None] = {None if one is None else one[0] for one in passed}
|
|
78
|
+
only: str | None = types.pop() if len(types) == 1 else None
|
|
79
|
+
if not param[3] and only:
|
|
80
|
+
found[name] = (only, frozenset().union(*(one[1] for one in passed if one is not None)))
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _bound(params: Sequence[Param], call: Call) -> dict[str, Passed | None] | None:
|
|
85
|
+
"""Bind a call's arguments' types to a function's parameters, as Python binds the arguments.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Each parameter's argument's type (`None`: unknown), by name, a parameter it leaves to its
|
|
89
|
+
default missing; or `None` if they can't be bound (unpacked, too many, an unknown keyword, one
|
|
90
|
+
bound twice): a call that fails, or can't be read.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
positional: list[str] = [param[0] for param in params if param[1] in _POSITIONAL]
|
|
94
|
+
if call.unpacked or len(call.positional) > len(positional):
|
|
95
|
+
return None
|
|
96
|
+
bound: dict[str, Passed | None] = dict(zip(positional, call.positional, strict=False))
|
|
97
|
+
keyword: str
|
|
98
|
+
typed: Passed | None
|
|
99
|
+
for keyword, typed in call.keywords:
|
|
100
|
+
if keyword in bound or not any(param[0] == keyword and param[1] in _KEYWORD for param in params):
|
|
101
|
+
return None
|
|
102
|
+
bound[keyword] = typed
|
|
103
|
+
return bound
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def callees(catalog: project.Index, path: Path) -> dict[str, Callee]:
|
|
107
|
+
"""Find the checked files' functions the file at `path` may call whose parameters aren't all annotated.
|
|
108
|
+
|
|
109
|
+
Its own (`f`), and those it imports (`f`, `u.f`, through re-exports); not an installed package's.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Each, as the file spells it, mapped to its module and name; nothing for a file `catalog` doesn't
|
|
113
|
+
have.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
target: project.Module | None
|
|
117
|
+
if path.suffix != SUFFIX or (target := catalog.modules.get(project.module_name(path))) is None:
|
|
118
|
+
return {}
|
|
119
|
+
found: dict[str, Callee] = {name: (target.name, name) for name in target.open}
|
|
120
|
+
key: str
|
|
121
|
+
origin: Origin
|
|
122
|
+
for key, origin in project.spellings(catalog, target, project.OPEN):
|
|
123
|
+
defined: tuple[project.Module, str] | None = project.definition(catalog.modules, origin, project.OPEN)
|
|
124
|
+
if defined is not None and not defined[0].installed:
|
|
125
|
+
found[key] = (defined[0].name, defined[1])
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def own_parameters(catalog: project.Index, path: Path) -> Seeds:
|
|
130
|
+
"""Find what every call passes the unannotated parameters of the file at `path`'s functions.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Each function's parameters' types, by name (see `with_parameters`).
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
target: project.Module | None = (
|
|
137
|
+
catalog.modules.get(project.module_name(path)) if path.suffix == SUFFIX else None
|
|
138
|
+
)
|
|
139
|
+
return {} if target is None else target.parameters
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def with_parameters(catalog: project.Index, found: Mapping[str, Seeds]) -> project.Index:
|
|
143
|
+
"""Record what every call passes checked modules' functions' unannotated parameters (by module name).
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The index, with them.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
modules: dict[str, project.Module] = dict(catalog.modules)
|
|
150
|
+
name: str
|
|
151
|
+
for name in found.keys() & modules.keys():
|
|
152
|
+
modules[name] = modules[name]._replace(parameters=found[name])
|
|
153
|
+
return project.Index(modules, catalog.names)
|
constricter/fix/doubts.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
- A copy, attribute or subscript of a union may be narrowed where it's read (`if x is not None:`,
|
|
5
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)]`)
|
|
6
|
+
its condition (`[c for c in cs if isinstance(c, Column)]`), and a read whose type a call takes as
|
|
7
|
+
its own (`deque([x])`). Their fixes are guesses.
|
|
7
8
|
- An ALL_CAPS module-level name is a constant to pyright, which keeps its literal's type
|
|
8
9
|
(`Literal["r"]`) where `str` would widen it: a guess too, where the module passes it to a call
|
|
9
10
|
or a default (see `passed`), for a parameter that may take only some values.
|
|
@@ -79,20 +80,22 @@ class Owner(NamedTuple):
|
|
|
79
80
|
selfish: frozenset[str] # its methods declared to return a bare `Self` (see `self_returns`)
|
|
80
81
|
|
|
81
82
|
|
|
82
|
-
def doubts(value: ast.expr, found: Inference, *, constant: bool, narrowed:
|
|
83
|
+
def doubts(value: ast.expr, found: Inference, *, constant: bool, narrowed: frozenset[str]) -> frozenset[str]:
|
|
83
84
|
"""Find what makes `found`, a certain inference of `value`, a guess (see the module docstring).
|
|
84
85
|
|
|
85
|
-
`constant`: whether the value is bound to an ALL_CAPS module-level name; `narrowed`:
|
|
86
|
-
function tests
|
|
87
|
-
|
|
86
|
+
`constant`: whether the value is bound to an ALL_CAPS module-level name; `narrowed`: what the
|
|
87
|
+
function tests somewhere (`isinstance(x, C)`, `x is None`, `is_c(x)`), where a type checker
|
|
88
|
+
narrows it, whatever its type: the value itself, or a read whose type it takes (`found.reads`).
|
|
88
89
|
|
|
89
90
|
Returns:
|
|
90
91
|
The guessing mechanisms (`FIX_KINDS`) it then rests on; none if it stays certain.
|
|
91
92
|
|
|
92
93
|
"""
|
|
93
94
|
kind: str | None = _READS.get(type(value))
|
|
94
|
-
if kind is not None and (narrowed or len(members(found.annotation) or ()) > 1):
|
|
95
|
+
if kind is not None and (ast.unparse(value) in narrowed or len(members(found.annotation) or ()) > 1):
|
|
95
96
|
return frozenset({kind})
|
|
97
|
+
if narrowed.intersection(found.reads):
|
|
98
|
+
return frozenset({_READS[ast.Name]})
|
|
96
99
|
if isinstance(value, _COMPREHENSIONS) and any(g.ifs for g in value.generators) and _has_union(found):
|
|
97
100
|
return frozenset({_COMPREHENSION})
|
|
98
101
|
if constant and _literal(value):
|
constricter/fix/imports.py
CHANGED
|
@@ -6,7 +6,7 @@ from collections.abc import Iterator
|
|
|
6
6
|
from functools import lru_cache
|
|
7
7
|
from typing import Final
|
|
8
8
|
|
|
9
|
-
from constricter.fix.known import ImportPlan
|
|
9
|
+
from constricter.fix.known import ImportPlan, Origin
|
|
10
10
|
from constricter.rules.syntax import import_bindings
|
|
11
11
|
from constricter.rules.walked import of_type
|
|
12
12
|
|
|
@@ -51,6 +51,19 @@ def plan(tree: ast.Module) -> ImportPlan:
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def added_origin(statement: str) -> Origin:
|
|
55
|
+
"""Read what an import `ImportPlan.spell` added binds: `from m import T`, or `import m`.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Its origin: `("m", "T")`, or `("m", None)`.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
module: str
|
|
62
|
+
name: str
|
|
63
|
+
module, _, name = statement.removeprefix("from ").removeprefix("import ").partition(" import ")
|
|
64
|
+
return module, name or None
|
|
65
|
+
|
|
66
|
+
|
|
54
67
|
def _is_checking(test: ast.expr) -> bool:
|
|
55
68
|
"""Check whether an `if`'s test is `TYPE_CHECKING` (or `typing.TYPE_CHECKING`).
|
|
56
69
|
|