python-constricter 0.4.4__py3-none-any.whl → 0.4.5__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 (61) hide show
  1. constricter/_version.py +1 -1
  2. constricter/cli/rounds.py +34 -5
  3. constricter/fix/core/imports.py +32 -4
  4. constricter/fix/core/known.py +76 -10
  5. constricter/fix/index/across.py +33 -5
  6. constricter/fix/index/crossed.py +92 -9
  7. constricter/fix/index/linked.py +10 -5
  8. constricter/fix/index/modules.py +273 -122
  9. constricter/fix/index/offers.py +76 -2
  10. constricter/fix/index/origins.py +28 -0
  11. constricter/fix/index/own_methods.py +56 -8
  12. constricter/fix/index/own_overloads.py +25 -6
  13. constricter/fix/index/own_stubs.py +95 -0
  14. constricter/fix/index/own_types.py +21 -0
  15. constricter/fix/index/plain.py +136 -24
  16. constricter/fix/index/project.py +85 -2
  17. constricter/fix/index/spelling.py +17 -21
  18. constricter/fix/index/stubs/atoms.py +6 -2
  19. constricter/fix/index/stubs/classnames.py +6 -2
  20. constricter/fix/index/stubs/declared.py +55 -4
  21. constricter/fix/index/stubs/installed.py +168 -6
  22. constricter/fix/index/stubs/signatures.py +52 -8
  23. constricter/fix/index/used.py +130 -0
  24. constricter/fix/libraries/stdlib.py +28 -0
  25. constricter/fix/tables/attributes.json +151 -56
  26. constricter/fix/tables/awaited.json +1 -0
  27. constricter/fix/tables/classes.json +4 -0
  28. constricter/fix/tables/method_overloads.json +7 -6
  29. constricter/fix/tables/method_signatures.json +14 -0
  30. constricter/fix/tables/methods.json +68 -58
  31. constricter/fix/tables/returns.json +3 -0
  32. constricter/fix/tables/source.json +1 -1
  33. constricter/fix/values/classvars.py +333 -15
  34. constricter/fix/values/documented.py +440 -0
  35. constricter/fix/values/doubts.py +63 -7
  36. constricter/fix/values/guesses.py +81 -4
  37. constricter/fix/values/inference.py +67 -6
  38. constricter/fix/values/members.py +96 -8
  39. constricter/fix/values/reads.py +15 -0
  40. constricter/fix/values/refused.py +2 -1
  41. constricter/fix/values/returned.py +110 -13
  42. constricter/fix/values/returns.py +133 -4
  43. constricter/fix/values/tables.py +64 -17
  44. constricter/offences.py +4 -0
  45. constricter/rules/attributes.py +17 -1
  46. constricter/rules/checker.py +36 -4
  47. constricter/rules/scope.py +49 -14
  48. constricter/rules/scoping.py +6 -3
  49. constricter/rules/settings.py +6 -4
  50. constricter/source/annotations.py +32 -8
  51. constricter/source/concrete.py +247 -0
  52. constricter/source/declarations.py +187 -86
  53. constricter/source/inert.py +194 -0
  54. constricter/source/syntax.py +63 -0
  55. constricter/source/usable.py +63 -0
  56. constricter/traced.py +33 -7
  57. {python_constricter-0.4.4.dist-info → python_constricter-0.4.5.dist-info}/METADATA +1 -1
  58. {python_constricter-0.4.4.dist-info → python_constricter-0.4.5.dist-info}/RECORD +61 -55
  59. {python_constricter-0.4.4.dist-info → python_constricter-0.4.5.dist-info}/WHEEL +0 -0
  60. {python_constricter-0.4.4.dist-info → python_constricter-0.4.5.dist-info}/entry_points.txt +0 -0
  61. {python_constricter-0.4.4.dist-info → python_constricter-0.4.5.dist-info}/licenses/LICENSE.md +0 -0
constricter/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """The version this was built as, from its git tag (written by the build, not tracked)."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.4.5"
constricter/cli/rounds.py CHANGED
@@ -18,7 +18,7 @@ from constricter.cli.runs import CheckRun, FileRun, merged
18
18
  from constricter.cli.workers import Workers, running, started
19
19
  from constricter.empty import Empty
20
20
  from constricter.fix.core.known import Callee, Hints, Outside
21
- from constricter.fix.index import callers, decorated, loose, plain, project
21
+ from constricter.fix.index import callers, crossed, decorated, loose, plain, project
22
22
  from constricter.fix.index.stubs import installed
23
23
  from constricter.offences import UNANNOTATED_ATTRIBUTE, UNANNOTATED_PARAMETER, Edit, Fix
24
24
 
@@ -67,10 +67,15 @@ def settled(
67
67
  again: list[int] = [
68
68
  index
69
69
  for index, run in enumerate(runs)
70
- if fixing and paths[index] != STDIN and _unsettled(run, session, options.guesses)
70
+ if fixing
71
+ and paths[index] != STDIN
72
+ and (_unsettled(run, session, options.guesses) or _imports_read(modules, paths[index], run))
71
73
  ]
72
74
  changed: list[int] = [
73
- at for at in range(len(runs)) if _reaching(paths[at], runs[at], onward=onward, signing=signing)
75
+ at
76
+ for at in range(len(runs))
77
+ if _reaching(paths[at], runs[at], onward=onward, signing=signing)
78
+ or (fixing and _imports_read(modules, paths[at], runs[at]))
74
79
  ]
75
80
  for _ in range(HINT_ROUNDS - 1):
76
81
  held: dict[int, FileRun] = {}
@@ -83,13 +88,37 @@ def settled(
83
88
  latest: project.Index
84
89
  fresh, latest = _round(paths, runs, (again, held), _How(check, options, session), modules)
85
90
  modules = latest if onward or signing else modules
86
- again = [index for index, run in fresh if _unsettled(run, session, options.guesses)]
91
+ again = [
92
+ index
93
+ for index, run in fresh
94
+ if _unsettled(run, session, options.guesses) or _imports_read(modules, paths[index], run)
95
+ ]
87
96
  changed = [
88
- index for index, run in fresh if _reaching(paths[index], run, onward=onward, signing=signing)
97
+ index
98
+ for index, run in fresh
99
+ if _reaching(paths[index], run, onward=onward, signing=signing)
100
+ or _imports_read(modules, paths[index], run)
89
101
  ]
90
102
  return runs
91
103
 
92
104
 
105
+ def _imports_read(modules: project.Index, path: Path, run: FileRun) -> bool:
106
+ """Check whether a round of `--fix` imported a checked file's class whose members the file reads.
107
+
108
+ What's read of them had no type while the file didn't spell the class (`f: TextIO`, from the
109
+ standard-library tables, then `f.encoding`, on a corpus that checks `typing.py`).
110
+
111
+ Returns:
112
+ Whether it did.
113
+
114
+ """
115
+ return _changed(path, run) and any(
116
+ isinstance(made.offence.edit, Fix)
117
+ and crossed.reads_imported(modules, path, made.offence.edit.imports)
118
+ for made in cast("CheckRun", run).made
119
+ )
120
+
121
+
93
122
  def _reaching(path: Path, run: FileRun, *, onward: bool, signing: bool) -> bool:
94
123
  """Check whether a round of `--fix` changed a file in a way the other files may see.
95
124
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import ast
4
4
  import bisect
5
+ import builtins
5
6
  import re
6
7
  import sys
7
8
  from collections.abc import Callable, Iterator, Mapping, Sequence
@@ -11,7 +12,7 @@ from weakref import WeakKeyDictionary
11
12
 
12
13
  from constricter.fix.core.known import Checking, Guarded, ImportPlan, Origin, Returns
13
14
  from constricter.source.annotations import dotted, roots
14
- from constricter.source.syntax import FunctionDef, Start, import_bindings
15
+ from constricter.source.syntax import FunctionDef, Start, generic_named, import_bindings
15
16
  from constricter.source.walked import classes, of_type
16
17
 
17
18
  _TYPE_CHECKING: Final = "TYPE_CHECKING"
@@ -22,6 +23,8 @@ _FUTURE: Final = "__future__"
22
23
  _IMPORTORSKIP: Final = "pytest.importorskip" # a module it names may be missing
23
24
  _MODNAME: Final = "modname" # its parameter
24
25
  _DOT: Final = "."
26
+ BUILTINS: Final = frozenset(dir(builtins))
27
+ _NONE: Final[frozenset[str]] = frozenset()
25
28
  _ANNOTATIONS: Final = "annotations" # `from __future__ import annotations` postpones them all
26
29
  # Each module's names, for as long as its tree lives: the index reads them, then the check.
27
30
  _Taken: TypeAlias = tuple[frozenset[str], frozenset[str]]
@@ -43,12 +46,14 @@ _BINDERS: Final = (
43
46
  )
44
47
 
45
48
 
46
- def plan(tree: ast.Module) -> ImportPlan:
49
+ def plan(tree: ast.Module, imported_values: frozenset[str] = _NONE) -> ImportPlan:
47
50
  """Read what `tree` already imports, every name it binds, and where its imports end.
48
51
 
49
52
  Only imports that run count as binding a name to use (not those under `if TYPE_CHECKING:`: a
50
53
  module's own annotations are evaluated when it's imported); every name bound anywhere in the
51
- file is taken, so an added import never shadows one (nor a builtin).
54
+ file is taken, so an added import never shadows one (nor a builtin). `imported_values`: the
55
+ names it imports that another module binds as a value (`linked.values`), which with its own
56
+ functions and assignments say which builtins it shadows.
52
57
 
53
58
  Returns:
54
59
  A fresh plan for the module's fixes.
@@ -64,6 +69,7 @@ def plan(tree: ast.Module) -> ImportPlan:
64
69
  taken,
65
70
  _after(tree),
66
71
  defined,
72
+ shadowed=_shadowed(tree) | (imported_values & BUILTINS),
67
73
  checking=Checking(
68
74
  _block(tree),
69
75
  _lazy(tree, bound),
@@ -76,6 +82,7 @@ def plan(tree: ast.Module) -> ImportPlan:
76
82
  ),
77
83
  postponed=_postponed(tree),
78
84
  values=values,
85
+ generic=generic_named(tree),
79
86
  )
80
87
 
81
88
 
@@ -149,6 +156,8 @@ def unwritable(annotation: str, planned: ImportPlan, nested: Mapping[str, str])
149
156
  def added_origin(statement: str) -> Origin:
150
157
  """Read what an import `ImportPlan.spell` added binds: `from m import T`, or `import m`.
151
158
 
159
+ `import m.sub` binds `m`, as one in the source does (`syntax.import_bindings`).
160
+
152
161
  Returns:
153
162
  Its origin: `("m", "T")`, or `("m", None)`.
154
163
 
@@ -156,7 +165,7 @@ def added_origin(statement: str) -> Origin:
156
165
  module: str
157
166
  name: str
158
167
  module, _, name = statement.removeprefix("from ").removeprefix("import ").partition(" import ")
159
- return module, name or None
168
+ return (module, name) if name else (module.partition(_DOT)[0], None)
160
169
 
161
170
 
162
171
  def added_dotted(statement: str) -> str:
@@ -264,6 +273,25 @@ def _bound(tree: ast.Module) -> dict[str, str]:
264
273
  return {name: origin for name, origin, _ in import_bindings(_running(tree.body))}
265
274
 
266
275
 
276
+ def _shadowed(tree: ast.Module) -> frozenset[str]:
277
+ """Name the builtins the module binds at its top level to a function or a value of its own.
278
+
279
+ `def str(val)` (`locale`'s), `str = str` (a Python 2 shim): a type written with the name would
280
+ name those, which a type checker refuses ("Variable not allowed in type expression"). A class
281
+ of the name (`enum`'s `property`) is a class, named as any is.
282
+
283
+ Returns:
284
+ Them.
285
+
286
+ """
287
+ found: set[str] = set()
288
+ stmt: ast.stmt
289
+ for stmt in _running(tree.body):
290
+ if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.Assign | ast.AnnAssign):
291
+ found.update(name for name in _names(stmt) if name in BUILTINS)
292
+ return frozenset(found)
293
+
294
+
267
295
  def _defined(tree: ast.Module) -> dict[str, int]:
268
296
  """Map each name the module binds at its top level (or under a top-level `if`/`try`) to its line.
269
297
 
@@ -16,7 +16,7 @@ from constricter.fix.core.inherited import Beyond, Lineage
16
16
  from constricter.fix.core.signatures import Expansion, ReadSignature
17
17
  from constricter.offences import MAX_LENGTH, VAGUE
18
18
  from constricter.overrides import override
19
- from constricter.source.annotations import free_of, free_of_all, roots
19
+ from constricter.source.annotations import GENERIC_HOMES, free_of, free_of_all, roots
20
20
  from constricter.source.declarations import GenericClass
21
21
 
22
22
  if TYPE_CHECKING:
@@ -40,6 +40,7 @@ Origin: TypeAlias = tuple[str, str | None]
40
40
  SPELLED: Final = "="
41
41
  # After a fixture's `request` parameter's name: the key its `request.param` is typed under (see `Typed`).
42
42
  PARAM: Final = ".param"
43
+ OWN_SELF: Final = "self.own" # in a scope's types: the class whose method reads `self`, unannotated or not
43
44
 
44
45
 
45
46
  # A `dict` itself, not a `UserDict`: every inference reads it, as fast as a `dict` is read.
@@ -164,7 +165,9 @@ class ImportPlan: # pylint: disable=too-many-instance-attributes
164
165
  `bound`: each name its imports bind, and what that is (`io`, `io.BytesIO`); `taken`: every name
165
166
  bound anywhere in it; `after`: the line added imports go after; `defined`: each name it binds
166
167
  at its top level (an import, a class, a function, an assignment), and the line it's first bound
167
- on; `added`: each name an added import binds, and that import's statement, as `spell` chose them.
168
+ on; `shadowed`: the builtins it binds at its top level to what a checker reads as no class (a
169
+ function, an assignment, an import of a value), so no type is written with them; `added`: each
170
+ name an added import binds, and that import's statement, as `spell` chose them.
168
171
  `guarded`: the names other checked files' types and a type checker's hints are written with
169
172
  that the module imports (or is to import) under `if TYPE_CHECKING:` alone; `checking`: its own
170
173
  such imports, where one goes, and whose types need one (see `Checking`); `postponed`: whether
@@ -175,30 +178,72 @@ class ImportPlan: # pylint: disable=too-many-instance-attributes
175
178
  taken: frozenset[str]
176
179
  after: int
177
180
  defined: Mapping[str, int] = field(default_factory=dict[str, int])
181
+ shadowed: frozenset[str] = frozenset()
178
182
  added: dict[str, str] = field(default_factory=dict[str, str])
179
183
  guarded: Mapping[str, Guarded] = field(default_factory=dict[str, Guarded])
180
184
  checking: Checking = field(default_factory=Checking)
181
185
  postponed: bool = False
182
186
  values: frozenset[str] = frozenset() # names it binds as values somewhere (see `imports.taken_names`)
187
+ generic: frozenset[str] = (
188
+ frozenset()
189
+ ) # its classes of a generic's name, generic too (`syntax.generic_named`)
183
190
  # Names it imports from another checked file that re-exports a type's factory there (`Outside`).
184
191
  factories: frozenset[str] = frozenset()
185
192
 
193
+ def own_names(self) -> frozenset[str]:
194
+ """Name what the module binds, or is to import, other than from where typing's generics come.
195
+
196
+ Its own `class Generator`, `from numpy.random import Generator`, `import numpy as np`: a
197
+ generic's name written bare (`Generator`) or through one (`np.random.Generator`) is then
198
+ that, not the generic missing its arguments (see `annotations.is_vague`).
199
+
200
+ Returns:
201
+ Them.
202
+
203
+ """
204
+ found: set[str] = {
205
+ name
206
+ for name in self.defined.keys() - self.generic
207
+ if name not in self.bound
208
+ or not {self.bound[name], self.bound[name].rpartition(_DOT)[0]} & GENERIC_HOMES
209
+ }
210
+ name: str
211
+ statement: str
212
+ for name, statement in self.added.items():
213
+ if statement.split()[1] not in GENERIC_HOMES:
214
+ found.add(name)
215
+ each: Guarded
216
+ for name, each in self.guarded.items():
217
+ if each.origin[0] not in GENERIC_HOMES:
218
+ found.add(name)
219
+ return frozenset(found)
220
+
186
221
  def type_checking(self) -> str | None:
187
222
  """Name `typing.TYPE_CHECKING` for a new block, by an import that runs: the module reads it.
188
223
 
224
+ The block goes after `after`, so through an import the module has only where it binds the
225
+ name by then: an `import typing` below a `try:` of other imports isn't, and a new import
226
+ goes with the block.
227
+
189
228
  Returns:
190
229
  The name, or `None` if every way to write it is taken.
191
230
 
192
231
  """
193
- return self.spell("typing.TYPE_CHECKING", runs=True)
232
+ qualified: str = "typing.TYPE_CHECKING"
233
+ found: str | None = self.named(qualified)
234
+ if found is not None and self.defined.get(found.partition(_DOT)[0], self.after + 1) <= self.after:
235
+ return found
236
+ return self._added(qualified, runs=True)
194
237
 
195
238
  def spell(self, qualified: str, *, runs: bool = False) -> str | None:
196
239
  """Name `qualified` (`io.BufferedReader`) in this module, adding an import if it has to.
197
240
 
198
241
  Through an import it has (see `named`), for type checking alone too (`checking`, or one
199
- `guarded` already), else a new `from io import BufferedReader`, else a new `import io`, but
200
- only binding a name nothing in the module binds. Under `if TYPE_CHECKING:` for a module in
201
- `checking.lazy`, and not at all for one only some platforms have. With `runs`, only through
242
+ `guarded` already), else a new `from io import BufferedReader`, else a new `import io`
243
+ (`import collections.abc`, binding `collections`, unless the module imports the name or
244
+ defines a class of it), but only binding a name nothing in the module binds. Under
245
+ `if TYPE_CHECKING:` for a module in `checking.lazy`, and not at all for one only some
246
+ platforms have. With `runs`, only through
202
247
  an import that runs: the name is read as the module is imported (`TYPE_CHECKING` itself).
203
248
 
204
249
  Returns:
@@ -208,6 +253,15 @@ class ImportPlan: # pylint: disable=too-many-instance-attributes
208
253
  found: str | None
209
254
  if (found := self.named(qualified) or (None if runs else self._checked(qualified))) is not None:
210
255
  return found
256
+ return self._added(qualified, runs=runs)
257
+
258
+ def _added(self, qualified: str, *, runs: bool) -> str | None:
259
+ """Name `qualified` by an import to add, as `spell` does past the module's own.
260
+
261
+ Returns:
262
+ The name, or `None` if every way to write it is taken.
263
+
264
+ """
211
265
  module: str
212
266
  name: str
213
267
  module, _, name = qualified.rpartition(".")
@@ -220,9 +274,14 @@ class ImportPlan: # pylint: disable=too-many-instance-attributes
220
274
  if self._free(name, statement):
221
275
  self.added[name] = statement
222
276
  return name
223
- statement = f"import {module}"
224
- if _DOT not in module and self._free(module, statement):
225
- self.added[module] = statement
277
+ statement = f"import {module}" # `import collections.abc` binds `collections`
278
+ # What the module imports or defines a class of the name as, anywhere (`from distutils.core
279
+ # import Distribution`, `_collections_abc`'s own `Iterator`), is the one meant, or the same:
280
+ # no new import beside it, past a module of one name.
281
+ if _DOT in module and name in self.taken and name not in self.values:
282
+ return None
283
+ if self._free(top, statement):
284
+ self.added[top] = statement
226
285
  return qualified
227
286
  return None
228
287
 
@@ -310,7 +369,8 @@ class LibraryNames(NamedTuple):
310
369
  every Python the module runs on parses an unpacked tuple in a subscript (`tuple[int, *Ts]`,
311
370
  3.11's syntax), so an annotation may be written with one. `aliased`: whether `typing` has
312
371
  `TypeAlias` on every Python the module runs on (3.10's; yes where that isn't known), so a new
313
- import may name it.
372
+ import may name it. `written`: the installed and checked files' classes it names, none generic,
373
+ which a docstring's return may name (see `Lineages.written`).
314
374
  """
315
375
 
316
376
  casts: frozenset[str] = frozenset()
@@ -323,6 +383,7 @@ class LibraryNames(NamedTuple):
323
383
  aliases: Mapping[str, Expansion] = MappingProxyType({})
324
384
  starred: bool = False
325
385
  aliased: bool = True
386
+ written: frozenset[str] = frozenset()
326
387
 
327
388
 
328
389
  class Returned(NamedTuple):
@@ -579,9 +640,12 @@ class Outside: # pylint: disable=too-many-instance-attributes
579
640
  classes: Classes | None = None
580
641
  hints: tuple[Hints, ...] = () # each checker's, in the order they were named
581
642
  type_vars: frozenset[str] = frozenset()
643
+ defaulted: frozenset[str] = frozenset() # those of them with a default (see `project.defaulted`)
582
644
  returned: Returns = field(default_factory=Returns)
583
645
  guarded: Mapping[str, Guarded] = field(default_factory=dict[str, Guarded])
584
646
  generics: frozenset[str] = frozenset() # other checked files' generic classes, as it spells them
647
+ # Those of theirs a type checker reads bare with their defaults (see `Module.defaulted_generics`).
648
+ defaulted_generics: frozenset[str] = frozenset()
585
649
  # Those classes' type parameters and the members it takes of them (see `GenericClass`).
586
650
  generic: Mapping[str, GenericClass] = field(default_factory=dict[str, GenericClass])
587
651
  # Their descriptors, as it spells them, each with what an instance's attribute of it gives.
@@ -610,6 +674,8 @@ class Outside: # pylint: disable=too-many-instance-attributes
610
674
  installed_aliases: Mapping[str, Expansion] = field(
611
675
  default_factory=dict[str, Expansion],
612
676
  )
677
+ # See `LibraryNames.written`
678
+ written: frozenset[str] = frozenset()
613
679
  # The classes it imports under `if TYPE_CHECKING:` alone, which an annotation can name (see `offers.own`).
614
680
  checking: Mapping[str, Guarded] = field(default_factory=dict[str, Guarded])
615
681
  plain: frozenset[str] | None = None
@@ -4,9 +4,11 @@ Each file is checked knowing what the files it imports declare and return, the h
4
4
  checker gave it, and what the index's other readers say of the names it imports (see `Outside`).
5
5
  """
6
6
 
7
+ import re
7
8
  from collections.abc import Mapping
9
+ from dataclasses import replace
8
10
  from pathlib import Path
9
- from typing import TYPE_CHECKING
11
+ from typing import TYPE_CHECKING, Final
10
12
 
11
13
  from constricter.fix.core.known import Guarded, Hints, Origin, Outside, Passed
12
14
  from constricter.fix.index import (
@@ -38,6 +40,10 @@ if TYPE_CHECKING:
38
40
  from constricter.fix.core.signatures import ReadSignature
39
41
 
40
42
 
43
+ # A dotted name in a type (`trio.SocketListener`): a class spelled through a module.
44
+ _DOTTED: Final = re.compile(r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+")
45
+
46
+
41
47
  def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hints, ...]]) -> Outside:
42
48
  """Find what's known of a file from outside it: what it imports from the others, and its hints.
43
49
 
@@ -60,17 +66,17 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
60
66
  hints: tuple[Hints, ...] = offers.vetted(modules, path, hinted.get(path, ()))
61
67
  # What only a hint can name: the classes the file imports for type checking alone.
62
68
  own: offers.Own = offers.own(modules, path) if hints else offers.Own()
69
+ lineage: own_types.Lineages = own_types.lineages(modules, path, guarded, methods.lineage)
63
70
  overloaded: dict[str, tuple[ReadSignature, ...]] = {
64
71
  **stubbed.overloaded(modules, path, guarded),
65
72
  **methods.signatures,
66
73
  **own_methods.overloaded(modules, path, guarded),
67
74
  }
68
- return Outside(
75
+ found: Outside = Outside(
69
76
  calls={**imported.calls, **side_calls, **decorated.own(modules, path)},
70
- # With those of the classes the overloaded methods' returns import.
71
- classes=crossed.guarded_classes(modules, path, imported, guarded),
72
77
  hints=hints,
73
78
  type_vars=project.type_vars(modules, path),
79
+ defaulted=project.defaulted(modules, path),
74
80
  returned=awaits.returned(
75
81
  modules,
76
82
  path,
@@ -79,6 +85,7 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
79
85
  ),
80
86
  guarded=guarded,
81
87
  generics=imported.generics | own.generics,
88
+ defaulted_generics=imported.defaulted_generics,
82
89
  generic=dict(imported.generic),
83
90
  descriptors=dict(imported.descriptors),
84
91
  callees=callers.callees(modules, path),
@@ -88,7 +95,8 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
88
95
  installed_classes=stubbed.classes(modules, path),
89
96
  read_members=crossed.member_names(modules, path),
90
97
  installed_parameters=methods.parameters,
91
- installed_lineage=own_types.lineages(modules, path, guarded, methods.lineage),
98
+ installed_lineage=lineage,
99
+ written=lineage.written(),
92
100
  installed_aliases=methods.aliases,
93
101
  checking=own.guarded,
94
102
  plain=plain.classes(modules, path),
@@ -109,6 +117,26 @@ def outside(modules: project.Index, path: Path, hinted: Mapping[Path, tuple[Hint
109
117
  emptied=beyond.emptied(modules, path),
110
118
  factories=factories(modules, path),
111
119
  )
120
+ # With those of the classes the types above import for type checking (an overloaded method's
121
+ # return, an awaited call's, an item's): read last, as each of them adds to `guarded`.
122
+ return replace(found, classes=crossed.guarded_classes(modules, path, imported, guarded, _spelled(found)))
123
+
124
+
125
+ def _spelled(found: Outside) -> frozenset[str]:
126
+ """Find the dotted names in the types found of a file from outside it: `trio.SocketListener`.
127
+
128
+ Returns:
129
+ Them.
130
+
131
+ """
132
+ types: list[str] = [
133
+ *found.calls.values(),
134
+ *found.awaits.values(),
135
+ *found.tuples.values(),
136
+ *found.unions.values(),
137
+ *found.returned.calls.values(),
138
+ ]
139
+ return frozenset(match.group() for each in types for match in _DOTTED.finditer(each))
112
140
 
113
141
 
114
142
  def factories(modules: project.Index, path: Path) -> frozenset[str]:
@@ -7,13 +7,23 @@ only where every name in a type means the same in the file as where it was writt
7
7
  `spelling.portable`).
8
8
  """
9
9
 
10
- from collections.abc import Iterator, Mapping
10
+ import ast
11
+ from collections.abc import Iterable, Iterator, Mapping
11
12
  from pathlib import Path
12
13
  from typing import Final, NamedTuple, TypeAlias
13
14
 
14
15
  from constricter.fix.core.known import Classes, Guarded, Origin, Partial, Returns
15
16
  from constricter.fix.index.modules import Index, Memo, Module, classes_under, exported, module_at
16
- from constricter.fix.index.project import BUILTINS, CLASS, HOPS, NO_TYPE, PARTIAL, Imported, definition
17
+ from constricter.fix.index.project import (
18
+ BUILTINS,
19
+ CLASS,
20
+ HOPS,
21
+ NO_TYPE,
22
+ PARTIAL,
23
+ Imported,
24
+ definition,
25
+ starred_names,
26
+ )
17
27
  from constricter.fix.index.spelling import (
18
28
  calls,
19
29
  generic_portable,
@@ -62,7 +72,15 @@ def imported(
62
72
  checking: dict[str, Origin] = {
63
73
  name: found.origin for name, found in guarded.items() if name not in target.names
64
74
  }
75
+ # What it imports for type checking alone, `if TYPE_CHECKING:`: a parameter annotated with one is one.
76
+ own: dict[str, Origin] = {
77
+ name: origin
78
+ for name, origin in target.guarded.items()
79
+ if name not in target.names and name not in checking and origin[0] != target.name
80
+ }
65
81
  _take(catalog, target, (checking, attributes), taken, guarded)
82
+ if own:
83
+ _take(catalog, target, (own, attributes), taken, guarded)
66
84
  _take(catalog, target, (target.names, attributes), taken, guarded)
67
85
  found: tuple[dict[str, str], Returns, dict[str, str]] = (
68
86
  calls(catalog, path, guarded),
@@ -85,6 +103,7 @@ def imported(
85
103
  Partial(found[2], {name: each for name, each in taken.partial.items() if each}),
86
104
  taken.generic,
87
105
  taken.descriptors,
106
+ frozenset(taken.defaulted_generics),
88
107
  )
89
108
 
90
109
 
@@ -127,12 +146,21 @@ def _bound_members(module: Module, name: str, seen: set[str]) -> frozenset[str]:
127
146
  )
128
147
 
129
148
 
130
- def guarded_classes(catalog: Index, path: Path, found: Imported, guarded: Mapping[str, Guarded]) -> Classes:
149
+ def guarded_classes(
150
+ catalog: Index,
151
+ path: Path,
152
+ found: Imported,
153
+ guarded: Mapping[str, Guarded],
154
+ spelled: frozenset[str],
155
+ ) -> Classes:
131
156
  """Add the members of the classes `guarded` names that `found` hasn't taken, as the file names them.
132
157
 
133
158
  Those of the imports later steps add for type checking (an overloaded method's return):
134
159
  `df = pd.DataFrame(...).set_index("C")`, typed `DataFrame` by its import: `df.explode()` is one of
135
- that class's methods in the same pass, not only once the import is written.
160
+ that class's methods in the same pass, not only once the import is written. And those `spelled`
161
+ names through a module the file imports, as the types those steps give write them
162
+ (`trio.SocketListener`, which `trio` re-exports): the file reads no such attribute until a fix
163
+ writes the type, so `found` took none of them.
136
164
 
137
165
  Returns:
138
166
  `found`'s classes, with theirs.
@@ -146,13 +174,24 @@ def guarded_classes(catalog: Index, path: Path, found: Imported, guarded: Mappin
146
174
  for name, each in guarded.items()
147
175
  if name not in found.guarded and name not in target.names and name not in found.classes.methods
148
176
  }
149
- if not fresh:
177
+ through: dict[str, Origin] = {}
178
+ attributes: set[str] = set()
179
+ each: str
180
+ for each in spelled:
181
+ root: str = each.partition(".")[0]
182
+ if each not in found.classes.methods and root in target.names:
183
+ through[root] = target.names[root]
184
+ attributes.add(each.rpartition(".")[2])
185
+ if not fresh and not through:
150
186
  return found.classes
151
187
  taken: _Taken = _taking(
152
188
  {key: dict(each) for key, each in found.classes.attributes.items()},
153
189
  {key: dict(each) for key, each in found.classes.methods.items()},
154
190
  )
155
- _take(catalog, target, (fresh, None), taken, dict(guarded))
191
+ if fresh:
192
+ _take(catalog, target, (fresh, None), taken, dict(guarded))
193
+ if through:
194
+ _take(catalog, target, (through, frozenset(attributes)), taken, dict(guarded))
156
195
  return Classes(taken.attributes, taken.methods)
157
196
 
158
197
 
@@ -166,6 +205,7 @@ class _Taken(NamedTuple):
166
205
  generics: set[str]
167
206
  generic: dict[str, GenericClass]
168
207
  descriptors: dict[str, str | None]
208
+ defaulted_generics: set[str]
169
209
 
170
210
 
171
211
  def _taking(attributes: dict[str, dict[str, str]], methods: dict[str, dict[str, str]]) -> _Taken:
@@ -175,7 +215,7 @@ def _taking(attributes: dict[str, dict[str, str]], methods: dict[str, dict[str,
175
215
  It, nothing else taken yet.
176
216
 
177
217
  """
178
- return _Taken(attributes, methods, {}, {}, set(), {}, {})
218
+ return _Taken(attributes, methods, {}, {}, set(), {}, {}, set())
179
219
 
180
220
 
181
221
  def _take(catalog: Index, target: Module, named: _Named, taken: _Taken, guarded: dict[str, Guarded]) -> None:
@@ -185,6 +225,8 @@ def _take(catalog: Index, target: Module, named: _Named, taken: _Taken, guarded:
185
225
  for key, defined in spelled_classes(catalog, target, named, taken.generics):
186
226
  if defined[1] in defined[0].generics:
187
227
  taken.generics.add(key)
228
+ if defined[1] in defined[0].defaulted_generics:
229
+ taken.defaulted_generics.add(key)
188
230
  if defined[1] in defined[0].generic_members:
189
231
  klass: GenericClass = defined[0].generic_members[defined[1]]
190
232
  taken.generic[key] = generic_portable(catalog.modules, (target, defined), key, klass, guarded)
@@ -210,6 +252,35 @@ def _take(catalog: Index, target: Module, named: _Named, taken: _Taken, guarded:
210
252
  )
211
253
 
212
254
 
255
+ def reads_imported(catalog: Index, path: Path, statements: Iterable[str]) -> bool:
256
+ """Check whether a file reads a member of a checked file's class that `statements` import into it.
257
+
258
+ It took none of the class's members while it didn't spell the class (see `_take`).
259
+
260
+ Returns:
261
+ Whether it does; not for a file `catalog` doesn't have.
262
+
263
+ """
264
+ target: Module | None
265
+ if (target := module_at(catalog, path)) is None:
266
+ return False
267
+ statement: str
268
+ for statement in statements:
269
+ node: ast.stmt = ast.parse(statement).body[0]
270
+ names: list[ast.alias] = node.names if isinstance(node, ast.ImportFrom) else []
271
+ module: str = node.module or "" if isinstance(node, ast.ImportFrom) else ""
272
+ alias: ast.alias
273
+ for alias in names:
274
+ defined: tuple[Module, str] | None = definition(catalog.modules, (module, alias.name), CLASS)
275
+ if (
276
+ defined is not None
277
+ and not defined[0].installed
278
+ and not _declared_members(catalog, defined).isdisjoint(target.attributes)
279
+ ):
280
+ return True
281
+ return False
282
+
283
+
213
284
  def _written(spelled: str, attributes: frozenset[str]) -> bool:
214
285
  """Check whether a file can write a class as `spelled`: each part after its first an attribute it takes.
215
286
 
@@ -343,7 +414,9 @@ def _spelled_classes(
343
414
  *classes_under(catalog, local, origin[0], CLASS),
344
415
  *(
345
416
  (f"{local}.{name}", where)
346
- for name, where in (() if package is None else exported(package).items())
417
+ for name, where in (
418
+ () if package is None else _reexported(catalog.modules, package).items()
419
+ )
347
420
  if (named[1] is None or name in named[1])
348
421
  and where[1] is not None
349
422
  and where[0] != origin[0]
@@ -358,6 +431,16 @@ def _spelled_classes(
358
431
  yield key, defined
359
432
 
360
433
 
434
+ def _reexported(modules: Mapping[str, Module], module: Module) -> Mapping[str, Origin]:
435
+ """Name what a module imports from others, by name or everything (see `exported`, `starred_names`).
436
+
437
+ Returns:
438
+ Each, by its name there.
439
+
440
+ """
441
+ return {**starred_names(modules, module), **exported(module)} if module.stars else exported(module)
442
+
443
+
361
444
  def _reexported_generics(modules: Mapping[str, Module], local: str, name: str) -> Iterator[str]:
362
445
  """Spell the generic classes module `name` re-exports (`from ._c import OrderedSet`) as `local.C`.
363
446
 
@@ -371,6 +454,6 @@ def _reexported_generics(modules: Mapping[str, Module], local: str, name: str) -
371
454
  module: Module | None = modules.get(name)
372
455
  reexported: str
373
456
  origin: Origin
374
- for reexported, origin in () if module is None else exported(module).items():
457
+ for reexported, origin in () if module is None else _reexported(modules, module).items():
375
458
  if origin[1] is not None and origin[0] != name and is_generic(modules, origin):
376
459
  yield f"{local}.{reexported}"
@@ -48,10 +48,13 @@ def same(catalog: Index, path: Path, guarded: Mapping[str, Guarded]) -> tuple[fr
48
48
 
49
49
 
50
50
  def values(catalog: Index, path: Path) -> frozenset[str]:
51
- """Find the names the file at `path` imports that another checked module binds by assignment.
51
+ """Find the names the file at `path` imports that another module binds as a value, not a class.
52
52
 
53
- `F`, by `F = duckdb.FunctionExpression` there: a value, whatever its name looks like, and a call
54
- of it constructs nothing a type can be written for.
53
+ `F`, by `F = duckdb.FunctionExpression` in another checked module (its `assigned`): a value,
54
+ whatever its name looks like, and a call of it constructs nothing a type can be written for. And
55
+ `Observer`, by `Observer = _get_observer_cls()` in an installed module, or bound there in more than
56
+ one arm of an `if` (its `variables`): a variable to a checker, whatever it holds at run time.
57
+ Imported at the file's top level, or inside a function for its own use (`inner`).
55
58
 
56
59
  Returns:
57
60
  Them, as the file binds them; none for a file `catalog` doesn't have.
@@ -63,10 +66,12 @@ def values(catalog: Index, path: Path) -> frozenset[str]:
63
66
  found: set[str] = set()
64
67
  local: str
65
68
  origin: Origin
66
- for local, origin in target.names.items():
69
+ for local, origin in {**target.inner, **target.names}.items():
67
70
  where: Origin = spelling.canonical_origin(catalog.modules, origin)
68
71
  defining: Module | None = catalog.modules.get(where[0])
69
- if defining is not None and where[1] in defining.assigned and where[0] != target.name:
72
+ if defining is None or where[0] == target.name:
73
+ continue
74
+ if where[1] in (defining.variables if defining.installed else defining.assigned):
70
75
  found.add(local)
71
76
  return frozenset(found)
72
77