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 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