texpkg 0.1.0__tar.gz → 0.2.1__tar.gz
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-0.2.1/PKG-INFO +37 -0
- texpkg-0.2.1/README.md +17 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/pyproject.toml +2 -2
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/adopt.py +46 -7
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/classify.py +12 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/envelope.py +10 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/handedits.py +17 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/release.py +73 -13
- texpkg-0.2.1/src/texpkg/_internal/state.py +232 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/declaration.py +115 -11
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/document.py +56 -17
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/errors.py +9 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/layout.py +8 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/_internal/checks.py +4 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/_internal/generate.py +24 -8
- texpkg-0.2.1/src/texpkg_build/_internal/generated.py +121 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/_internal/snapshot.py +6 -2
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/cli.py +30 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/hook.py +8 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/testing.py +16 -8
- texpkg-0.2.1/tests/consumers/acme-series-tex/pdm_build.py +3 -0
- texpkg-0.2.1/tests/consumers/acme-series-tex/pyproject.toml +20 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/__init__.py +7 -3
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/glossary.tex +0 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/__init__.py +13 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/model.py +10 -4
- texpkg-0.2.1/tests/fixtures/acme_series/__init__.py +19 -0
- texpkg-0.2.1/tests/fixtures/acme_series/_kinds/__init__.py +5 -0
- texpkg-0.2.1/tests/fixtures/acme_series/_kinds/long.py +67 -0
- texpkg-0.2.1/tests/fixtures/acme_series/_kinds/short.py +66 -0
- texpkg-0.2.1/tests/fixtures/acme_series/assets/lmroman10-regular.otf +0 -0
- texpkg-0.2.1/tests/fixtures/acme_series/model.py +19 -0
- texpkg-0.2.1/tests/fixtures/acme_series/series.json +6 -0
- texpkg-0.2.1/tests/fixtures/acme_series/series.tex +3 -0
- texpkg-0.2.1/tests/fixtures/acme_series_gen/__init__.py +104 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_sheet/__init__.py +1 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/projects.py +5 -5
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_build.py +10 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_compile.py +1 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_contracts.py +4 -2
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_declaration.py +90 -8
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_document.py +14 -14
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_embed.py +6 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_end_to_end.py +2 -2
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_export.py +1 -1
- texpkg-0.2.1/tests/test_extras.py +209 -0
- texpkg-0.2.1/tests/test_generated.py +268 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_isolated.py +13 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_migration.py +1 -1
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_module.py +8 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_payload.py +4 -4
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_profile.py +107 -7
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_provenance.py +2 -2
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_typing.py +14 -4
- texpkg-0.1.0/PKG-INFO +0 -30
- texpkg-0.1.0/README.md +0 -10
- texpkg-0.1.0/src/texpkg/_internal/state.py +0 -113
- {texpkg-0.1.0 → texpkg-0.2.1}/LICENSE +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/directory.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/export.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/history.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/payload.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/provenance_tex.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/snapshots.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/_internal/versions.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/assets.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/contracts.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/migration.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/protocol.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/provenance.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg/py.typed +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/_internal/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/_internal/embed.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/_internal/isolate.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/src/texpkg_build/py.typed +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/conftest.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/consumers/acme-brand-tex/pdm_build.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/consumers/acme-brand-tex/pyproject.toml +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/consumers/acme-deck-tex/pdm_build.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/consumers/acme-deck-tex/pyproject.toml +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/acme.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/assets/NOTICE.txt +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/README-expert.md +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/acme-brand.sty +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/deck-compact.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/deck-wide.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/deck.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/expert-template.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/plan.json +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/state.json +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/model.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/other/lockup.pdf +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/tokens.json +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/assets/DECK.txt +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/generated/brief.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/fixtures/acme_sheet/model.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/helpers.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/spawn.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/test_public_api.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.1}/tests/venvs.py +0 -0
texpkg-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: texpkg
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: LaTeX package management.
|
|
5
|
+
Author-Email: Ilja Orlovs <ilja@release.art>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Project-URL: Homepage, https://github.com/release-art/texpkg
|
|
12
|
+
Project-URL: Repository, https://github.com/release-art/texpkg
|
|
13
|
+
Requires-Python: <3.16,>=3.15
|
|
14
|
+
Requires-Dist: texmodel>=0.7.0
|
|
15
|
+
Requires-Dist: pydantic>=2.14.1
|
|
16
|
+
Provides-Extra: build
|
|
17
|
+
Requires-Dist: typer>=0.27.3; extra == "build"
|
|
18
|
+
Requires-Dist: pdm-backend>=2.4; extra == "build"
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# texpkg
|
|
22
|
+
|
|
23
|
+
TeX packages published as Python packages. A texpkg package is one wheel: a [texmodel](https://github.com/release-art/texmodel)
|
|
24
|
+
API and the TeX it compiles against, at one version. Installed, it is a Python module with a fixed API to load, render,
|
|
25
|
+
save and check document directories, with versioned data and migrations; packages can build on each other, the base
|
|
26
|
+
embedded in the dependent at build time.
|
|
27
|
+
|
|
28
|
+
An author declares a package (`Distribution`, `Kind`, `Asset`) and builds it with `texpkg-build`, a pdm build hook. The
|
|
29
|
+
spec, the API reference and the decisions are in [docs/texpkg-plan.md](docs/texpkg-plan.md); what texpkg does not do is in
|
|
30
|
+
[docs/known-limitations.md](docs/known-limitations.md).
|
|
31
|
+
|
|
32
|
+
```zsh
|
|
33
|
+
pdm install
|
|
34
|
+
git config core.hooksPath .githooks # pre-commit runs ruff, pyright and import-linter
|
|
35
|
+
bin/autoformat.sh
|
|
36
|
+
bin/test.sh # pytest, 100% coverage gate
|
|
37
|
+
```
|
texpkg-0.2.1/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# texpkg
|
|
2
|
+
|
|
3
|
+
TeX packages published as Python packages. A texpkg package is one wheel: a [texmodel](https://github.com/release-art/texmodel)
|
|
4
|
+
API and the TeX it compiles against, at one version. Installed, it is a Python module with a fixed API to load, render,
|
|
5
|
+
save and check document directories, with versioned data and migrations; packages can build on each other, the base
|
|
6
|
+
embedded in the dependent at build time.
|
|
7
|
+
|
|
8
|
+
An author declares a package (`Distribution`, `Kind`, `Asset`) and builds it with `texpkg-build`, a pdm build hook. The
|
|
9
|
+
spec, the API reference and the decisions are in [docs/texpkg-plan.md](docs/texpkg-plan.md); what texpkg does not do is in
|
|
10
|
+
[docs/known-limitations.md](docs/known-limitations.md).
|
|
11
|
+
|
|
12
|
+
```zsh
|
|
13
|
+
pdm install
|
|
14
|
+
git config core.hooksPath .githooks # pre-commit runs ruff, pyright and import-linter
|
|
15
|
+
bin/autoformat.sh
|
|
16
|
+
bin/test.sh # pytest, 100% coverage gate
|
|
17
|
+
```
|
|
@@ -7,10 +7,12 @@ left aborts with the report.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import copy
|
|
10
|
+
import re
|
|
10
11
|
from collections.abc import Iterator, Mapping, Sequence
|
|
11
|
-
from typing import Any
|
|
12
|
+
from typing import Any, cast
|
|
12
13
|
|
|
13
14
|
from texmodel.components import Node
|
|
15
|
+
from texmodel.package import Package
|
|
14
16
|
|
|
15
17
|
from ..declaration import Kind
|
|
16
18
|
from ..errors import MigrationError
|
|
@@ -18,10 +20,12 @@ from ..migration import DefaultChange, Migration, Unresolved
|
|
|
18
20
|
from . import state
|
|
19
21
|
from .envelope import Envelope
|
|
20
22
|
from .history import problems, unplaced
|
|
23
|
+
from .state import Extras
|
|
21
24
|
|
|
22
25
|
UNRESOLVED = (
|
|
23
26
|
"$unresolved" # in the state a migration gets: what the earlier ones left, as `{"path", "reason", "value"}`
|
|
24
27
|
)
|
|
28
|
+
EXTRAS = "$extras" # in the state a migration gets: the document's `settings`, `options` and `preamble` (D10)
|
|
25
29
|
|
|
26
30
|
|
|
27
31
|
def _policy(changes: DefaultChange | Mapping[str, DefaultChange], path: str, key: str) -> DefaultChange:
|
|
@@ -76,9 +80,40 @@ def _kind_migrations(kind: Kind, boundary: int, base_data: Mapping[int, int] | N
|
|
|
76
80
|
return [m for m in kind.migrations if low < m.to <= high]
|
|
77
81
|
|
|
78
82
|
|
|
83
|
+
def _data_path(path: str) -> list[str]:
|
|
84
|
+
"""The parts of a texmodel path (`settings[1].value`) as data keys (`settings`, `1`, `value`)."""
|
|
85
|
+
return [part for part in re.split(r"[.\[\]]+", path) if part]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _value_at(data: object, keys: list[str]) -> object:
|
|
89
|
+
for key in keys:
|
|
90
|
+
try:
|
|
91
|
+
data = (
|
|
92
|
+
cast("list[object]", data)[int(key)] if isinstance(data, list) else cast("dict[str, object]", data)[key]
|
|
93
|
+
)
|
|
94
|
+
except KeyError, IndexError, TypeError, ValueError:
|
|
95
|
+
return None
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extras_problems(kind: Kind, package: Package, data: dict[str, Any]) -> tuple[Extras, list[dict[str, Any]]]:
|
|
100
|
+
"""The extras `data` describes and what stops them from being the kind's: texmodel's errors and what the kind
|
|
101
|
+
does not allow, as unresolved items."""
|
|
102
|
+
try:
|
|
103
|
+
extras = state.extras_from(package, data)
|
|
104
|
+
except ExceptionGroup as group:
|
|
105
|
+
found: list[dict[str, Any]] = []
|
|
106
|
+
for e in group.exceptions:
|
|
107
|
+
keys = _data_path(str(getattr(e, "path", "")))
|
|
108
|
+
found.append({"path": "/" + "/".join(keys), "reason": str(e), "value": _value_at(data, keys), "schema": {}})
|
|
109
|
+
return Extras(), found
|
|
110
|
+
return extras, state.not_allowed(kind, extras)
|
|
111
|
+
|
|
112
|
+
|
|
79
113
|
def adopt(
|
|
80
114
|
*,
|
|
81
115
|
kind: Kind,
|
|
116
|
+
package: Package,
|
|
82
117
|
envelope: Envelope,
|
|
83
118
|
current: int,
|
|
84
119
|
age: int,
|
|
@@ -86,10 +121,11 @@ def adopt(
|
|
|
86
121
|
policy: DefaultChange | Mapping[str, DefaultChange],
|
|
87
122
|
saved_defaults: Mapping[str, Any] | None,
|
|
88
123
|
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
|
|
91
|
-
that no migration placed. Nothing is written."""
|
|
124
|
+
) -> tuple[Node, Extras]:
|
|
125
|
+
"""The tree and extras of `kind` that `envelope` describes at `data` version `current`, or a `MigrationError` naming
|
|
126
|
+
everything that no migration placed. Nothing is written."""
|
|
92
127
|
tree = copy.deepcopy(envelope.tree)
|
|
128
|
+
extras_data = copy.deepcopy(envelope.extras)
|
|
93
129
|
left: list[dict[str, Any]] = []
|
|
94
130
|
if saved_defaults is not None:
|
|
95
131
|
left += default_changes(tree, saved_defaults, state.defaults_table(kind.model), policy)
|
|
@@ -97,11 +133,14 @@ def adopt(
|
|
|
97
133
|
for boundary in range(envelope.data + 1, current + 1):
|
|
98
134
|
label = f"{boundary - 1} -> {boundary}"
|
|
99
135
|
for migration in (*_kind_migrations(kind, boundary, base_data), *(m for m in consumer if m.to == boundary)):
|
|
100
|
-
tree[UNRESOLVED] = left
|
|
136
|
+
tree[UNRESOLVED], tree[EXTRAS] = left, extras_data
|
|
101
137
|
result = migration(tree)
|
|
102
138
|
tree = result.state
|
|
139
|
+
extras_data = tree.pop(EXTRAS, {})
|
|
103
140
|
left = [*tree.pop(UNRESOLVED, []), *({**vars(u), "boundary": label} for u in result.unresolved)]
|
|
104
|
-
schema = state.
|
|
141
|
+
schema = state.tree_schema(kind.model, "validation")
|
|
142
|
+
extras, found = _extras_problems(kind, package, extras_data)
|
|
143
|
+
left += [{**p, "boundary": "validation"} for p in found]
|
|
105
144
|
left += [{**p, "boundary": "validation"} for p in unplaced(tree, schema)]
|
|
106
145
|
left += [{**p, "boundary": "validation"} for p in problems(kind.model, state.given(tree), schema)]
|
|
107
146
|
if left:
|
|
@@ -116,4 +155,4 @@ def adopt(
|
|
|
116
155
|
unresolved=items,
|
|
117
156
|
hint="extend the process with a migration of your own: load(path, migrations=[...])",
|
|
118
157
|
)
|
|
119
|
-
return state.tree_from_state(kind.model, tree)
|
|
158
|
+
return state.tree_from_state(kind.model, tree), extras
|
|
@@ -50,12 +50,24 @@ def _compare_property(where: str, old: dict[str, Any], new: dict[str, Any]) -> l
|
|
|
50
50
|
out.append(Change(Level.BREAKING if tighter else Level.COMPATIBLE, where, f"{key} {was} -> {now}"))
|
|
51
51
|
if old.get("items") != new.get("items") and "items" in old:
|
|
52
52
|
out += _compare_items(where, old["items"], new.get("items", {}))
|
|
53
|
+
out += _compare_inline(where, old.get("properties", {}), new.get("properties", {}))
|
|
53
54
|
for key in ("default", "description", "title"):
|
|
54
55
|
if old.get(key) != new.get(key):
|
|
55
56
|
out.append(Change(Level.REVISION, where, f"{key} changed"))
|
|
56
57
|
return out
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def _compare_inline(where: str, before: dict[str, Any], after: dict[str, Any]) -> list[Change]:
|
|
61
|
+
"""The properties of an object written inline in a property, not as a class of its own."""
|
|
62
|
+
out: list[Change] = []
|
|
63
|
+
for name in before:
|
|
64
|
+
if name in after:
|
|
65
|
+
out += _compare_property(f"{where}.{name}", before[name], after[name])
|
|
66
|
+
else:
|
|
67
|
+
out.append(Change(Level.BREAKING, f"{where}.{name}", "removed"))
|
|
68
|
+
return out + [Change(Level.COMPATIBLE, f"{where}.{name}", "added") for name in sorted(after.keys() - before.keys())]
|
|
69
|
+
|
|
70
|
+
|
|
59
71
|
def _compare_items(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
60
72
|
"""The element schema of a list: a branch of a union that went is breaking, one that came is compatible."""
|
|
61
73
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
"""The envelope of a stored document: `{package, kind, data, tree}` (K3)
|
|
2
|
-
to and the `data` version it was written
|
|
1
|
+
"""The envelope of a stored document: `{package, kind, data, tree}` (K3), and `settings`, `options` and `preamble` when
|
|
2
|
+
the document has any (D10). It names the package and kind the tree belongs to and the `data` version it was written
|
|
3
|
+
for (V14)."""
|
|
3
4
|
|
|
4
5
|
import dataclasses
|
|
5
6
|
import json
|
|
@@ -7,6 +8,8 @@ from typing import Any
|
|
|
7
8
|
|
|
8
9
|
from ..errors import NotADocumentError
|
|
9
10
|
|
|
11
|
+
EXTRA_KEYS = ("settings", "options", "preamble")
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
def dumps(value: object) -> str:
|
|
12
15
|
"""The text of a state file: stable, readable, one trailing newline."""
|
|
@@ -19,15 +22,18 @@ class Envelope:
|
|
|
19
22
|
kind: str
|
|
20
23
|
data: int
|
|
21
24
|
tree: dict[str, Any]
|
|
25
|
+
extras: dict[str, Any] = dataclasses.field(default_factory=dict[str, Any]) # `settings`, `options`, `preamble`
|
|
22
26
|
|
|
23
27
|
def to_text(self) -> str:
|
|
24
|
-
|
|
28
|
+
head = {"package": self.package, "kind": self.kind, "data": self.data, "tree": self.tree}
|
|
29
|
+
return dumps(head | self.extras)
|
|
25
30
|
|
|
26
31
|
@classmethod
|
|
27
32
|
def parse(cls, text: str, where: str) -> "Envelope":
|
|
28
33
|
try:
|
|
29
34
|
found = json.loads(text)
|
|
30
|
-
|
|
35
|
+
extras = {key: found[key] for key in EXTRA_KEYS if key in found}
|
|
36
|
+
return cls(str(found["package"]), str(found["kind"]), int(found["data"]), dict(found["tree"]), extras)
|
|
31
37
|
except (ValueError, KeyError, TypeError) as exc:
|
|
32
38
|
raise NotADocumentError(
|
|
33
39
|
f"{where}: not a document state ({exc!r})",
|
|
@@ -8,12 +8,29 @@ import re
|
|
|
8
8
|
from collections.abc import Sequence
|
|
9
9
|
|
|
10
10
|
from texmodel.plan import Edit, Plan
|
|
11
|
+
from texmodel.preamble import PreambleSlots
|
|
11
12
|
|
|
12
13
|
from . import directory
|
|
13
14
|
|
|
14
15
|
_NODE = re.compile(r"[A-Z][A-Za-z]*\[(\d+)\]") # a component's name: a capital, then letters (TM-M001)
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
_SETTING = re.compile(r"settings\[(\d+)\]")
|
|
19
|
+
_ENTRY = re.compile(r"preamble\.(before_class|before_package|after_package)\[(\d+)\]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extra_path(plan_path: str, *, locked: int, leading: PreambleSlots) -> str:
|
|
23
|
+
"""The data path of a line that a setting or a preamble entry of the document wrote (D10), or `""`: the kind's own
|
|
24
|
+
`locked` settings come first in the plan, then the document's, and the `leading` entries (the layout's and the
|
|
25
|
+
kind's locked ones) lead each preamble slot."""
|
|
26
|
+
if found := _SETTING.fullmatch(plan_path):
|
|
27
|
+
return f"/settings/{int(found[1]) - locked}" if int(found[1]) >= locked else ""
|
|
28
|
+
if found := _ENTRY.fullmatch(plan_path):
|
|
29
|
+
own = int(found[2]) - len(getattr(leading, found[1]))
|
|
30
|
+
return f"/preamble/{found[1]}/{own}" if own >= 0 else ""
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
|
|
17
34
|
def data_path(plan_path: str) -> str:
|
|
18
35
|
"""The data path of a plan path: `Deck[0]/Card[1]/Title[0]` is `/content/1/content/0`, the root being the document.
|
|
19
36
|
A line that no node wrote (`header`, `preamble.before_package[0]`, `settings[2]`) keeps its plan path."""
|
|
@@ -13,8 +13,10 @@ from collections.abc import Mapping, Sequence
|
|
|
13
13
|
from typing import Any, cast
|
|
14
14
|
|
|
15
15
|
from texmodel.components import AnyValue, Command, Environment, Node
|
|
16
|
+
from texmodel.document import Document as TexDocument
|
|
16
17
|
from texmodel.package import setting_line
|
|
17
18
|
from texmodel.plan import Plan
|
|
19
|
+
from texmodel.preamble import PreambleSlots
|
|
18
20
|
|
|
19
21
|
from .. import provenance
|
|
20
22
|
from ..assets import Role
|
|
@@ -27,11 +29,13 @@ from ..errors import (
|
|
|
27
29
|
KindMismatchError,
|
|
28
30
|
NewerDataError,
|
|
29
31
|
NotADocumentError,
|
|
32
|
+
NotAllowedError,
|
|
30
33
|
)
|
|
31
34
|
from ..migration import DefaultChange, Migration
|
|
32
35
|
from . import directory, export, payload, snapshots, state
|
|
33
36
|
from .adopt import adopt
|
|
34
37
|
from .envelope import Envelope, dumps
|
|
38
|
+
from .state import Extras
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -67,13 +71,45 @@ class Release:
|
|
|
67
71
|
base = self.dist.base_kind(kind)
|
|
68
72
|
return (*(base.presets if base else ()), *kind.settings(variant))
|
|
69
73
|
|
|
70
|
-
def
|
|
71
|
-
|
|
74
|
+
def _has_graphics(self, kind: Kind) -> bool:
|
|
75
|
+
kinds = (kind.name, *([kind.over] if kind.over else []))
|
|
76
|
+
return any(r.asset.role is Role.GRAPHIC and r.asset.needed_by(kinds) for r in self.dist.resolved_assets())
|
|
77
|
+
|
|
78
|
+
def layout_preamble(self, kind: Kind) -> PreambleSlots:
|
|
79
|
+
"""The lines that find the payload, which come before a document's own entries (A5)."""
|
|
80
|
+
return self.dist.layout.preamble(graphics=self._has_graphics(kind))
|
|
81
|
+
|
|
82
|
+
def fixed_preamble(self, kind: Kind) -> PreambleSlots:
|
|
83
|
+
"""What leads each preamble slot of every document of `kind`: the layout's lines, then the entries the base kind
|
|
84
|
+
locks (a profile's), then the kind's own (K020)."""
|
|
85
|
+
base = self.dist.base_kind(kind)
|
|
86
|
+
slots = [self.layout_preamble(kind), *([base.locked_preamble] if base else []), kind.locked_preamble]
|
|
87
|
+
return PreambleSlots(
|
|
88
|
+
before_class=tuple(e for s in slots for e in s.before_class),
|
|
89
|
+
before_package=tuple(e for s in slots for e in s.before_package),
|
|
90
|
+
after_package=tuple(e for s in slots for e in s.after_package),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def plan(self, kind: Kind, tree: Node, variant: str | None, extras: Extras = Extras()) -> Plan: # noqa: B008
|
|
94
|
+
"""The `.tex` of `tree` as lines with provenance: the kind's presets, then the variant's settings (L8), then the
|
|
95
|
+
document's own settings (D10); in each preamble slot the fixed entries, then the document's own.
|
|
72
96
|
|
|
73
97
|
An exposed base kind is written by the base's package; the directory's layout is this distribution's."""
|
|
74
98
|
body = tree if kind.expand is None else kind.expand(tree)
|
|
75
99
|
package = self.dist.owner(kind.name).package
|
|
76
|
-
|
|
100
|
+
found, own = self.fixed_preamble(kind), extras.preamble
|
|
101
|
+
return package.plan(
|
|
102
|
+
TexDocument(
|
|
103
|
+
body,
|
|
104
|
+
options=extras.options,
|
|
105
|
+
settings=(*self.settings(kind, variant), *extras.settings),
|
|
106
|
+
preamble=PreambleSlots(
|
|
107
|
+
before_class=(*found.before_class, *own.before_class),
|
|
108
|
+
before_package=(*found.before_package, *own.before_package),
|
|
109
|
+
after_package=(*found.after_package, *own.after_package),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
77
113
|
|
|
78
114
|
def export(
|
|
79
115
|
self, destination: pathlib.Path | str, now: datetime.datetime | None, stamp: str | None
|
|
@@ -102,11 +138,11 @@ class Release:
|
|
|
102
138
|
files = {**self.sty, **(provenance or {})}
|
|
103
139
|
return payload.build(files, tuple(self.dist.resolved_assets()), self.dist.layout, kinds)
|
|
104
140
|
|
|
105
|
-
def rendered(self, kind: Kind, tree: Node) -> dict[str, str]:
|
|
141
|
+
def rendered(self, kind: Kind, tree: Node, extras: Extras = Extras()) -> dict[str, str]: # noqa: B008
|
|
106
142
|
"""Every `.tex` of the kind with the plan of it that `save` keeps (D7), by path in the directory."""
|
|
107
143
|
files: dict[str, str] = {}
|
|
108
144
|
for variant, name in kind.documents():
|
|
109
|
-
plan = self.plan(kind, tree, variant)
|
|
145
|
+
plan = self.plan(kind, tree, variant, extras)
|
|
110
146
|
files |= {name: plan.text, directory.plan_file(variant): plan.to_json()}
|
|
111
147
|
return files
|
|
112
148
|
|
|
@@ -131,9 +167,11 @@ class Release:
|
|
|
131
167
|
"stack": self.stack(),
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
def states(self, kind: Kind, tree: Node) -> dict[str, str]:
|
|
170
|
+
def states(self, kind: Kind, tree: Node, extras: Extras = Extras()) -> dict[str, str]: # noqa: B008
|
|
135
171
|
"""The pair of states `save` and `create` write (D1)."""
|
|
136
|
-
envelope = Envelope(
|
|
172
|
+
envelope = Envelope(
|
|
173
|
+
self.dist.name, kind.name, self.contracts.data.current, state.tree_state(tree), state.extras_state(extras)
|
|
174
|
+
)
|
|
137
175
|
return {directory.STATE: envelope.to_text(), directory.TEMPLATE: dumps(self.template(kind))}
|
|
138
176
|
|
|
139
177
|
def read(self, path: pathlib.Path) -> Envelope:
|
|
@@ -196,7 +234,27 @@ class KindApi[T: Command | Environment]:
|
|
|
196
234
|
)
|
|
197
235
|
self.release, self.kind = release, kind
|
|
198
236
|
|
|
199
|
-
def
|
|
237
|
+
def extras(self, settings: Sequence[AnyValue], options: Sequence[str], preamble: PreambleSlots | None) -> Extras:
|
|
238
|
+
"""What a document carries besides its tree, refused whole if the kind does not allow it (D10)."""
|
|
239
|
+
found = Extras(tuple(options), tuple(settings), preamble or PreambleSlots())
|
|
240
|
+
if items := state.not_allowed(self.kind, found):
|
|
241
|
+
shown = "; ".join(f"{i['path']}: {i['reason']}" for i in items)
|
|
242
|
+
raise NotAllowedError(
|
|
243
|
+
f"kind {self.kind.name}: {shown}",
|
|
244
|
+
items=tuple(items),
|
|
245
|
+
hint="the kind lists what a document may set: Kind(settable=, options=, preamble=)",
|
|
246
|
+
)
|
|
247
|
+
return found
|
|
248
|
+
|
|
249
|
+
def create(
|
|
250
|
+
self,
|
|
251
|
+
path: pathlib.Path | str,
|
|
252
|
+
tree: T,
|
|
253
|
+
*,
|
|
254
|
+
settings: Sequence[AnyValue] = (),
|
|
255
|
+
options: Sequence[str] = (),
|
|
256
|
+
preamble: PreambleSlots | None = None,
|
|
257
|
+
) -> Document[T]:
|
|
200
258
|
"""A document in `path` (made if need be) with both states written; refuses a directory that holds one."""
|
|
201
259
|
path = pathlib.Path(path)
|
|
202
260
|
if (path / directory.STATE).exists():
|
|
@@ -208,8 +266,9 @@ class KindApi[T: Command | Environment]:
|
|
|
208
266
|
)
|
|
209
267
|
if problems := tree.violations():
|
|
210
268
|
raise ExceptionGroup("invalid document", [v.error() for v in problems])
|
|
211
|
-
|
|
212
|
-
|
|
269
|
+
extras = self.extras(settings, options, preamble)
|
|
270
|
+
directory.write(path, self.release.states(self.kind, tree, extras))
|
|
271
|
+
return Document(tree, path, self.release, self.kind, extras)
|
|
213
272
|
|
|
214
273
|
def load(
|
|
215
274
|
self,
|
|
@@ -232,8 +291,9 @@ class KindApi[T: Command | Environment]:
|
|
|
232
291
|
)
|
|
233
292
|
saved = directory.read(path, directory.TEMPLATE)
|
|
234
293
|
template: dict[str, Any] = json.loads(saved) if saved else {}
|
|
235
|
-
tree = adopt(
|
|
294
|
+
tree, extras = adopt(
|
|
236
295
|
kind=self.kind,
|
|
296
|
+
package=self.release.dist.owner(self.kind.name).package,
|
|
237
297
|
envelope=envelope,
|
|
238
298
|
current=self.release.contracts.data.current,
|
|
239
299
|
age=self.release.contracts.data.age,
|
|
@@ -242,10 +302,10 @@ class KindApi[T: Command | Environment]:
|
|
|
242
302
|
saved_defaults=template.get("defaults"),
|
|
243
303
|
base_data=dict(self.release.contracts.base_data) if self.kind.name in self.release.dist.expose else None,
|
|
244
304
|
)
|
|
245
|
-
return Document(cast("T", tree), path, self.release, self.kind)
|
|
305
|
+
return Document(cast("T", tree), path, self.release, self.kind, extras)
|
|
246
306
|
|
|
247
307
|
def schema(self, mode: state.Mode) -> dict[str, Any]:
|
|
248
|
-
return state.schema(self.kind.
|
|
308
|
+
return state.schema(self.kind, self.release.dist.owner(self.kind.name).package, mode)
|
|
249
309
|
|
|
250
310
|
def example(self) -> T:
|
|
251
311
|
return cast("T", self.kind.example())
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The stored form of a document: its schemas (V26) and what it carries besides its tree (D10).
|
|
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`). A document's data is `{"tree": …}` and, when it has any, `settings`, `options` and `preamble` in the
|
|
6
|
+
shapes texmodel writes a `Document`'s in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import dataclasses
|
|
11
|
+
import hashlib
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any, Literal, cast
|
|
14
|
+
|
|
15
|
+
from texmodel import spec
|
|
16
|
+
from texmodel.body import Comment
|
|
17
|
+
from texmodel.components import AnyValue, Command, Environment, Node, Passthrough
|
|
18
|
+
from texmodel.document import Document as TexDocument
|
|
19
|
+
from texmodel.package import Package
|
|
20
|
+
from texmodel.preamble import PreambleSlots
|
|
21
|
+
|
|
22
|
+
from ..declaration import Kind, Model
|
|
23
|
+
from ..errors import DeclarationError
|
|
24
|
+
from .envelope import EXTRA_KEYS
|
|
25
|
+
|
|
26
|
+
type Mode = Literal["validation", "serialization"]
|
|
27
|
+
|
|
28
|
+
DEFAULTS = "$defaults" # V24: the fields of a node that hold the package's defaults
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stored(validation: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
"""The stored schema of an input schema: every property required, `$defaults` limited to the node's own defaulted
|
|
33
|
+
fields (V27)."""
|
|
34
|
+
out = copy.deepcopy(validation)
|
|
35
|
+
for node in (out, *out.get("$defs", {}).values()):
|
|
36
|
+
properties: dict[str, Any] | None = node.get("properties")
|
|
37
|
+
if not properties:
|
|
38
|
+
continue
|
|
39
|
+
defaulted = sorted(name for name, schema in properties.items() if "default" in schema)
|
|
40
|
+
node["required"] = sorted(properties)
|
|
41
|
+
properties[DEFAULTS] = (
|
|
42
|
+
{"type": "array", "items": {"enum": defaulted}, "uniqueItems": True}
|
|
43
|
+
if defaulted
|
|
44
|
+
else {"type": "array", "maxItems": 0}
|
|
45
|
+
)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tree_schema(model: Model, mode: Mode) -> dict[str, Any]:
|
|
50
|
+
"""The JSON Schema of the tree a kind's `model` takes (`validation`) or stores (`serialization`)."""
|
|
51
|
+
if mode not in ("validation", "serialization"):
|
|
52
|
+
raise DeclarationError(
|
|
53
|
+
f"schema mode {mode!r}", code="TP-K008", hint='use "validation" (the input) or "serialization" (the stored)'
|
|
54
|
+
)
|
|
55
|
+
validation = spec.json_schema(model)
|
|
56
|
+
return validation if mode == "validation" else stored(validation)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass(frozen=True)
|
|
60
|
+
class Extras:
|
|
61
|
+
"""What a document carries besides its tree, within what its kind allows: class `options`, `settings` (values set
|
|
62
|
+
after the package loads) and `preamble` entries."""
|
|
63
|
+
|
|
64
|
+
options: tuple[str, ...] = ()
|
|
65
|
+
settings: tuple[AnyValue, ...] = ()
|
|
66
|
+
preamble: PreambleSlots = dataclasses.field(default_factory=PreambleSlots)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extras_state(extras: Extras) -> dict[str, Any]:
|
|
70
|
+
"""The keys of a document's data besides `tree`, in texmodel's shapes; empty ones left out."""
|
|
71
|
+
data = spec.to_dict(TexDocument(options=extras.options, settings=extras.settings, preamble=extras.preamble))
|
|
72
|
+
return {key: data[key] for key in EXTRA_KEYS if key in data}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extras_from(package: Package, data: Mapping[str, Any]) -> Extras:
|
|
76
|
+
"""The extras `data` describes; raises an `ExceptionGroup` of texmodel `ValidationError` for data the package does
|
|
77
|
+
not read."""
|
|
78
|
+
found = spec.from_dict(package, {"type": "Document", **data, "content": []})
|
|
79
|
+
return Extras(found.options, found.settings, found.preamble)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def not_allowed(kind: Kind, extras: Extras) -> list[dict[str, Any]]:
|
|
83
|
+
"""What `extras` holds that `kind` does not allow, as unresolved items (`path`, `reason`, `value`)."""
|
|
84
|
+
data = extras_state(extras)
|
|
85
|
+
settable = {c.__name__ for c in kind.settable}
|
|
86
|
+
entries = {c.__name__ for c in kind.preamble}
|
|
87
|
+
locked = set(kind.locked_classes())
|
|
88
|
+
found = [
|
|
89
|
+
(f"/settings/{i}", f"{type(v).__name__} is not settable in kind {kind.name}", data["settings"][i])
|
|
90
|
+
for i, v in enumerate(extras.settings)
|
|
91
|
+
if type(v).__name__ not in settable
|
|
92
|
+
]
|
|
93
|
+
found += [
|
|
94
|
+
(f"/options/{i}", f"option {o!r} is not allowed in kind {kind.name}", o)
|
|
95
|
+
for i, o in enumerate(extras.options)
|
|
96
|
+
if o not in kind.options
|
|
97
|
+
]
|
|
98
|
+
found += [
|
|
99
|
+
(
|
|
100
|
+
f"/preamble/{slot}/{i}",
|
|
101
|
+
f"{type(item).__name__} is locked by kind {kind.name}"
|
|
102
|
+
if type(item).__name__ in locked
|
|
103
|
+
else f"{type(item).__name__} is not an allowed preamble entry in kind {kind.name}",
|
|
104
|
+
data["preamble"][slot][i],
|
|
105
|
+
)
|
|
106
|
+
for slot, i, item in extras.preamble.items()
|
|
107
|
+
if type(item).__name__ not in entries
|
|
108
|
+
]
|
|
109
|
+
return [{"path": path, "reason": reason, "value": value, "schema": {}} for path, reason, value in found]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _closure(defs: Mapping[str, Any], roots: list[str]) -> dict[str, Any]:
|
|
113
|
+
"""The definitions `roots` name and the ones those name, in name order."""
|
|
114
|
+
found: dict[str, Any] = {}
|
|
115
|
+
todo = list(roots)
|
|
116
|
+
while todo:
|
|
117
|
+
name = todo.pop()
|
|
118
|
+
if name not in found:
|
|
119
|
+
found[name] = defs[name]
|
|
120
|
+
todo += [r.rsplit("/", 1)[1] for r in _refs(defs[name])]
|
|
121
|
+
return dict(sorted(found.items()))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _refs(node: object) -> list[str]:
|
|
125
|
+
"""Every `$ref` under `node`."""
|
|
126
|
+
if isinstance(node, dict):
|
|
127
|
+
found: list[str] = []
|
|
128
|
+
for key, value in cast("dict[str, object]", node).items():
|
|
129
|
+
found += [str(value)] if key == "$ref" else _refs(value)
|
|
130
|
+
return found
|
|
131
|
+
if isinstance(node, list):
|
|
132
|
+
return [ref for item in cast("list[object]", node) for ref in _refs(item)]
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def schema(kind: Kind, package: Package, mode: Mode) -> dict[str, Any]:
|
|
137
|
+
"""The JSON Schema of the data of a document of `kind`: its `tree`, and the settings, options and preamble entries
|
|
138
|
+
the kind allows, read by `package`, the one that renders it."""
|
|
139
|
+
tree = tree_schema(kind.model, mode)
|
|
140
|
+
defs: dict[str, Any] = tree.pop("$defs", {})
|
|
141
|
+
title = str(tree["title"])
|
|
142
|
+
defs[title] = tree
|
|
143
|
+
properties: dict[str, Any] = {"tree": {"$ref": f"#/$defs/{title}"}}
|
|
144
|
+
wanted: list[str] = []
|
|
145
|
+
if kind.settable:
|
|
146
|
+
names = [f"{c.__name__}Setting" for c in kind.settable]
|
|
147
|
+
wanted += names
|
|
148
|
+
properties["settings"] = {"type": "array", "items": {"oneOf": [{"$ref": f"#/$defs/{n}"} for n in names]}}
|
|
149
|
+
if kind.options:
|
|
150
|
+
properties["options"] = {"type": "array", "uniqueItems": True, "items": {"enum": list(kind.options)}}
|
|
151
|
+
if kind.preamble:
|
|
152
|
+
names = [f"{c.__name__}Entry" for c in kind.preamble]
|
|
153
|
+
wanted += names
|
|
154
|
+
slot = {"type": "array", "items": {"oneOf": [{"$ref": f"#/$defs/{n}"} for n in names]}}
|
|
155
|
+
properties["preamble"] = {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"additionalProperties": False,
|
|
158
|
+
"properties": dict.fromkeys(("before_class", "before_package", "after_package"), slot),
|
|
159
|
+
}
|
|
160
|
+
shared: dict[str, Any] = spec.json_schema(package)["$defs"] if wanted else {}
|
|
161
|
+
return {
|
|
162
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
163
|
+
"title": kind.name,
|
|
164
|
+
"type": "object",
|
|
165
|
+
"additionalProperties": False,
|
|
166
|
+
"properties": properties,
|
|
167
|
+
"required": ["tree"],
|
|
168
|
+
"$defs": dict(sorted((defs | _closure(shared, wanted)).items())),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fields(node: Command | Environment | Passthrough) -> dict[str, Any]:
|
|
173
|
+
return node.model_dump(mode="json", exclude={"content"})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def tree_state(node: Node) -> dict[str, Any]:
|
|
177
|
+
"""The state of a tree: every value written (V21), and for each node the fields that hold the package's defaults in
|
|
178
|
+
`$defaults` (V24), known from `model_fields_set`, which says what the caller passed (V25)."""
|
|
179
|
+
if isinstance(node, Comment):
|
|
180
|
+
return {"type": type(node).__name__} | {f: getattr(node, f) for f in node.__tm_fields__}
|
|
181
|
+
if isinstance(node, Passthrough):
|
|
182
|
+
return {"type": "Passthrough"} | _fields(node)
|
|
183
|
+
fields = _fields(node)
|
|
184
|
+
out: dict[str, Any] = {"type": type(node).__name__} | fields
|
|
185
|
+
if defaulted := [name for name in fields if name not in node.model_fields_set]:
|
|
186
|
+
out[DEFAULTS] = defaulted
|
|
187
|
+
if isinstance(node, Environment):
|
|
188
|
+
out["content"] = [tree_state(child) for child in node.content]
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def given(state: dict[str, Any]) -> dict[str, Any]:
|
|
193
|
+
"""`state` as the caller would have passed it: the fields marked as defaults left out, so the model fills them with
|
|
194
|
+
its current defaults and `model_fields_set` is what the author set (V27)."""
|
|
195
|
+
out = {k: v for k, v in state.items() if k not in (DEFAULTS, *state.get(DEFAULTS, ()))}
|
|
196
|
+
if "content" in out:
|
|
197
|
+
out["content"] = [given(child) for child in out["content"]]
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def tree_from_state(model: Model, state: dict[str, Any]) -> Node:
|
|
202
|
+
"""The tree a stored state describes; raises an `ExceptionGroup` of texmodel `ValidationError` for one that does not
|
|
203
|
+
fit the model."""
|
|
204
|
+
return spec.from_dict(model, given(state))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def default_paths(node: Node, path: str = "") -> tuple[str, ...]:
|
|
208
|
+
"""The data paths of the fields that hold defaults (`/content/1/accent`), in document order."""
|
|
209
|
+
if isinstance(node, Comment):
|
|
210
|
+
return ()
|
|
211
|
+
own = tuple(f"{path}/{name}" for name in _fields(node) if name not in node.model_fields_set)
|
|
212
|
+
if isinstance(node, Environment):
|
|
213
|
+
return own + tuple(
|
|
214
|
+
p for i, child in enumerate(node.content) for p in default_paths(child, f"{path}/content/{i}")
|
|
215
|
+
)
|
|
216
|
+
return own
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def defaults_table(model: Model) -> dict[str, Any]:
|
|
220
|
+
"""The defaults of the classes a kind's trees are made of, by `Class.field`: what the package would fill in."""
|
|
221
|
+
validation = spec.json_schema(model)
|
|
222
|
+
table: dict[str, Any] = {}
|
|
223
|
+
for node in (validation, *validation.get("$defs", {}).values()):
|
|
224
|
+
for name, schema in node.get("properties", {}).items():
|
|
225
|
+
if "default" in schema:
|
|
226
|
+
table[f"{node['title']}.{name}"] = schema["default"]
|
|
227
|
+
return dict(sorted(table.items()))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def sty_hashes(sty: Mapping[str, str]) -> dict[str, str]:
|
|
231
|
+
"""The `.sty` files the template state records by hash, not by value (D6)."""
|
|
232
|
+
return {name: hashlib.sha256(text.encode("utf-8")).hexdigest() for name, text in sorted(sty.items())}
|