python-constricter 0.2.7__py3-none-any.whl → 0.2.9__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.
Files changed (44) hide show
  1. constricter/__init__.py +1 -1
  2. constricter/cli/command.py +82 -6
  3. constricter/cli/hints.py +51 -24
  4. constricter/cli/options.py +36 -2
  5. constricter/cli/protocol.py +57 -0
  6. constricter/cli/runs.py +2 -1
  7. constricter/cli/schedule.py +9 -1
  8. constricter/fix/atoms.py +407 -0
  9. constricter/fix/callers.py +153 -0
  10. constricter/fix/classnames.py +107 -0
  11. constricter/fix/declared.py +426 -0
  12. constricter/fix/doubts.py +9 -6
  13. constricter/fix/guesses.py +6 -2
  14. constricter/fix/imports.py +14 -1
  15. constricter/fix/inference.py +85 -4
  16. constricter/fix/installed.py +295 -0
  17. constricter/fix/known.py +68 -3
  18. constricter/fix/library.py +87 -3
  19. constricter/fix/members.py +9 -2
  20. constricter/fix/modules.py +132 -9
  21. constricter/fix/overloads.py +467 -35
  22. constricter/fix/project.py +180 -40
  23. constricter/fix/returned.py +26 -1
  24. constricter/fix/signatures.py +85 -0
  25. constricter/fix/stdlib.py +117 -40
  26. constricter/fix/stubbed.py +990 -0
  27. constricter/fix/tables/generic_attributes.json +55 -0
  28. constricter/fix/tables/method_signatures.json +149 -149
  29. constricter/fix/tables/overloads.json +251 -125
  30. constricter/fix/tables/scalar_members.json +16 -0
  31. constricter/fix/tables/scalars.json +2702 -0
  32. constricter/fix/tables/subscriptable.json +76 -0
  33. constricter/fix/tables/variables.json +2938 -0
  34. constricter/offences.py +3 -1
  35. constricter/rules/binding.py +13 -3
  36. constricter/rules/calls.py +211 -0
  37. constricter/rules/checker.py +22 -11
  38. constricter/rules/late.py +3 -2
  39. constricter/rules/scope.py +52 -18
  40. {python_constricter-0.2.7.dist-info → python_constricter-0.2.9.dist-info}/METADATA +4 -2
  41. {python_constricter-0.2.7.dist-info → python_constricter-0.2.9.dist-info}/RECORD +44 -31
  42. {python_constricter-0.2.7.dist-info → python_constricter-0.2.9.dist-info}/WHEEL +0 -0
  43. {python_constricter-0.2.7.dist-info → python_constricter-0.2.9.dist-info}/entry_points.txt +0 -0
  44. {python_constricter-0.2.7.dist-info → python_constricter-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py CHANGED
@@ -25,7 +25,7 @@ from constricter.offences import (
25
25
  )
26
26
  from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
27
27
 
28
- __version__ = "0.2.7"
28
+ __version__ = "0.2.9"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -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(_handled(path, (raw, name), checked.offences, options), returned=checked.returned)
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 not options.infer_with or options.mode in {Mode.COVERAGE, Mode.WRITE_BASELINE}:
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 = _checked_all(paths, check, options, session)
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
- name: str = server.executable
329
- executable: str | None = shutil.which(name) or shutil.which(name, path=str(Path(sys.executable).parent))
330
- if executable is None:
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 `{name}`, which isn't installed (`pip install {checker}`)"
338
+ f"--infer-with={checker} needs `{server.executable}`, which isn't installed ({install})"
333
339
  )
334
340
  raise HintError(message)
335
- return [executable, *server.args]
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
- asked: dict[int, Path] = {
454
- self._ask(
455
- "textDocument/inlayHint",
456
- {
457
- "textDocument": {"uri": path.resolve().as_uri()},
458
- "range": {
459
- "start": {"line": 0, "character": 0},
460
- "end": {"line": len(lines[path]), "character": 0},
461
- },
462
- },
463
- ): path
464
- for path in files
465
- }
466
- return _Asked(asked, lines)
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
- answers: dict[int, _Json] = self._collect(set(asked.numbers), "textDocument/inlayHint")
476
- return {
477
- path: self._found(answers[number], asked.lines[path]) for number, path in asked.numbers.items()
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")
@@ -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
 
@@ -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)
@@ -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, stubbed
17
17
  from constricter.fix.known import Hints, Outside
18
18
 
19
19
  CYCLE_ROUNDS: Final = 3 # how many times to check again files calling each other's functions
@@ -27,6 +27,7 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
27
27
 
28
28
  """
29
29
  imported: project.Imported = project.imported(modules, path)
30
+ methods: stubbed.Methods = stubbed.methods(modules, path, imported.guarded)
30
31
  return Outside(
31
32
  imported.calls,
32
33
  imported.classes,
@@ -35,6 +36,13 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
35
36
  imported.returned,
36
37
  imported.guarded,
37
38
  imported.generics,
39
+ callers.callees(modules, path),
40
+ callers.own_parameters(modules, path),
41
+ {**stubbed.overloaded(modules, path), **methods.signatures},
42
+ stubbed.classes(modules, path),
43
+ methods.parameters,
44
+ methods.lineage,
45
+ methods.aliases,
38
46
  )
39
47
 
40
48