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.
- livery/extensions/python/__init__.py +51 -0
- livery/extensions/python/_answers.py +127 -0
- livery/extensions/python/_backend.py +1532 -0
- livery/extensions/python/_declared.py +252 -0
- livery/extensions/python/_docs/index.md +165 -0
- livery/extensions/python/_docs/nav.toml +14 -0
- livery/extensions/python/_installed.py +219 -0
- livery/extensions/python/_parse.py +324 -0
- livery/extensions/python/_reaches.py +165 -0
- livery/extensions/python/_steps.py +71 -0
- livery/extensions/python/_terminal.py +118 -0
- livery/extensions/python/_verify.py +112 -0
- livery/extensions/python/content/templates/python-library/LICENSE.jinja +21 -0
- livery/extensions/python/content/templates/python-library/README.md.jinja +7 -0
- livery/extensions/python/content/templates/python-library/pyproject.toml.jinja +43 -0
- livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__init__.py.jinja +15 -0
- livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__main__.py.jinja +30 -0
- livery/extensions/python/content/templates/python-library/src/{{ source_path }}/py.typed +0 -0
- livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_cli.py.jinja +39 -0
- livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_package.py.jinja +22 -0
- livery/extensions/python/content/templates/python-library/workshop.toml.jinja +10 -0
- livery/extensions/python/extension.toml +165 -0
- livery/extensions/python/py.typed +0 -0
- livery_extensions_python-0.0.0.dist-info/METADATA +31 -0
- livery_extensions_python-0.0.0.dist-info/RECORD +28 -0
- livery_extensions_python-0.0.0.dist-info/WHEEL +4 -0
- livery_extensions_python-0.0.0.dist-info/entry_points.txt +3 -0
- 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
|