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,118 @@
|
|
|
1
|
+
"""A payload: the rendered `.sty` files and the assets a document needs, placed by a layout (A3, A6, P10, P11).
|
|
2
|
+
|
|
3
|
+
It reads assets with `importlib.resources`, so a wheel, a zip and an editable install behave alike (P5); a file that is
|
|
4
|
+
not a real one on disk (a zipped package) is copied out for as long as the write takes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import dataclasses
|
|
9
|
+
import importlib.resources
|
|
10
|
+
import importlib.resources.abc
|
|
11
|
+
import pathlib
|
|
12
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
from texmodel import bundle, drift
|
|
15
|
+
|
|
16
|
+
from ..assets import Resolved, Role
|
|
17
|
+
from ..errors import DeclarationError, PayloadError
|
|
18
|
+
from ..layout import Project
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclasses.dataclass(frozen=True)
|
|
22
|
+
class Placed:
|
|
23
|
+
"""One asset at its place under the document directory."""
|
|
24
|
+
|
|
25
|
+
source: importlib.resources.abc.Traversable
|
|
26
|
+
dest: pathlib.PurePosixPath
|
|
27
|
+
why: str
|
|
28
|
+
origin: str # the asset's declared path, for messages
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclasses.dataclass(frozen=True)
|
|
32
|
+
class Payload:
|
|
33
|
+
generated: Mapping[str, str] # dest -> text: the rendered `.sty` files
|
|
34
|
+
assets: tuple[Placed, ...]
|
|
35
|
+
|
|
36
|
+
def absent(self) -> tuple[Placed, ...]:
|
|
37
|
+
return tuple(p for p in self.assets if not p.source.is_file())
|
|
38
|
+
|
|
39
|
+
def complete(self) -> None:
|
|
40
|
+
"""Raise `PayloadError` unless every source file is there (A3)."""
|
|
41
|
+
if absent := self.absent():
|
|
42
|
+
names = ", ".join(str(p.dest) for p in absent)
|
|
43
|
+
raise PayloadError(
|
|
44
|
+
f"payload incomplete, missing sources for: {names}",
|
|
45
|
+
code="TP-A004",
|
|
46
|
+
hint="the package was built without them, or its files were removed",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def expected(self) -> dict[str, str | bytes]:
|
|
50
|
+
"""Every file of the payload by destination, as bytes or text (A4 compares content, never time)."""
|
|
51
|
+
self.complete()
|
|
52
|
+
return {str(p.dest): p.source.read_bytes() for p in self.assets} | dict(self.generated)
|
|
53
|
+
|
|
54
|
+
def pending(self, root: pathlib.Path) -> tuple[str, ...]:
|
|
55
|
+
"""The files `root` does not hold, byte for byte."""
|
|
56
|
+
return tuple(
|
|
57
|
+
dest
|
|
58
|
+
for dest, content in self.expected().items()
|
|
59
|
+
if not (root / dest).is_file()
|
|
60
|
+
or (root / dest).read_bytes() != (content if isinstance(content, bytes) else content.encode("utf-8"))
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def check(self, root: pathlib.Path, owned: Sequence[str]) -> drift.Drift:
|
|
64
|
+
"""What `root` holds against the payload: `stale`, `missing`, and `orphaned` (files under `owned` globs that no
|
|
65
|
+
payload file explains)."""
|
|
66
|
+
return drift.check_drift(self.expected(), root, owned)
|
|
67
|
+
|
|
68
|
+
def write(self, root: pathlib.Path) -> tuple[pathlib.Path, ...]:
|
|
69
|
+
"""The files `root` does not hold already, byte for byte. Refuses the whole payload if one source is absent
|
|
70
|
+
(A3) and removes nothing."""
|
|
71
|
+
self.complete()
|
|
72
|
+
with contextlib.ExitStack() as stack:
|
|
73
|
+
items = [
|
|
74
|
+
bundle.Item(
|
|
75
|
+
stack.enter_context(importlib.resources.as_file(p.source)), pathlib.Path(*p.dest.parts), p.why
|
|
76
|
+
)
|
|
77
|
+
for p in self.assets
|
|
78
|
+
]
|
|
79
|
+
changed = {
|
|
80
|
+
dest: text
|
|
81
|
+
for dest, text in self.generated.items()
|
|
82
|
+
if not (root / dest).is_file() or (root / dest).read_text(encoding="utf-8") != text
|
|
83
|
+
}
|
|
84
|
+
return bundle.write(bundle.stale(items, root), root, changed)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build(
|
|
88
|
+
sty: Mapping[str, str], assets: Sequence[Resolved], layout: Project, kinds: Collection[str] | None = None
|
|
89
|
+
) -> Payload:
|
|
90
|
+
"""The payload of a document of `kinds` (a kind and the base kind a profile kind expands to; `None`: every kind, for
|
|
91
|
+
an export) in `layout`. An asset naming other kinds is left out
|
|
92
|
+
(A6). The same file twice is one (P10); two different files on one path are an error (P11)."""
|
|
93
|
+
placed: dict[pathlib.PurePosixPath, Placed] = {}
|
|
94
|
+
for resolved in assets:
|
|
95
|
+
asset = resolved.asset
|
|
96
|
+
if not asset.needed_by(kinds):
|
|
97
|
+
continue
|
|
98
|
+
dest = layout.place(asset.role, pathlib.PurePosixPath(asset.path))
|
|
99
|
+
earlier = placed.get(dest)
|
|
100
|
+
if earlier is not None:
|
|
101
|
+
if earlier.source.read_bytes() != resolved.source.read_bytes():
|
|
102
|
+
raise DeclarationError(
|
|
103
|
+
f"two different files would be written to {dest}: {earlier.origin!r} and {asset.path!r}",
|
|
104
|
+
code="TP-A002",
|
|
105
|
+
path=asset.path,
|
|
106
|
+
hint="rename one, or give one role/layout another place",
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
placed[dest] = Placed(resolved.source, dest, asset.why, asset.path)
|
|
110
|
+
generated = {str(layout.place(Role.PACKAGE, pathlib.PurePosixPath(name))): text for name, text in sty.items()}
|
|
111
|
+
if clash := sorted(set(generated) & {str(d) for d in placed}):
|
|
112
|
+
raise DeclarationError(
|
|
113
|
+
f"{clash[0]} is both a generated .sty and an asset",
|
|
114
|
+
code="TP-A002",
|
|
115
|
+
path=clash[0],
|
|
116
|
+
hint="a package's name is the name of its rendered .sty",
|
|
117
|
+
)
|
|
118
|
+
return Payload(generated, tuple(placed.values()))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The TeX of provenance (V2, V34): written to move into texmodel (N5, V9), so it imports nothing of texpkg.
|
|
2
|
+
|
|
3
|
+
A package declares `\\<prefix>ProvenanceInfo`, which says what it is and, once a provenance file has been written beside
|
|
4
|
+
the document, which stamp and time it was materialised with. The file renews two macros and adds the same text to the
|
|
5
|
+
PDF's Info dictionary when PDF management is active.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import textwrap
|
|
10
|
+
|
|
11
|
+
STAMP = re.compile(r"[A-Za-z0-9.+:/@ -]*") # text that is the same in TeX, in a PDF string and on the page
|
|
12
|
+
TIME = re.compile(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def file_name(package: str) -> str:
|
|
16
|
+
return f"{package}-provenance.sty"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def info(package: str, version: str, stamp: str, time: str) -> str:
|
|
20
|
+
"""The text `\\<prefix>ProvenanceInfo` prints, parts that are empty left out."""
|
|
21
|
+
return f"{package} {version}" + (f", stamp {stamp}" if stamp else "") + (f", materialised {time}" if time else "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def declarations(prefix: str, package: str, version: str) -> str:
|
|
25
|
+
"""The block a package's `.sty` carries: both parts empty, the command that composes them, the file read if there.
|
|
26
|
+
Declared with `\\def`, not `\\newcommand`, which makes a `\\long` macro: `\\ifx` against `\\@empty` never holds."""
|
|
27
|
+
return textwrap.dedent(
|
|
28
|
+
rf"""
|
|
29
|
+
\makeatletter
|
|
30
|
+
\def\{prefix}ProvenanceStamp{{}}
|
|
31
|
+
\def\{prefix}ProvenanceTime{{}}
|
|
32
|
+
\newcommand{{\{prefix}ProvenanceInfo}}{{%
|
|
33
|
+
{package} {version}%
|
|
34
|
+
\ifx\{prefix}ProvenanceStamp\@empty\else, stamp \{prefix}ProvenanceStamp\fi
|
|
35
|
+
\ifx\{prefix}ProvenanceTime\@empty\else, materialised \{prefix}ProvenanceTime\fi}}
|
|
36
|
+
\InputIfFileExists{{{file_name(package)}}}{{}}{{}}
|
|
37
|
+
\makeatother
|
|
38
|
+
"""
|
|
39
|
+
).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def companion(prefix: str, package: str, version: str, stamp: str, time: str) -> str:
|
|
43
|
+
"""The provenance file: it renews the stamp and the time, and adds the text to the PDF's Info dictionary as
|
|
44
|
+
`/<Prefix>Provenance` when PDF management is active (without it the entry is skipped)."""
|
|
45
|
+
text = info(package, version, stamp, time)
|
|
46
|
+
key = prefix.capitalize() + "Provenance"
|
|
47
|
+
return textwrap.dedent(
|
|
48
|
+
rf"""
|
|
49
|
+
% {package}: what this directory was materialised from. Generated; do not edit.
|
|
50
|
+
\def\{prefix}ProvenanceStamp{{{stamp}}}
|
|
51
|
+
\def\{prefix}ProvenanceTime{{{time}}}
|
|
52
|
+
\ifdefined\IfPDFManagementActiveTF
|
|
53
|
+
\IfPDFManagementActiveTF
|
|
54
|
+
{{\csname pdfmanagement_add:nnn\endcsname{{Info}}{{{key}}}{{(\detokenize{{{text}}})}}}}
|
|
55
|
+
{{}}
|
|
56
|
+
\fi
|
|
57
|
+
"""
|
|
58
|
+
).lstrip("\n")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read(text: str, prefix: str) -> tuple[str, str]:
|
|
62
|
+
"""The stamp and the time a provenance file holds, or two empty strings if it holds none."""
|
|
63
|
+
stamp = re.search(rf"^\\def\\{prefix}ProvenanceStamp\{{(.*)\}}$", text, re.MULTILINE)
|
|
64
|
+
time = re.search(rf"^\\def\\{prefix}ProvenanceTime\{{(.*)\}}$", text, re.MULTILINE)
|
|
65
|
+
return (stamp[1] if stamp else "", time[1] if time else "")
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""A distribution bound to a release: what a built module's functions call (P7).
|
|
2
|
+
|
|
3
|
+
texpkg adds no document logic of its own: rendering is texmodel's `Package.plan`, given the kind's settings and the
|
|
4
|
+
layout's preamble lines.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import datetime
|
|
9
|
+
import importlib.resources.abc
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from texmodel.components import AnyValue, Command, Environment, Node
|
|
16
|
+
from texmodel.package import setting_line
|
|
17
|
+
from texmodel.plan import Plan
|
|
18
|
+
|
|
19
|
+
from .. import provenance
|
|
20
|
+
from ..assets import Role
|
|
21
|
+
from ..contracts import Changes, Contracts
|
|
22
|
+
from ..declaration import Distribution, Kind
|
|
23
|
+
from ..document import Document
|
|
24
|
+
from ..errors import (
|
|
25
|
+
DeclarationError,
|
|
26
|
+
DocumentExistsError,
|
|
27
|
+
KindMismatchError,
|
|
28
|
+
NewerDataError,
|
|
29
|
+
NotADocumentError,
|
|
30
|
+
)
|
|
31
|
+
from ..migration import DefaultChange, Migration
|
|
32
|
+
from . import directory, export, payload, snapshots, state
|
|
33
|
+
from .adopt import adopt
|
|
34
|
+
from .envelope import Envelope, dumps
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclasses.dataclass(frozen=True)
|
|
38
|
+
class Release:
|
|
39
|
+
"""A distribution at the release it was built as. `sty` holds the `.sty` of every package in the chain, as the build
|
|
40
|
+
rendered them (P4): what is shipped is what `save` writes. `bases` are the releases embedded in it (P13)."""
|
|
41
|
+
|
|
42
|
+
dist: Distribution
|
|
43
|
+
version: str
|
|
44
|
+
contracts: Contracts
|
|
45
|
+
sty: Mapping[str, str]
|
|
46
|
+
bases: tuple["Release", ...] = ()
|
|
47
|
+
snapshots: importlib.resources.abc.Traversable | None = None # `_contracts`: the frozen snapshots a wheel carries
|
|
48
|
+
|
|
49
|
+
def releases(self) -> tuple["Release", ...]:
|
|
50
|
+
"""This release and the ones embedded in it, this one first."""
|
|
51
|
+
return (self, *(r for base in self.bases for r in base.releases()))
|
|
52
|
+
|
|
53
|
+
def stack(self) -> list[dict[str, Any]]:
|
|
54
|
+
"""The packages a document is rendered through, this one first (V18)."""
|
|
55
|
+
return [
|
|
56
|
+
{"package": r.dist.name, "release": r.version, "output": list(dataclasses.astuple(r.contracts.output))}
|
|
57
|
+
for r in self.releases()
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def layout_path(self, name: str) -> str:
|
|
61
|
+
"""Where the layout puts the package file `name` in a document directory."""
|
|
62
|
+
return str(self.dist.layout.place(Role.PACKAGE, pathlib.PurePosixPath(name)))
|
|
63
|
+
|
|
64
|
+
def settings(self, kind: Kind, variant: str | None) -> tuple[AnyValue, ...]:
|
|
65
|
+
"""What a document of `kind` is written with (L8): for a profile kind, the base kind's presets first, then
|
|
66
|
+
its own locks, then the variant's."""
|
|
67
|
+
base = self.dist.base_kind(kind)
|
|
68
|
+
return (*(base.presets if base else ()), *kind.settings(variant))
|
|
69
|
+
|
|
70
|
+
def plan(self, kind: Kind, tree: Node, variant: str | None) -> Plan:
|
|
71
|
+
"""The `.tex` of `tree` as lines with provenance: the kind's presets, then the variant's settings (L8).
|
|
72
|
+
|
|
73
|
+
An exposed base kind is written by the base's package; the directory's layout is this distribution's."""
|
|
74
|
+
body = tree if kind.expand is None else kind.expand(tree)
|
|
75
|
+
package = self.dist.owner(kind.name).package
|
|
76
|
+
return package.plan(body, settings=self.settings(kind, variant), preamble=self.dist.layout.preamble())
|
|
77
|
+
|
|
78
|
+
def export(
|
|
79
|
+
self, destination: pathlib.Path | str, now: datetime.datetime | None, stamp: str | None
|
|
80
|
+
) -> tuple[pathlib.Path, ...]:
|
|
81
|
+
"""Write the whole payload in the layout, the provenance files, and a template and a README per kind (E1).
|
|
82
|
+
Refused whole if a file is missing (A3)."""
|
|
83
|
+
root = pathlib.Path(destination)
|
|
84
|
+
wanted = provenance.stamp_of(stamp, self)
|
|
85
|
+
files = provenance.files(self, wanted, provenance.time_of(now))
|
|
86
|
+
everything = payload.build({**self.sty, **files}, tuple(self.dist.resolved_assets()), self.dist.layout, None)
|
|
87
|
+
everything.complete()
|
|
88
|
+
names = sorted(everything.expected())
|
|
89
|
+
texts: dict[str, str] = {}
|
|
90
|
+
for kind in self.dist.kinds_offered():
|
|
91
|
+
texts[export.template_name(kind)] = self.plan(kind, kind.example(), None).text
|
|
92
|
+
listing = [*names, *texts, *(export.readme_name(k) for k in self.dist.kinds_offered())]
|
|
93
|
+
for kind in self.dist.kinds_offered():
|
|
94
|
+
texts[export.readme_name(kind)] = export.readme(self.dist, kind, self.version, sorted(listing))
|
|
95
|
+
everything.write(root)
|
|
96
|
+
directory.write(root, directory.differing(root, texts))
|
|
97
|
+
return tuple(root / name for name in sorted(listing))
|
|
98
|
+
|
|
99
|
+
def payload(self, kind: Kind, provenance: Mapping[str, str] | None = None) -> payload.Payload:
|
|
100
|
+
"""The payload of a document of `kind`: the `.sty` files, the provenance files if given, the assets."""
|
|
101
|
+
kinds = (kind.name, *([kind.over] if kind.over else []))
|
|
102
|
+
files = {**self.sty, **(provenance or {})}
|
|
103
|
+
return payload.build(files, tuple(self.dist.resolved_assets()), self.dist.layout, kinds)
|
|
104
|
+
|
|
105
|
+
def rendered(self, kind: Kind, tree: Node) -> dict[str, str]:
|
|
106
|
+
"""Every `.tex` of the kind with the plan of it that `save` keeps (D7), by path in the directory."""
|
|
107
|
+
files: dict[str, str] = {}
|
|
108
|
+
for variant, name in kind.documents():
|
|
109
|
+
plan = self.plan(kind, tree, variant)
|
|
110
|
+
files |= {name: plan.text, directory.plan_file(variant): plan.to_json()}
|
|
111
|
+
return files
|
|
112
|
+
|
|
113
|
+
def template(self, kind: Kind) -> dict[str, Any]:
|
|
114
|
+
"""The package as a document of `kind` meets it (D6): the settings each `.tex` is written with, the defaults the
|
|
115
|
+
kind's classes have, the `.sty` by hash, and the stack (V18). Not the design file."""
|
|
116
|
+
c = self.contracts
|
|
117
|
+
return {
|
|
118
|
+
"package": self.dist.name,
|
|
119
|
+
"release": self.version,
|
|
120
|
+
"contracts": {
|
|
121
|
+
"data": [c.data.current, c.data.revision, c.data.age],
|
|
122
|
+
"interface": [c.interface.current, c.interface.revision, c.interface.age],
|
|
123
|
+
"output": [c.output.current, c.output.revision],
|
|
124
|
+
},
|
|
125
|
+
"settings": {
|
|
126
|
+
name: {type(v).__name__: setting_line(v) for v in self.settings(kind, variant)}
|
|
127
|
+
for variant, name in kind.documents()
|
|
128
|
+
},
|
|
129
|
+
"defaults": state.defaults_table(kind.model),
|
|
130
|
+
"sty": state.sty_hashes(self.sty),
|
|
131
|
+
"stack": self.stack(),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def states(self, kind: Kind, tree: Node) -> dict[str, str]:
|
|
135
|
+
"""The pair of states `save` and `create` write (D1)."""
|
|
136
|
+
envelope = Envelope(self.dist.name, kind.name, self.contracts.data.current, state.tree_state(tree))
|
|
137
|
+
return {directory.STATE: envelope.to_text(), directory.TEMPLATE: dumps(self.template(kind))}
|
|
138
|
+
|
|
139
|
+
def read(self, path: pathlib.Path) -> Envelope:
|
|
140
|
+
"""The envelope of the document in `path`, refused if it is another package's or not readable (K3)."""
|
|
141
|
+
text = directory.read(path, directory.STATE)
|
|
142
|
+
if text is None:
|
|
143
|
+
raise NotADocumentError(
|
|
144
|
+
f"{path}: no document here (no {directory.STATE})",
|
|
145
|
+
code="TP-D001",
|
|
146
|
+
path=str(path),
|
|
147
|
+
hint="create one with a kind's create, or point at the directory that holds it",
|
|
148
|
+
)
|
|
149
|
+
envelope = Envelope.parse(text, str(path / directory.STATE))
|
|
150
|
+
if envelope.package != self.dist.name:
|
|
151
|
+
raise KindMismatchError(
|
|
152
|
+
f"{path} holds a document of package {envelope.package!r}, not {self.dist.name!r}",
|
|
153
|
+
found=f"{envelope.package}:{envelope.kind}",
|
|
154
|
+
expected=self.dist.name,
|
|
155
|
+
path=str(path),
|
|
156
|
+
)
|
|
157
|
+
current = self.contracts.data.current
|
|
158
|
+
if envelope.data > current:
|
|
159
|
+
raise NewerDataError(
|
|
160
|
+
f"{path} was written for `data` version {envelope.data}, this release reads {current}",
|
|
161
|
+
code="TP-D004",
|
|
162
|
+
path=str(path),
|
|
163
|
+
hint="install the release that wrote it, or a newer one",
|
|
164
|
+
)
|
|
165
|
+
return envelope
|
|
166
|
+
|
|
167
|
+
def load(
|
|
168
|
+
self,
|
|
169
|
+
path: pathlib.Path | str,
|
|
170
|
+
migrations: Sequence[Migration] = (),
|
|
171
|
+
default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
|
|
172
|
+
) -> "Document[Any]":
|
|
173
|
+
"""The document in `path`, whatever its kind (the generic `load`, P26)."""
|
|
174
|
+
envelope = self.read(pathlib.Path(path))
|
|
175
|
+
return self.kind_of(envelope).load(path, migrations, default_changes)
|
|
176
|
+
|
|
177
|
+
def kind_of(self, envelope: Envelope) -> "KindApi[Any]":
|
|
178
|
+
kind = self.dist.kind(envelope.kind)
|
|
179
|
+
return KindApi(self, kind, kind.model)
|
|
180
|
+
|
|
181
|
+
def kind[T: Command | Environment](self, name: str, model: type[T]) -> "KindApi[T]":
|
|
182
|
+
return KindApi(self, self.dist.kind(name), model)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class KindApi[T: Command | Environment]:
|
|
186
|
+
"""What a kind module's functions call."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, release: Release, kind: Kind, model: type[T]) -> None:
|
|
189
|
+
if kind.model is not model:
|
|
190
|
+
raise DeclarationError(
|
|
191
|
+
f"kind {kind.name}: the module was generated for {model.__name__}, "
|
|
192
|
+
f"the declaration has {kind.model.__name__}",
|
|
193
|
+
code="TP-B001",
|
|
194
|
+
path=kind.name,
|
|
195
|
+
hint="build the package again",
|
|
196
|
+
)
|
|
197
|
+
self.release, self.kind = release, kind
|
|
198
|
+
|
|
199
|
+
def create(self, path: pathlib.Path | str, tree: T) -> Document[T]:
|
|
200
|
+
"""A document in `path` (made if need be) with both states written; refuses a directory that holds one."""
|
|
201
|
+
path = pathlib.Path(path)
|
|
202
|
+
if (path / directory.STATE).exists():
|
|
203
|
+
raise DocumentExistsError(
|
|
204
|
+
f"{path} already holds a document",
|
|
205
|
+
code="TP-D002",
|
|
206
|
+
path=str(path),
|
|
207
|
+
hint="load it, or choose another directory",
|
|
208
|
+
)
|
|
209
|
+
if problems := tree.violations():
|
|
210
|
+
raise ExceptionGroup("invalid document", [v.error() for v in problems])
|
|
211
|
+
directory.write(path, self.release.states(self.kind, tree))
|
|
212
|
+
return Document(tree, path, self.release, self.kind)
|
|
213
|
+
|
|
214
|
+
def load(
|
|
215
|
+
self,
|
|
216
|
+
path: pathlib.Path | str,
|
|
217
|
+
migrations: Sequence[Migration] = (),
|
|
218
|
+
default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
|
|
219
|
+
) -> Document[T]:
|
|
220
|
+
"""The document in `path`, adopted whole or not at all; writes nothing (D3). Refuses another kind's.
|
|
221
|
+
|
|
222
|
+
Older data runs through the kind's migrations and the consumer's `migrations` (V32); `default_changes` says what
|
|
223
|
+
happens to a value still marked as a default whose default this release changed (V23)."""
|
|
224
|
+
path = pathlib.Path(path)
|
|
225
|
+
envelope = self.release.read(path)
|
|
226
|
+
if envelope.kind != self.kind.name:
|
|
227
|
+
raise KindMismatchError(
|
|
228
|
+
f"{path} holds a {envelope.kind!r} document, not a {self.kind.name!r} one",
|
|
229
|
+
found=envelope.kind,
|
|
230
|
+
expected=self.kind.name,
|
|
231
|
+
path=str(path),
|
|
232
|
+
)
|
|
233
|
+
saved = directory.read(path, directory.TEMPLATE)
|
|
234
|
+
template: dict[str, Any] = json.loads(saved) if saved else {}
|
|
235
|
+
tree = adopt(
|
|
236
|
+
kind=self.kind,
|
|
237
|
+
envelope=envelope,
|
|
238
|
+
current=self.release.contracts.data.current,
|
|
239
|
+
age=self.release.contracts.data.age,
|
|
240
|
+
consumer=migrations,
|
|
241
|
+
policy=default_changes,
|
|
242
|
+
saved_defaults=template.get("defaults"),
|
|
243
|
+
base_data=dict(self.release.contracts.base_data) if self.kind.name in self.release.dist.expose else None,
|
|
244
|
+
)
|
|
245
|
+
return Document(cast("T", tree), path, self.release, self.kind)
|
|
246
|
+
|
|
247
|
+
def schema(self, mode: state.Mode) -> dict[str, Any]:
|
|
248
|
+
return state.schema(self.kind.model, mode)
|
|
249
|
+
|
|
250
|
+
def example(self) -> T:
|
|
251
|
+
return cast("T", self.kind.example())
|
|
252
|
+
|
|
253
|
+
def variants(self) -> tuple[str, ...]:
|
|
254
|
+
return tuple(v.name for v in self.kind.variants)
|
|
255
|
+
|
|
256
|
+
def changes(self, old: int, new: int) -> Changes:
|
|
257
|
+
return snapshots.changes(self.release.snapshots, self.kind.name, old, new)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The contract snapshots a wheel ships (V11, V33, V37), read at run time: what changed between two `data` versions."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources.abc
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..contracts import Changes
|
|
8
|
+
from . import classify
|
|
9
|
+
from .versions import Versions
|
|
10
|
+
|
|
11
|
+
_RELEASE = re.compile(r"\d+\.\d+\.\d+")
|
|
12
|
+
VERSIONS = "versions.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def releases(root: importlib.resources.abc.Traversable | None) -> list[str]:
|
|
16
|
+
"""The frozen releases a wheel carries, oldest first."""
|
|
17
|
+
found = (
|
|
18
|
+
[p.name for p in root.iterdir() if p.is_dir() and _RELEASE.fullmatch(p.name)] if root and root.is_dir() else []
|
|
19
|
+
)
|
|
20
|
+
return sorted(found, key=classify.parse_version)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def changes(root: importlib.resources.abc.Traversable | None, kind: str, old: int, new: int) -> Changes:
|
|
24
|
+
"""What changed in `kind` between `data` versions `old` and `new`: fields added, removed and tightened, the author's
|
|
25
|
+
notes and the release of each version crossed (V33), read from the shipped snapshots. Empty where there are none."""
|
|
26
|
+
held = releases(root)
|
|
27
|
+
if root is None or not held:
|
|
28
|
+
return Changes()
|
|
29
|
+
latest = Versions.parse(root.joinpath(held[-1], VERSIONS).read_text(encoding="utf-8"))
|
|
30
|
+
crossed = range(old + 1, new + 1)
|
|
31
|
+
out = Changes(
|
|
32
|
+
notes=tuple(latest.notes[v] for v in crossed if latest.notes.get(v)),
|
|
33
|
+
releases={v: latest.releases[v] for v in crossed if v in latest.releases},
|
|
34
|
+
)
|
|
35
|
+
first, last = latest.releases.get(old), latest.releases.get(new)
|
|
36
|
+
name = f"kinds/{kind}.input.schema.json"
|
|
37
|
+
if (
|
|
38
|
+
not first
|
|
39
|
+
or not last
|
|
40
|
+
or first == last
|
|
41
|
+
or not (root.joinpath(first, *name.split("/")).is_file() and root.joinpath(last, *name.split("/")).is_file())
|
|
42
|
+
):
|
|
43
|
+
return out
|
|
44
|
+
before = json.loads(root.joinpath(first, *name.split("/")).read_text(encoding="utf-8"))
|
|
45
|
+
after = json.loads(root.joinpath(last, *name.split("/")).read_text(encoding="utf-8"))
|
|
46
|
+
found = classify.schema_changes(before, after)
|
|
47
|
+
gone = {"removed", "no longer part of the kind"}
|
|
48
|
+
added = [c.where for c in found if c.what.startswith("added") or c.what.endswith("added to the kind")]
|
|
49
|
+
removed = [c.where for c in found if c.what in gone]
|
|
50
|
+
tightened = [
|
|
51
|
+
c.where
|
|
52
|
+
for c in found
|
|
53
|
+
if c.level is classify.Level.BREAKING and c.what not in gone and not c.what.startswith("added")
|
|
54
|
+
]
|
|
55
|
+
return Changes(
|
|
56
|
+
added=tuple(dict.fromkeys(added)),
|
|
57
|
+
removed=tuple(dict.fromkeys(removed)),
|
|
58
|
+
tightened=tuple(dict.fromkeys(tightened)),
|
|
59
|
+
notes=out.notes,
|
|
60
|
+
releases=out.releases,
|
|
61
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The stored form of a document: its schemas (V26).
|
|
2
|
+
|
|
3
|
+
Two schemas per kind, from pydantic's two modes: the input one (a field with a default is optional) and the stored one
|
|
4
|
+
(every field is written, V21, so every field is required, and a node lists the fields that hold defaults in
|
|
5
|
+
`$defaults`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import hashlib
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from texmodel import spec
|
|
14
|
+
from texmodel.body import Comment
|
|
15
|
+
from texmodel.components import Command, Environment, Node, Passthrough
|
|
16
|
+
|
|
17
|
+
from ..declaration import Model
|
|
18
|
+
from ..errors import DeclarationError
|
|
19
|
+
|
|
20
|
+
type Mode = Literal["validation", "serialization"]
|
|
21
|
+
|
|
22
|
+
DEFAULTS = "$defaults" # V24: the fields of a node that hold the package's defaults
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def stored(validation: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""The stored schema of an input schema: every property required, `$defaults` limited to the node's own defaulted
|
|
27
|
+
fields (V27)."""
|
|
28
|
+
out = copy.deepcopy(validation)
|
|
29
|
+
for node in (out, *out.get("$defs", {}).values()):
|
|
30
|
+
properties: dict[str, Any] | None = node.get("properties")
|
|
31
|
+
if not properties:
|
|
32
|
+
continue
|
|
33
|
+
defaulted = sorted(name for name, schema in properties.items() if "default" in schema)
|
|
34
|
+
node["required"] = sorted(properties)
|
|
35
|
+
properties[DEFAULTS] = (
|
|
36
|
+
{"type": "array", "items": {"enum": defaulted}, "uniqueItems": True}
|
|
37
|
+
if defaulted
|
|
38
|
+
else {"type": "array", "maxItems": 0}
|
|
39
|
+
)
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def schema(model: Model, mode: Mode) -> dict[str, Any]:
|
|
44
|
+
"""The JSON Schema of the data a kind's `model` takes (`validation`) or stores (`serialization`)."""
|
|
45
|
+
if mode not in ("validation", "serialization"):
|
|
46
|
+
raise DeclarationError(
|
|
47
|
+
f"schema mode {mode!r}", code="TP-K008", hint='use "validation" (the input) or "serialization" (the stored)'
|
|
48
|
+
)
|
|
49
|
+
validation = spec.json_schema(model)
|
|
50
|
+
return validation if mode == "validation" else stored(validation)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fields(node: Command | Environment | Passthrough) -> dict[str, Any]:
|
|
54
|
+
return node.model_dump(mode="json", exclude={"content"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tree_state(node: Node) -> dict[str, Any]:
|
|
58
|
+
"""The state of a tree: every value written (V21), and for each node the fields that hold the package's defaults in
|
|
59
|
+
`$defaults` (V24), known from `model_fields_set`, which says what the caller passed (V25)."""
|
|
60
|
+
if isinstance(node, Comment):
|
|
61
|
+
return {"type": type(node).__name__} | {f: getattr(node, f) for f in node.__tm_fields__}
|
|
62
|
+
if isinstance(node, Passthrough):
|
|
63
|
+
return {"type": "Passthrough"} | _fields(node)
|
|
64
|
+
fields = _fields(node)
|
|
65
|
+
out: dict[str, Any] = {"type": type(node).__name__} | fields
|
|
66
|
+
if defaulted := [name for name in fields if name not in node.model_fields_set]:
|
|
67
|
+
out[DEFAULTS] = defaulted
|
|
68
|
+
if isinstance(node, Environment):
|
|
69
|
+
out["content"] = [tree_state(child) for child in node.content]
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def given(state: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""`state` as the caller would have passed it: the fields marked as defaults left out, so the model fills them with
|
|
75
|
+
its current defaults and `model_fields_set` is what the author set (V27)."""
|
|
76
|
+
out = {k: v for k, v in state.items() if k not in (DEFAULTS, *state.get(DEFAULTS, ()))}
|
|
77
|
+
if "content" in out:
|
|
78
|
+
out["content"] = [given(child) for child in out["content"]]
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tree_from_state(model: Model, state: dict[str, Any]) -> Node:
|
|
83
|
+
"""The tree a stored state describes; raises an `ExceptionGroup` of texmodel `ValidationError` for one that does not
|
|
84
|
+
fit the model."""
|
|
85
|
+
return spec.from_dict(model, given(state))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_paths(node: Node, path: str = "") -> tuple[str, ...]:
|
|
89
|
+
"""The data paths of the fields that hold defaults (`/content/1/accent`), in document order."""
|
|
90
|
+
if isinstance(node, Comment):
|
|
91
|
+
return ()
|
|
92
|
+
own = tuple(f"{path}/{name}" for name in _fields(node) if name not in node.model_fields_set)
|
|
93
|
+
if isinstance(node, Environment):
|
|
94
|
+
return own + tuple(
|
|
95
|
+
p for i, child in enumerate(node.content) for p in default_paths(child, f"{path}/content/{i}")
|
|
96
|
+
)
|
|
97
|
+
return own
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def defaults_table(model: Model) -> dict[str, Any]:
|
|
101
|
+
"""The defaults of the classes a kind's trees are made of, by `Class.field`: what the package would fill in."""
|
|
102
|
+
validation = spec.json_schema(model)
|
|
103
|
+
table: dict[str, Any] = {}
|
|
104
|
+
for node in (validation, *validation.get("$defs", {}).values()):
|
|
105
|
+
for name, schema in node.get("properties", {}).items():
|
|
106
|
+
if "default" in schema:
|
|
107
|
+
table[f"{node['title']}.{name}"] = schema["default"]
|
|
108
|
+
return dict(sorted(table.items()))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def sty_hashes(sty: Mapping[str, str]) -> dict[str, str]:
|
|
112
|
+
"""The `.sty` files the template state records by hash, not by value (D6)."""
|
|
113
|
+
return {name: hashlib.sha256(text.encode("utf-8")).hexdigest() for name, text in sorted(sty.items())}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The versions file of a contract snapshot (V36): the triples, the release they are for, the author's statements
|
|
2
|
+
and the version maps. A snapshot ships in the wheel, so the run time reads it too."""
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..contracts import Contracts, Pair, Triple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass(frozen=True)
|
|
14
|
+
class Versions:
|
|
15
|
+
"""The versions file (V36): the triples, the release they are for, the author's statements and the version maps."""
|
|
16
|
+
|
|
17
|
+
release: str
|
|
18
|
+
data: Triple = Triple(1)
|
|
19
|
+
interface: Triple = Triple(1)
|
|
20
|
+
output: Pair = Pair(1)
|
|
21
|
+
output_change: str = "" # the author's `current` or `revision` for an output change (V12)
|
|
22
|
+
notes: Mapping[int, str] = dataclasses.field(default_factory=dict[int, str]) # one line per `data` version (V33)
|
|
23
|
+
releases: Mapping[int, str] = dataclasses.field(default_factory=dict[int, str]) # the release of each (V33)
|
|
24
|
+
base_data: Mapping[int, int] = dataclasses.field(default_factory=dict[int, int]) # own `data` -> the base's (P30)
|
|
25
|
+
base_interface: int = 0 # the base `interface` current relied on (V17); 0 without a base
|
|
26
|
+
|
|
27
|
+
def contracts(self) -> Contracts:
|
|
28
|
+
return Contracts(self.data, self.interface, self.output, tuple(sorted(self.base_data.items())))
|
|
29
|
+
|
|
30
|
+
def to_text(self) -> str:
|
|
31
|
+
def table(name: str, values: Mapping[int, Any]) -> str:
|
|
32
|
+
return f"\n[{name}]\n" + "".join(f'"{k}" = {json.dumps(v)}\n' for k, v in sorted(values.items()))
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
f'release = "{self.release}"\n'
|
|
36
|
+
f"\n[data]\ncurrent = {self.data.current}\nrevision = {self.data.revision}\nage = {self.data.age}\n"
|
|
37
|
+
f"\n[interface]\ncurrent = {self.interface.current}\nrevision = {self.interface.revision}\n"
|
|
38
|
+
f"age = {self.interface.age}\n"
|
|
39
|
+
f"\n[output]\ncurrent = {self.output.current}\nrevision = {self.output.revision}\n"
|
|
40
|
+
f'change = "{self.output_change}"\n'
|
|
41
|
+
+ table("notes", self.notes)
|
|
42
|
+
+ table("releases", self.releases)
|
|
43
|
+
+ f"\n[base]\ninterface = {self.base_interface}\n"
|
|
44
|
+
+ table("base.data", self.base_data)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def parse(cls, text: str) -> "Versions":
|
|
49
|
+
found = tomllib.loads(text)
|
|
50
|
+
data, interface, output = found["data"], found["interface"], found["output"]
|
|
51
|
+
return cls(
|
|
52
|
+
found["release"],
|
|
53
|
+
Triple(data["current"], data["revision"], data["age"]),
|
|
54
|
+
Triple(interface["current"], interface["revision"], interface["age"]),
|
|
55
|
+
Pair(output["current"], output["revision"]),
|
|
56
|
+
output.get("change", ""),
|
|
57
|
+
{int(k): v for k, v in found.get("notes", {}).items()},
|
|
58
|
+
{int(k): v for k, v in found.get("releases", {}).items()},
|
|
59
|
+
{int(k): v for k, v in found.get("base", {}).get("data", {}).items()},
|
|
60
|
+
found.get("base", {}).get("interface", 0),
|
|
61
|
+
)
|