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.
@@ -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
+ )