python-constricter 0.3.2__tar.gz → 0.3.3__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 (133) hide show
  1. {python_constricter-0.3.2 → python_constricter-0.3.3}/.gitignore +1 -0
  2. {python_constricter-0.3.2 → python_constricter-0.3.3}/PKG-INFO +5 -3
  3. {python_constricter-0.3.2 → python_constricter-0.3.3}/README.md +4 -2
  4. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/__init__.py +1 -1
  5. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/command.py +9 -2
  6. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/config.py +2 -1
  7. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/options.py +28 -2
  8. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/schedule.py +24 -4
  9. python_constricter-0.3.3/constricter/cli/traced.py +289 -0
  10. python_constricter-0.3.3/constricter/fix/core/asked.py +56 -0
  11. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/core/fixes.py +12 -2
  12. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/core/imports.py +98 -10
  13. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/core/inherited.py +20 -7
  14. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/core/known.py +99 -11
  15. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/core/signatures.py +7 -2
  16. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/atoms.py +4 -1
  17. python_constricter-0.3.3/constricter/fix/index/beyond.py +90 -0
  18. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/callers.py +1 -1
  19. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/declared.py +16 -0
  20. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/decorated.py +1 -1
  21. python_constricter-0.3.3/constricter/fix/index/fixtures.py +176 -0
  22. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/installed.py +35 -14
  23. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/modules.py +43 -5
  24. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/order.py +5 -2
  25. python_constricter-0.3.3/constricter/fix/index/own_overloads.py +57 -0
  26. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/plain.py +10 -7
  27. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/project.py +104 -46
  28. python_constricter-0.3.3/constricter/fix/index/read.py +70 -0
  29. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/stubbed.py +39 -72
  30. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/libraries/library.py +116 -2
  31. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/libraries/opened.py +33 -1
  32. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/libraries/overloads.py +126 -11
  33. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/libraries/stdlib.py +216 -13
  34. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/attributes.json +23 -0
  35. python_constricter-0.3.3/constricter/fix/tables/awaited.json +69 -0
  36. python_constricter-0.3.3/constricter/fix/tables/awaited_overloads.json +14 -0
  37. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/classes.json +2 -1
  38. python_constricter-0.3.3/constricter/fix/tables/elements.json +156 -0
  39. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/functions.json +0 -1
  40. python_constricter-0.3.3/constricter/fix/tables/generic_attributes.json +252 -0
  41. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/method_overloads.json +133 -94
  42. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/method_signatures.json +179 -20
  43. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/methods.json +34 -10
  44. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/overloads.json +52 -28
  45. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/scalars.json +3 -0
  46. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/source.json +4 -1
  47. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/subscriptable.json +1 -0
  48. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/type_parameters.json +2 -0
  49. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/variables.json +21 -0
  50. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/classvars.py +100 -27
  51. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/decided.py +52 -9
  52. python_constricter-0.3.3/constricter/fix/values/displays.py +87 -0
  53. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/doubts.py +48 -1
  54. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/entered.py +22 -1
  55. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/fills.py +56 -25
  56. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/guesses.py +34 -7
  57. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/hinted.py +94 -7
  58. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/inference.py +104 -99
  59. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/members.py +57 -9
  60. python_constricter-0.3.3/constricter/fix/values/operated.py +199 -0
  61. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/returned.py +184 -18
  62. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/returns.py +1 -0
  63. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/shapes.py +136 -21
  64. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/targets.py +9 -3
  65. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/offences.py +3 -0
  66. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/annotations.py +22 -3
  67. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/binding.py +11 -14
  68. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/calls.py +171 -25
  69. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/checker.py +139 -22
  70. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/decorators.py +12 -0
  71. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/late.py +2 -2
  72. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/parsed.py +42 -3
  73. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/rebinding.py +4 -2
  74. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/recorded.py +42 -3
  75. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/scope.py +51 -15
  76. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/tables.py +62 -6
  77. python_constricter-0.3.3/constricter/trace.py +385 -0
  78. python_constricter-0.3.3/stdlib_tables/elements.py +77 -0
  79. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/generate.py +165 -13
  80. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/overloads.py +100 -16
  81. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/reading.py +42 -11
  82. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/templates.py +6 -1
  83. python_constricter-0.3.2/constricter/fix/tables/generic_attributes.json +0 -55
  84. {python_constricter-0.3.2 → python_constricter-0.3.3}/LICENSE.md +0 -0
  85. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/__main__.py +0 -0
  86. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/__init__.py +0 -0
  87. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/baseline.py +0 -0
  88. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/collecting.py +0 -0
  89. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/edits.py +0 -0
  90. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/explain.py +0 -0
  91. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/guard.py +0 -0
  92. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/hints.py +0 -0
  93. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/paths.py +0 -0
  94. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/protocol.py +0 -0
  95. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/report.py +0 -0
  96. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/runs.py +0 -0
  97. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/cli/workers.py +0 -0
  98. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/__init__.py +0 -0
  99. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/core/__init__.py +0 -0
  100. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/__init__.py +0 -0
  101. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/classnames.py +0 -0
  102. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/offers.py +0 -0
  103. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/sides.py +0 -0
  104. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/index/tuples.py +0 -0
  105. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/libraries/__init__.py +0 -0
  106. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/aliases.json +0 -0
  107. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/bases.json +0 -0
  108. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/returns.json +0 -0
  109. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/tables/scalar_members.json +0 -0
  110. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/__init__.py +0 -0
  111. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/aliased.py +0 -0
  112. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/called.py +0 -0
  113. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/fix/values/narrowed.py +0 -0
  114. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/jsonc.py +0 -0
  115. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/noqa.py +0 -0
  116. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/notebook.py +0 -0
  117. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/plugins/__init__.py +0 -0
  118. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/plugins/flake8.py +0 -0
  119. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/plugins/pylint.py +0 -0
  120. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/py.typed +0 -0
  121. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/__init__.py +0 -0
  122. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/flow.py +0 -0
  123. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/narrowing.py +0 -0
  124. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/quoted.py +0 -0
  125. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/redundant.py +0 -0
  126. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/syntax.py +0 -0
  127. {python_constricter-0.3.2 → python_constricter-0.3.3}/constricter/rules/walked.py +0 -0
  128. {python_constricter-0.3.2 → python_constricter-0.3.3}/hatch_build.py +0 -0
  129. {python_constricter-0.3.2 → python_constricter-0.3.3}/pyproject.toml +0 -0
  130. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/__init__.py +0 -0
  131. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/__main__.py +0 -0
  132. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/partial.json +0 -0
  133. {python_constricter-0.3.2 → python_constricter-0.3.3}/stdlib_tables/stubs.py +0 -0
@@ -42,6 +42,7 @@ stdlib_tables/partial.json
42
42
  /.claude/skills
43
43
  /.claude/workflows
44
44
  /.claude/.cc-writes
45
+ /.vscode
45
46
 
46
47
  # Backups merge tools leave next to a conflicted file.
47
48
  *.orig
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-constricter
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Import-Name: constricter
5
5
  Summary: Lint rules: every local variable is typed where it's first bound. A flake8 plugin, a pylint plugin and a standalone CLI.
6
6
  Author: Ivy Duggan
@@ -187,6 +187,7 @@ Options:
187
187
  | type hierarchy | - | `narrower` (a table) | `--constricter-narrower` | `constricter-narrower` |
188
188
  | fix | `--fix` (`--unsafe-fixes` for guesses), `--diff` to preview | - | - | - |
189
189
  | infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`, `pyrefly`, several: inferred types, as guesses) | `infer-with` | - | - |
190
+ | infer from | `--infer-from FILE` (the types `python -m constricter.trace -m pytest` recorded from a run, as guesses) | `infer-from` | - | - |
190
191
  | show fixes | `--show-fixes` (each fix and how it was decided, text) | - | - | - |
191
192
  | fix levels | `--fix-select`, `--fix-ignore`, `--unsafe-fix-select` (mechanisms: [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md#fix-levels)) | `fix-select`, `fix-ignore`, `unsafe-fix-select` | - | - |
192
193
  | fix plain bases | `--fix-plain-bases BASES` (frameworks' classes whose subclasses' bodies `--fix` annotates; django's are built in) | `fix-plain-bases` | - | - |
@@ -246,7 +247,8 @@ method call of a local whose type is known, and values computed from those; a lo
246
247
  unpacking's names get a declaration on the line before. `--unsafe-fixes` adds guesses, and
247
248
  `--show-fixes` lists each fix and how its value decided it. `--infer-with basedpyright` (or `ty`, or
248
249
  `pyrefly`, or several: `basedpyright,ty`; each installed alongside) asks those type checkers for
249
- what `--fix` can't type itself, as guesses. The full list is in
250
+ what `--fix` can't type itself, as guesses; `--infer-from FILE` takes the types a run bound each
251
+ name to, recorded by `python -m constricter.trace -m pytest`, the same way. The full list is in
250
252
  [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
251
253
 
252
254
  ### Installing and running
@@ -272,7 +274,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
272
274
 
273
275
  ```yaml
274
276
  - repo: https://github.com/ivylikethevine/python-constricter
275
- rev: v0.3.2
277
+ rev: v0.3.3
276
278
  hooks:
277
279
  - id: constricter
278
280
  ```
@@ -160,6 +160,7 @@ Options:
160
160
  | type hierarchy | - | `narrower` (a table) | `--constricter-narrower` | `constricter-narrower` |
161
161
  | fix | `--fix` (`--unsafe-fixes` for guesses), `--diff` to preview | - | - | - |
162
162
  | infer with | `--infer-with CHECKERS` (`basedpyright`, `ty`, `pyrefly`, several: inferred types, as guesses) | `infer-with` | - | - |
163
+ | infer from | `--infer-from FILE` (the types `python -m constricter.trace -m pytest` recorded from a run, as guesses) | `infer-from` | - | - |
163
164
  | show fixes | `--show-fixes` (each fix and how it was decided, text) | - | - | - |
164
165
  | fix levels | `--fix-select`, `--fix-ignore`, `--unsafe-fix-select` (mechanisms: [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md#fix-levels)) | `fix-select`, `fix-ignore`, `unsafe-fix-select` | - | - |
165
166
  | fix plain bases | `--fix-plain-bases BASES` (frameworks' classes whose subclasses' bodies `--fix` annotates; django's are built in) | `fix-plain-bases` | - | - |
@@ -219,7 +220,8 @@ method call of a local whose type is known, and values computed from those; a lo
219
220
  unpacking's names get a declaration on the line before. `--unsafe-fixes` adds guesses, and
220
221
  `--show-fixes` lists each fix and how its value decided it. `--infer-with basedpyright` (or `ty`, or
221
222
  `pyrefly`, or several: `basedpyright,ty`; each installed alongside) asks those type checkers for
222
- what `--fix` can't type itself, as guesses. The full list is in
223
+ what `--fix` can't type itself, as guesses; `--infer-from FILE` takes the types a run bound each
224
+ name to, recorded by `python -m constricter.trace -m pytest`, the same way. The full list is in
223
225
  [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
224
226
 
225
227
  ### Installing and running
@@ -245,7 +247,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
245
247
 
246
248
  ```yaml
247
249
  - repo: https://github.com/ivylikethevine/python-constricter
248
- rev: v0.3.2
250
+ rev: v0.3.3
249
251
  hooks:
250
252
  - id: constricter
251
253
  ```
@@ -25,7 +25,7 @@ from constricter.offences import (
25
25
  )
26
26
  from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
27
27
 
28
- __version__ = "0.3.2"
28
+ __version__ = "0.3.3"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  from typing import TYPE_CHECKING, Final, NamedTuple, TextIO, TypeAlias, cast
17
17
 
18
18
  from constricter import notebook
19
- from constricter.cli import baseline, collecting, schedule
19
+ from constricter.cli import baseline, collecting, schedule, traced
20
20
  from constricter.cli.options import Mode, Options, Output
21
21
  from constricter.cli.paths import STDIN, python_files
22
22
  from constricter.cli.protocol import HintError
@@ -33,6 +33,7 @@ from constricter.offences import (
33
33
  Edit,
34
34
  Offence,
35
35
  )
36
+ from constricter.rules import parsed
36
37
  from constricter.rules.checker import Checked, Coverage, annotation_coverage, checked_source
37
38
 
38
39
  if TYPE_CHECKING: # slow to import, and only needed for `--infer-with`
@@ -297,13 +298,17 @@ def _handled(path: Path, text: tuple[str, Path], offences: list[Offence], option
297
298
  left: list[Result] = [r for r in results if r.offence not in fixing]
298
299
  if path == STDIN: # the fixed source goes to stdout
299
300
  return CheckRun(left, baselined, len(fixing), _fixed(raw, name, fixing).text)
301
+ fixed: int
300
302
  try:
301
- return CheckRun(left, baselined, fix_file(path, fixing))
303
+ fixed = fix_file(path, fixing)
302
304
  except UnicodeEncodeError as failure: # the file is left as it was, its offences unfixed
303
305
  # Its canonical name: PyPy reports `latin1` where CPython says `latin-1`.
304
306
  encoding: str = codecs.lookup(failure.encoding).name
305
307
  message: str = f"an annotation can't be written in its encoding, {encoding}; left as it was"
306
308
  return CheckRun(results, baselined, error=f"{name}: error: {message}")
309
+ if fixed:
310
+ _ = parsed.take(raw) # the tree of the text it had: no check will ask for it again
311
+ return CheckRun(left, baselined, fixed)
307
312
 
308
313
 
309
314
  def _baseline_path(path: Path, outside: Outside, options: Options) -> BaselineRun:
@@ -505,6 +510,8 @@ def _checked_all(
505
510
 
506
511
  """
507
512
  hinted: dict[Path, tuple[Hints, ...]] = {} if session is None else session.hints(_texts(paths))
513
+ if options.input.trace is not None:
514
+ hinted = traced.merged(hinted, traced.load(options.input.trace), paths)
508
515
  coverage: bool = options.mode is Mode.COVERAGE # needs nothing from the other files
509
516
  stack: contextlib.ExitStack
510
517
  with contextlib.ExitStack() as stack:
@@ -223,7 +223,7 @@ def _lists(value: _Toml, read: Callable[[_Toml], list[str] | None]) -> dict[str,
223
223
 
224
224
 
225
225
  def _baseline(value: _Toml, root: Path) -> str | None:
226
- """Read a baseline path, relative to `root` (the pyproject.toml that names it).
226
+ """Read a baseline's or a trace's path, relative to `root` (the pyproject.toml that names it).
227
227
 
228
228
  Returns:
229
229
  The resolved path, or `None` if `value` isn't a non-empty string.
@@ -276,6 +276,7 @@ def config_defaults(start: Path) -> dict[str, Default]:
276
276
  readers: dict[str, Callable[[_Toml], Default | None]] = {
277
277
  **_READERS,
278
278
  "baseline": partial(_baseline, root=path.parent),
279
+ "infer-from": partial(_baseline, root=path.parent),
279
280
  }
280
281
  defaults: dict[str, Default] = {}
281
282
  key: str
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from typing import Final, cast
12
12
 
13
13
  from constricter import __version__
14
- from constricter.cli import baseline
14
+ from constricter.cli import baseline, traced
15
15
  from constricter.cli.config import (
16
16
  DEFAULT_BASELINE,
17
17
  config_defaults,
@@ -353,6 +353,12 @@ def _parser() -> argparse.ArgumentParser:
353
353
  metavar="GB",
354
354
  help="with --infer-with: the most memory each checker's servers use together (default: 8)",
355
355
  )
356
+ _ = parser.add_argument(
357
+ "--infer-from",
358
+ type=Path,
359
+ metavar="FILE",
360
+ help="type what --fix can't with the types `python -m constricter.trace` recorded, as guesses",
361
+ )
356
362
  _ = parser.add_argument(
357
363
  "--diff",
358
364
  action="store_true",
@@ -440,6 +446,7 @@ class Input:
440
446
  paths: list[Path]
441
447
  exclude: list[str]
442
448
  stdin_name: Path = STDIN
449
+ trace: Path | None = None # the trace whose types `--fix` guesses with (`--infer-from`)
443
450
 
444
451
  def name(self, path: Path) -> Path:
445
452
  """Name `path` for reports.
@@ -566,7 +573,12 @@ class Options:
566
573
  parser.error("`-` (standard input) must be the only path")
567
574
  mode: Mode = _mode(parser, args)
568
575
  return cls(
569
- input=Input(paths, cast("list[str]", args.exclude), cast("Path", args.stdin_filename)),
576
+ input=Input(
577
+ paths,
578
+ cast("list[str]", args.exclude),
579
+ cast("Path", args.stdin_filename),
580
+ _trace(parser, cast("Path | None", args.infer_from)),
581
+ ),
570
582
  checks=Checks(
571
583
  type_comments=cast("bool", args.type_comments),
572
584
  all_scopes=cast("bool", args.all_scopes),
@@ -605,6 +617,20 @@ class Options:
605
617
  )
606
618
 
607
619
 
620
+ def _trace(parser: argparse.ArgumentParser, path: Path | None) -> Path | None:
621
+ """Check that `--infer-from`'s file is a trace, read here once for the run.
622
+
623
+ Returns:
624
+ The path.
625
+
626
+ """
627
+ try:
628
+ _ = None if path is None else traced.load(path)
629
+ except ValueError as error:
630
+ parser.error(str(error))
631
+ return path
632
+
633
+
608
634
  def _maximal(args: argparse.Namespace) -> argparse.Namespace:
609
635
  """Apply `--max-fix` (`--max`, every fix, every installed checker) and `--max`.
610
636
 
@@ -13,8 +13,20 @@ 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.core.known import Guarded, Hints, Outside
17
- from constricter.fix.index import callers, decorated, offers, order, plain, project, sides, stubbed, tuples
16
+ from constricter.fix.core.known import Guarded, Hints, Outside, Passed
17
+ from constricter.fix.index import (
18
+ beyond,
19
+ callers,
20
+ decorated,
21
+ fixtures,
22
+ offers,
23
+ order,
24
+ plain,
25
+ project,
26
+ sides,
27
+ stubbed,
28
+ tuples,
29
+ )
18
30
 
19
31
  CYCLE_ROUNDS: Final = 3 # how many times to check again files calling each other's functions
20
32
 
@@ -26,7 +38,13 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
26
38
  It.
27
39
 
28
40
  """
29
- imported: project.Imported = project.imported(modules, path)
41
+ seeded: dict[str, Guarded] = {}
42
+ seeds: dict[str, Passed] = fixtures.visible(modules, path, seeded)
43
+ imported: project.Imported = project.imported(
44
+ modules,
45
+ path,
46
+ (seeded, fixtures.attributes(seeds)),
47
+ )
30
48
  side_calls: dict[str, str]
31
49
  guarded: dict[str, Guarded]
32
50
  side_calls, guarded = sides.calls(modules, path, imported.guarded)
@@ -44,7 +62,7 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
44
62
  imported.generics | own.generics,
45
63
  callers.callees(modules, path),
46
64
  callers.own_parameters(modules, path),
47
- {**stubbed.overloaded(modules, path), **methods.signatures},
65
+ {**stubbed.overloaded(modules, path, guarded), **methods.signatures},
48
66
  stubbed.classes(modules, path),
49
67
  methods.parameters,
50
68
  methods.lineage,
@@ -55,6 +73,8 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
55
73
  project.same(modules, path, guarded),
56
74
  imported.partial,
57
75
  tuples.fields(modules, path, guarded),
76
+ seeds,
77
+ beyond.library_bases(modules, path),
58
78
  )
59
79
 
60
80
 
@@ -0,0 +1,289 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--infer-from`: the types a traced run saw (`python -m constricter.trace`), as a type checker's hints are.
3
+
4
+ A trace holds, for each file by its SHA-256, the types each function's locals held when it returned
5
+ (see `constricter.trace`). `hints` turns a file's into `Hints`, which `--fix` judges as it does a
6
+ checker's (`constricter.fix.values.hinted`): each a guess, of kind `traced`. Only for a local no
7
+ annotation types: one assigned a value rooted at a parameter its function leaves unannotated
8
+ (`row = rows[0].load()`; not `self` or `cls`). Anything else a type checker may
9
+ type wider than the run saw (an `X | None` that was never `None`, a base class, a `TypedDict`),
10
+ which the narrower annotation would make an error. And only one its function binds once, outside any
11
+ loop: the trace says what it held last, not where. Up to three types seen are their union, `None`
12
+ last.
13
+
14
+ A class is shown bare, with the import that names it for type checking alone (`Offered`), unless
15
+ the file defines it: `constricter.fix.index.offers` then takes only a class the index knows. One of
16
+ a private module of the standard library has no hint.
17
+ """
18
+
19
+ import ast
20
+ import hashlib
21
+ import json
22
+ import re
23
+ import sys
24
+ from collections import Counter
25
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
26
+ from functools import cache
27
+ from pathlib import Path
28
+ from typing import Final, NamedTuple, TypeAlias, cast
29
+
30
+ from constricter import trace
31
+ from constricter.fix.core.known import Hints, Offered
32
+ from constricter.fix.values.hinted import TRACED
33
+
34
+ _LABEL: Final = "a traced run"
35
+ _SUFFIX: Final = ".py"
36
+ _MEMBERS: Final = 3 # how many types seen make a union
37
+ _CLASS: Final = re.compile(rf"([\w.]+){trace.SEPARATOR}(\w+)")
38
+ _STDLIB: Final = sys.stdlib_module_names
39
+ _TYPED: Final = frozenset({"self", "cls"}) # the parameters a class types, unannotated
40
+ _SCOPES: Final = (
41
+ ast.FunctionDef,
42
+ ast.AsyncFunctionDef,
43
+ ast.ClassDef,
44
+ ast.Lambda,
45
+ ast.ListComp,
46
+ ast.SetComp,
47
+ ast.DictComp,
48
+ ast.GeneratorExp,
49
+ )
50
+ _Function: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef
51
+ # Each function's locals' spellings, by name, by the function's first line.
52
+ _Functions: TypeAlias = Mapping[int, Mapping[str, tuple[str, ...]]]
53
+
54
+
55
+ class Trace(NamedTuple):
56
+ """A trace file: each file's functions by its SHA-256, and the file each module named in it is."""
57
+
58
+ files: Mapping[str, _Functions]
59
+ modules: Mapping[str, str]
60
+
61
+
62
+ @cache
63
+ def load(path: Path) -> Trace:
64
+ """Read the trace file at `path`, once.
65
+
66
+ Returns:
67
+ It.
68
+
69
+ Raises:
70
+ ValueError: It can't be read, or isn't a trace this version reads.
71
+
72
+ """
73
+ try:
74
+ return _read(_table(cast("object", json.loads(path.read_text(encoding="utf-8")))))
75
+ except (OSError, ValueError) as error:
76
+ message: str = f"{path}: {error}"
77
+ raise ValueError(message) from error
78
+
79
+
80
+ def _read(found: Mapping[str, object]) -> Trace:
81
+ """Read a trace file's contents.
82
+
83
+ Returns:
84
+ The trace, without what isn't as the recorder writes it.
85
+
86
+ Raises:
87
+ ValueError: It isn't a trace this version reads.
88
+
89
+ """
90
+ if found.get("version") != trace.VERSION:
91
+ message: str = f"not a trace `python -m constricter.trace` version {trace.VERSION} wrote"
92
+ raise ValueError(message)
93
+ return Trace(
94
+ {
95
+ digest: {
96
+ int(line): {
97
+ name: tuple(cast("list[str]", types))
98
+ for name, types in _table(held).items()
99
+ if isinstance(types, list)
100
+ }
101
+ for line, held in _table(_table(entry).get("functions")).items()
102
+ if line.isdecimal()
103
+ }
104
+ for digest, entry in _table(found.get("files")).items()
105
+ },
106
+ {name: digest for name, digest in _table(found.get("modules")).items() if isinstance(digest, str)},
107
+ )
108
+
109
+
110
+ def _table(value: object) -> dict[str, object]:
111
+ return cast("dict[str, object]", value) if isinstance(value, dict) else {}
112
+
113
+
114
+ def merged(
115
+ hinted: Mapping[Path, tuple[Hints, ...]],
116
+ found: Trace,
117
+ paths: Iterable[Path],
118
+ ) -> dict[Path, tuple[Hints, ...]]:
119
+ """Add the trace's hints for each of `paths` after the type checkers' (`hinted`).
120
+
121
+ Returns:
122
+ Each file's hints.
123
+
124
+ """
125
+ every: dict[Path, tuple[Hints, ...]] = dict(hinted)
126
+ path: Path
127
+ for path in paths:
128
+ try:
129
+ traced: Hints | None = hints(found, path.read_bytes()) if path.suffix == _SUFFIX else None
130
+ except OSError: # checking it reports that
131
+ continue
132
+ if traced is not None:
133
+ every[path] = (*every.get(path, ()), traced)
134
+ return every
135
+
136
+
137
+ def hints(found: Trace, source: bytes) -> Hints | None:
138
+ """Make the hints a trace has for a file's `source`.
139
+
140
+ Returns:
141
+ Them; `None` for a file the trace doesn't have as it is, or has nothing for.
142
+
143
+ """
144
+ digest: str = hashlib.sha256(source).hexdigest()
145
+ functions: _Functions = found.files.get(digest, {})
146
+ try:
147
+ tree: ast.Module | None = ast.parse(source) if functions else None
148
+ except (SyntaxError, ValueError):
149
+ tree = None
150
+ types: dict[tuple[int, int], str] = {}
151
+ offered: dict[tuple[int, int], Offered] = {}
152
+ node: ast.AST
153
+ for node in ast.walk(tree) if tree is not None else ():
154
+ if not isinstance(node, _Function):
155
+ continue
156
+ held: Mapping[str, tuple[str, ...]] = functions.get(
157
+ min([node.lineno, *(decorator.lineno for decorator in node.decorator_list)]),
158
+ {},
159
+ )
160
+ target: ast.Name
161
+ for target in _untyped(node) if held else ():
162
+ typed: Offered | None
163
+ if (typed := _typed(held.get(target.id, ()), found.modules, digest)) is not None:
164
+ where: tuple[int, int] = (target.lineno, target.end_col_offset or 0)
165
+ types[where] = typed.text
166
+ if typed.imports:
167
+ offered[where] = typed
168
+ return Hints(_LABEL, types, offered, TRACED) if types else None
169
+
170
+
171
+ def _typed(spellings: Sequence[str], modules: Mapping[str, str], digest: str) -> Offered | None:
172
+ """Join a local's `spellings` into the annotation they make, with the imports its classes need.
173
+
174
+ `modules`, `digest`: see `Trace.modules`, and the file's own SHA-256, whose classes need none.
175
+
176
+ Returns:
177
+ It, or `None`: nothing seen, something that couldn't be spelled, more than `_MEMBERS` types,
178
+ two classes of one name, or one of a private module of the standard library.
179
+
180
+ """
181
+ if not spellings or trace.UNKNOWN in spellings or len(spellings) > _MEMBERS:
182
+ return None
183
+ origins: dict[str, str] = {}
184
+ spelling: str
185
+ for spelling in spellings:
186
+ module: str
187
+ name: str
188
+ for module, name in cast("list[tuple[str, str]]", _CLASS.findall(spelling)):
189
+ private: bool = module.partition(".")[0] in _STDLIB and any(
190
+ part.startswith("_") for part in module.split(".")
191
+ )
192
+ if private or origins.setdefault(name, module) != module:
193
+ return None
194
+ return Offered(
195
+ " | ".join(
196
+ _CLASS.sub(r"\2", spelling)
197
+ for spelling in sorted(spellings, key=lambda text: (text == trace.NONE, text))
198
+ ),
199
+ tuple(
200
+ f"from {module} import {name}"
201
+ for name, module in sorted(origins.items())
202
+ if modules.get(module) != digest
203
+ ),
204
+ )
205
+
206
+
207
+ def _untyped(function: _Function) -> list[ast.Name]:
208
+ """Find the locals `function` binds once, outside any loop, to a value no annotation types.
209
+
210
+ One rooted at a parameter it leaves unannotated (`rows[0].load()`, `make(n)`; not `self` or
211
+ `cls`, which its class types): a type checker has no type for it either, so none to set against
212
+ what the run saw. Not one rooted at such a local, which a checker types by its new annotation.
213
+
214
+ Returns:
215
+ Each such name's binding.
216
+
217
+ """
218
+ args: ast.arguments = function.args
219
+ params: list[ast.arg] = [
220
+ arg
221
+ for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg)
222
+ if arg is not None
223
+ ]
224
+ others: set[str] = {arg.arg for arg in params}
225
+ rooted: set[str | None] = {arg.arg for arg in params if arg.annotation is None and arg.arg not in _TYPED}
226
+ stores: list[ast.Name] = []
227
+ looped: set[int] = set() # the nodes a loop runs again
228
+ node: ast.AST
229
+ for node in _own(function):
230
+ if isinstance(node, ast.For | ast.AsyncFor | ast.While):
231
+ looped.update(id(inside) for inside in _own(node))
232
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
233
+ stores.append(node)
234
+ elif isinstance(node, ast.Global | ast.Nonlocal):
235
+ others.update(node.names)
236
+ elif isinstance(node, ast.alias):
237
+ others.add((node.asname or node.name).partition(".")[0])
238
+ elif isinstance(node, ast.ExceptHandler | ast.MatchAs | ast.MatchStar) and node.name is not None:
239
+ others.add(node.name)
240
+ elif isinstance(node, ast.MatchMapping) and node.rest is not None:
241
+ others.add(node.rest)
242
+ counts: Counter[str] = Counter(store.id for store in stores)
243
+ others.update(store.id for store in stores if id(store) in looped or counts[store.id] > 1)
244
+ return [
245
+ target for target, value in _assigned(function) if target.id not in others and _root(value) in rooted
246
+ ]
247
+
248
+
249
+ def _assigned(function: _Function) -> Iterator[tuple[ast.Name, ast.expr]]:
250
+ """Find the plain assignments of one name in a function's own scope, in order.
251
+
252
+ Yields:
253
+ Each one's name and value.
254
+
255
+ """
256
+ node: ast.AST
257
+ for node in _own(function):
258
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
259
+ target: ast.expr = node.targets[0]
260
+ if isinstance(target, ast.Name):
261
+ yield target, node.value
262
+
263
+
264
+ def _root(value: ast.expr) -> str | None:
265
+ """Find the name a value is taken from: `rows` in `rows[0].load()`, `make` in `make(n)`.
266
+
267
+ Returns:
268
+ It, or `None` for a value that's computed some other way.
269
+
270
+ """
271
+ if isinstance(value, ast.Attribute | ast.Subscript | ast.Await):
272
+ return _root(value.value)
273
+ if isinstance(value, ast.Call):
274
+ return _root(value.func)
275
+ return value.id if isinstance(value, ast.Name) else None
276
+
277
+
278
+ def _own(node: ast.AST) -> Iterator[ast.AST]:
279
+ """Walk what a function's own scope holds: not what a function, class or comprehension inside it does.
280
+
281
+ Yields:
282
+ Each node under `node`, a nested scope's own node included.
283
+
284
+ """
285
+ child: ast.AST
286
+ for child in ast.iter_child_nodes(node):
287
+ yield child
288
+ if not isinstance(child, _SCOPES):
289
+ yield from _own(child)
@@ -0,0 +1,56 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """What a value was inferred to be, kept until its scope types another name.
3
+
4
+ A statement's value is asked for its type, then for whether that's a guess, then as a part of the
5
+ next value (a `with` statement's manager, three times): `asked` answers again what `keep` was told,
6
+ for the same value (by `id`), the same scope's types (`Typed`) unchanged since, and the same number
7
+ of imports the module's plan has added or guarded (an import `--fix` adds can resolve a name the
8
+ next time).
9
+ """
10
+
11
+ import ast
12
+ from collections.abc import Mapping
13
+ from typing import Final, TypeAlias
14
+
15
+ from constricter.fix.core.known import ImportPlan, Inference, Known, Typed
16
+
17
+ # What was asked: the value and the scope's types (by `id`), how often those had changed, and how
18
+ # many imports the module's plan had added or guarded.
19
+ _Asked: TypeAlias = tuple[int, int, int, int]
20
+ # Its answer, with what was asked kept alive beside it: an `id` is only theirs while they live.
21
+ _Answered: TypeAlias = tuple[ast.expr, Known, Typed, Inference | None]
22
+ _ANSWERS: Final[dict[_Asked, _Answered]] = {}
23
+ _KEPT: Final = 2048 # then all are dropped: a few functions' worth
24
+
25
+
26
+ def _key(value: ast.expr, known: Known, declared: Typed) -> _Asked:
27
+ plan: ImportPlan | None = known.names.plan
28
+ return (
29
+ id(value),
30
+ id(declared),
31
+ declared.version,
32
+ 0 if plan is None else len(plan.added) + len(plan.guarded),
33
+ )
34
+
35
+
36
+ def asked(value: ast.expr, known: Known, declared: Mapping[str, str]) -> tuple[Inference | None] | None:
37
+ """Find what `value` was inferred to be, if it was asked before with nothing changed since.
38
+
39
+ Returns:
40
+ The inference (or `None`, for no type), in a tuple; `None` if it's yet to be worked out.
41
+
42
+ """
43
+ held: _Answered | None = (
44
+ _ANSWERS.get(_key(value, known, declared)) if isinstance(declared, Typed) else None
45
+ )
46
+ if held is None or held[0] is not value or held[1] is not known or held[2] is not declared:
47
+ return None
48
+ return (held[3],)
49
+
50
+
51
+ def keep(value: ast.expr, known: Known, declared: Mapping[str, str], found: Inference | None) -> None:
52
+ """Keep what `value` was inferred to be, for a scope's own types (any other mapping may change unseen)."""
53
+ if isinstance(declared, Typed):
54
+ if len(_ANSWERS) >= _KEPT:
55
+ _ANSWERS.clear()
56
+ _ANSWERS[_key(value, known, declared)] = (value, known, declared, found)
@@ -10,6 +10,9 @@ from typing import Final, NamedTuple, TypeAlias
10
10
  from constricter.offences import Edit, Fix, Offence
11
11
 
12
12
  _HEADER: Final = 2 # a file's shebang and coding lines come first, if it has them
13
+ # The last lines `_missing` read, stripped each way (see there): the lines, their count, and the set.
14
+ _Present: TypeAlias = tuple[Sequence[str], int, frozenset[str]]
15
+ _PRESENT: Final[dict[str | None, _Present]] = {}
13
16
  _HEADER_LINE: Final = re.compile(r"#!|#.*coding[:=]")
14
17
  # Lines to insert, and the number of lines before them.
15
18
  _Run: TypeAlias = tuple[int, list[str]]
@@ -155,8 +158,15 @@ def _missing(lines: Sequence[str], statements: Sequence[str], stripped: str | No
155
158
  """
156
159
  if not statements: # most fixes add none: the file's lines needn't be read for them
157
160
  return []
158
- present: set[str] = {line.rstrip(stripped) if stripped else line.strip() for line in lines}
159
- return [statement for statement in statements if statement not in present]
161
+ # A file's fixes are asked one at a time, of the same lines: stripped once for them all.
162
+ held: _Present | None = _PRESENT.get(stripped)
163
+ if held is None or held[0] is not lines or held[1] != len(lines):
164
+ held = _PRESENT[stripped] = (
165
+ lines,
166
+ len(lines),
167
+ frozenset(line.rstrip(stripped) if stripped else line.strip() for line in lines),
168
+ )
169
+ return [statement for statement in statements if statement not in held[2]]
160
170
 
161
171
 
162
172
  def _import_line(lines: Sequence[str], after: int) -> int: