python-constricter 0.2.7__tar.gz → 0.2.8__tar.gz

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 (80) hide show
  1. {python_constricter-0.2.7 → python_constricter-0.2.8}/PKG-INFO +4 -2
  2. {python_constricter-0.2.7 → python_constricter-0.2.8}/README.md +3 -1
  3. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/__init__.py +1 -1
  4. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/command.py +82 -6
  5. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/hints.py +51 -24
  6. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/options.py +36 -2
  7. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/protocol.py +57 -0
  8. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/runs.py +2 -1
  9. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/schedule.py +3 -1
  10. python_constricter-0.2.8/constricter/fix/callers.py +153 -0
  11. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/doubts.py +9 -6
  12. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/imports.py +14 -1
  13. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/inference.py +74 -3
  14. python_constricter-0.2.8/constricter/fix/installed.py +237 -0
  15. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/known.py +44 -2
  16. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/library.py +21 -0
  17. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/members.py +9 -2
  18. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/modules.py +96 -9
  19. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/overloads.py +138 -18
  20. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/project.py +148 -29
  21. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/returned.py +26 -1
  22. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/stdlib.py +83 -16
  23. python_constricter-0.2.8/constricter/fix/tables/generic_attributes.json +55 -0
  24. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/method_signatures.json +149 -149
  25. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/overloads.json +251 -125
  26. python_constricter-0.2.8/constricter/fix/tables/subscriptable.json +76 -0
  27. python_constricter-0.2.8/constricter/fix/tables/variables.json +2938 -0
  28. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/offences.py +3 -1
  29. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/binding.py +13 -3
  30. python_constricter-0.2.8/constricter/rules/calls.py +211 -0
  31. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/checker.py +17 -11
  32. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/late.py +3 -2
  33. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/scope.py +52 -18
  34. {python_constricter-0.2.7 → python_constricter-0.2.8}/pyproject.toml +6 -2
  35. {python_constricter-0.2.7 → python_constricter-0.2.8}/LICENSE.md +0 -0
  36. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/__main__.py +0 -0
  37. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/__init__.py +0 -0
  38. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/baseline.py +0 -0
  39. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/collecting.py +0 -0
  40. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/config.py +0 -0
  41. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/explain.py +0 -0
  42. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/guard.py +0 -0
  43. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/paths.py +0 -0
  44. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/report.py +0 -0
  45. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/cli/workers.py +0 -0
  46. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/__init__.py +0 -0
  47. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/fills.py +0 -0
  48. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/fixes.py +0 -0
  49. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/guesses.py +0 -0
  50. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/hinted.py +0 -0
  51. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/narrowed.py +0 -0
  52. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/opened.py +0 -0
  53. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/order.py +0 -0
  54. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/returns.py +0 -0
  55. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/aliases.json +0 -0
  56. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/attributes.json +0 -0
  57. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/bases.json +0 -0
  58. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/classes.json +0 -0
  59. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/method_overloads.json +0 -0
  60. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/methods.json +0 -0
  61. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/returns.json +0 -0
  62. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/source.json +0 -0
  63. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/tables/type_parameters.json +0 -0
  64. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/fix/targets.py +0 -0
  65. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/jsonc.py +0 -0
  66. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/noqa.py +0 -0
  67. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/notebook.py +0 -0
  68. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/plugins/__init__.py +0 -0
  69. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/plugins/flake8.py +0 -0
  70. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/plugins/pylint.py +0 -0
  71. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/py.typed +0 -0
  72. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/__init__.py +0 -0
  73. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/annotations.py +0 -0
  74. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/flow.py +0 -0
  75. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/narrowing.py +0 -0
  76. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/parsed.py +0 -0
  77. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/rebinding.py +0 -0
  78. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/redundant.py +0 -0
  79. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/syntax.py +0 -0
  80. {python_constricter-0.2.7 → python_constricter-0.2.8}/constricter/rules/walked.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-constricter
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Lint rules: every local variable is typed where it's first bound. A flake8 plugin, a pylint plugin and a standalone CLI.
5
5
  Author: Ivy Duggan
6
6
  Requires-Python: >=3.11
@@ -173,6 +173,8 @@ Options:
173
173
  | Option | CLI | `[tool.constricter]` | flake8 (CLI or config) | pylint |
174
174
  | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------- | --------------------------------- |
175
175
  | level | `--level` | `level` | `--constricter-level` | `constricter-level` |
176
+ | strictest | `--max`: `--level=suffocate` for every path, `--all-scopes`, and the opt-in `LVA012` | — | — | — |
177
+ | every fix | `--max-fix`: `--max`, `--fix --unsafe-fixes`, and `--infer-with` each of basedpyright and ty that's installed and runs | — | — | — |
176
178
  | type comments | `--type-comments` | `type-comments` | `--constricter-type-comments` | `constricter-type-comments = yes` |
177
179
  | all scopes | `--all-scopes` | `all-scopes` | `--constricter-all-scopes` | `constricter-all-scopes = yes` |
178
180
  | nesting | `--nesting N` | `nesting` | `--constricter-nesting` | `constricter-nesting` |
@@ -262,7 +264,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
262
264
 
263
265
  ```yaml
264
266
  - repo: https://github.com/ivylikethevine/python-constricter
265
- rev: v0.2.7
267
+ rev: v0.2.8
266
268
  hooks:
267
269
  - id: constricter
268
270
  ```
@@ -146,6 +146,8 @@ Options:
146
146
  | Option | CLI | `[tool.constricter]` | flake8 (CLI or config) | pylint |
147
147
  | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------- | --------------------------------- |
148
148
  | level | `--level` | `level` | `--constricter-level` | `constricter-level` |
149
+ | strictest | `--max`: `--level=suffocate` for every path, `--all-scopes`, and the opt-in `LVA012` | — | — | — |
150
+ | every fix | `--max-fix`: `--max`, `--fix --unsafe-fixes`, and `--infer-with` each of basedpyright and ty that's installed and runs | — | — | — |
149
151
  | type comments | `--type-comments` | `type-comments` | `--constricter-type-comments` | `constricter-type-comments = yes` |
150
152
  | all scopes | `--all-scopes` | `all-scopes` | `--constricter-all-scopes` | `constricter-all-scopes = yes` |
151
153
  | nesting | `--nesting N` | `nesting` | `--constricter-nesting` | `constricter-nesting` |
@@ -235,7 +237,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
235
237
 
236
238
  ```yaml
237
239
  - repo: https://github.com/ivylikethevine/python-constricter
238
- rev: v0.2.7
240
+ rev: v0.2.8
239
241
  hooks:
240
242
  - id: constricter
241
243
  ```
@@ -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.8"
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,
@@ -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):
@@ -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
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)