texpkg 0.1.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.
- texpkg/__init__.py +18 -0
- texpkg/_internal/__init__.py +1 -0
- texpkg/_internal/adopt.py +119 -0
- texpkg/_internal/classify.py +190 -0
- texpkg/_internal/directory.py +48 -0
- texpkg/_internal/envelope.py +37 -0
- texpkg/_internal/export.py +49 -0
- texpkg/_internal/handedits.py +33 -0
- texpkg/_internal/history.py +91 -0
- texpkg/_internal/payload.py +118 -0
- texpkg/_internal/provenance_tex.py +65 -0
- texpkg/_internal/release.py +257 -0
- texpkg/_internal/snapshots.py +61 -0
- texpkg/_internal/state.py +113 -0
- texpkg/_internal/versions.py +61 -0
- texpkg/assets.py +56 -0
- texpkg/contracts.py +46 -0
- texpkg/declaration.py +379 -0
- texpkg/document.py +225 -0
- texpkg/errors.py +66 -0
- texpkg/layout.py +48 -0
- texpkg/migration.py +73 -0
- texpkg/protocol.py +61 -0
- texpkg/provenance.py +61 -0
- texpkg/py.typed +0 -0
- texpkg-0.1.0.dist-info/METADATA +30 -0
- texpkg-0.1.0.dist-info/RECORD +41 -0
- texpkg-0.1.0.dist-info/WHEEL +4 -0
- texpkg-0.1.0.dist-info/entry_points.txt +5 -0
- texpkg-0.1.0.dist-info/licenses/LICENSE +21 -0
- texpkg_build/__init__.py +1 -0
- texpkg_build/_internal/__init__.py +1 -0
- texpkg_build/_internal/checks.py +154 -0
- texpkg_build/_internal/embed.py +254 -0
- texpkg_build/_internal/generate.py +254 -0
- texpkg_build/_internal/isolate.py +86 -0
- texpkg_build/_internal/snapshot.py +319 -0
- texpkg_build/cli.py +83 -0
- texpkg_build/hook.py +76 -0
- texpkg_build/py.typed +0 -0
- texpkg_build/testing.py +176 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""What the build refuses (B4): a defect fails `pdm build`, not a consumer's import."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from texmodel.components import control_name
|
|
10
|
+
from texmodel.errors import DeclarationError as TexmodelDeclarationError
|
|
11
|
+
|
|
12
|
+
from texpkg._internal import classify, payload
|
|
13
|
+
from texpkg._internal.release import Release
|
|
14
|
+
from texpkg.contracts import Contracts
|
|
15
|
+
from texpkg.declaration import Distribution
|
|
16
|
+
from texpkg.errors import DeclarationError
|
|
17
|
+
from texpkg.protocol import KindModule, KindsModule, PackageModule
|
|
18
|
+
|
|
19
|
+
from . import snapshot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def declaration_directory(dist: Distribution) -> pathlib.Path:
|
|
23
|
+
"""The directory of the declaration package, which the build copies whole (P22)."""
|
|
24
|
+
path = pathlib.Path(str(dist.files))
|
|
25
|
+
if not (path / "__init__.py").is_file():
|
|
26
|
+
raise DeclarationError(
|
|
27
|
+
f"{dist.declared_in}: the declaration must be a package (a directory with an __init__.py)",
|
|
28
|
+
code="TP-B004",
|
|
29
|
+
path=dist.declared_in,
|
|
30
|
+
hint="move the declaration into a package; its assets and templates live beside it",
|
|
31
|
+
)
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assets_are_there(dist: Distribution) -> None:
|
|
36
|
+
"""Every asset of the chain is a file (A3), and no two land on one payload path (P11, `TP-A002`)."""
|
|
37
|
+
if gone := [r.asset.path for r in dist.resolved_assets() if not r.source.is_file()]:
|
|
38
|
+
raise DeclarationError(
|
|
39
|
+
f"missing assets: {', '.join(gone)}",
|
|
40
|
+
code="TP-B005",
|
|
41
|
+
path=gone[0],
|
|
42
|
+
hint=f"they are read from the package {dist.declared_in}",
|
|
43
|
+
)
|
|
44
|
+
payload.build({f"{d.package.name}.sty": "" for d in dist.chain()}, tuple(dist.resolved_assets()), dist.layout)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def module_satisfies_the_protocols(module: ModuleType) -> None:
|
|
48
|
+
"""The generated module has the fixed surface (P6)."""
|
|
49
|
+
name = module.__name__
|
|
50
|
+
candidate: object = module
|
|
51
|
+
kinds: object = getattr(module, "kinds", None)
|
|
52
|
+
problems = [] if isinstance(candidate, PackageModule) else ["the package module"]
|
|
53
|
+
if isinstance(kinds, KindsModule):
|
|
54
|
+
problems += [
|
|
55
|
+
f"kinds.{n}" for n, m in dict[str, object](kinds.modules()).items() if not isinstance(m, KindModule)
|
|
56
|
+
]
|
|
57
|
+
else:
|
|
58
|
+
problems.append("kinds")
|
|
59
|
+
if problems:
|
|
60
|
+
raise DeclarationError(
|
|
61
|
+
f"the generated module {name} does not satisfy the protocol: {', '.join(problems)}",
|
|
62
|
+
code="TP-B006",
|
|
63
|
+
path=name,
|
|
64
|
+
hint="this is a texpkg defect: texpkg.protocol lists the surface",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tex_names_are_unique(dist: Distribution) -> None:
|
|
69
|
+
"""No two packages of the chain define one control sequence (P11): the later `.sty` would redefine the earlier."""
|
|
70
|
+
seen: dict[str, str] = {}
|
|
71
|
+
for member in reversed(dist.chain()):
|
|
72
|
+
for cls in member.package.registered:
|
|
73
|
+
try:
|
|
74
|
+
name = control_name(cls)
|
|
75
|
+
except TexmodelDeclarationError:
|
|
76
|
+
continue # a colour has no control sequence
|
|
77
|
+
if seen.setdefault(name, member.name) != member.name:
|
|
78
|
+
raise DeclarationError(
|
|
79
|
+
f"\\{name} is defined by {seen[name]} and by {member.name}",
|
|
80
|
+
code="TP-B009",
|
|
81
|
+
path=name,
|
|
82
|
+
hint="a package built on another must not reuse its names: choose another prefix or name",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def design_is_valid(dist: Distribution) -> None:
|
|
87
|
+
"""The embedded data validates against its model (L1)."""
|
|
88
|
+
if dist.design is None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
dist.design.load(dist.files)
|
|
92
|
+
except (OSError, ValueError) as exc: # a missing file, bad JSON and pydantic's ValidationError are all of these
|
|
93
|
+
raise DeclarationError(
|
|
94
|
+
f"{dist.name}: the design data {dist.design.path} is not a valid {dist.design.model.__name__}: {exc}",
|
|
95
|
+
code="TP-B010",
|
|
96
|
+
path=dist.design.path,
|
|
97
|
+
hint="fix the file, or the model it is validated against",
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_RELEASE = re.compile(r"\d+\.\d+\.\d+")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def contract_gate(
|
|
105
|
+
root: pathlib.Path, dist: Distribution, release: Release, version: str
|
|
106
|
+
) -> tuple[Contracts, list[pathlib.Path]]:
|
|
107
|
+
"""The contract versions this build carries, and the frozen snapshots that ship (V12, V13, V17, V37). Without a
|
|
108
|
+
`contracts` directory a package has the first versions and ships none. A development build is held to `next`: it
|
|
109
|
+
must be current and an output change must be classified. A release build must have its own newest frozen
|
|
110
|
+
directory, and its number must not understate the contracts."""
|
|
111
|
+
if not (root / snapshot.CONTRACTS).is_dir():
|
|
112
|
+
return Contracts(), []
|
|
113
|
+
held = snapshot.frozen(root)
|
|
114
|
+
current = snapshot.base_current(dist)
|
|
115
|
+
proposal = snapshot.propose(root, dist, release, snapshot.sty_of(dist), base_current=current)
|
|
116
|
+
if _RELEASE.fullmatch(version):
|
|
117
|
+
if not held or held[-1].name != version:
|
|
118
|
+
raise DeclarationError(
|
|
119
|
+
f"release {version}: contracts/{version} is not the newest frozen snapshot",
|
|
120
|
+
code="TP-C003",
|
|
121
|
+
hint="freeze it first: texpkg-build contracts --freeze",
|
|
122
|
+
)
|
|
123
|
+
required = snapshot.required_for(held[-2] if len(held) > 1 else None, held[-1])
|
|
124
|
+
if classify.parse_version(version) < classify.parse_version(required):
|
|
125
|
+
raise DeclarationError(
|
|
126
|
+
f"release {version} understates the contracts: they need {required}",
|
|
127
|
+
code="TP-C004",
|
|
128
|
+
hint=f"tag {required} or later",
|
|
129
|
+
)
|
|
130
|
+
versions = snapshot.read_versions(held[-1])
|
|
131
|
+
else:
|
|
132
|
+
if stale := snapshot.stale(root, proposal.files):
|
|
133
|
+
raise DeclarationError(
|
|
134
|
+
f"contracts/next is not current: {', '.join(stale)}",
|
|
135
|
+
code="TP-C001",
|
|
136
|
+
hint="run texpkg-build contracts and review the files",
|
|
137
|
+
)
|
|
138
|
+
if proposal.output and not proposal.versions.output_change:
|
|
139
|
+
raise DeclarationError(
|
|
140
|
+
f"the output changed ({', '.join(proposal.output)}) and contracts/next/versions.toml does not say how",
|
|
141
|
+
code="TP-C005",
|
|
142
|
+
hint='under [output] write change = "current" (visible: proofs need approving again) or "revision"',
|
|
143
|
+
)
|
|
144
|
+
versions = snapshot.read_versions(root / snapshot.CONTRACTS / snapshot.NEXT)
|
|
145
|
+
assert versions is not None
|
|
146
|
+
if dist.base is not None and versions.base_interface:
|
|
147
|
+
base: Any = importlib.import_module(dist.base.module).CONTRACTS
|
|
148
|
+
if versions.base_interface < base.interface.current - base.interface.age:
|
|
149
|
+
raise DeclarationError(
|
|
150
|
+
f"the base no longer supports the interface {versions.base_interface} this package relies on",
|
|
151
|
+
code="TP-C006",
|
|
152
|
+
hint="adapt to the new base, then set [base] interface in contracts/next/versions.toml",
|
|
153
|
+
)
|
|
154
|
+
return versions.contracts(), held
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Copying embedded packages and rewriting their imports (P13-P19, B7, B10, 1.5).
|
|
2
|
+
|
|
3
|
+
A wheel holds one copy of texmodel, of texpkg's run time and of each package in its base chain, side by side under
|
|
4
|
+
`<module>/_embedded/`. Every import of an embedded name, absolute or relative into another embedded copy, is found with
|
|
5
|
+
`ast` and rewritten as a relative import of the private copy; nothing is matched by regular expression.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import pathlib
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
13
|
+
|
|
14
|
+
from texpkg.declaration import Distribution
|
|
15
|
+
from texpkg.errors import DeclarationError
|
|
16
|
+
|
|
17
|
+
EMBEDDED = "_embedded"
|
|
18
|
+
RUN_TIME = ("texmodel", "texpkg")
|
|
19
|
+
ALLOWED = frozenset({"pydantic", "pydantic_core", "annotated_types", "typing_extensions", "typing_inspection"})
|
|
20
|
+
_SKIP = shutil.ignore_patterns("__pycache__", "*.pyc")
|
|
21
|
+
|
|
22
|
+
type Mapping = Callable[[str], str | None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def relative(package: tuple[str, ...], target: tuple[str, ...]) -> tuple[int, str]:
|
|
26
|
+
"""The level and module of the relative import that reaches `target` from a module in `package`."""
|
|
27
|
+
common = 0
|
|
28
|
+
while common < min(len(package), len(target)) and package[common] == target[common]:
|
|
29
|
+
common += 1
|
|
30
|
+
return len(package) - common + 1, ".".join(target[common:])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _absolute(node: ast.ImportFrom, package: tuple[str, ...]) -> tuple[str, ...]:
|
|
34
|
+
base = package[: len(package) - (node.level - 1)] if node.level else ()
|
|
35
|
+
return (*base, *(node.module.split(".") if node.module else ()))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fail(code: str, message: str, where: str, hint: str) -> DeclarationError:
|
|
39
|
+
return DeclarationError(message, code=code, path=where, hint=hint)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _names(aliases: list[ast.alias]) -> str:
|
|
43
|
+
return ", ".join(a.name + (f" as {a.asname}" if a.asname else "") for a in aliases)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _from_statement(node: ast.ImportFrom, old: tuple[str, ...], new: tuple[str, ...], mapping: Mapping) -> str | None:
|
|
47
|
+
mapped = mapping(".".join(_absolute(node, old)))
|
|
48
|
+
if mapped is None:
|
|
49
|
+
return None
|
|
50
|
+
level, module = relative(new, tuple(mapped.split(".")))
|
|
51
|
+
if (level, module) == (node.level, node.module or ""):
|
|
52
|
+
return None # a relative import inside what moves rigidly stays as it is
|
|
53
|
+
return f"from {'.' * level}{module} import {_names(node.names)}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _import_statement(node: ast.Import, new: tuple[str, ...], mapping: Mapping, where: str) -> str | None:
|
|
57
|
+
if not any(mapping(a.name) is not None for a in node.names):
|
|
58
|
+
return None
|
|
59
|
+
lines: list[str] = []
|
|
60
|
+
for alias in node.names:
|
|
61
|
+
mapped = mapping(alias.name)
|
|
62
|
+
if mapped is None:
|
|
63
|
+
lines.append(f"import {_names([alias])}")
|
|
64
|
+
continue
|
|
65
|
+
if "." in alias.name and not alias.asname:
|
|
66
|
+
parent, _, child = alias.name.rpartition(".")
|
|
67
|
+
raise _fail(
|
|
68
|
+
"TP-E001",
|
|
69
|
+
f"{where}: `import {alias.name}` binds the top package, which cannot be pointed at the private copy",
|
|
70
|
+
where,
|
|
71
|
+
f"write `from {parent} import {child}`",
|
|
72
|
+
)
|
|
73
|
+
*parent, last = mapped.split(".")
|
|
74
|
+
level, module = relative(new, tuple(parent))
|
|
75
|
+
lines.append(f"from {'.' * level}{module} import {last}" + (f" as {alias.asname}" if alias.asname else ""))
|
|
76
|
+
return ("\n" + " " * node.col_offset).join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def rewrite(source: str, old: tuple[str, ...], new: tuple[str, ...], mapping: Mapping, where: str) -> str:
|
|
80
|
+
"""`source`, a module of package `old` that is moved to package `new`, with each import whose absolute name
|
|
81
|
+
`mapping` changes written as a relative import from `new`. Other imports are left as they are."""
|
|
82
|
+
raw = source.encode("utf-8")
|
|
83
|
+
starts = [0]
|
|
84
|
+
for line in raw.splitlines(keepends=True):
|
|
85
|
+
starts.append(starts[-1] + len(line))
|
|
86
|
+
edits: list[tuple[int, int, str]] = []
|
|
87
|
+
for node in ast.walk(ast.parse(source)):
|
|
88
|
+
if isinstance(node, ast.ImportFrom):
|
|
89
|
+
text = _from_statement(node, old, new, mapping)
|
|
90
|
+
elif isinstance(node, ast.Import):
|
|
91
|
+
text = _import_statement(node, new, mapping, where)
|
|
92
|
+
else:
|
|
93
|
+
continue
|
|
94
|
+
if text is not None and node.end_lineno is not None and node.end_col_offset is not None:
|
|
95
|
+
edits.append(
|
|
96
|
+
(starts[node.lineno - 1] + node.col_offset, starts[node.end_lineno - 1] + node.end_col_offset, text)
|
|
97
|
+
)
|
|
98
|
+
for start, end, text in sorted(edits, reverse=True):
|
|
99
|
+
raw = raw[:start] + text.encode("utf-8") + raw[end:]
|
|
100
|
+
return raw.decode("utf-8")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def imported_names(source: str) -> Iterator[tuple[str, ast.Import | ast.ImportFrom]]:
|
|
104
|
+
"""Every absolute module name `source` imports, with its node: `import a.b` and `from a.b import c` give `a.b`."""
|
|
105
|
+
for node in ast.walk(ast.parse(source)):
|
|
106
|
+
if isinstance(node, ast.Import):
|
|
107
|
+
for alias in node.names:
|
|
108
|
+
yield alias.name, node
|
|
109
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
110
|
+
yield node.module, node
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scan_declaration(source: str, where: str, own: set[str], embedded: set[str]) -> None:
|
|
114
|
+
"""What the build refuses in the author's code (B4): an absolute import of the package itself (P19), a read of the
|
|
115
|
+
distribution's metadata (B10), a dynamic import of an embedded name, and a module the wheel does not carry."""
|
|
116
|
+
for name, node in imported_names(source):
|
|
117
|
+
top = name.split(".")[0]
|
|
118
|
+
if top in own:
|
|
119
|
+
raise _fail(
|
|
120
|
+
"TP-E002",
|
|
121
|
+
f"{where}:{node.lineno}: absolute import of {name}, which is this package's own name",
|
|
122
|
+
where,
|
|
123
|
+
"import the package's own modules relatively (from .model import X), so a copy works anywhere",
|
|
124
|
+
)
|
|
125
|
+
if name.startswith("importlib.metadata") or (
|
|
126
|
+
name == "importlib" and isinstance(node, ast.ImportFrom) and any(a.name == "metadata" for a in node.names)
|
|
127
|
+
):
|
|
128
|
+
raise _fail(
|
|
129
|
+
"TP-E003",
|
|
130
|
+
f"{where}: reads distribution metadata ({name}); an embedded copy has none of its own",
|
|
131
|
+
where,
|
|
132
|
+
"take the version from the constant the build writes (the module's __version__)",
|
|
133
|
+
)
|
|
134
|
+
if top not in sys.stdlib_module_names and top not in ALLOWED and top not in embedded:
|
|
135
|
+
raise _fail(
|
|
136
|
+
"TP-E004",
|
|
137
|
+
f"{where}: imports {name}, which the wheel does not carry",
|
|
138
|
+
where,
|
|
139
|
+
"a wheel needs only the standard library, pydantic, texmodel, texpkg and its bases",
|
|
140
|
+
)
|
|
141
|
+
for node in ast.walk(ast.parse(source)):
|
|
142
|
+
if isinstance(node, ast.Call) and node.args and isinstance(node.args[0], ast.Constant):
|
|
143
|
+
called = node.func.attr if isinstance(node.func, ast.Attribute) else getattr(node.func, "id", "")
|
|
144
|
+
value = node.args[0].value
|
|
145
|
+
if (
|
|
146
|
+
called in ("import_module", "__import__")
|
|
147
|
+
and isinstance(value, str)
|
|
148
|
+
and value.split(".")[0] in own | embedded
|
|
149
|
+
):
|
|
150
|
+
raise _fail(
|
|
151
|
+
"TP-E005",
|
|
152
|
+
f"{where}:{node.lineno}: dynamic import of {value}",
|
|
153
|
+
where,
|
|
154
|
+
"import it with a plain import statement, which the build can point at the private copy",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def set_generator_version(source: str, version: str) -> str:
|
|
159
|
+
"""texmodel's `GENERATOR_VERSION` set to `version` (B10): a copy has no installed metadata to ask."""
|
|
160
|
+
unset = "GENERATOR_VERSION: str | None = None\n"
|
|
161
|
+
if source.count(unset) != 1:
|
|
162
|
+
raise _fail(
|
|
163
|
+
"TP-E006",
|
|
164
|
+
"texmodel.package no longer declares GENERATOR_VERSION as None",
|
|
165
|
+
"texmodel/package.py",
|
|
166
|
+
"this texpkg needs a texmodel that reads GENERATOR_VERSION when it is set (texmodel>=0.4.0)",
|
|
167
|
+
)
|
|
168
|
+
return source.replace(unset, f"GENERATOR_VERSION: str | None = {version!r}\n")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def copy_tree(source: pathlib.Path, dest: pathlib.Path, *, skip: tuple[str, ...] = ()) -> list[pathlib.Path]:
|
|
172
|
+
"""Copy a package directory, leaving out caches and `skip`; the `.py` files copied, for rewriting."""
|
|
173
|
+
shutil.copytree(source, dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", *skip), dirs_exist_ok=True)
|
|
174
|
+
return sorted(dest.rglob("*.py"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def rewrite_tree(root: pathlib.Path, old: tuple[str, ...], new: tuple[str, ...], mapping: Mapping) -> None:
|
|
178
|
+
"""Rewrite every `.py` under `root`, a package that stands at `old` and is moved to `new`."""
|
|
179
|
+
for path in sorted(root.rglob("*.py")):
|
|
180
|
+
rel = path.relative_to(root)
|
|
181
|
+
package_old = (*old, *rel.parts[:-1])
|
|
182
|
+
package_new = (*new, *rel.parts[:-1])
|
|
183
|
+
text = path.read_text(encoding="utf-8")
|
|
184
|
+
changed = rewrite(text, package_old, package_new, mapping, str(path))
|
|
185
|
+
if changed != text:
|
|
186
|
+
path.write_text(changed, encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _package_dir(name: str) -> pathlib.Path:
|
|
190
|
+
"""Where the importable package `name` is installed in this build environment."""
|
|
191
|
+
module = sys.modules.get(name) or __import__(name)
|
|
192
|
+
return pathlib.Path(next(iter(module.__path__)))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def embed(
|
|
196
|
+
out: pathlib.Path,
|
|
197
|
+
dist: Distribution,
|
|
198
|
+
declaration: pathlib.Path,
|
|
199
|
+
generated: dict[str, str],
|
|
200
|
+
frozen: Sequence[pathlib.Path] = (),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Write the module of `dist` into `out` (`<build dir>/<module>`): the generated files, the declaration copied to
|
|
203
|
+
`_declaration`, and under `_embedded` one copy each of texmodel, texpkg's run time and the base chain (P19), all
|
|
204
|
+
with their imports of one another rewritten (1.5)."""
|
|
205
|
+
from texmodel.package import generator_version
|
|
206
|
+
|
|
207
|
+
module = dist.module
|
|
208
|
+
embedded = {*RUN_TIME, *(d.module for d in dist.chain()[1:])}
|
|
209
|
+
own = {dist.declared_in.split(".")[0], module}
|
|
210
|
+
|
|
211
|
+
def to_copy(name: str) -> str | None:
|
|
212
|
+
return f"{module}._embedded.{name}" if name.split(".")[0] in embedded else None
|
|
213
|
+
|
|
214
|
+
copy_tree(declaration, out / "_declaration")
|
|
215
|
+
for snapshot in frozen:
|
|
216
|
+
copy_tree(snapshot, out / "_contracts" / snapshot.name)
|
|
217
|
+
for path in sorted((out / "_declaration").rglob("*.py")):
|
|
218
|
+
scan_declaration(path.read_text(encoding="utf-8"), str(path.relative_to(out)), own, embedded)
|
|
219
|
+
for rel, text in generated.items():
|
|
220
|
+
(out / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
(out / rel).write_text(text, encoding="utf-8")
|
|
222
|
+
rewrite_tree(out, (module,), (module,), to_copy)
|
|
223
|
+
base = out / EMBEDDED
|
|
224
|
+
base.mkdir()
|
|
225
|
+
(base / "__init__.py").write_text("", encoding="utf-8")
|
|
226
|
+
for name, skip in (("texmodel", ("cli.py",)), ("texpkg", ())):
|
|
227
|
+
copy_tree(_package_dir(name), base / name, skip=skip)
|
|
228
|
+
rewrite_tree(base / name, (name,), (module, EMBEDDED, name), to_copy)
|
|
229
|
+
patched = base / "texmodel" / "package.py"
|
|
230
|
+
patched.write_text(
|
|
231
|
+
set_generator_version(patched.read_text(encoding="utf-8"), generator_version()), encoding="utf-8"
|
|
232
|
+
)
|
|
233
|
+
if dist.base is not None:
|
|
234
|
+
_embed_base(base, module, dist.base.module)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _embed_base(base: pathlib.Path, module: str, base_module: str) -> None:
|
|
238
|
+
"""The installed base, flattened: its own files, then each package of its `_embedded` that is not texmodel or texpkg
|
|
239
|
+
(the dependent has its own copy of those, and the base's code is pointed at it)."""
|
|
240
|
+
source = _package_dir(base_module)
|
|
241
|
+
prefix = f"{base_module}.{EMBEDDED}."
|
|
242
|
+
|
|
243
|
+
def moved(name: str) -> str | None:
|
|
244
|
+
if name == prefix.removesuffix("."):
|
|
245
|
+
return f"{module}.{EMBEDDED}" # `from .._embedded import x`: the bases are now beside this one
|
|
246
|
+
return f"{module}.{EMBEDDED}.{name.removeprefix(prefix)}" if name.startswith(prefix) else None
|
|
247
|
+
|
|
248
|
+
copy_tree(source, base / base_module, skip=(EMBEDDED,))
|
|
249
|
+
rewrite_tree(base / base_module, (base_module,), (module, EMBEDDED, base_module), moved)
|
|
250
|
+
lifted = source / EMBEDDED
|
|
251
|
+
for path in sorted(lifted.iterdir()) if lifted.is_dir() else ():
|
|
252
|
+
if path.is_dir() and path.name not in RUN_TIME:
|
|
253
|
+
copy_tree(path, base / path.name)
|
|
254
|
+
rewrite_tree(base / path.name, (base_module, EMBEDDED, path.name), (module, EMBEDDED, path.name), moved)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""The generated module's source (P6, K4, V1): plain Python, deterministic (B5), importing the run time by its absolute
|
|
2
|
+
name (`texpkg`), which the embedding step later points at the private copy (1.5).
|
|
3
|
+
|
|
4
|
+
The module is `acme_tex/{__init__,_bound,_version,errors}.py`, `kinds/{__init__,<kind>}.py` and `_sty/<name>.sty`; the
|
|
5
|
+
declaration is imported from `acme_tex._declaration`, so its classes are real classes a type checker reads (K4).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import inspect
|
|
10
|
+
import textwrap
|
|
11
|
+
import typing
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
from texpkg import errors
|
|
16
|
+
from texpkg._internal import provenance_tex
|
|
17
|
+
from texpkg.contracts import Contracts
|
|
18
|
+
from texpkg.declaration import Distribution, Kind, reachable
|
|
19
|
+
from texpkg.errors import DeclarationError
|
|
20
|
+
|
|
21
|
+
_HEADER = '"""Generated by texpkg-build from the declaration. Do not edit."""\n'
|
|
22
|
+
_FOREIGN = ("builtins", "typing", "types", "collections", "texmodel", "enum", "pydantic")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_sty(dist: Distribution, version: str) -> dict[str, str]:
|
|
26
|
+
"""The `.sty` of the distribution's own package, rendered at `version` (P4, V1). The author's object is left as it
|
|
27
|
+
is. A base's was rendered when the base was built."""
|
|
28
|
+
package = copy.deepcopy(dist.package) # a copy that shares no list with the author's: the block is added to it
|
|
29
|
+
if not package.prefix:
|
|
30
|
+
raise DeclarationError(
|
|
31
|
+
f"package {package.name} has no prefix, so it cannot declare its provenance command",
|
|
32
|
+
code="TP-B011",
|
|
33
|
+
path=package.name,
|
|
34
|
+
hint='give the Package a prefix: Package("acme", prefix="ac")',
|
|
35
|
+
)
|
|
36
|
+
package.version = version
|
|
37
|
+
package.library("texpkg-provenance", provenance_tex.declarations(package.prefix, package.name, version))
|
|
38
|
+
return {f"{package.name}.sty": package.render()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error_names() -> tuple[str, ...]:
|
|
42
|
+
return tuple(
|
|
43
|
+
sorted(name for name, cls in inspect.getmembers(errors, inspect.isclass) if issubclass(cls, errors.TexpkgError))
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _types(hint: object) -> Iterator[type]:
|
|
48
|
+
"""The classes a field's annotation names that the declaration defines: a `Choice`, a bounded length (P24)."""
|
|
49
|
+
if typing.get_origin(hint) is Annotated:
|
|
50
|
+
yield from _types(typing.get_args(hint)[0])
|
|
51
|
+
elif args := typing.get_args(hint):
|
|
52
|
+
for arg in args:
|
|
53
|
+
yield from _types(arg)
|
|
54
|
+
elif isinstance(hint, type) and hint.__module__.split(".")[0] not in _FOREIGN:
|
|
55
|
+
yield hint
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def exported(kind: Kind) -> tuple[type, ...]:
|
|
59
|
+
"""The classes a kind module exports: the components its trees are made of, and the value types they use, once each
|
|
60
|
+
and in the order met."""
|
|
61
|
+
found: dict[type, None] = {}
|
|
62
|
+
for cls in reachable(kind.model):
|
|
63
|
+
found[cls] = None
|
|
64
|
+
for arg in cls.__tm_args__:
|
|
65
|
+
found.update(dict.fromkeys(_types(arg.hint)))
|
|
66
|
+
return tuple(found)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _import_from(dist: Distribution, cls: type) -> str:
|
|
70
|
+
"""`from .._declaration.model import Deck as Deck` for `cls`, relative to `kinds/<kind>.py`, wherever in the chain
|
|
71
|
+
it is declared."""
|
|
72
|
+
for owner in dist.chain():
|
|
73
|
+
prefix = owner.declared_in
|
|
74
|
+
if cls.__module__ == prefix or cls.__module__.startswith(prefix + "."):
|
|
75
|
+
if "." in cls.__qualname__:
|
|
76
|
+
break
|
|
77
|
+
where = "_declaration" if owner is dist else f"_embedded.{owner.module}._declaration"
|
|
78
|
+
return f"from ..{where}{cls.__module__.removeprefix(prefix)} import {cls.__name__} as {cls.__name__}"
|
|
79
|
+
raise DeclarationError(
|
|
80
|
+
f"{cls.__module__}.{cls.__qualname__} is not a top-level class of the declaration package or of a base",
|
|
81
|
+
code="TP-B002",
|
|
82
|
+
path=cls.__qualname__,
|
|
83
|
+
hint="declare it at module level inside the package named by declared_in, so the module can export it",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _module(body: str) -> str:
|
|
88
|
+
return _HEADER + "\n" + textwrap.dedent(body).strip() + "\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _kind_source(dist: Distribution, kind: Kind) -> str:
|
|
92
|
+
imports = "\n ".join(sorted({_import_from(dist, cls) for cls in exported(kind)})) # the template's indent
|
|
93
|
+
root = kind.model.__name__
|
|
94
|
+
return _module(f"""
|
|
95
|
+
from collections.abc import Mapping, Sequence
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
from typing import Any, Final, Literal
|
|
98
|
+
|
|
99
|
+
from texpkg.contracts import Changes
|
|
100
|
+
from texpkg.document import Document
|
|
101
|
+
from texpkg.migration import DefaultChange, Migration
|
|
102
|
+
|
|
103
|
+
from .. import _bound
|
|
104
|
+
{imports}
|
|
105
|
+
|
|
106
|
+
NAME: Final = {kind.name!r}
|
|
107
|
+
_api = _bound.RELEASE.kind(NAME, {root})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load(
|
|
111
|
+
path: Path | str,
|
|
112
|
+
*,
|
|
113
|
+
migrations: Sequence[Migration] = (),
|
|
114
|
+
default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
|
|
115
|
+
) -> Document[{root}]:
|
|
116
|
+
return _api.load(path, migrations, default_changes)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def create(path: Path | str, tree: {root}) -> Document[{root}]:
|
|
120
|
+
return _api.create(path, tree)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def schema(mode: Literal["validation", "serialization"] = "validation") -> dict[str, Any]:
|
|
124
|
+
return _api.schema(mode)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def example() -> {root}:
|
|
128
|
+
return _api.example()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def variants() -> tuple[str, ...]:
|
|
132
|
+
return _api.variants()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def changes(old: int, new: int) -> Changes:
|
|
136
|
+
return _api.changes(old, new)
|
|
137
|
+
""")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _tuple(items: list[str]) -> str:
|
|
141
|
+
return "(" + "".join(f"{item!r}, " for item in items).strip() + ")"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sources(dist: Distribution, pointer_attr: str, version: str, contracts: Contracts) -> dict[str, str]:
|
|
145
|
+
"""The generated files by path inside the module, for `dist` released at `version`. `pointer_attr` is the name
|
|
146
|
+
`[tool.texpkg] distribution` gives the declaration inside its package."""
|
|
147
|
+
offered = dist.kinds_offered()
|
|
148
|
+
names = [k.name for k in offered]
|
|
149
|
+
sty = render_sty(dist, version)
|
|
150
|
+
c = contracts
|
|
151
|
+
imports = f"from . import {', '.join(names)}" if names else ""
|
|
152
|
+
base = dist.base
|
|
153
|
+
base_import = f"from ._embedded.{base.module} import _bound as _base" if base else ""
|
|
154
|
+
base_sty = "**_base.STY, " if base else ""
|
|
155
|
+
base_release = "(_base.RELEASE,), " if base else "(), "
|
|
156
|
+
return (
|
|
157
|
+
{f"_sty/{name}": text for name, text in sty.items()}
|
|
158
|
+
| {
|
|
159
|
+
"__init__.py": _module("""
|
|
160
|
+
import datetime
|
|
161
|
+
from collections.abc import Mapping, Sequence
|
|
162
|
+
from pathlib import Path
|
|
163
|
+
from typing import Any, Final
|
|
164
|
+
|
|
165
|
+
from texpkg.document import Document
|
|
166
|
+
from texpkg.migration import DefaultChange, Migrated, Migration, Unresolved, migration
|
|
167
|
+
|
|
168
|
+
from . import errors, kinds
|
|
169
|
+
from ._bound import RELEASE
|
|
170
|
+
from ._version import CONTRACTS, VERSION
|
|
171
|
+
|
|
172
|
+
__version__: Final = VERSION
|
|
173
|
+
dist: Final = RELEASE.dist # for a package built on this one (P14)
|
|
174
|
+
__all__ = [
|
|
175
|
+
"CONTRACTS",
|
|
176
|
+
"DefaultChange",
|
|
177
|
+
"Migrated",
|
|
178
|
+
"Unresolved",
|
|
179
|
+
"__version__",
|
|
180
|
+
"dist",
|
|
181
|
+
"errors",
|
|
182
|
+
"export",
|
|
183
|
+
"kinds",
|
|
184
|
+
"load",
|
|
185
|
+
"migration",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def export(
|
|
190
|
+
dest: Path | str, *, now: datetime.datetime | None = None, stamp: str | None = None
|
|
191
|
+
) -> tuple[Path, ...]:
|
|
192
|
+
return RELEASE.export(dest, now, stamp)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load(
|
|
196
|
+
path: Path | str,
|
|
197
|
+
*,
|
|
198
|
+
migrations: Sequence[Migration] = (),
|
|
199
|
+
default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
|
|
200
|
+
) -> Document[Any]:
|
|
201
|
+
return RELEASE.load(path, migrations, default_changes)
|
|
202
|
+
"""),
|
|
203
|
+
"_version.py": _module(f"""
|
|
204
|
+
from typing import Final
|
|
205
|
+
|
|
206
|
+
from texpkg.contracts import Contracts, Pair, Triple
|
|
207
|
+
|
|
208
|
+
VERSION: Final = {version!r}
|
|
209
|
+
CONTRACTS: Final = Contracts(
|
|
210
|
+
Triple({c.data.current}, {c.data.revision}, {c.data.age}),
|
|
211
|
+
Triple({c.interface.current}, {c.interface.revision}, {c.interface.age}),
|
|
212
|
+
Pair({c.output.current}, {c.output.revision}),
|
|
213
|
+
{c.base_data!r},
|
|
214
|
+
)
|
|
215
|
+
"""),
|
|
216
|
+
"_bound.py": _module(f"""
|
|
217
|
+
from importlib.resources import files
|
|
218
|
+
from typing import Final
|
|
219
|
+
|
|
220
|
+
from texpkg._internal.release import Release
|
|
221
|
+
|
|
222
|
+
from ._declaration import {pointer_attr}
|
|
223
|
+
from ._version import CONTRACTS, VERSION
|
|
224
|
+
{base_import}
|
|
225
|
+
|
|
226
|
+
_NAMES: Final = {_tuple(list(sty))}
|
|
227
|
+
_SNAPSHOTS: Final = files(__package__).joinpath("_contracts")
|
|
228
|
+
_OWN: Final = {{
|
|
229
|
+
name: files(__package__).joinpath("_sty", name).read_text(encoding="utf-8") for name in _NAMES
|
|
230
|
+
}}
|
|
231
|
+
STY: Final = {{{base_sty}**_OWN}}
|
|
232
|
+
RELEASE: Final = Release({pointer_attr}, VERSION, CONTRACTS, STY, {base_release}_SNAPSHOTS)
|
|
233
|
+
"""),
|
|
234
|
+
"errors.py": _module(
|
|
235
|
+
"from texpkg.errors import (\n" + "".join(f" {name} as {name},\n" for name in error_names()) + ")\n"
|
|
236
|
+
),
|
|
237
|
+
"kinds/__init__.py": _module(f"""
|
|
238
|
+
from collections.abc import Mapping
|
|
239
|
+
|
|
240
|
+
from texpkg.protocol import KindModule
|
|
241
|
+
|
|
242
|
+
{imports}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def names() -> tuple[str, ...]:
|
|
246
|
+
return {_tuple(names)}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def modules() -> Mapping[str, KindModule]:
|
|
250
|
+
return {{{", ".join(f"{n!r}: {n}" for n in names)}}}
|
|
251
|
+
"""),
|
|
252
|
+
}
|
|
253
|
+
| {f"kinds/{kind.name}.py": _kind_source(dist, kind) for kind in offered}
|
|
254
|
+
)
|