livery-extensions-python 0.0.0__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 (28) hide show
  1. livery/extensions/python/__init__.py +51 -0
  2. livery/extensions/python/_answers.py +127 -0
  3. livery/extensions/python/_backend.py +1532 -0
  4. livery/extensions/python/_declared.py +252 -0
  5. livery/extensions/python/_docs/index.md +165 -0
  6. livery/extensions/python/_docs/nav.toml +14 -0
  7. livery/extensions/python/_installed.py +219 -0
  8. livery/extensions/python/_parse.py +324 -0
  9. livery/extensions/python/_reaches.py +165 -0
  10. livery/extensions/python/_steps.py +71 -0
  11. livery/extensions/python/_terminal.py +118 -0
  12. livery/extensions/python/_verify.py +112 -0
  13. livery/extensions/python/content/templates/python-library/LICENSE.jinja +21 -0
  14. livery/extensions/python/content/templates/python-library/README.md.jinja +7 -0
  15. livery/extensions/python/content/templates/python-library/pyproject.toml.jinja +43 -0
  16. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__init__.py.jinja +15 -0
  17. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__main__.py.jinja +30 -0
  18. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/py.typed +0 -0
  19. livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_cli.py.jinja +39 -0
  20. livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_package.py.jinja +22 -0
  21. livery/extensions/python/content/templates/python-library/workshop.toml.jinja +10 -0
  22. livery/extensions/python/extension.toml +165 -0
  23. livery/extensions/python/py.typed +0 -0
  24. livery_extensions_python-0.0.0.dist-info/METADATA +31 -0
  25. livery_extensions_python-0.0.0.dist-info/RECORD +28 -0
  26. livery_extensions_python-0.0.0.dist-info/WHEEL +4 -0
  27. livery_extensions_python-0.0.0.dist-info/entry_points.txt +3 -0
  28. livery_extensions_python-0.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,324 @@
1
+ """Parse each python source once, and say what it references.
2
+
3
+ The layering phase reads every python source through this module: the
4
+ python extension's ``references`` answer and its layering steps,
5
+ private-reaches, runner-terminal and declared-imports. ``parsed_source``
6
+ parses a file once for a given content, memoised by the file's path,
7
+ size and modification time, so the steps, which run in one process,
8
+ read one tree, and an unchanged file is never parsed twice.
9
+ ``parsed_modules`` hands a rule the sources in its scope, every one or
10
+ the files a narrowed run selects, `RuleContext` is what a rule judges,
11
+ and ``graph_changed`` is what each step's inputs widen on.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ from collections.abc import Iterator
18
+ from dataclasses import dataclass, replace
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, cast
21
+
22
+ from livery.workshop import ReferencedName
23
+
24
+ if TYPE_CHECKING:
25
+ from livery.workshop import Changes, Package
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Parsed:
30
+ """One parsed source.
31
+
32
+ Attributes:
33
+ tree: The module's syntax tree.
34
+ references: Every dotted name it imports, once for each place
35
+ it imports it from: at import time or lazily, and under
36
+ which guard.
37
+ """
38
+
39
+ tree: ast.Module
40
+ references: tuple[ReferencedName, ...]
41
+
42
+
43
+ _PARSED: dict[tuple[Path, int, int], Parsed] = {}
44
+
45
+
46
+ def parsed_source(path: Path) -> Parsed | None:
47
+ """*path* parsed once for its content; None when it does not parse.
48
+
49
+ The memo key is the path with its size and modification time, so
50
+ an edited file parses again and an unchanged one never does.
51
+ """
52
+ try:
53
+ stat = path.stat()
54
+ except OSError:
55
+ return None
56
+ key = (path, stat.st_size, stat.st_mtime_ns)
57
+ found = _PARSED.get(key)
58
+ if found is None:
59
+ found = _parse(path)
60
+ if found is None:
61
+ return None
62
+ _PARSED[key] = found
63
+ return found
64
+
65
+
66
+ def _parse(path: Path) -> Parsed | None:
67
+ """Read and parse *path*: the one call site, which a test may count."""
68
+ try:
69
+ tree = ast.parse(path.read_text("utf-8"), filename=str(path))
70
+ except SyntaxError:
71
+ return None # the syntax gate names it; the rules read what parses
72
+ return Parsed(tree, references_of(tree))
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class _Where:
77
+ """Where an import statement sits.
78
+
79
+ Attributes:
80
+ lazy: Inside a function or a method body.
81
+ type_checking: In the body of ``if TYPE_CHECKING:``.
82
+ optional: In the body of a ``try`` that handles the module's
83
+ absence.
84
+ """
85
+
86
+ lazy: bool = False
87
+ type_checking: bool = False
88
+ optional: bool = False
89
+
90
+
91
+ def references_of(tree: ast.Module) -> tuple[ReferencedName, ...]:
92
+ """Every dotted name *tree* imports, in source order, once for each way.
93
+
94
+ A ``from`` import references the module and each name imported
95
+ from it: under a PEP 420 namespace the module alone can be the
96
+ namespace, and the package that owns the code is one segment
97
+ further down. A name imported twice the same way is listed once. A
98
+ relative import names the source's own package and is left out.
99
+
100
+ An import inside a function or a method body is lazy: importing the
101
+ module does not run it. One in the body of ``if TYPE_CHECKING:`` is
102
+ made by a type checker alone. One in the body of a ``try`` with a
103
+ handler for ``ImportError``, ``ModuleNotFoundError`` or every
104
+ exception is optional: the source runs without the module.
105
+
106
+ Only a statement imports, so the walk reads the statements and
107
+ never descends into an expression.
108
+ """
109
+ found: dict[ReferencedName, None] = {}
110
+ pending = [(statement, _Where()) for statement in reversed(tree.body)]
111
+ while pending:
112
+ statement, where = pending.pop()
113
+ if isinstance(statement, ast.Import):
114
+ names = [alias.name for alias in statement.names]
115
+ elif isinstance(statement, ast.ImportFrom):
116
+ module = statement.module
117
+ names = (
118
+ [module, *(f"{module}.{alias.name}" for alias in statement.names)]
119
+ if module and statement.level == 0
120
+ else []
121
+ )
122
+ else:
123
+ pending.extend(reversed(list(_inside(statement, where))))
124
+ continue
125
+ for name in names:
126
+ found.setdefault(
127
+ ReferencedName(
128
+ name,
129
+ lazy=where.lazy,
130
+ type_checking=where.type_checking,
131
+ optional=where.optional,
132
+ )
133
+ )
134
+ return tuple(found)
135
+
136
+
137
+ def _inside(statement: ast.stmt, where: _Where) -> Iterator[tuple[ast.stmt, _Where]]:
138
+ """The statements *statement* holds, in source order, each with where it sits."""
139
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
140
+ where = replace(where, lazy=True)
141
+ for field, value in ast.iter_fields(statement):
142
+ if not isinstance(value, list):
143
+ continue
144
+ held = where
145
+ if field == "body":
146
+ held = replace(
147
+ where,
148
+ type_checking=where.type_checking or _only_for_type_checking(statement),
149
+ optional=where.optional or _handles_absence(statement),
150
+ )
151
+ for item in cast("list[object]", value):
152
+ if isinstance(item, ast.stmt):
153
+ yield item, held
154
+ elif isinstance(item, (ast.ExceptHandler, ast.match_case)):
155
+ # A handler's body and a case's body sit where the
156
+ # statement does: a try's guard covers its own body alone.
157
+ for inner in item.body:
158
+ yield inner, where
159
+
160
+
161
+ def _only_for_type_checking(statement: ast.stmt) -> bool:
162
+ """Whether *statement* is ``if TYPE_CHECKING:``, the name bare or qualified."""
163
+ if not isinstance(statement, ast.If):
164
+ return False
165
+ test = statement.test
166
+ return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (
167
+ isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
168
+ )
169
+
170
+
171
+ def _handles_absence(statement: ast.stmt) -> bool:
172
+ """Whether *statement* is a ``try`` with a handler for an absent module.
173
+
174
+ A handler for ``ImportError``, ``ModuleNotFoundError`` or every
175
+ exception says the code runs without what the body imports, which
176
+ is a declaration of its own and needs no other.
177
+ """
178
+ if not isinstance(statement, ast.Try):
179
+ return False
180
+ return any(
181
+ handler.type is None
182
+ or (
183
+ isinstance(handler.type, ast.Name)
184
+ and handler.type.id in ("ImportError", "ModuleNotFoundError")
185
+ )
186
+ for handler in statement.handlers
187
+ )
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class RuleContext:
192
+ """What a layering rule over the python sources judges: the workspace and a scope.
193
+
194
+ Attributes:
195
+ root: The workspace root.
196
+ packages: Every package the workspace carries.
197
+ files: The root-relative sources the run judges; None for every
198
+ one.
199
+ """
200
+
201
+ root: Path
202
+ packages: tuple[Package, ...]
203
+ files: frozenset[str] | None = None
204
+
205
+ def packages_in_scope(self) -> tuple[Package, ...]:
206
+ """The packages holding a source this run judges, every one without a scope."""
207
+ if self.files is None:
208
+ return self.packages
209
+ files = self.files
210
+ return tuple(
211
+ package
212
+ for package in self.packages
213
+ if any(path.startswith(f"{package.path}/") for path in files)
214
+ )
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class ParsedModule:
219
+ """One python source as a rule sees it.
220
+
221
+ Attributes:
222
+ package: The package the file belongs to; None for the root's
223
+ own ``tasks.py``.
224
+ path: The file, absolute.
225
+ relative: The file, relative to the workspace root, posix.
226
+ area: ``src``, ``tests``, or ``root``.
227
+ dotted: The module's dotted path under ``src``; empty elsewhere.
228
+ tree: The syntax tree.
229
+ """
230
+
231
+ package: Package | None
232
+ path: Path
233
+ relative: str
234
+ area: str
235
+ dotted: str
236
+ tree: ast.Module
237
+
238
+
239
+ def parsed_modules(
240
+ root: Path, packages: tuple[Package, ...], files: frozenset[str] | None = None
241
+ ) -> tuple[ParsedModule, ...]:
242
+ """Every python source the workspace holds, or the named ones, parsed once.
243
+
244
+ The root's own ``tasks.py``, then each package's ``src`` and
245
+ ``tests`` trees in package order. *files* keeps the root-relative
246
+ sources it names, and a named file that no longer exists is left
247
+ out. A file that does not parse is left out: the syntax gate names
248
+ it.
249
+ """
250
+
251
+ def wanted(source: Path) -> bool:
252
+ return files is None or source.relative_to(root).as_posix() in files
253
+
254
+ found: list[ParsedModule] = []
255
+ tasks = root / "tasks.py"
256
+ if tasks.is_file() and wanted(tasks):
257
+ _collect(found, None, tasks, root, "root", tasks.parent)
258
+ for package in packages:
259
+ for area in ("src", "tests"):
260
+ base = package.directory / area
261
+ if not base.is_dir():
262
+ continue
263
+ if files is not None:
264
+ prefix = f"{package.path}/{area}/"
265
+ named = sorted(
266
+ root / path
267
+ for path in files
268
+ if path.startswith(prefix)
269
+ and path.endswith(".py")
270
+ and (root / path).is_file()
271
+ )
272
+ for source in named:
273
+ _collect(found, package, source, root, area, base)
274
+ continue
275
+ for source in sorted(base.rglob("*.py")):
276
+ _collect(found, package, source, root, area, base)
277
+ return tuple(found)
278
+
279
+
280
+ def dotted_name(source: Path, src: Path) -> str:
281
+ """The dotted module path of *source*, a file under the ``src`` tree *src*.
282
+
283
+ ``livery/forge/_dev/__init__.py`` under ``src`` is ``livery.forge._dev``.
284
+ """
285
+ dotted = ".".join(source.relative_to(src).parts)
286
+ return dotted.removesuffix(".py").removesuffix(".__init__")
287
+
288
+
289
+ def _collect(
290
+ found: list[ParsedModule],
291
+ package: Package | None,
292
+ source: Path,
293
+ root: Path,
294
+ area: str,
295
+ base: Path,
296
+ ) -> None:
297
+ parsed = parsed_source(source)
298
+ if parsed is None:
299
+ return
300
+ found.append(
301
+ ParsedModule(
302
+ package=package,
303
+ path=source,
304
+ relative=source.relative_to(root).as_posix(),
305
+ area=area,
306
+ dotted=dotted_name(source, base) if area == "src" else "",
307
+ tree=parsed.tree,
308
+ )
309
+ )
310
+
311
+
312
+ def graph_changed(changes: Changes) -> bool:
313
+ """Whether a contract, a native manifest or a root ``src/`` changed.
314
+
315
+ The layering steps' inputs widen on them: the distributions' roots
316
+ and declarations come from the manifests, and the allowances from
317
+ the root contract, so a change there judges every source, as the
318
+ base's layering steps do. The workshop answers it
319
+ ([livery.workshop.graph_changed][]); a step's ``widen`` reference
320
+ names the extension's own code, so this function hands it on.
321
+ """
322
+ from livery.workshop import graph_changed as workspace_graph_changed
323
+
324
+ return workspace_graph_changed(changes)
@@ -0,0 +1,165 @@
1
+ """The private-reaches rule: a source reaching another distribution's privates.
2
+
3
+ A private is a module or a name with a leading underscore under another
4
+ distribution's root, imported (guarded or under ``TYPE_CHECKING``
5
+ alike) or read as an attribute of something imported from there. A
6
+ test may reach one; a package's source may not, except through the
7
+ root contract's ``[[python.private-reaches]]``, each entry with its
8
+ reason. An entry no source uses any more refuses, so the list only
9
+ shrinks. Each distribution's roots are what its build ships, so the
10
+ roots one wheel ships are one distribution's.
11
+
12
+ The rule is a step of the layering phase,
13
+ ``[phases.layering.private-reaches]``, judged over the one parse of
14
+ the python sources (``parsed_modules``): the files a narrowed run
15
+ reaches, every source otherwise.
16
+
17
+ The scan reads names, not types: a private read through a value
18
+ (``current()._x``) or named in a string (an entry point,
19
+ ``import_module``) is not seen.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from typing import TYPE_CHECKING
26
+
27
+ from livery.footman import fail
28
+ from livery.workshop import selected_files
29
+
30
+ if TYPE_CHECKING:
31
+ from pathlib import Path
32
+
33
+ from livery.extensions.python._parse import ParsedModule, RuleContext
34
+ from livery.workshop import Package, PhaseContext
35
+
36
+ #: Where the allowance lives in the root contract.
37
+ ALLOWANCE = "[[python.private-reaches]]"
38
+
39
+
40
+ def private_reaches(ctx: PhaseContext) -> None:
41
+ """The step: refuse each reach outside the allowance, and each unused entry.
42
+
43
+ The files the run reaches ([livery.workshop.selected_files][]) are
44
+ judged over the one parse; an unused entry is judged on a run over
45
+ every source alone.
46
+ """
47
+ from livery.extensions.python._parse import RuleContext, parsed_modules
48
+
49
+ files = selected_files(ctx)
50
+ context = RuleContext(root=ctx.root, packages=ctx.packages, files=files)
51
+ found = problems(parsed_modules(ctx.root, ctx.packages, files), context)
52
+ if found:
53
+ fail("the sources break the private-reaches rule:\n " + "\n ".join(found))
54
+
55
+
56
+ def problems(modules: tuple[ParsedModule, ...], context: RuleContext) -> list[str]:
57
+ """Each reach outside the allowance, by its place; each allowance entry unused.
58
+
59
+ An unused entry is judged only when the run reads every source: a
60
+ narrowed run cannot tell an entry nothing uses from one its scope
61
+ leaves out.
62
+ """
63
+ reaches = scan(modules, context.packages)
64
+ allowed = allowance(context.root)
65
+ problems = [
66
+ f"{place}: {source} reaches {name}, another distribution's private; make"
67
+ f" the name public where it lives, or allow the reach in the root"
68
+ f" contract's {ALLOWANCE} with its reason"
69
+ for (source, name), places in sorted(reaches.items())
70
+ if (source, name) not in allowed
71
+ for place in places
72
+ ]
73
+ if context.files is None:
74
+ problems += [
75
+ f"workshop.toml: {ALLOWANCE} allows {source} to reach {name}, and no"
76
+ " source reaches it any more; delete the entry"
77
+ for source, name in sorted(allowed)
78
+ if (source, name) not in reaches
79
+ ]
80
+ return problems
81
+
82
+
83
+ def scan(
84
+ modules: tuple[ParsedModule, ...], packages: tuple[Package, ...]
85
+ ) -> dict[tuple[str, str], list[str]]:
86
+ """Every reach in *modules*' sources, by distribution and private, to its places."""
87
+ owners = _owners(packages)
88
+ reaches: dict[tuple[str, str], list[str]] = {}
89
+ for module in modules:
90
+ if module.package is None or module.area != "src":
91
+ continue
92
+ source = module.package.name
93
+ for name, line in _named(module.tree):
94
+ roots = [
95
+ root for root in owners if name == root or name.startswith(root + ".")
96
+ ]
97
+ if not roots:
98
+ continue
99
+ owner = max(roots, key=len)
100
+ below = name[len(owner) :].split(".")
101
+ if owners[owner] == source or not any(map(_private, below)):
102
+ continue
103
+ reaches.setdefault((source, name), []).append(f"{module.relative}:{line}")
104
+ return reaches
105
+
106
+
107
+ def allowance(root: Path) -> dict[tuple[str, str], str]:
108
+ """The root contract's allowed reaches: each distribution and private, to why."""
109
+ from livery.workshop import read_contract
110
+
111
+ table = read_contract(root).get("python", {})
112
+ entries = table.get("private-reaches", []) if isinstance(table, dict) else []
113
+ return {
114
+ (str(entry["from"]), str(entry["reaches"])): str(entry.get("reason", ""))
115
+ for entry in entries
116
+ if isinstance(entry, dict)
117
+ }
118
+
119
+
120
+ def _owners(packages: tuple[Package, ...]) -> dict[str, str]:
121
+ """Each module root a distribution ships, to the distribution."""
122
+ from livery.workshop import MODULE_ROOTS, answer
123
+
124
+ return {
125
+ root: package.name
126
+ for package in packages
127
+ for root in answer(package, MODULE_ROOTS)
128
+ }
129
+
130
+
131
+ def _private(part: str) -> bool:
132
+ return part.startswith("_") and not (part.startswith("__") and part.endswith("__"))
133
+
134
+
135
+ def _dotted(node: ast.expr, imported: dict[str, str]) -> str | None:
136
+ """The dotted path an attribute chain names, when it starts at an import."""
137
+ if isinstance(node, ast.Name):
138
+ return imported.get(node.id)
139
+ if isinstance(node, ast.Attribute):
140
+ base = _dotted(node.value, imported)
141
+ return f"{base}.{node.attr}" if base else None
142
+ return None
143
+
144
+
145
+ def _named(tree: ast.Module) -> list[tuple[str, int]]:
146
+ """Every dotted path *tree* imports or reads through an import, with its line."""
147
+ named: list[tuple[str, int]] = []
148
+ imported: dict[str, str] = {}
149
+ for node in ast.walk(tree):
150
+ if isinstance(node, ast.Import):
151
+ for alias in node.names:
152
+ named.append((alias.name, node.lineno))
153
+ local = alias.asname or alias.name.partition(".")[0]
154
+ imported[local] = alias.name if alias.asname else local
155
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
156
+ for alias in node.names:
157
+ path = f"{node.module}.{alias.name}"
158
+ named.append((path, node.lineno))
159
+ imported[alias.asname or alias.name] = path
160
+ for node in ast.walk(tree):
161
+ if isinstance(node, ast.Attribute) and _private(node.attr):
162
+ read = _dotted(node, imported)
163
+ if read is not None:
164
+ named.append((read, node.lineno))
165
+ return named
@@ -0,0 +1,71 @@
1
+ """The python extension's steps: a package's lifecycle phases and its isolated test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from livery.extensions.python import _backend
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping
11
+
12
+ from livery.workshop import PhaseContext
13
+
14
+
15
+ def stamp(ctx: PhaseContext) -> None:
16
+ """Write the engine's version into ``pyproject.toml`` and every ``__version__``."""
17
+ version = cast("str", ctx.read("version"))
18
+ ctx.provide("changed", _backend.stamp_version(ctx.member).stamp(version))
19
+
20
+
21
+ def build(ctx: PhaseContext) -> None:
22
+ """Build the package's wheel and sdist into its ``dist/``, at the engine's epoch."""
23
+ epoch = cast("int", ctx.read("epoch"))
24
+ ctx.provide("dist", _backend.build(ctx.member, ctx.root, epoch=epoch))
25
+
26
+
27
+ def publish(ctx: PhaseContext) -> None:
28
+ """Upload the built distributions to the registry target the engine resolved."""
29
+ from livery.workshop import RegistryTarget
30
+
31
+ version = cast("str", ctx.read("version"))
32
+ target = RegistryTarget.from_table(
33
+ cast("Mapping[str, object]", ctx.read("registry-target"))
34
+ )
35
+ published = _backend.publish_artifact(
36
+ ctx.member, ctx.root, version=version, target=target
37
+ )
38
+ ctx.provide("published", published)
39
+
40
+
41
+ def isolated_test(ctx: PhaseContext) -> None:
42
+ """Test the built wheel in a fresh venv at the engine's resolution; what it got."""
43
+ from pathlib import Path
44
+
45
+ resolution = cast("str", ctx.read("resolution"))
46
+ dirs = tuple(Path(path) for path in cast("list[Path]", ctx.read("release-dirs")))
47
+ resolved = _backend.isolated_test(
48
+ ctx.member, ctx.root, release_dirs=dirs, resolution=resolution
49
+ )
50
+ ctx.provide("resolved", dict(resolved))
51
+
52
+
53
+ def run(ctx: PhaseContext) -> None:
54
+ """Start the script the engine names with the words after the dashes; its code."""
55
+ name = cast("str", ctx.read("executable"))
56
+ ctx.exit(_backend.run_script(ctx.member, ctx.root, name, ctx.arguments))
57
+
58
+
59
+ def verify(ctx: PhaseContext) -> None:
60
+ """Install the released version alone and run its tests at the engine's tree."""
61
+ from pathlib import Path
62
+
63
+ code = _backend.verify(
64
+ ctx.member,
65
+ tree=Path(cast("Path", ctx.read("tree"))),
66
+ version=cast("str", ctx.read("version")),
67
+ python=cast("str", ctx.read("interpreter")),
68
+ index=cast("str", ctx.read("index")),
69
+ extras=cast("str", ctx.read("extras")),
70
+ )
71
+ ctx.provide("exit-code", code)
@@ -0,0 +1,118 @@
1
+ """The runner-terminal rule: a module that imports the runner never asks the terminal.
2
+
3
+ Whether a person is there has one answer, [livery.footman.attended][],
4
+ which knows ``--no-input`` and ``--dry-run`` as well as the terminal. A
5
+ module that imports the runner has that answer in reach, and asking
6
+ the terminal itself (``sys.stdin.isatty()`` and its relatives) ignores
7
+ both flags in silence. The runner's own sources implement the answer
8
+ and are exempt, and a module that never imports the runner is the
9
+ workspace's own business.
10
+
11
+ The rule is a step of the layering phase,
12
+ ``[phases.layering.runner-terminal]``, over the one parse of the python
13
+ sources: the root's ``tasks.py`` and each package's ``src``, the files
14
+ a narrowed run reaches or every one. It runs while the python
15
+ extension is mounted, which a package whose set holds python mounts.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ from typing import TYPE_CHECKING
22
+
23
+ from livery.footman import fail
24
+ from livery.workshop import selected_files
25
+
26
+ if TYPE_CHECKING:
27
+ from livery.extensions.python._parse import ParsedModule
28
+ from livery.workshop import PhaseContext
29
+
30
+ #: What a module that imports the runner must never call: a terminal
31
+ #: says nothing about ``--no-input`` or ``--dry-run``.
32
+ TERMINAL_CALLS = (
33
+ "sys.stdin.isatty",
34
+ "sys.stdout.isatty",
35
+ "sys.stderr.isatty",
36
+ "os.isatty",
37
+ )
38
+
39
+
40
+ def runner_terminal(ctx: PhaseContext) -> None:
41
+ """The step: refuse each terminal question in a module that imports the runner.
42
+
43
+ The sources the run reaches ([livery.workshop.selected_files][])
44
+ are judged over the one parse; every source when the run knows
45
+ nothing of what changed.
46
+ """
47
+ from livery.extensions.python._parse import parsed_modules
48
+
49
+ found = problems(parsed_modules(ctx.root, ctx.packages, selected_files(ctx)))
50
+ if found:
51
+ fail("the sources break the runner-terminal rule:\n " + "\n ".join(found))
52
+
53
+
54
+ def problems(modules: tuple[ParsedModule, ...]) -> list[str]:
55
+ """Each terminal question asked by a module of *modules* that imports the runner.
56
+
57
+ The root's ``tasks.py`` and the sources under each package's
58
+ ``src`` are judged, apart from the runner's own.
59
+ """
60
+ from livery.extensions.python._backend import RUNNER_DISTRIBUTION
61
+
62
+ found: list[str] = []
63
+ for module in modules:
64
+ if module.area not in ("root", "src"):
65
+ continue
66
+ if module.package is not None and module.package.name == RUNNER_DISTRIBUTION:
67
+ continue
68
+ if not imports_the_runner(module.tree):
69
+ continue
70
+ found.extend(
71
+ f"{module.relative} asks the terminal with {spelling}(): a module"
72
+ " that imports the runner asks livery.footman.attended() instead,"
73
+ " which knows --no-input and --dry-run as well"
74
+ for spelling in terminal_calls(module.tree)
75
+ )
76
+ return found
77
+
78
+
79
+ def imports_the_runner(tree: ast.Module) -> bool:
80
+ """Whether the module *tree* parses imports the runner, in any spelling."""
81
+ for node in ast.walk(tree):
82
+ if isinstance(node, ast.Import):
83
+ if any(
84
+ alias.name == "livery.footman"
85
+ or alias.name.startswith("livery.footman.")
86
+ for alias in node.names
87
+ ):
88
+ return True
89
+ elif isinstance(node, ast.ImportFrom) and node.module:
90
+ if node.module == "livery.footman" or node.module.startswith(
91
+ "livery.footman."
92
+ ):
93
+ return True
94
+ if node.module == "livery" and any(
95
+ alias.name == "footman" for alias in node.names
96
+ ):
97
+ return True
98
+ return False
99
+
100
+
101
+ def terminal_calls(tree: ast.Module) -> list[str]:
102
+ """Every call in the module *tree* parses that asks a terminal directly."""
103
+ bare = {
104
+ alias.asname or alias.name
105
+ for node in ast.walk(tree)
106
+ if isinstance(node, ast.ImportFrom) and node.module == "sys"
107
+ for alias in node.names
108
+ if alias.name in ("stdin", "stdout", "stderr")
109
+ }
110
+ found: list[str] = []
111
+ for node in ast.walk(tree):
112
+ if not isinstance(node, ast.Call):
113
+ continue
114
+ spelling = ast.unparse(node.func)
115
+ named = spelling.endswith(".isatty") and spelling.split(".")[0] in bare
116
+ if spelling in TERMINAL_CALLS or named:
117
+ found.append(spelling)
118
+ return found