python-constricter 0.3.1__py3-none-any.whl → 0.3.2__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 (88) hide show
  1. constricter/__init__.py +1 -1
  2. constricter/cli/command.py +46 -13
  3. constricter/cli/config.py +64 -11
  4. constricter/cli/edits.py +188 -0
  5. constricter/cli/explain.py +2 -1
  6. constricter/cli/hints.py +122 -29
  7. constricter/cli/options.py +60 -3
  8. constricter/cli/protocol.py +6 -1
  9. constricter/cli/report.py +1 -1
  10. constricter/cli/runs.py +1 -1
  11. constricter/cli/schedule.py +19 -7
  12. constricter/cli/workers.py +2 -2
  13. constricter/fix/core/__init__.py +2 -0
  14. constricter/fix/{fixes.py → core/fixes.py} +27 -21
  15. constricter/fix/{imports.py → core/imports.py} +58 -4
  16. constricter/fix/core/inherited.py +197 -0
  17. constricter/fix/{known.py → core/known.py} +145 -26
  18. constricter/fix/{signatures.py → core/signatures.py} +7 -7
  19. constricter/fix/index/__init__.py +2 -0
  20. constricter/fix/{atoms.py → index/atoms.py} +7 -7
  21. constricter/fix/{callers.py → index/callers.py} +3 -3
  22. constricter/fix/{classnames.py → index/classnames.py} +5 -5
  23. constricter/fix/{declared.py → index/declared.py} +1 -1
  24. constricter/fix/index/decorated.py +119 -0
  25. constricter/fix/{installed.py → index/installed.py} +2 -2
  26. constricter/fix/{modules.py → index/modules.py} +156 -10
  27. constricter/fix/index/offers.py +135 -0
  28. constricter/fix/{order.py → index/order.py} +2 -2
  29. constricter/fix/index/plain.py +120 -0
  30. constricter/fix/{project.py → index/project.py} +268 -81
  31. constricter/fix/index/sides.py +65 -0
  32. constricter/fix/{stubbed.py → index/stubbed.py} +25 -21
  33. constricter/fix/index/tuples.py +86 -0
  34. constricter/fix/libraries/__init__.py +2 -0
  35. constricter/fix/{library.py → libraries/library.py} +40 -32
  36. constricter/fix/{opened.py → libraries/opened.py} +2 -2
  37. constricter/fix/{overloads.py → libraries/overloads.py} +3 -3
  38. constricter/fix/{stdlib.py → libraries/stdlib.py} +90 -8
  39. constricter/fix/tables/functions.json +44 -0
  40. constricter/fix/tables/method_overloads.json +96 -35
  41. constricter/fix/tables/method_signatures.json +52 -0
  42. constricter/fix/tables/methods.json +157 -85
  43. constricter/fix/tables/overloads.json +18 -5
  44. constricter/fix/tables/returns.json +34 -0
  45. constricter/fix/tables/source.json +2 -1
  46. constricter/fix/values/__init__.py +2 -0
  47. constricter/fix/values/aliased.py +182 -0
  48. constricter/fix/values/called.py +108 -0
  49. constricter/fix/values/classvars.py +365 -0
  50. constricter/fix/values/decided.py +333 -0
  51. constricter/fix/{doubts.py → values/doubts.py} +175 -27
  52. constricter/fix/values/entered.py +152 -0
  53. constricter/fix/{fills.py → values/fills.py} +46 -18
  54. constricter/fix/{guesses.py → values/guesses.py} +74 -41
  55. constricter/fix/values/hinted.py +558 -0
  56. constricter/fix/{inference.py → values/inference.py} +298 -106
  57. constricter/fix/{members.py → values/members.py} +73 -10
  58. constricter/fix/{returned.py → values/returned.py} +18 -15
  59. constricter/fix/{returns.py → values/returns.py} +1 -1
  60. constricter/fix/values/shapes.py +279 -0
  61. constricter/fix/values/targets.py +350 -0
  62. constricter/notebook.py +1 -1
  63. constricter/offences.py +27 -3
  64. constricter/plugins/flake8.py +11 -1
  65. constricter/plugins/pylint.py +11 -0
  66. constricter/rules/annotations.py +298 -74
  67. constricter/rules/binding.py +239 -33
  68. constricter/rules/calls.py +79 -27
  69. constricter/rules/checker.py +121 -98
  70. constricter/rules/decorators.py +209 -0
  71. constricter/rules/flow.py +18 -3
  72. constricter/rules/late.py +59 -24
  73. constricter/rules/parsed.py +2 -2
  74. constricter/rules/quoted.py +88 -0
  75. constricter/rules/rebinding.py +1 -1
  76. constricter/rules/recorded.py +124 -0
  77. constricter/rules/scope.py +231 -78
  78. constricter/rules/syntax.py +123 -5
  79. constricter/rules/tables.py +99 -0
  80. {python_constricter-0.3.1.dist-info → python_constricter-0.3.2.dist-info}/METADATA +16 -8
  81. python_constricter-0.3.2.dist-info/RECORD +110 -0
  82. constricter/fix/hinted.py +0 -242
  83. constricter/fix/targets.py +0 -184
  84. python_constricter-0.3.1.dist-info/RECORD +0 -88
  85. /constricter/fix/{narrowed.py → values/narrowed.py} +0 -0
  86. {python_constricter-0.3.1.dist-info → python_constricter-0.3.2.dist-info}/WHEEL +0 -0
  87. {python_constricter-0.3.1.dist-info → python_constricter-0.3.2.dist-info}/entry_points.txt +0 -0
  88. {python_constricter-0.3.1.dist-info → python_constricter-0.3.2.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py CHANGED
@@ -25,7 +25,7 @@ from constricter.offences import (
25
25
  )
26
26
  from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
27
27
 
28
- __version__ = "0.3.1"
28
+ __version__ = "0.3.2"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -23,8 +23,9 @@ 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 callers, fixes, installed, project
27
- from constricter.fix.known import Callee, Hints, Outside, Returns
26
+ from constricter.fix.core import fixes
27
+ from constricter.fix.core.known import Callee, Hints, Outside, Returns
28
+ from constricter.fix.index import callers, decorated, installed, plain, project
28
29
  from constricter.noqa import lines, unsuppressed
29
30
  from constricter.offences import (
30
31
  DEFAULT_CHECKS,
@@ -397,6 +398,11 @@ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
397
398
  for index, run in zip(again, redone, strict=True):
398
399
  runs[index] = _merged(runs[index], run)
399
400
  again = [index for index, run in zip(again, redone, strict=True) if cast("CheckRun", run).fixed]
401
+ checker: str
402
+ path: Path
403
+ for checker, path in session.abandoned:
404
+ name: Path = options.input.name(path)
405
+ _ = sys.stderr.write(f"constricter: warning: {checker} hung on {name} twice: no hints for it\n")
400
406
  return names, runs
401
407
 
402
408
 
@@ -430,16 +436,40 @@ def _called_again(
430
436
  if not (again := [at for at, name in enumerate(names) if name in found and names.count(name) == 1]):
431
437
  return runs, modules
432
438
  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
439
+ before: project.Index = callers.with_parameters(modules, found)
440
+ modules = _checked_more(paths, runs, again, (check, options, session), before)
441
+ # A caller is checked again only if a function it imports now returns something else.
442
+ calling = [
443
+ at
444
+ for at in calling
445
+ if project.returned(modules, paths[at], {}) != project.returned(before, paths[at], {})
446
+ ]
447
+ return runs, _checked_more(paths, runs, calling, (check, options, session), modules)
448
+
449
+
450
+ def _checked_more(
451
+ paths: Sequence[Path],
452
+ runs: list[FileRun],
453
+ which: Sequence[int],
454
+ how: "tuple[Callable[[Path, Outside], FileRun], Options, hints.Session | None]",
455
+ modules: project.Index,
456
+ ) -> project.Index:
457
+ """Check the files of `paths` at `which` again, knowing `modules`, into `runs`.
458
+
459
+ `how`: the check, the options and the type checkers' session. With `--fix` a file's rounds are
460
+ joined; otherwise the later one stands.
461
+
462
+ Returns:
463
+ The index, with what their functions now return.
464
+
465
+ """
466
+ redone: list[FileRun]
467
+ redone, modules = _checked_all([paths[at] for at in which], how[0], how[1], how[2], modules)
468
+ at: int
469
+ run: FileRun
470
+ for at, run in zip(which, redone, strict=True):
471
+ runs[at] = _merged(runs[at], run) if how[1].mode is Mode.FIX else run
472
+ return modules
443
473
 
444
474
 
445
475
  def _calling(
@@ -487,7 +517,10 @@ def _checked_all(
487
517
  modules = project.Index({}, []) if coverage else project.index(paths)
488
518
  collecting.indexed()
489
519
  if not coverage:
490
- modules = installed.with_installed(modules, installed.search_path())
520
+ modules = plain.settled(
521
+ decorated.passed(installed.with_installed(modules, installed.search_path())),
522
+ options.checks.plain_bases,
523
+ )
491
524
  return schedule.checked(
492
525
  paths,
493
526
  check,
constricter/cli/config.py CHANGED
@@ -1,11 +1,12 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  """The command's defaults, from the nearest `pyproject.toml`'s `[tool.constricter]` table."""
3
3
 
4
+ import re
4
5
  import tomllib
5
6
  from collections.abc import Callable, Sequence
6
7
  from functools import partial
7
8
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Final, TypeAlias
9
+ from typing import TYPE_CHECKING, Final, TypeAlias, cast
9
10
 
10
11
  from constricter.cli.protocol import SERVERS
11
12
  from constricter.jsonc import is_int
@@ -30,6 +31,10 @@ def unknown_codes(codes: Sequence[str]) -> list[str]:
30
31
 
31
32
 
32
33
  DEFAULT_BASELINE: Final = "constricter-baseline.json"
34
+ _MIN_PYTHON: Final = "min_python"
35
+ _VERSION: Final = re.compile(r"\d+\.\d+") # `min-python`'s: `3.11`
36
+ # A lower bound among `requires-python`'s specifiers: `>=3.11`, `~=3.11`, `==3.11.*`, `>3.10`.
37
+ _LOWER_BOUND: Final = re.compile(r"(?:>=|~=|==|>)\s*(\d+)(?:\.(\d+))?")
33
38
 
34
39
 
35
40
  def project_root(start: Path) -> Path:
@@ -58,36 +63,65 @@ def _pyproject(start: Path) -> Path | None:
58
63
  return None
59
64
 
60
65
 
61
- def _table(path: Path) -> dict[str, _Toml]:
62
- """Return `path`'s `[tool.constricter]` table, or an empty one.
66
+ def _document(path: Path) -> dict[str, _Toml]:
67
+ """Read `path`, a `pyproject.toml`.
63
68
 
64
69
  Returns:
65
- The table's keys and values, as TOML parsed them.
70
+ Its tables, as TOML parsed them.
66
71
 
67
72
  Raises:
68
- ValueError: The file isn't TOML, or `tool.constricter` isn't a table.
73
+ ValueError: The file isn't TOML.
69
74
 
70
75
  """
71
76
  file: BufferedReader
72
- document: dict[str, _Toml]
73
- message: str
74
77
  with path.open("rb") as file:
75
78
  try:
76
- document = tomllib.load(file)
79
+ return tomllib.load(file)
77
80
  except tomllib.TOMLDecodeError as error:
78
- message = f"{path}: {error}"
81
+ message: str = f"{path}: {error}"
79
82
  raise ValueError(message) from error
83
+
84
+
85
+ def _table(path: Path, document: dict[str, _Toml]) -> dict[str, _Toml]:
86
+ """Return the `[tool.constricter]` table of `document` (`path`'s), or an empty one.
87
+
88
+ Returns:
89
+ The table's keys and values, as TOML parsed them.
90
+
91
+ Raises:
92
+ ValueError: `tool.constricter` isn't a table.
93
+
94
+ """
80
95
  table: dict[str, _Toml]
81
96
  match document.get("tool"):
82
97
  case {"constricter": dict() as table}:
83
98
  return table
84
99
  case {"constricter": _}:
85
- message = f"{path}: [tool.constricter] isn't a table"
100
+ message: str = f"{path}: [tool.constricter] isn't a table"
86
101
  raise ValueError(message)
87
102
  case _:
88
103
  return {}
89
104
 
90
105
 
106
+ def _required(document: dict[str, _Toml]) -> str | None:
107
+ """Read the oldest Python a project's `requires-python` allows: its highest lower bound.
108
+
109
+ Returns:
110
+ It (`3.11`, for `>=3.11,<4`), or `None` if the project doesn't say.
111
+
112
+ """
113
+ required: str
114
+ match document.get("project"):
115
+ case {"requires-python": str() as required}:
116
+ pass
117
+ case _:
118
+ return None
119
+ found: list[tuple[str, str]] = cast("list[tuple[str, str]]", _LOWER_BOUND.findall(required))
120
+ bounds: list[tuple[int, int]] = [(int(major), int(minor or 0)) for major, minor in found]
121
+ oldest: tuple[int, int] | None = max(bounds, default=None)
122
+ return None if oldest is None else f"{oldest[0]}.{oldest[1]}"
123
+
124
+
91
125
  def _level(value: _Toml) -> str | None:
92
126
  """Read a level's name or number as the options take it.
93
127
 
@@ -135,6 +169,16 @@ def _flag(value: _Toml) -> bool | None:
135
169
  return value if isinstance(value, bool) else None
136
170
 
137
171
 
172
+ def _version(value: _Toml) -> str | None:
173
+ """Read `min-python`: a Python version, as text (`"3.11"`).
174
+
175
+ Returns:
176
+ It, or `None` for anything else.
177
+
178
+ """
179
+ return value if isinstance(value, str) and _VERSION.fullmatch(value) else None
180
+
181
+
138
182
  def _gigabytes(value: _Toml) -> float | None:
139
183
  """Read `infer-memory`: a positive number of gigabytes.
140
184
 
@@ -193,6 +237,7 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
193
237
  "level": _level,
194
238
  "nesting": partial(_whole, minimum=1),
195
239
  "max-length": partial(_whole, minimum=1),
240
+ "vague": partial(_whole, minimum=-1),
196
241
  "jobs": partial(_whole, minimum=0),
197
242
  "exclude": _strings,
198
243
  "select": _codes,
@@ -201,6 +246,7 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
201
246
  "fix-select": _fix_kinds,
202
247
  "fix-ignore": _fix_kinds,
203
248
  "unsafe-fix-select": _fix_kinds,
249
+ "fix-plain-bases": _strings,
204
250
  "type-comments": _flag,
205
251
  "all-scopes": _flag,
206
252
  "per-path-levels": _levels,
@@ -208,12 +254,15 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
208
254
  "narrower": partial(_lists, read=_strings), # a type hierarchy: each type, and those it's narrower than
209
255
  "infer-with": _checkers,
210
256
  "infer-memory": _gigabytes,
257
+ "min-python": _version,
211
258
  }
212
259
 
213
260
 
214
261
  def config_defaults(start: Path) -> dict[str, Default]:
215
262
  """Return the option defaults in the nearest `pyproject.toml`'s `[tool.constricter]`.
216
263
 
264
+ And `min-python`'s from the project's `requires-python`, where the table doesn't set it.
265
+
217
266
  Returns:
218
267
  Each option's default, keyed by its `argparse` name (`type_comments`, not `type-comments`).
219
268
 
@@ -231,10 +280,14 @@ def config_defaults(start: Path) -> dict[str, Default]:
231
280
  defaults: dict[str, Default] = {}
232
281
  key: str
233
282
  value: _Toml
234
- for key, value in _table(path).items():
283
+ document: dict[str, _Toml] = _document(path)
284
+ for key, value in _table(path, document).items():
235
285
  default: Default | None
236
286
  if key not in readers or (default := readers[key](value)) is None:
237
287
  message: str = f"{path}: [tool.constricter] has an invalid {key} = {value!r}"
238
288
  raise ValueError(message)
239
289
  defaults[key.replace("-", "_")] = default
290
+ required: str | None
291
+ if _MIN_PYTHON not in defaults and (required := _required(document)) is not None:
292
+ defaults[_MIN_PYTHON] = required
240
293
  return defaults
@@ -0,0 +1,188 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--infer-with`: what a hint's own edits would write (see `Offered`).
3
+
4
+ An editor that accepts an inlay hint applies its `textEdits`: the annotation, spelled as the file can
5
+ write it, and the imports its names take. An import comes as a statement to add
6
+ (`from shapes import Shape`), or as names inserted into a `from` import the file has (`, Shape`).
7
+
8
+ A hint with no edits (pyrefly's, for a loop's or an unpacking's names) may say where each class in
9
+ its label is defined instead: the import is from the module that file is.
10
+ """
11
+
12
+ import ast
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Final, TypeAlias, cast
17
+ from urllib.parse import urlsplit
18
+ from urllib.request import url2pathname
19
+
20
+ from constricter.cli.protocol import Json, Object
21
+ from constricter.fix.core.known import Offered
22
+ from constricter.fix.index.modules import SUFFIX, module_name
23
+ from constricter.rules import parsed
24
+
25
+ _Where: TypeAlias = tuple[int, int] # a line (from 1) and a UTF-8 byte column, as `ast` counts
26
+ _Locate: TypeAlias = Callable[[Object], _Where | None] # a position's, or `None` past the file's end
27
+ _SITE: Final = "site-packages"
28
+ _BUNDLED: Final = "pyrefly_bundled_typeshed" # how the folders pyrefly unpacks its stubs into start
29
+ _STUBS: Final = "-stubs" # a stub package's folder, after the package's name
30
+ _PACKAGE: Final = "__init__"
31
+ _BUILTINS: Final = "builtins"
32
+
33
+
34
+ @dataclass
35
+ class FromImports:
36
+ """A file's `from` imports (`found`), read from its `lines` when one is first looked for."""
37
+
38
+ lines: Sequence[str]
39
+ found: list[ast.ImportFrom] | None = None
40
+
41
+ def at(self, where: _Where) -> ast.ImportFrom | None:
42
+ """Find the `from` import `where` is in.
43
+
44
+ Returns:
45
+ It, or `None` (always, for a file that doesn't parse).
46
+
47
+ """
48
+ if self.found is None:
49
+ self.found = _from_imports(self.lines)
50
+ return next(
51
+ (
52
+ node
53
+ for node in self.found
54
+ if (node.lineno, node.col_offset)
55
+ <= where
56
+ <= (node.end_lineno or node.lineno, node.end_col_offset or 0)
57
+ ),
58
+ None,
59
+ )
60
+
61
+
62
+ def _from_imports(lines: Sequence[str]) -> list[ast.ImportFrom]:
63
+ """Read a file's `from` imports, wherever they are.
64
+
65
+ Returns:
66
+ Them; none for a file that doesn't parse.
67
+
68
+ """
69
+ try:
70
+ tree: ast.Module = parsed.parse("\n".join(lines))
71
+ except (SyntaxError, ValueError): # a null byte is a `ValueError` before Python 3.12
72
+ return []
73
+ return [node for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)]
74
+
75
+
76
+ def offered(hint: Object, label: str, locate: _Locate, existing: FromImports) -> Offered | None:
77
+ """Read what a variable-type hint (showing `label`) would write: its annotation and its imports.
78
+
79
+ Returns:
80
+ Them; `None` for a hint whose edits say no more than its label, one with an edit that is
81
+ neither, or one with no edits whose label doesn't say where a class in it is defined.
82
+
83
+ """
84
+ edits: list[Json]
85
+ if not (edits := cast("list[Json]", hint.get("textEdits") or [])):
86
+ located: tuple[str, ...] = _located(hint.get("label"))
87
+ return Offered(label, located) if located else None
88
+ text: str = ""
89
+ imports: list[str] = []
90
+ edit: Json
91
+ for edit in edits:
92
+ new: str = str(cast("Object", edit).get("newText", ""))
93
+ start: Object = cast("Object", cast("Object", cast("Object", edit)["range"])["start"])
94
+ if start == hint["position"] and new.startswith(":"):
95
+ text = new[1:].strip()
96
+ continue
97
+ found: list[str] | None
98
+ if (found := _imports(new.strip(), start, locate, existing)) is None:
99
+ return None
100
+ imports += found
101
+ return Offered(text, tuple(imports)) if text and (imports or text != label) else None
102
+
103
+
104
+ def _located(label: Json) -> tuple[str, ...]:
105
+ """Read the imports a label's parts take: each class a part names, from the module its file is.
106
+
107
+ Returns:
108
+ A statement for each class whose defining file's module can be named, once; not a builtin's.
109
+
110
+ """
111
+ found: dict[str, None] = {}
112
+ part: Json
113
+ for part in label if isinstance(label, list) else []:
114
+ name: str = str(cast("Object", part).get("value", ""))
115
+ location: Json = cast("Object", part).get("location")
116
+ if location is None or not name.isidentifier():
117
+ continue
118
+ uri: str = str(cast("Object", location)["uri"])
119
+ module: str | None = _defined_in(Path(url2pathname(urlsplit(uri).path)))
120
+ if module and module != _BUILTINS:
121
+ found[f"from {module} import {name}"] = None
122
+ return tuple(found)
123
+
124
+
125
+ def _defined_in(path: Path) -> str | None:
126
+ """Name the module a file is: an installed one, one of pyrefly's own stubs, or a project's.
127
+
128
+ Returns:
129
+ It, by the folders after `site-packages` or pyrefly's stubs' folder, else by its package
130
+ folders as the project index names it; `None` for a stub anywhere else, or no file at all.
131
+
132
+ """
133
+ if not path.name: # a location that isn't a file's (`untitled:`)
134
+ return None
135
+ parts: tuple[str, ...] = path.with_suffix("").parts
136
+ index: int
137
+ part: str
138
+ for index, part in enumerate(parts[:-1]):
139
+ if part == _SITE or part.startswith(_BUNDLED):
140
+ after: int = index + 2
141
+ inside: list[str] = [parts[index + 1].removesuffix(_STUBS), *parts[after:]]
142
+ return ".".join(inside[:-1] if inside[-1] == _PACKAGE and len(inside) > 1 else inside)
143
+ return module_name(path) if path.suffix == SUFFIX else None
144
+
145
+
146
+ def _imports(new: str, start: Object, locate: _Locate, existing: FromImports) -> list[str] | None:
147
+ """Read an edit that adds imports: whole statements, or names into a `from` import at `start`.
148
+
149
+ Returns:
150
+ Each name's import, as a statement of its own; `None` for any other edit.
151
+
152
+ """
153
+ try:
154
+ body: list[ast.stmt] = ast.parse(new).body
155
+ except SyntaxError: # `, Shape`: names for an import the file has
156
+ body = []
157
+ if body and all(isinstance(stmt, ast.Import | ast.ImportFrom) for stmt in body):
158
+ return [statement for stmt in body for statement in _statements(stmt)]
159
+ where: _Where | None = locate(start)
160
+ node: ast.ImportFrom | None = None if where is None else existing.at(where)
161
+ names: list[str] = [name.strip() for name in new.split(",") if name.strip()]
162
+ if node is None or not names or not all(name.isidentifier() for name in names):
163
+ return None
164
+ return [f"from {_module(node)} import {name}" for name in names]
165
+
166
+
167
+ def _statements(stmt: ast.stmt) -> list[str]:
168
+ """Write an import as one statement per name it binds.
169
+
170
+ Returns:
171
+ Them.
172
+
173
+ """
174
+ prefix: str = f"from {_module(stmt)} import " if isinstance(stmt, ast.ImportFrom) else "import "
175
+ return [
176
+ prefix + alias.name + (f" as {alias.asname}" if alias.asname else "")
177
+ for alias in cast("ast.Import | ast.ImportFrom", stmt).names
178
+ ]
179
+
180
+
181
+ def _module(node: ast.ImportFrom) -> str:
182
+ """Write the module a `from` import names.
183
+
184
+ Returns:
185
+ It, a relative one with its dots.
186
+
187
+ """
188
+ return "." * node.level + (node.module or "")
@@ -43,7 +43,8 @@ _WHY: Final = {
43
43
  ),
44
44
  VAGUE_TYPE: (
45
45
  "`Any`, `object` and generics without their parameters (`list`, `dict`) say almost nothing\n"
46
- "about the value. Name the real type: `list[str]`, a `TypedDict`, a union."
46
+ "about the value. Name the real type: `list[str]`, a `TypedDict`, a union. `vague` lets some\n"
47
+ "through: at 0 one inside a type that says the rest (`tuple[str, Any]`), at N N+1 or one alone."
47
48
  ),
48
49
  NESTED_TYPE: (
49
50
  "An annotation nested `nesting` deep (3 by default) is hard to read and to change. Name a part\n"