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
texpkg/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""texpkg: TeX packages published as Python packages. The declaration types an author writes."""
|
|
2
|
+
|
|
3
|
+
from .assets import Asset, Role
|
|
4
|
+
from .declaration import Design, Distribution, Kind, Variant
|
|
5
|
+
from .migration import DefaultChange, Migrated, Unresolved, migration
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Asset",
|
|
9
|
+
"DefaultChange",
|
|
10
|
+
"Design",
|
|
11
|
+
"Distribution",
|
|
12
|
+
"Kind",
|
|
13
|
+
"Migrated",
|
|
14
|
+
"Role",
|
|
15
|
+
"Unresolved",
|
|
16
|
+
"Variant",
|
|
17
|
+
"migration",
|
|
18
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The internal modules of the run time: not public API."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Adopting a release: the stored state of a document becomes a valid tree of this release, or nothing does (V14, V22,
|
|
2
|
+
V23, V29, V31, V32).
|
|
3
|
+
|
|
4
|
+
Every migration across the `data` versions crossed runs, in the order the build's, the kind's, the consumer's; the
|
|
5
|
+
result is validated whole; any unresolved item, any validation error and any stored field the model no longer has
|
|
6
|
+
left aborts with the report.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from texmodel.components import Node
|
|
14
|
+
|
|
15
|
+
from ..declaration import Kind
|
|
16
|
+
from ..errors import MigrationError
|
|
17
|
+
from ..migration import DefaultChange, Migration, Unresolved
|
|
18
|
+
from . import state
|
|
19
|
+
from .envelope import Envelope
|
|
20
|
+
from .history import problems, unplaced
|
|
21
|
+
|
|
22
|
+
UNRESOLVED = (
|
|
23
|
+
"$unresolved" # in the state a migration gets: what the earlier ones left, as `{"path", "reason", "value"}`
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _policy(changes: DefaultChange | Mapping[str, DefaultChange], path: str, key: str) -> DefaultChange:
|
|
28
|
+
"""The policy for a default: the one named by the field's data path, else by `Class.field`, else `FOLLOW` (V23)."""
|
|
29
|
+
if isinstance(changes, DefaultChange):
|
|
30
|
+
return changes
|
|
31
|
+
return changes.get(path, changes.get(key, DefaultChange.FOLLOW))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _walk(node: dict[str, Any], path: str = "") -> Iterator[tuple[str, dict[str, Any]]]:
|
|
35
|
+
yield path, node
|
|
36
|
+
for i, child in enumerate(node.get("content", ())):
|
|
37
|
+
yield from _walk(child, f"{path}/content/{i}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def default_changes(
|
|
41
|
+
tree: dict[str, Any],
|
|
42
|
+
old: Mapping[str, Any],
|
|
43
|
+
new: Mapping[str, Any],
|
|
44
|
+
policy: DefaultChange | Mapping[str, DefaultChange],
|
|
45
|
+
) -> list[dict[str, Any]]:
|
|
46
|
+
"""Apply the consumer's policy to every value still marked as a default whose default this release changed (V22): a
|
|
47
|
+
value that follows needs nothing (the model fills the new default); one that is frozen keeps the old value and stops
|
|
48
|
+
being marked; one that asks is returned as unresolved."""
|
|
49
|
+
changed = {key for key in old.keys() & new.keys() if old[key] != new[key]}
|
|
50
|
+
asked: list[dict[str, Any]] = []
|
|
51
|
+
for path, node in _walk(tree):
|
|
52
|
+
for field in list(node.get(state.DEFAULTS, ())):
|
|
53
|
+
key = f"{node.get('type')}.{field}"
|
|
54
|
+
if key not in changed:
|
|
55
|
+
continue
|
|
56
|
+
action = _policy(policy, f"{path}/{field}", key)
|
|
57
|
+
if action is DefaultChange.FREEZE:
|
|
58
|
+
node[field] = old[key]
|
|
59
|
+
node[state.DEFAULTS].remove(field)
|
|
60
|
+
elif action is DefaultChange.ASK:
|
|
61
|
+
reason = f"the default of {key} changed from {old[key]!r} to {new[key]!r}"
|
|
62
|
+
asked.append(
|
|
63
|
+
{"path": f"{path}/{field}", "reason": reason, "value": node.get(field), "boundary": "defaults"}
|
|
64
|
+
)
|
|
65
|
+
if not node.get(state.DEFAULTS, True):
|
|
66
|
+
del node[state.DEFAULTS]
|
|
67
|
+
return asked
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _kind_migrations(kind: Kind, boundary: int, base_data: Mapping[int, int] | None) -> list[Migration]:
|
|
71
|
+
"""The kind's own migrations for `boundary`. An exposed base kind's are numbered by the base: the ones for the base
|
|
72
|
+
versions that this package's `boundary` stands for run at it (P30)."""
|
|
73
|
+
if base_data is None:
|
|
74
|
+
return [m for m in kind.migrations if m.to == boundary]
|
|
75
|
+
low, high = base_data.get(boundary - 1, 0), base_data.get(boundary, 0)
|
|
76
|
+
return [m for m in kind.migrations if low < m.to <= high]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def adopt(
|
|
80
|
+
*,
|
|
81
|
+
kind: Kind,
|
|
82
|
+
envelope: Envelope,
|
|
83
|
+
current: int,
|
|
84
|
+
age: int,
|
|
85
|
+
consumer: Sequence[Migration],
|
|
86
|
+
policy: DefaultChange | Mapping[str, DefaultChange],
|
|
87
|
+
saved_defaults: Mapping[str, Any] | None,
|
|
88
|
+
base_data: Mapping[int, int] | None = None,
|
|
89
|
+
) -> Node:
|
|
90
|
+
"""The tree of `kind` that `envelope` describes at `data` version `current`, or a `MigrationError` naming everything
|
|
91
|
+
that no migration placed. Nothing is written."""
|
|
92
|
+
tree = copy.deepcopy(envelope.tree)
|
|
93
|
+
left: list[dict[str, Any]] = []
|
|
94
|
+
if saved_defaults is not None:
|
|
95
|
+
left += default_changes(tree, saved_defaults, state.defaults_table(kind.model), policy)
|
|
96
|
+
if envelope.data < current - age:
|
|
97
|
+
for boundary in range(envelope.data + 1, current + 1):
|
|
98
|
+
label = f"{boundary - 1} -> {boundary}"
|
|
99
|
+
for migration in (*_kind_migrations(kind, boundary, base_data), *(m for m in consumer if m.to == boundary)):
|
|
100
|
+
tree[UNRESOLVED] = left
|
|
101
|
+
result = migration(tree)
|
|
102
|
+
tree = result.state
|
|
103
|
+
left = [*tree.pop(UNRESOLVED, []), *({**vars(u), "boundary": label} for u in result.unresolved)]
|
|
104
|
+
schema = state.schema(kind.model, "validation")
|
|
105
|
+
left += [{**p, "boundary": "validation"} for p in unplaced(tree, schema)]
|
|
106
|
+
left += [{**p, "boundary": "validation"} for p in problems(kind.model, state.given(tree), schema)]
|
|
107
|
+
if left:
|
|
108
|
+
items = tuple(
|
|
109
|
+
Unresolved(i["path"], i["reason"], i.get("value"), i.get("schema") or {}, i.get("boundary", ""))
|
|
110
|
+
for i in left
|
|
111
|
+
)
|
|
112
|
+
shown = "; ".join(f"{u.path or '/'} ({u.boundary}): {u.reason}" for u in items)
|
|
113
|
+
raise MigrationError(
|
|
114
|
+
f"{len(items)} unresolved: {shown}",
|
|
115
|
+
code="TP-M003",
|
|
116
|
+
unresolved=items,
|
|
117
|
+
hint="extend the process with a migration of your own: load(path, migrations=[...])",
|
|
118
|
+
)
|
|
119
|
+
return state.tree_from_state(kind.model, tree)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Diffing two snapshots into bumps (V12, V13): Avro-style rules over schemas, names and signatures over the interface.
|
|
2
|
+
|
|
3
|
+
In the run time because `changes(old, new)` diffs two shipped snapshots. Written to move into texmodel (V9, X5): it
|
|
4
|
+
reads
|
|
5
|
+
JSON Schema and plain dicts, and only the triples of texpkg.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..contracts import Pair, Triple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@enum.unique
|
|
16
|
+
class Level(enum.IntEnum):
|
|
17
|
+
"""How much a change asks of a contract's consumers; the largest of a contract's changes decides its bump (V10)."""
|
|
18
|
+
|
|
19
|
+
NONE = 0
|
|
20
|
+
REVISION = 1 # implementation only: a description, a default, a doc
|
|
21
|
+
COMPATIBLE = 2 # an addition: old data and old callers still work
|
|
22
|
+
BREAKING = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class Change:
|
|
27
|
+
level: Level
|
|
28
|
+
where: str # `Class.field`, `kind expert`, `component Fact`
|
|
29
|
+
what: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_UPPER = ("maxLength", "maximum", "maxItems", "exclusiveMaximum") # lowering one tightens it
|
|
33
|
+
_LOWER = ("minLength", "minimum", "minItems", "exclusiveMinimum") # raising one tightens it
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compare_property(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
37
|
+
out: list[Change] = []
|
|
38
|
+
for key in ("type", "$ref", "const", "format", "pattern"):
|
|
39
|
+
if old.get(key) != new.get(key):
|
|
40
|
+
out.append(Change(Level.BREAKING, where, f"{key} changed from {old.get(key)!r} to {new.get(key)!r}"))
|
|
41
|
+
if (gone := [v for v in old.get("enum", ()) if v not in new.get("enum", ())]) or ("enum" in old) != ("enum" in new):
|
|
42
|
+
out.append(Change(Level.BREAKING, where, f"values no longer allowed: {gone or 'any'}"))
|
|
43
|
+
elif added := [v for v in new.get("enum", ()) if v not in old.get("enum", ())]:
|
|
44
|
+
out.append(Change(Level.COMPATIBLE, where, f"values added: {added}"))
|
|
45
|
+
for key in _UPPER + _LOWER:
|
|
46
|
+
was, now = old.get(key), new.get(key)
|
|
47
|
+
if was == now:
|
|
48
|
+
continue
|
|
49
|
+
tighter = now is not None and (was is None or (now < was if key in _UPPER else now > was))
|
|
50
|
+
out.append(Change(Level.BREAKING if tighter else Level.COMPATIBLE, where, f"{key} {was} -> {now}"))
|
|
51
|
+
if old.get("items") != new.get("items") and "items" in old:
|
|
52
|
+
out += _compare_items(where, old["items"], new.get("items", {}))
|
|
53
|
+
for key in ("default", "description", "title"):
|
|
54
|
+
if old.get(key) != new.get(key):
|
|
55
|
+
out.append(Change(Level.REVISION, where, f"{key} changed"))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _compare_items(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
60
|
+
"""The element schema of a list: a branch of a union that went is breaking, one that came is compatible."""
|
|
61
|
+
|
|
62
|
+
def refs(items: dict[str, Any]) -> set[str]:
|
|
63
|
+
branches = items.get("oneOf") or [items]
|
|
64
|
+
return {b.get("$ref", b.get("type", "")) for b in branches}
|
|
65
|
+
|
|
66
|
+
gone, added = refs(old) - refs(new), refs(new) - refs(old)
|
|
67
|
+
out = [Change(Level.BREAKING, where, f"{g} is no longer allowed here") for g in sorted(gone)]
|
|
68
|
+
out += [Change(Level.COMPATIBLE, where, f"{a} is allowed here") for a in sorted(added)]
|
|
69
|
+
return out or ([] if old == new else _compare_property(where, old, new))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _classes(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
73
|
+
out = {str(schema.get("title")): schema}
|
|
74
|
+
out |= {str(node.get("title", name)): node for name, node in schema.get("$defs", {}).items()}
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def schema_changes(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
79
|
+
"""What changed in the data a kind accepts (the input schema): the changes, each with its level (V12)."""
|
|
80
|
+
out: list[Change] = []
|
|
81
|
+
was, now = _classes(old), _classes(new)
|
|
82
|
+
for title in sorted(was.keys() - now.keys()):
|
|
83
|
+
out.append(Change(Level.BREAKING, title, "no longer part of the kind"))
|
|
84
|
+
for title in sorted(now.keys() - was.keys()):
|
|
85
|
+
out.append(Change(Level.COMPATIBLE, title, "added to the kind"))
|
|
86
|
+
for title in sorted(was.keys() & now.keys()):
|
|
87
|
+
before, after = was[title].get("properties", {}), now[title].get("properties", {})
|
|
88
|
+
renamed = {p.get("x-renamed-from"): name for name, p in after.items() if p.get("x-renamed-from")}
|
|
89
|
+
for name in before:
|
|
90
|
+
if name in after:
|
|
91
|
+
out += _compare_property(f"{title}.{name}", before[name], after[name])
|
|
92
|
+
elif name in renamed:
|
|
93
|
+
out.append(
|
|
94
|
+
Change(Level.COMPATIBLE, f"{title}.{name}", f"renamed {renamed[name]} (old name still read)")
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
out.append(Change(Level.BREAKING, f"{title}.{name}", "removed"))
|
|
98
|
+
now_required, was_required = set(now[title].get("required", ())), set(was[title].get("required", ()))
|
|
99
|
+
for name in sorted((now_required - was_required) & before.keys() & after.keys()):
|
|
100
|
+
out.append(Change(Level.BREAKING, f"{title}.{name}", "became required"))
|
|
101
|
+
for name in sorted(after.keys() - before.keys() - set(renamed.values())):
|
|
102
|
+
level = Level.BREAKING if name in now_required else Level.COMPATIBLE
|
|
103
|
+
out.append(Change(level, f"{title}.{name}", "added" + (" and required" if name in now_required else "")))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def interface_changes(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
108
|
+
"""What changed in the public names and their signatures, and in the kinds and variants (V12)."""
|
|
109
|
+
out: list[Change] = []
|
|
110
|
+
for section, label in (("components", "component"), ("kinds", "kind")):
|
|
111
|
+
was, now = old.get(section, {}), new.get(section, {})
|
|
112
|
+
out += [Change(Level.BREAKING, f"{label} {n}", "removed") for n in sorted(was.keys() - now.keys())]
|
|
113
|
+
out += [Change(Level.COMPATIBLE, f"{label} {n}", "added") for n in sorted(now.keys() - was.keys())]
|
|
114
|
+
for name in sorted(was.keys() & now.keys()):
|
|
115
|
+
out += _compare_entry(f"{label} {name}", was[name], now[name])
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _compare_entry(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
120
|
+
out: list[Change] = []
|
|
121
|
+
for key in sorted(old.keys() | new.keys()):
|
|
122
|
+
if old.get(key) == new.get(key):
|
|
123
|
+
continue
|
|
124
|
+
if key == "variants":
|
|
125
|
+
gone, added = set(old.get(key, ())) - set(new.get(key, ())), set(new.get(key, ())) - set(old.get(key, ()))
|
|
126
|
+
out += [Change(Level.BREAKING, where, f"variant {v} removed") for v in sorted(gone)]
|
|
127
|
+
out += [Change(Level.COMPATIBLE, where, f"variant {v} added") for v in sorted(added)]
|
|
128
|
+
elif key == "doc":
|
|
129
|
+
out.append(Change(Level.REVISION, where, "documentation changed"))
|
|
130
|
+
elif key == "signature":
|
|
131
|
+
added = new.get(key, [])[len(old.get(key, [])) :]
|
|
132
|
+
keeps = new.get(key, [])[: len(old.get(key, []))] == old.get(key, [])
|
|
133
|
+
if keeps and all(arg[2] for arg in added): # trailing optional arguments: old calls still work
|
|
134
|
+
out.append(Change(Level.COMPATIBLE, where, f"optional arguments added: {[a[0] for a in added]}"))
|
|
135
|
+
else:
|
|
136
|
+
out.append(Change(Level.BREAKING, where, "signature changed"))
|
|
137
|
+
else:
|
|
138
|
+
out.append(Change(Level.BREAKING, where, f"{key} changed from {old.get(key)!r} to {new.get(key)!r}"))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def worst(changes: list[Change]) -> Level:
|
|
143
|
+
return max((c.level for c in changes), default=Level.NONE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def bump(previous: Triple, level: Level) -> Triple:
|
|
147
|
+
"""libtool's rules (V10): none keeps it, a revision adds one, an addition raises `current` and `age`, a break
|
|
148
|
+
resets them."""
|
|
149
|
+
match level:
|
|
150
|
+
case Level.NONE:
|
|
151
|
+
return previous
|
|
152
|
+
case Level.REVISION:
|
|
153
|
+
return Triple(previous.current, previous.revision + 1, previous.age)
|
|
154
|
+
case Level.COMPATIBLE:
|
|
155
|
+
return Triple(previous.current + 1, 0, previous.age + 1)
|
|
156
|
+
case Level.BREAKING:
|
|
157
|
+
return Triple(previous.current + 1, 0, 0)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def bump_output(previous: Pair, change: str) -> Pair:
|
|
161
|
+
"""`output` has no age: `current` when the appearance changed, `revision` when only bytes did (V10). `change` is the
|
|
162
|
+
author's statement; an empty one means nothing changed."""
|
|
163
|
+
match change:
|
|
164
|
+
case "current":
|
|
165
|
+
return Pair(previous.current + 1, 0)
|
|
166
|
+
case "revision":
|
|
167
|
+
return Pair(previous.current, previous.revision + 1)
|
|
168
|
+
case _:
|
|
169
|
+
return previous
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
Version = tuple[int, int, int]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_version(text: str) -> Version:
|
|
176
|
+
major, minor, patch = (int(p) for p in text.split(".")[:3])
|
|
177
|
+
return major, minor, patch
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def required_release(previous: str, data: Level, interface: Level, output_changed: bool) -> str:
|
|
181
|
+
"""The smallest release number that does not understate the contracts (V13): a break is major, any other change
|
|
182
|
+
minor, no contract change a patch; each one place lower before 1.0."""
|
|
183
|
+
major, minor, patch = parse_version(previous)
|
|
184
|
+
breaking = Level.BREAKING in (data, interface)
|
|
185
|
+
changed = bool(data or interface) or output_changed
|
|
186
|
+
if major == 0:
|
|
187
|
+
return f"0.{minor + 1}.0" if breaking else f"0.{minor}.{patch + 1}"
|
|
188
|
+
if breaking:
|
|
189
|
+
return f"{major + 1}.0.0"
|
|
190
|
+
return f"{major}.{minor + 1}.0" if changed else f"{major}.{minor}.{patch + 1}"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The document directory (P27, D5): one visible `_texpkg/` folder beside the `.tex`, the payload and the consumer's own
|
|
2
|
+
files. Everything here takes the directory and text; nothing knows what a kind is."""
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
DIR = "_texpkg"
|
|
9
|
+
STATE = f"{DIR}/state.json" # the user's state (D1)
|
|
10
|
+
TEMPLATE = f"{DIR}/template.json" # the package as it was when the document was saved (D1, D6)
|
|
11
|
+
HISTORY = 3 # the last saves kept (D2)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plan_file(variant: str | None) -> str:
|
|
15
|
+
"""The saved plan of the `.tex` of `variant` (D7)."""
|
|
16
|
+
return f"{DIR}/plan.json" if variant is None else f"{DIR}/plan-{variant}.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read(root: pathlib.Path, rel: str) -> str | None:
|
|
20
|
+
path = root / rel
|
|
21
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def differing(root: pathlib.Path, files: Mapping[str, str]) -> dict[str, str]:
|
|
25
|
+
"""The files whose text on disk is not `files`' (absent counts): content, never time (A4)."""
|
|
26
|
+
return {rel: text for rel, text in files.items() if read(root, rel) != text}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write(root: pathlib.Path, files: Mapping[str, str]) -> None:
|
|
30
|
+
for rel, text in files.items():
|
|
31
|
+
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
(root / rel).write_text(text, encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rotate_history(root: pathlib.Path) -> None:
|
|
36
|
+
"""Keep the pair of states on disk as `history/1`, the earlier ones one further, and drop what is beyond `HISTORY`
|
|
37
|
+
(D2, D3). Nothing happens when there is no pair yet."""
|
|
38
|
+
pair = [rel for rel in (STATE, TEMPLATE) if (root / rel).is_file()]
|
|
39
|
+
if not pair:
|
|
40
|
+
return
|
|
41
|
+
history = root / DIR / "history"
|
|
42
|
+
shutil.rmtree(history / str(HISTORY), ignore_errors=True)
|
|
43
|
+
for n in range(HISTORY - 1, 0, -1):
|
|
44
|
+
if (history / str(n)).is_dir():
|
|
45
|
+
(history / str(n)).rename(history / str(n + 1))
|
|
46
|
+
(history / "1").mkdir(parents=True)
|
|
47
|
+
for rel in pair:
|
|
48
|
+
shutil.copyfile(root / rel, history / "1" / pathlib.PurePosixPath(rel).name)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The envelope of a stored document: `{package, kind, data, tree}` (K3). It names the package and kind the tree belongs
|
|
2
|
+
to and the `data` version it was written for (V14)."""
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import NotADocumentError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dumps(value: object) -> str:
|
|
12
|
+
"""The text of a state file: stable, readable, one trailing newline."""
|
|
13
|
+
return json.dumps(value, indent=2, ensure_ascii=False) + "\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass(frozen=True)
|
|
17
|
+
class Envelope:
|
|
18
|
+
package: str
|
|
19
|
+
kind: str
|
|
20
|
+
data: int
|
|
21
|
+
tree: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
def to_text(self) -> str:
|
|
24
|
+
return dumps({"package": self.package, "kind": self.kind, "data": self.data, "tree": self.tree})
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def parse(cls, text: str, where: str) -> "Envelope":
|
|
28
|
+
try:
|
|
29
|
+
found = json.loads(text)
|
|
30
|
+
return cls(str(found["package"]), str(found["kind"]), int(found["data"]), dict(found["tree"]))
|
|
31
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
32
|
+
raise NotADocumentError(
|
|
33
|
+
f"{where}: not a document state ({exc!r})",
|
|
34
|
+
code="TP-D001",
|
|
35
|
+
path=where,
|
|
36
|
+
hint="it is written by a kind's create and save",
|
|
37
|
+
) from exc
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The plain-LaTeX export (E1): what a project that does not use Python needs, written for each kind.
|
|
2
|
+
|
|
3
|
+
A kind's template is its example rendered with the kind's settings (L8), so locks reach a plain-LaTeX user; its
|
|
4
|
+
README is generated from the kind's classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from texmodel.components import Command, Environment, control_name
|
|
10
|
+
from texmodel.package import setting_line
|
|
11
|
+
|
|
12
|
+
from ..declaration import Distribution, Kind, reachable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def template_name(kind: Kind) -> str:
|
|
16
|
+
return f"{kind.name}-template.tex"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def readme_name(kind: Kind) -> str:
|
|
20
|
+
return f"README-{kind.name}.md"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _call(cls: type[Command] | type[Environment]) -> str:
|
|
24
|
+
"""The TeX shape of a component: `\\abFact[accent]{text}` or `\\begin{abCard}` ... `\\end{abCard}`."""
|
|
25
|
+
optional_first = [a for a in cls.__tm_args__ if a.optional and not a.placed]
|
|
26
|
+
ordered = [*optional_first, *(a for a in cls.__tm_args__ if a not in optional_first)]
|
|
27
|
+
args = "".join(f"{a.delimiter.open}{a.name}{a.delimiter.close}" for a in ordered)
|
|
28
|
+
name = control_name(cls)
|
|
29
|
+
return f"\\begin{{{name}}}{args} ... \\end{{{name}}}" if issubclass(cls, Environment) else f"\\{name}{args}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def readme(dist: Distribution, kind: Kind, release: str, files: Sequence[str]) -> str:
|
|
33
|
+
"""The README of `kind`: how to compile its template, what the template is written with, and its commands."""
|
|
34
|
+
base = dist.base_kind(kind)
|
|
35
|
+
model = base.model if base else kind.model
|
|
36
|
+
commands = "\n".join(f"- `{_call(c)}`: {c.__tm_doc__}" for c in reachable(model))
|
|
37
|
+
settings = "\n".join(f"- `{setting_line(v)}`" for v in (*(base.presets if base else ()), *kind.presets))
|
|
38
|
+
variants = ", ".join(f"`{v.name}`" for v in kind.variants) or "none"
|
|
39
|
+
listing = "\n".join(f"- `{name}`" for name in files)
|
|
40
|
+
return (
|
|
41
|
+
f"# {kind.name}: {dist.name} {release}\n\n"
|
|
42
|
+
f"{kind.model.__tm_doc__}\n\n"
|
|
43
|
+
f"Compile the template with LuaLaTeX from this directory:\n\n"
|
|
44
|
+
f" latexmk -lualatex {template_name(kind)}\n\n"
|
|
45
|
+
f"## Files\n\n{listing}\n\n"
|
|
46
|
+
f"## The template is written with\n\n{settings or 'no settings of its own'}\n\n"
|
|
47
|
+
f"Variants of the kind (the Python module writes them; the template is the kind's own): {variants}.\n\n"
|
|
48
|
+
f"## Commands\n\n{commands}\n"
|
|
49
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Hand edits: a `.tex` against the plan of what was last written (D7-D9).
|
|
2
|
+
|
|
3
|
+
The plan says which node wrote each line, so an edit is found by path even when the model has moved on.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from texmodel.plan import Edit, Plan
|
|
11
|
+
|
|
12
|
+
from . import directory
|
|
13
|
+
|
|
14
|
+
_NODE = re.compile(r"[A-Z][A-Za-z]*\[(\d+)\]") # a component's name: a capital, then letters (TM-M001)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def data_path(plan_path: str) -> str:
|
|
18
|
+
"""The data path of a plan path: `Deck[0]/Card[1]/Title[0]` is `/content/1/content/0`, the root being the document.
|
|
19
|
+
A line that no node wrote (`header`, `preamble.before_package[0]`, `settings[2]`) keeps its plan path."""
|
|
20
|
+
nodes = [_NODE.fullmatch(part) for part in plan_path.split("/")]
|
|
21
|
+
if not all(nodes):
|
|
22
|
+
return plan_path
|
|
23
|
+
return "".join(f"/content/{node[1]}" for node in nodes[1:] if node)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def edits(root: pathlib.Path, files: Sequence[tuple[str | None, str]]) -> list[tuple[str, Edit]]:
|
|
27
|
+
"""For each `(variant, file name)` that exists beside a saved plan: the file and its edits against that plan."""
|
|
28
|
+
found: list[tuple[str, Edit]] = []
|
|
29
|
+
for variant, name in files:
|
|
30
|
+
text, saved = directory.read(root, name), directory.read(root, directory.plan_file(variant))
|
|
31
|
+
if text is not None and saved is not None:
|
|
32
|
+
found += [(name, edit) for edit in Plan.from_json(saved).edits(text)]
|
|
33
|
+
return found
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""One model's history: what a stored state needs to become a valid tree of the current model (V8, V9, V28, V30).
|
|
2
|
+
|
|
3
|
+
Written to move into texmodel (N5): it knows models, schemas and paths, not packages, kinds or releases. A problem is a
|
|
4
|
+
plain dict `{"path", "reason", "value", "schema"}` with the path a data path (`/content/1/content/0/text`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import pydantic
|
|
12
|
+
from texmodel.components import Command, Environment
|
|
13
|
+
|
|
14
|
+
type Problem = dict[str, Any]
|
|
15
|
+
|
|
16
|
+
_NODE = re.compile(r"([A-Z][A-Za-z]*)(?:\[(\d+)\])?")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pointer(loc: Sequence[str | int]) -> str:
|
|
20
|
+
"""The data path of a pydantic error location: a tagged child's class name follows its index and is not data."""
|
|
21
|
+
out: list[str] = []
|
|
22
|
+
after_index = False
|
|
23
|
+
for part in loc:
|
|
24
|
+
if after_index and isinstance(part, str):
|
|
25
|
+
after_index = False
|
|
26
|
+
continue
|
|
27
|
+
after_index = isinstance(part, int)
|
|
28
|
+
out.append(str(part))
|
|
29
|
+
return "".join(f"/{part}" for part in out)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def violation_pointer(path: str) -> str:
|
|
33
|
+
"""The data path of a grammar violation's path (`Deck/Card[1]` is `/content/1`)."""
|
|
34
|
+
parts = path.split("/")
|
|
35
|
+
return "".join(f"/content/{m[2]}" for part in parts[1:] if (m := _NODE.fullmatch(part)) and m[2] is not None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def schema_at(schema: Mapping[str, Any], loc: Sequence[str | int]) -> dict[str, Any]:
|
|
39
|
+
"""The schema of the field an error location names: `$defs[<class>].properties[<field>]`, `{}` if there is none."""
|
|
40
|
+
classes = [str(p) for i, p in enumerate(loc) if isinstance(p, str) and i > 0 and isinstance(loc[i - 1], int)]
|
|
41
|
+
owner = schema.get("$defs", {}).get(classes[-1]) if classes else schema
|
|
42
|
+
field = loc[-1]
|
|
43
|
+
found = (owner or {}).get("properties", {}).get(field) if isinstance(field, str) and field != "content" else None
|
|
44
|
+
return cast("dict[str, Any]", found) if isinstance(found, dict) else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def problems(
|
|
48
|
+
model: type[Command] | type[Environment], data: dict[str, Any], schema: Mapping[str, Any]
|
|
49
|
+
) -> list[Problem]:
|
|
50
|
+
"""Everything that stops `data` from being a valid tree of `model`: pydantic's errors with their constraint,
|
|
51
|
+
then the grammar's violations (V30)."""
|
|
52
|
+
try:
|
|
53
|
+
tree = model.model_validate(data)
|
|
54
|
+
except pydantic.ValidationError as exc:
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
"path": pointer(e["loc"]),
|
|
58
|
+
"reason": e["msg"],
|
|
59
|
+
"value": e.get("input"),
|
|
60
|
+
"schema": schema_at(schema, e["loc"]),
|
|
61
|
+
}
|
|
62
|
+
for e in exc.errors(include_url=False)
|
|
63
|
+
]
|
|
64
|
+
return [
|
|
65
|
+
{"path": violation_pointer(v.path), "reason": v.message, "value": None, "schema": {}} for v in tree.violations()
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unplaced(state: Mapping[str, Any], schema: Mapping[str, Any], path: str = "") -> list[Problem]:
|
|
70
|
+
"""The fields of a stored state that the model has no field for, which loading it would drop without a word: a
|
|
71
|
+
migration places each one or removes it on purpose. A node whose class the schema does not know is left to the
|
|
72
|
+
validation, which names it."""
|
|
73
|
+
fields = {schema.get("title"): schema.get("properties", {})} | {
|
|
74
|
+
name: node.get("properties", {}) for name, node in schema.get("$defs", {}).items()
|
|
75
|
+
}
|
|
76
|
+
known = fields.get(state.get("type"))
|
|
77
|
+
if known is None:
|
|
78
|
+
return []
|
|
79
|
+
out: list[Problem] = [
|
|
80
|
+
{
|
|
81
|
+
"path": f"{path}/{key}",
|
|
82
|
+
"reason": f"the model has no field {key!r}: a migration has to place it or remove it on purpose",
|
|
83
|
+
"value": value,
|
|
84
|
+
"schema": {},
|
|
85
|
+
}
|
|
86
|
+
for key, value in state.items()
|
|
87
|
+
if key not in known and key != "$defaults"
|
|
88
|
+
]
|
|
89
|
+
for i, child in enumerate(state.get("content", ())):
|
|
90
|
+
out += unplaced(child, schema, f"{path}/content/{i}")
|
|
91
|
+
return out
|