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,86 @@
|
|
|
1
|
+
"""The build imports the author's declaration into the process it runs in, and leaves no trace of it (B2).
|
|
2
|
+
|
|
3
|
+
While it is imported, the author's source is on `sys.path`, the modules of the declaration and of the generated module
|
|
4
|
+
are the build's own copies (a copy that is already loaded is put aside and restored), and
|
|
5
|
+
`<base>._embedded.texmodel` and
|
|
6
|
+
`.texpkg` are the modules this process runs on, as the wheel's single copy will be.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import importlib
|
|
11
|
+
import importlib.abc
|
|
12
|
+
import importlib.machinery
|
|
13
|
+
import importlib.util
|
|
14
|
+
import pathlib
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Generator
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
|
|
19
|
+
from . import embed
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LinkRunTime(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
23
|
+
"""Static linking at build time: an installed base carries its own private texmodel and texpkg, but the declaration
|
|
24
|
+
being built must use classes of one texmodel. While it is imported, `<base>._embedded.texmodel` and `.texpkg` are
|
|
25
|
+
the
|
|
26
|
+
modules this build runs on, as the wheel's single copy will be."""
|
|
27
|
+
|
|
28
|
+
def find_spec(
|
|
29
|
+
self, fullname: str, path: object = None, target: object = None
|
|
30
|
+
) -> importlib.machinery.ModuleSpec | None:
|
|
31
|
+
parts = fullname.split(".")
|
|
32
|
+
if len(parts) > 2 and parts[1] == embed.EMBEDDED and parts[2] in embed.RUN_TIME:
|
|
33
|
+
return importlib.util.spec_from_loader(fullname, self)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType:
|
|
37
|
+
return importlib.import_module(spec.name.split(f".{embed.EMBEDDED}.", 1)[1])
|
|
38
|
+
|
|
39
|
+
def exec_module(self, module: ModuleType) -> None:
|
|
40
|
+
"""The module is the build's own and already runs."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Isolation:
|
|
44
|
+
"""The modules of the author's declaration and of the module generated from it, imported for the build and forgotten
|
|
45
|
+
after: the build runs in the author's environment, which need not install the project, and a build must not see (or
|
|
46
|
+
leave behind) another copy of them already imported in the process."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._tops: set[str] = set()
|
|
50
|
+
self._stashed: dict[str, ModuleType] = {}
|
|
51
|
+
|
|
52
|
+
def forget(self, top: str) -> None:
|
|
53
|
+
"""Take the modules of the package `top` out of the way until the build is over."""
|
|
54
|
+
self._tops.add(top)
|
|
55
|
+
self._stashed |= {n: sys.modules.pop(n) for n in list(sys.modules) if n.split(".")[0] == top}
|
|
56
|
+
|
|
57
|
+
def drop(self, top: str) -> None:
|
|
58
|
+
"""Forget what is imported under `top` from now on, keeping what is imported now."""
|
|
59
|
+
self._tops.add(top)
|
|
60
|
+
|
|
61
|
+
def restore(self) -> None:
|
|
62
|
+
for name in [n for n in sys.modules if n.split(".")[0] in self._tops]:
|
|
63
|
+
del sys.modules[name]
|
|
64
|
+
sys.modules.update(self._stashed)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def author_source(source: pathlib.Path) -> Generator[Isolation]:
|
|
69
|
+
"""`source` on `sys.path` for the block, and the modules that were forgotten and imported put right afterwards.
|
|
70
|
+
|
|
71
|
+
Importing `<base>._embedded.texpkg.migration` through the finder binds `migration` on the real `texpkg` package,
|
|
72
|
+
over the function of that name it exports; the packages' namespaces are put back too."""
|
|
73
|
+
where = str(source)
|
|
74
|
+
sys.path.insert(0, where)
|
|
75
|
+
linker = LinkRunTime()
|
|
76
|
+
sys.meta_path.insert(0, linker)
|
|
77
|
+
linked = {name: dict(vars(sys.modules[name])) for name in embed.RUN_TIME if name in sys.modules}
|
|
78
|
+
isolation = Isolation()
|
|
79
|
+
try:
|
|
80
|
+
yield isolation
|
|
81
|
+
finally:
|
|
82
|
+
sys.meta_path.remove(linker)
|
|
83
|
+
sys.path.remove(where)
|
|
84
|
+
isolation.restore()
|
|
85
|
+
for name, namespace in linked.items():
|
|
86
|
+
vars(sys.modules[name]).update(namespace)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Contract snapshots, the versions file and freezing (V11, V35-V37).
|
|
2
|
+
|
|
3
|
+
`contracts/next/` is the working snapshot an author reviews and commits; `contracts/<release>/` is a frozen copy that is
|
|
4
|
+
never rewritten and ships in the wheel. Nothing here needs TeX: the output fingerprint is the hash of what is rendered.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import hashlib
|
|
9
|
+
import importlib
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from texmodel.components import Command, Environment, Value, control_name
|
|
18
|
+
from texmodel.errors import DeclarationError as TexmodelDeclarationError
|
|
19
|
+
from texmodel.interface import Body
|
|
20
|
+
|
|
21
|
+
from texpkg import errors
|
|
22
|
+
from texpkg._internal import classify, state
|
|
23
|
+
from texpkg._internal.release import Release
|
|
24
|
+
from texpkg._internal.versions import Versions
|
|
25
|
+
from texpkg.declaration import Distribution
|
|
26
|
+
|
|
27
|
+
from . import generate
|
|
28
|
+
|
|
29
|
+
CONTRACTS = "contracts"
|
|
30
|
+
NEXT = "next"
|
|
31
|
+
VERSIONS = "versions.toml"
|
|
32
|
+
_RELEASE = re.compile(r"\d+\.\d+\.\d+")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dumps(value: object) -> str:
|
|
36
|
+
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sha(data: str | bytes) -> str:
|
|
40
|
+
return hashlib.sha256(data.encode("utf-8") if isinstance(data, str) else data).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _body(body: Body) -> str:
|
|
44
|
+
"""What an argument holds, without a `Choice`'s members (they belong to the data contract, not the interface)."""
|
|
45
|
+
return body.kind.value + (f" {body.choice.__name__}" if body.choice else "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _component(cls: type) -> dict[str, Any]:
|
|
49
|
+
plain: type = cls # `cls` is narrowed below; the name lookups want the declared type
|
|
50
|
+
entry: dict[str, Any] = {"doc": str(getattr(cls, "__tm_doc__", ""))}
|
|
51
|
+
if issubclass(cls, Command | Environment):
|
|
52
|
+
entry["kind"] = "environment" if issubclass(cls, Environment) else "command"
|
|
53
|
+
entry["signature"] = [[a.name, _body(a.body), a.optional] for a in cls.__tm_args__]
|
|
54
|
+
elif issubclass(cls, Value):
|
|
55
|
+
entry["kind"] = "value"
|
|
56
|
+
entry["type"] = cls.__tm_kind__.name
|
|
57
|
+
try:
|
|
58
|
+
entry["cs"] = control_name(plain)
|
|
59
|
+
except TexmodelDeclarationError:
|
|
60
|
+
entry["cs"] = str(getattr(plain, "colour_name", None) or "") # a colour is named, not a control sequence
|
|
61
|
+
return entry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def interface_of(dist: Distribution) -> dict[str, Any]:
|
|
65
|
+
"""The public TeX names with their signatures, and the kinds with their variants (V11): from the declaration."""
|
|
66
|
+
components = {cls.__name__: _component(cls) for cls in dist.package.registered}
|
|
67
|
+
kinds = {
|
|
68
|
+
k.name: {"variants": [v.name for v in k.variants], "over": k.over or "", "exposed": k.name in dist.expose}
|
|
69
|
+
for k in dist.kinds_offered()
|
|
70
|
+
}
|
|
71
|
+
return {"components": components, "kinds": kinds}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build(dist: Distribution, release: Release, sty: Mapping[str, str]) -> dict[str, str]:
|
|
75
|
+
"""Every file of a snapshot except the versions file, by path: both schemas of every kind, the interface, each
|
|
76
|
+
example's data and rendered `.tex`, and the fingerprint of the output (one hash per file, V35)."""
|
|
77
|
+
files: dict[str, str] = {"interface.json": _dumps(interface_of(dist))}
|
|
78
|
+
examples: dict[str, str] = {}
|
|
79
|
+
for kind in dist.kinds_offered():
|
|
80
|
+
files[f"kinds/{kind.name}.input.schema.json"] = _dumps(state.schema(kind.model, "validation"))
|
|
81
|
+
files[f"kinds/{kind.name}.stored.schema.json"] = _dumps(state.schema(kind.model, "serialization"))
|
|
82
|
+
tree = kind.example()
|
|
83
|
+
files[f"examples/{kind.name}.json"] = _dumps(state.tree_state(tree))
|
|
84
|
+
for variant, name in kind.documents():
|
|
85
|
+
tex = release.plan(kind, tree, variant).text
|
|
86
|
+
files[f"examples/{kind.name}/{name}"] = tex
|
|
87
|
+
examples[f"{kind.name}/{name}"] = _sha(tex)
|
|
88
|
+
output = {
|
|
89
|
+
"sty": {name: _sha(text) for name, text in sorted(sty.items())},
|
|
90
|
+
"assets": {r.asset.path: _sha(r.source.read_bytes()) for r in dist.resolved_assets()},
|
|
91
|
+
"examples": examples,
|
|
92
|
+
}
|
|
93
|
+
files["output.json"] = _dumps(output)
|
|
94
|
+
return files
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def frozen(root: pathlib.Path) -> list[pathlib.Path]:
|
|
98
|
+
"""The frozen snapshots under `root/contracts`, oldest first."""
|
|
99
|
+
base = root / CONTRACTS
|
|
100
|
+
found = [p for p in base.iterdir() if p.is_dir() and _RELEASE.fullmatch(p.name)] if base.is_dir() else []
|
|
101
|
+
return sorted(found, key=lambda p: classify.parse_version(p.name))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_versions(directory: pathlib.Path) -> Versions | None:
|
|
105
|
+
path = directory / VERSIONS
|
|
106
|
+
return Versions.parse(path.read_text(encoding="utf-8")) if path.is_file() else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def output_changes(
|
|
110
|
+
previous: pathlib.Path, dist: Distribution, release: Release, sty: Mapping[str, str]
|
|
111
|
+
) -> tuple[list[str], list[str]]:
|
|
112
|
+
"""The files whose bytes changed against the previous release, and the examples that could not be compared (V35).
|
|
113
|
+
|
|
114
|
+
The previous release's example data is rendered with this package and compared with the previous release's
|
|
115
|
+
`.tex`, so editing an example is not a change; an example whose data no longer loads is reported, never guessed.
|
|
116
|
+
"""
|
|
117
|
+
before = json.loads((previous / "output.json").read_text(encoding="utf-8"))
|
|
118
|
+
now = {
|
|
119
|
+
"sty": {n: _sha(t) for n, t in sty.items()},
|
|
120
|
+
"assets": {r.asset.path: _sha(r.source.read_bytes()) for r in dist.resolved_assets()},
|
|
121
|
+
}
|
|
122
|
+
changed = [
|
|
123
|
+
f"{part}: {name}"
|
|
124
|
+
for part in ("sty", "assets")
|
|
125
|
+
for name in sorted(before[part].keys() | now[part].keys())
|
|
126
|
+
if before[part].get(name) != now[part].get(name)
|
|
127
|
+
]
|
|
128
|
+
not_compared: list[str] = []
|
|
129
|
+
for kind in dist.kinds_offered():
|
|
130
|
+
saved = previous / "examples" / f"{kind.name}.json"
|
|
131
|
+
if not saved.is_file():
|
|
132
|
+
continue # a kind that is new has no baseline
|
|
133
|
+
try:
|
|
134
|
+
tree = state.tree_from_state(kind.model, json.loads(saved.read_text(encoding="utf-8")))
|
|
135
|
+
except ExceptionGroup, ValueError, KeyError:
|
|
136
|
+
not_compared.append(kind.name)
|
|
137
|
+
continue
|
|
138
|
+
for variant, name in kind.documents():
|
|
139
|
+
was = before["examples"].get(f"{kind.name}/{name}")
|
|
140
|
+
if was is not None and was != _sha(release.plan(kind, tree, variant).text):
|
|
141
|
+
changed.append(f"example: {kind.name}/{name}")
|
|
142
|
+
return changed, not_compared
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclasses.dataclass(frozen=True)
|
|
146
|
+
class Proposal:
|
|
147
|
+
files: dict[str, str] # the snapshot, with the versions file as `versions.toml`
|
|
148
|
+
versions: Versions
|
|
149
|
+
data: list[classify.Change]
|
|
150
|
+
interface: list[classify.Change]
|
|
151
|
+
output: list[str]
|
|
152
|
+
not_compared: list[str]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def propose(
|
|
156
|
+
root: pathlib.Path,
|
|
157
|
+
dist: Distribution,
|
|
158
|
+
release: Release,
|
|
159
|
+
sty: Mapping[str, str],
|
|
160
|
+
*,
|
|
161
|
+
first: str = "0.1.0",
|
|
162
|
+
base_current: tuple[int, int] = (0, 0),
|
|
163
|
+
) -> Proposal:
|
|
164
|
+
"""The snapshot of this declaration and the versions it implies against the newest frozen release (V12, V13),
|
|
165
|
+
keeping what the author wrote in `contracts/next/versions.toml`: the `output` classification and the notes."""
|
|
166
|
+
files = build(dist, release, sty)
|
|
167
|
+
authored = read_versions(root / CONTRACTS / NEXT)
|
|
168
|
+
notes = dict(authored.notes) if authored else {}
|
|
169
|
+
change = authored.output_change if authored else ""
|
|
170
|
+
previous = frozen(root)
|
|
171
|
+
if not previous:
|
|
172
|
+
versions = Versions(
|
|
173
|
+
first,
|
|
174
|
+
notes=notes,
|
|
175
|
+
releases={1: first},
|
|
176
|
+
base_data={1: base_current[0]} if dist.base else {},
|
|
177
|
+
base_interface=authored.base_interface if authored else base_current[1],
|
|
178
|
+
)
|
|
179
|
+
return Proposal(files | {VERSIONS: versions.to_text()}, versions, [], [], [], [])
|
|
180
|
+
last = previous[-1]
|
|
181
|
+
old = read_versions(last)
|
|
182
|
+
assert old is not None
|
|
183
|
+
data_changes: list[classify.Change] = []
|
|
184
|
+
for kind in dist.kinds_offered():
|
|
185
|
+
was = last / "kinds" / f"{kind.name}.input.schema.json"
|
|
186
|
+
if was.is_file():
|
|
187
|
+
data_changes += [
|
|
188
|
+
dataclasses.replace(c, where=f"{kind.name}: {c.where}")
|
|
189
|
+
for c in classify.schema_changes(
|
|
190
|
+
json.loads(was.read_text(encoding="utf-8")),
|
|
191
|
+
json.loads(files[f"kinds/{kind.name}.input.schema.json"]),
|
|
192
|
+
)
|
|
193
|
+
]
|
|
194
|
+
else:
|
|
195
|
+
data_changes.append(classify.Change(classify.Level.COMPATIBLE, f"kind {kind.name}", "added"))
|
|
196
|
+
kinds_before = {p.name.removesuffix(".input.schema.json") for p in (last / "kinds").glob("*.input.schema.json")}
|
|
197
|
+
data_changes += [
|
|
198
|
+
classify.Change(classify.Level.BREAKING, f"kind {n}", "removed")
|
|
199
|
+
for n in sorted(kinds_before - {k.name for k in dist.kinds_offered()})
|
|
200
|
+
]
|
|
201
|
+
interface_changes = classify.interface_changes(
|
|
202
|
+
json.loads((last / "interface.json").read_text(encoding="utf-8")), json.loads(files["interface.json"])
|
|
203
|
+
)
|
|
204
|
+
changed, not_compared = output_changes(last, dist, release, sty)
|
|
205
|
+
data = classify.bump(old.data, classify.worst(data_changes))
|
|
206
|
+
interface = classify.bump(old.interface, classify.worst(interface_changes))
|
|
207
|
+
output = classify.bump_output(old.output, change if changed else "")
|
|
208
|
+
required = classify.required_release(
|
|
209
|
+
old.release, classify.worst(data_changes), classify.worst(interface_changes), bool(changed)
|
|
210
|
+
)
|
|
211
|
+
releases = dict(old.releases)
|
|
212
|
+
if data.current != old.data.current:
|
|
213
|
+
releases[data.current] = required
|
|
214
|
+
base_data = dict(old.base_data)
|
|
215
|
+
if dist.base is not None and data.current != old.data.current:
|
|
216
|
+
base_data[data.current] = base_current[0]
|
|
217
|
+
versions = Versions(
|
|
218
|
+
required,
|
|
219
|
+
data,
|
|
220
|
+
interface,
|
|
221
|
+
output,
|
|
222
|
+
change if changed else "",
|
|
223
|
+
notes,
|
|
224
|
+
releases,
|
|
225
|
+
base_data,
|
|
226
|
+
authored.base_interface if authored and authored.base_interface else old.base_interface,
|
|
227
|
+
)
|
|
228
|
+
return Proposal(
|
|
229
|
+
files | {VERSIONS: versions.to_text()}, versions, data_changes, interface_changes, changed, not_compared
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def write_next(root: pathlib.Path, files: Mapping[str, str]) -> None:
|
|
234
|
+
"""Write the working snapshot, replacing the previous one."""
|
|
235
|
+
target = root / CONTRACTS / NEXT
|
|
236
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
237
|
+
for rel, text in files.items():
|
|
238
|
+
(target / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
(target / rel).write_text(text, encoding="utf-8")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def stale(root: pathlib.Path, files: Mapping[str, str]) -> list[str]:
|
|
243
|
+
"""The files of `contracts/next` that are not what this declaration gives (V12): none means it is current."""
|
|
244
|
+
target = root / CONTRACTS / NEXT
|
|
245
|
+
held: set[str] = (
|
|
246
|
+
{p.relative_to(target).as_posix() for p in target.rglob("*") if p.is_file()} if target.is_dir() else set()
|
|
247
|
+
)
|
|
248
|
+
return sorted(
|
|
249
|
+
rel
|
|
250
|
+
for rel in held | files.keys()
|
|
251
|
+
if not (target / rel).is_file() or rel not in files or (target / rel).read_text(encoding="utf-8") != files[rel]
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def freeze(root: pathlib.Path) -> pathlib.Path:
|
|
256
|
+
"""Copy `contracts/next` to `contracts/<release>`, which is never rewritten (V37)."""
|
|
257
|
+
versions = read_versions(root / CONTRACTS / NEXT)
|
|
258
|
+
if versions is None:
|
|
259
|
+
raise errors.DeclarationError("no contracts/next to freeze", code="TP-C001", hint="run texpkg-build contracts")
|
|
260
|
+
target = root / CONTRACTS / versions.release
|
|
261
|
+
if target.exists():
|
|
262
|
+
raise errors.DeclarationError(
|
|
263
|
+
f"contracts/{versions.release} is frozen already",
|
|
264
|
+
code="TP-C002",
|
|
265
|
+
path=str(target),
|
|
266
|
+
hint="a frozen snapshot is never rewritten: the next release has another number",
|
|
267
|
+
)
|
|
268
|
+
shutil.copytree(root / CONTRACTS / NEXT, target)
|
|
269
|
+
return target
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sty_of(dist: Distribution) -> dict[str, str]:
|
|
273
|
+
"""The `.sty` files the output fingerprint is taken from: the base chain's as the bases shipped them, this package's
|
|
274
|
+
rendered at a fixed version so that a release number is not an output change."""
|
|
275
|
+
shipped: dict[str, str] = {}
|
|
276
|
+
if dist.base is not None:
|
|
277
|
+
shipped = dict(importlib.import_module(dist.base.module)._bound.STY) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownArgumentType]
|
|
278
|
+
return {**shipped, **generate.render_sty(dist, "0.0.0")}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def base_current(dist: Distribution) -> tuple[int, int]:
|
|
282
|
+
"""The `data` and `interface` `current` of the base this is built on (P30, V17); zeros without one."""
|
|
283
|
+
if dist.base is None:
|
|
284
|
+
return (0, 0)
|
|
285
|
+
contracts = importlib.import_module(dist.base.module).CONTRACTS # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
|
|
286
|
+
return (contracts.data.current, contracts.interface.current) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def required_for(previous: pathlib.Path | None, this: pathlib.Path) -> str:
|
|
290
|
+
"""The release number the frozen `this` needs against the one before it (V13); the first needs none."""
|
|
291
|
+
after = read_versions(this)
|
|
292
|
+
assert after is not None
|
|
293
|
+
if previous is None:
|
|
294
|
+
return after.release
|
|
295
|
+
before = read_versions(previous)
|
|
296
|
+
assert before is not None
|
|
297
|
+
data: list[classify.Change] = []
|
|
298
|
+
for path in sorted((this / "kinds").glob("*.input.schema.json")):
|
|
299
|
+
old = previous / "kinds" / path.name
|
|
300
|
+
data += (
|
|
301
|
+
classify.schema_changes(
|
|
302
|
+
json.loads(old.read_text(encoding="utf-8")), json.loads(path.read_text(encoding="utf-8"))
|
|
303
|
+
)
|
|
304
|
+
if old.is_file()
|
|
305
|
+
else [classify.Change(classify.Level.COMPATIBLE, path.name, "added")]
|
|
306
|
+
)
|
|
307
|
+
data += [
|
|
308
|
+
classify.Change(classify.Level.BREAKING, path.name, "removed")
|
|
309
|
+
for path in (previous / "kinds").glob("*.input.schema.json")
|
|
310
|
+
if not (this / "kinds" / path.name).is_file()
|
|
311
|
+
]
|
|
312
|
+
interface = classify.interface_changes(
|
|
313
|
+
json.loads((previous / "interface.json").read_text(encoding="utf-8")),
|
|
314
|
+
json.loads((this / "interface.json").read_text(encoding="utf-8")),
|
|
315
|
+
)
|
|
316
|
+
changed = (previous / "output.json").read_text(encoding="utf-8") != (this / "output.json").read_text(
|
|
317
|
+
encoding="utf-8"
|
|
318
|
+
)
|
|
319
|
+
return classify.required_release(before.release, classify.worst(data), classify.worst(interface), changed)
|
texpkg_build/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""`texpkg-build` (V12, V37). The only module of the build that prints."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pathlib
|
|
5
|
+
import tomllib
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from texpkg.declaration import Distribution
|
|
11
|
+
from texpkg.errors import TexpkgError
|
|
12
|
+
|
|
13
|
+
from ._internal import snapshot
|
|
14
|
+
from ._internal.isolate import author_source
|
|
15
|
+
from .testing import load_declaration
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(pretty_exceptions_enable=False, help="Build tools for texpkg packages.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _project(root: pathlib.Path) -> tuple[str, pathlib.Path]:
|
|
21
|
+
"""The pointer in `[tool.texpkg] distribution` and the directory the project's source is in."""
|
|
22
|
+
config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
23
|
+
pointer = str(config.get("tool", {}).get("texpkg", {}).get("distribution", ""))
|
|
24
|
+
if not all(pointer.partition(":")[::2]):
|
|
25
|
+
raise typer.BadParameter(f"{root / 'pyproject.toml'}: [tool.texpkg] distribution is not module:attribute")
|
|
26
|
+
return pointer, root / config.get("tool", {}).get("pdm", {}).get("build", {}).get("package-dir", ".")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def main() -> None:
|
|
31
|
+
"""Build tools for texpkg packages."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def contracts(
|
|
36
|
+
root: Annotated[pathlib.Path, typer.Option(help="The project directory.")] = pathlib.Path(),
|
|
37
|
+
freeze: Annotated[bool, typer.Option(help="Freeze contracts/next as contracts/<release>.")] = False,
|
|
38
|
+
first: Annotated[str, typer.Option(help="The release number of a first snapshot.")] = "0.1.0",
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Write contracts/next: the snapshot of the declaration and the versions it implies against the newest frozen
|
|
41
|
+
release. Review the files; with --freeze, keep them as the release."""
|
|
42
|
+
root = root.resolve()
|
|
43
|
+
pointer, source = _project(root)
|
|
44
|
+
module, _, attr = pointer.partition(":")
|
|
45
|
+
try:
|
|
46
|
+
with author_source(source) as isolation:
|
|
47
|
+
isolation.forget(module.split(".")[0])
|
|
48
|
+
dist = getattr(importlib.import_module(module), attr)
|
|
49
|
+
if not isinstance(dist, Distribution):
|
|
50
|
+
raise typer.BadParameter(f"{pointer} is not a Distribution")
|
|
51
|
+
isolation.forget(dist.module)
|
|
52
|
+
for base in dist.chain()[1:]:
|
|
53
|
+
isolation.drop(base.module)
|
|
54
|
+
release = load_declaration(pointer, version="0.0.0")._bound.RELEASE # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
|
|
55
|
+
proposal = snapshot.propose(
|
|
56
|
+
root,
|
|
57
|
+
dist,
|
|
58
|
+
release, # pyright: ignore[reportUnknownArgumentType]
|
|
59
|
+
snapshot.sty_of(dist),
|
|
60
|
+
first=first,
|
|
61
|
+
base_current=snapshot.base_current(dist),
|
|
62
|
+
)
|
|
63
|
+
snapshot.write_next(root, proposal.files)
|
|
64
|
+
for label, found in (("data", proposal.data), ("interface", proposal.interface)):
|
|
65
|
+
for change in found:
|
|
66
|
+
typer.echo(f"{label}: {change.level.name.lower()}: {change.where}: {change.what}")
|
|
67
|
+
for name in proposal.output:
|
|
68
|
+
typer.echo(f"output: changed: {name}")
|
|
69
|
+
for name in proposal.not_compared:
|
|
70
|
+
typer.echo(f"output: not compared: {name} (its example data no longer loads)")
|
|
71
|
+
v = proposal.versions
|
|
72
|
+
typer.echo(
|
|
73
|
+
f"release {v.release}: data {v.data.current}:{v.data.revision}:{v.data.age} "
|
|
74
|
+
f"interface {v.interface.current}:{v.interface.revision}:{v.interface.age} "
|
|
75
|
+
f"output {v.output.current}:{v.output.revision}"
|
|
76
|
+
)
|
|
77
|
+
if proposal.output and not v.output_change:
|
|
78
|
+
typer.echo('output changed: say how in contracts/next/versions.toml, change = "current" or "revision"')
|
|
79
|
+
if freeze:
|
|
80
|
+
typer.echo(f"frozen: {snapshot.freeze(root)}")
|
|
81
|
+
except TexpkgError as exc:
|
|
82
|
+
typer.echo(f"error {exc.code}: {exc}" + (f"\n hint: {exc.hint}" if exc.hint else ""), err=True)
|
|
83
|
+
raise typer.Exit(1) from exc
|
texpkg_build/hook.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The pdm build hook (B2): `pdm_build.py` re-exports these two functions.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from texpkg_build.hook import pdm_build_initialize, pdm_build_update_files
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
`[tool.texpkg] distribution = "module:attribute"` names the declaration (B3). The wheel holds the generated module and
|
|
8
|
+
the declaration copied into it; the sdist holds the author's sources and builds the same wheel (B6).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
from pdm.backend.hooks import Context
|
|
15
|
+
|
|
16
|
+
from texpkg.declaration import Distribution
|
|
17
|
+
from texpkg.errors import DeclarationError
|
|
18
|
+
|
|
19
|
+
from ._internal import checks, embed, generate
|
|
20
|
+
from ._internal.isolate import author_source
|
|
21
|
+
from .testing import load_declaration
|
|
22
|
+
|
|
23
|
+
PYDANTIC = "pydantic>=2,<3" # the one thing a wheel cannot embed: it is compiled (B7)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pointer(context: Context) -> tuple[str, str]:
|
|
27
|
+
given = context.config.data.get("tool", {}).get("texpkg", {}).get("distribution")
|
|
28
|
+
module, _, attr = str(given).partition(":")
|
|
29
|
+
if not (module and attr):
|
|
30
|
+
raise DeclarationError(
|
|
31
|
+
f"{context.root / 'pyproject.toml'}: [tool.texpkg] distribution is {given!r}",
|
|
32
|
+
code="TP-B007",
|
|
33
|
+
hint='write distribution = "module:attribute", the Distribution the build generates the module from',
|
|
34
|
+
)
|
|
35
|
+
return module, attr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pdm_build_initialize(context: Context) -> None:
|
|
39
|
+
"""A wheel needs pydantic and nothing else: texmodel, texpkg and the bases are embedded in it (B7)."""
|
|
40
|
+
metadata = context.config.metadata
|
|
41
|
+
metadata["dependencies"] = [PYDANTIC]
|
|
42
|
+
if "dynamic" in metadata and "dependencies" in metadata["dynamic"]:
|
|
43
|
+
metadata["dynamic"].remove("dependencies")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pdm_build_update_files(context: Context, files: dict[str, pathlib.Path]) -> None:
|
|
47
|
+
if context.target == "sdist":
|
|
48
|
+
return
|
|
49
|
+
if context.target != "wheel":
|
|
50
|
+
raise DeclarationError(
|
|
51
|
+
f"a {context.target} build of a texpkg package",
|
|
52
|
+
code="TP-B008",
|
|
53
|
+
hint="the module is generated from the declaration: test it with texpkg_build.testing.load_declaration",
|
|
54
|
+
)
|
|
55
|
+
module, attr = _pointer(context)
|
|
56
|
+
with author_source(context.root / (context.config.build_config.package_dir or ".")) as isolation:
|
|
57
|
+
isolation.forget(module.split(".")[0])
|
|
58
|
+
dist = getattr(importlib.import_module(module), attr, None)
|
|
59
|
+
if not isinstance(dist, Distribution):
|
|
60
|
+
raise DeclarationError(f"{module}:{attr} is not a Distribution", code="TP-B003", path=f"{module}:{attr}")
|
|
61
|
+
isolation.forget(dist.module)
|
|
62
|
+
for base in dist.chain()[1:]:
|
|
63
|
+
isolation.drop(base.module)
|
|
64
|
+
declaration = checks.declaration_directory(dist)
|
|
65
|
+
checks.assets_are_there(dist)
|
|
66
|
+
checks.tex_names_are_unique(dist)
|
|
67
|
+
checks.design_is_valid(dist)
|
|
68
|
+
version = str(context.config.metadata["version"])
|
|
69
|
+
loaded = load_declaration(f"{module}:{attr}", version=version)
|
|
70
|
+
checks.module_satisfies_the_protocols(loaded)
|
|
71
|
+
contracts, held = checks.contract_gate(context.root, dist, loaded._bound.RELEASE, version) # pyright: ignore[reportAttributeAccessIssue]
|
|
72
|
+
generated = generate.sources(dist, attr, version, contracts)
|
|
73
|
+
files.clear() # a wheel holds the generated module, not the author's sources
|
|
74
|
+
embed.embed(
|
|
75
|
+
context.build_dir / dist.module, dist, declaration, generated, held
|
|
76
|
+
) # not ensure_build_dir(): its .gitignore
|
texpkg_build/py.typed
ADDED
|
File without changes
|