texpkg 0.1.0__tar.gz → 0.2.0__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.0/PKG-INFO +37 -0
- texpkg-0.2.0/README.md +17 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/pyproject.toml +2 -2
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/adopt.py +46 -7
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/classify.py +12 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/envelope.py +10 -4
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/handedits.py +16 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/release.py +62 -13
- texpkg-0.2.0/src/texpkg/_internal/state.py +229 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/declaration.py +75 -11
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/document.py +56 -17
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/errors.py +9 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/layout.py +8 -4
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/checks.py +4 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/generate.py +18 -4
- texpkg-0.2.0/src/texpkg_build/_internal/generated.py +117 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/snapshot.py +6 -2
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/cli.py +30 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/hook.py +8 -4
- texpkg-0.2.0/tests/consumers/acme-series-tex/pdm_build.py +3 -0
- texpkg-0.2.0/tests/consumers/acme-series-tex/pyproject.toml +20 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/__init__.py +7 -3
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/glossary.tex +0 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/__init__.py +4 -3
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/model.py +10 -4
- texpkg-0.2.0/tests/fixtures/acme_series/__init__.py +19 -0
- texpkg-0.2.0/tests/fixtures/acme_series/_kinds/__init__.py +5 -0
- texpkg-0.2.0/tests/fixtures/acme_series/_kinds/long.py +67 -0
- texpkg-0.2.0/tests/fixtures/acme_series/_kinds/short.py +66 -0
- texpkg-0.2.0/tests/fixtures/acme_series/assets/lmroman10-regular.otf +0 -0
- texpkg-0.2.0/tests/fixtures/acme_series/model.py +19 -0
- texpkg-0.2.0/tests/fixtures/acme_series/series.json +6 -0
- texpkg-0.2.0/tests/fixtures/acme_series/series.tex +3 -0
- texpkg-0.2.0/tests/fixtures/acme_series_gen/__init__.py +104 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_sheet/__init__.py +1 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/projects.py +5 -5
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_build.py +10 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_compile.py +1 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_contracts.py +4 -2
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_declaration.py +79 -8
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_document.py +14 -14
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_embed.py +6 -4
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_end_to_end.py +2 -2
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_export.py +1 -1
- texpkg-0.2.0/tests/test_extras.py +209 -0
- texpkg-0.2.0/tests/test_generated.py +243 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_isolated.py +13 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_migration.py +1 -1
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_module.py +8 -4
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_payload.py +4 -4
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_profile.py +22 -6
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_provenance.py +2 -2
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_typing.py +13 -3
- 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.0}/LICENSE +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/directory.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/export.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/history.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/payload.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/provenance_tex.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/snapshots.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/versions.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/assets.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/contracts.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/migration.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/protocol.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/provenance.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/py.typed +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/embed.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/isolate.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/py.typed +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/testing.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/__init__.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/conftest.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-brand-tex/pdm_build.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-brand-tex/pyproject.toml +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-deck-tex/pdm_build.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-deck-tex/pyproject.toml +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/acme.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/assets/NOTICE.txt +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/README-expert.md +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/acme-brand.sty +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/deck-compact.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/deck-wide.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/deck.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/expert-template.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/plan.json +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/state.json +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/model.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/other/lockup.pdf +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/tokens.json +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/assets/DECK.txt +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/generated/brief.tex +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_sheet/model.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/helpers.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/spawn.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_public_api.py +0 -0
- {texpkg-0.1.0 → texpkg-0.2.0}/tests/venvs.py +0 -0
texpkg-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: texpkg
|
|
3
|
+
Version: 0.2.0
|
|
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.0/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,28 @@ 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, layout: 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 layout's lines lead each preamble slot."""
|
|
25
|
+
if found := _SETTING.fullmatch(plan_path):
|
|
26
|
+
return f"/settings/{int(found[1]) - locked}" if int(found[1]) >= locked else ""
|
|
27
|
+
if found := _ENTRY.fullmatch(plan_path):
|
|
28
|
+
own = int(found[2]) - len(getattr(layout, found[1]))
|
|
29
|
+
return f"/preamble/{found[1]}/{own}" if own >= 0 else ""
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
17
33
|
def data_path(plan_path: str) -> str:
|
|
18
34
|
"""The data path of a plan path: `Deck[0]/Card[1]/Title[0]` is `/content/1/content/0`, the root being the document.
|
|
19
35
|
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,34 @@ 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 plan(self, kind: Kind, tree: Node, variant: str | None, extras: Extras = Extras()) -> Plan: # noqa: B008
|
|
83
|
+
"""The `.tex` of `tree` as lines with provenance: the kind's presets, then the variant's settings (L8), then the
|
|
84
|
+
document's own settings (D10).
|
|
72
85
|
|
|
73
86
|
An exposed base kind is written by the base's package; the directory's layout is this distribution's."""
|
|
74
87
|
body = tree if kind.expand is None else kind.expand(tree)
|
|
75
88
|
package = self.dist.owner(kind.name).package
|
|
76
|
-
|
|
89
|
+
found, own = self.layout_preamble(kind), extras.preamble
|
|
90
|
+
return package.plan(
|
|
91
|
+
TexDocument(
|
|
92
|
+
body,
|
|
93
|
+
options=extras.options,
|
|
94
|
+
settings=(*self.settings(kind, variant), *extras.settings),
|
|
95
|
+
preamble=PreambleSlots(
|
|
96
|
+
before_class=own.before_class,
|
|
97
|
+
before_package=(*found.before_package, *own.before_package),
|
|
98
|
+
after_package=(*found.after_package, *own.after_package),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
)
|
|
77
102
|
|
|
78
103
|
def export(
|
|
79
104
|
self, destination: pathlib.Path | str, now: datetime.datetime | None, stamp: str | None
|
|
@@ -102,11 +127,11 @@ class Release:
|
|
|
102
127
|
files = {**self.sty, **(provenance or {})}
|
|
103
128
|
return payload.build(files, tuple(self.dist.resolved_assets()), self.dist.layout, kinds)
|
|
104
129
|
|
|
105
|
-
def rendered(self, kind: Kind, tree: Node) -> dict[str, str]:
|
|
130
|
+
def rendered(self, kind: Kind, tree: Node, extras: Extras = Extras()) -> dict[str, str]: # noqa: B008
|
|
106
131
|
"""Every `.tex` of the kind with the plan of it that `save` keeps (D7), by path in the directory."""
|
|
107
132
|
files: dict[str, str] = {}
|
|
108
133
|
for variant, name in kind.documents():
|
|
109
|
-
plan = self.plan(kind, tree, variant)
|
|
134
|
+
plan = self.plan(kind, tree, variant, extras)
|
|
110
135
|
files |= {name: plan.text, directory.plan_file(variant): plan.to_json()}
|
|
111
136
|
return files
|
|
112
137
|
|
|
@@ -131,9 +156,11 @@ class Release:
|
|
|
131
156
|
"stack": self.stack(),
|
|
132
157
|
}
|
|
133
158
|
|
|
134
|
-
def states(self, kind: Kind, tree: Node) -> dict[str, str]:
|
|
159
|
+
def states(self, kind: Kind, tree: Node, extras: Extras = Extras()) -> dict[str, str]: # noqa: B008
|
|
135
160
|
"""The pair of states `save` and `create` write (D1)."""
|
|
136
|
-
envelope = Envelope(
|
|
161
|
+
envelope = Envelope(
|
|
162
|
+
self.dist.name, kind.name, self.contracts.data.current, state.tree_state(tree), state.extras_state(extras)
|
|
163
|
+
)
|
|
137
164
|
return {directory.STATE: envelope.to_text(), directory.TEMPLATE: dumps(self.template(kind))}
|
|
138
165
|
|
|
139
166
|
def read(self, path: pathlib.Path) -> Envelope:
|
|
@@ -196,7 +223,27 @@ class KindApi[T: Command | Environment]:
|
|
|
196
223
|
)
|
|
197
224
|
self.release, self.kind = release, kind
|
|
198
225
|
|
|
199
|
-
def
|
|
226
|
+
def extras(self, settings: Sequence[AnyValue], options: Sequence[str], preamble: PreambleSlots | None) -> Extras:
|
|
227
|
+
"""What a document carries besides its tree, refused whole if the kind does not allow it (D10)."""
|
|
228
|
+
found = Extras(tuple(options), tuple(settings), preamble or PreambleSlots())
|
|
229
|
+
if items := state.not_allowed(self.kind, found):
|
|
230
|
+
shown = "; ".join(f"{i['path']}: {i['reason']}" for i in items)
|
|
231
|
+
raise NotAllowedError(
|
|
232
|
+
f"kind {self.kind.name}: {shown}",
|
|
233
|
+
items=tuple(items),
|
|
234
|
+
hint="the kind lists what a document may set: Kind(settable=, options=, preamble=)",
|
|
235
|
+
)
|
|
236
|
+
return found
|
|
237
|
+
|
|
238
|
+
def create(
|
|
239
|
+
self,
|
|
240
|
+
path: pathlib.Path | str,
|
|
241
|
+
tree: T,
|
|
242
|
+
*,
|
|
243
|
+
settings: Sequence[AnyValue] = (),
|
|
244
|
+
options: Sequence[str] = (),
|
|
245
|
+
preamble: PreambleSlots | None = None,
|
|
246
|
+
) -> Document[T]:
|
|
200
247
|
"""A document in `path` (made if need be) with both states written; refuses a directory that holds one."""
|
|
201
248
|
path = pathlib.Path(path)
|
|
202
249
|
if (path / directory.STATE).exists():
|
|
@@ -208,8 +255,9 @@ class KindApi[T: Command | Environment]:
|
|
|
208
255
|
)
|
|
209
256
|
if problems := tree.violations():
|
|
210
257
|
raise ExceptionGroup("invalid document", [v.error() for v in problems])
|
|
211
|
-
|
|
212
|
-
|
|
258
|
+
extras = self.extras(settings, options, preamble)
|
|
259
|
+
directory.write(path, self.release.states(self.kind, tree, extras))
|
|
260
|
+
return Document(tree, path, self.release, self.kind, extras)
|
|
213
261
|
|
|
214
262
|
def load(
|
|
215
263
|
self,
|
|
@@ -232,8 +280,9 @@ class KindApi[T: Command | Environment]:
|
|
|
232
280
|
)
|
|
233
281
|
saved = directory.read(path, directory.TEMPLATE)
|
|
234
282
|
template: dict[str, Any] = json.loads(saved) if saved else {}
|
|
235
|
-
tree = adopt(
|
|
283
|
+
tree, extras = adopt(
|
|
236
284
|
kind=self.kind,
|
|
285
|
+
package=self.release.dist.owner(self.kind.name).package,
|
|
237
286
|
envelope=envelope,
|
|
238
287
|
current=self.release.contracts.data.current,
|
|
239
288
|
age=self.release.contracts.data.age,
|
|
@@ -242,10 +291,10 @@ class KindApi[T: Command | Environment]:
|
|
|
242
291
|
saved_defaults=template.get("defaults"),
|
|
243
292
|
base_data=dict(self.release.contracts.base_data) if self.kind.name in self.release.dist.expose else None,
|
|
244
293
|
)
|
|
245
|
-
return Document(cast("T", tree), path, self.release, self.kind)
|
|
294
|
+
return Document(cast("T", tree), path, self.release, self.kind, extras)
|
|
246
295
|
|
|
247
296
|
def schema(self, mode: state.Mode) -> dict[str, Any]:
|
|
248
|
-
return state.schema(self.kind.
|
|
297
|
+
return state.schema(self.kind, self.release.dist.owner(self.kind.name).package, mode)
|
|
249
298
|
|
|
250
299
|
def example(self) -> T:
|
|
251
300
|
return cast("T", self.kind.example())
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
found = [
|
|
88
|
+
(f"/settings/{i}", f"{type(v).__name__} is not settable in kind {kind.name}", data["settings"][i])
|
|
89
|
+
for i, v in enumerate(extras.settings)
|
|
90
|
+
if type(v).__name__ not in settable
|
|
91
|
+
]
|
|
92
|
+
found += [
|
|
93
|
+
(f"/options/{i}", f"option {o!r} is not allowed in kind {kind.name}", o)
|
|
94
|
+
for i, o in enumerate(extras.options)
|
|
95
|
+
if o not in kind.options
|
|
96
|
+
]
|
|
97
|
+
found += [
|
|
98
|
+
(
|
|
99
|
+
f"/preamble/{slot}/{i}",
|
|
100
|
+
f"{type(item).__name__} is not an allowed preamble entry in kind {kind.name}",
|
|
101
|
+
data["preamble"][slot][i],
|
|
102
|
+
)
|
|
103
|
+
for slot, i, item in extras.preamble.items()
|
|
104
|
+
if type(item).__name__ not in entries
|
|
105
|
+
]
|
|
106
|
+
return [{"path": path, "reason": reason, "value": value, "schema": {}} for path, reason, value in found]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _closure(defs: Mapping[str, Any], roots: list[str]) -> dict[str, Any]:
|
|
110
|
+
"""The definitions `roots` name and the ones those name, in name order."""
|
|
111
|
+
found: dict[str, Any] = {}
|
|
112
|
+
todo = list(roots)
|
|
113
|
+
while todo:
|
|
114
|
+
name = todo.pop()
|
|
115
|
+
if name not in found:
|
|
116
|
+
found[name] = defs[name]
|
|
117
|
+
todo += [r.rsplit("/", 1)[1] for r in _refs(defs[name])]
|
|
118
|
+
return dict(sorted(found.items()))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _refs(node: object) -> list[str]:
|
|
122
|
+
"""Every `$ref` under `node`."""
|
|
123
|
+
if isinstance(node, dict):
|
|
124
|
+
found: list[str] = []
|
|
125
|
+
for key, value in cast("dict[str, object]", node).items():
|
|
126
|
+
found += [str(value)] if key == "$ref" else _refs(value)
|
|
127
|
+
return found
|
|
128
|
+
if isinstance(node, list):
|
|
129
|
+
return [ref for item in cast("list[object]", node) for ref in _refs(item)]
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def schema(kind: Kind, package: Package, mode: Mode) -> dict[str, Any]:
|
|
134
|
+
"""The JSON Schema of the data of a document of `kind`: its `tree`, and the settings, options and preamble entries
|
|
135
|
+
the kind allows, read by `package`, the one that renders it."""
|
|
136
|
+
tree = tree_schema(kind.model, mode)
|
|
137
|
+
defs: dict[str, Any] = tree.pop("$defs", {})
|
|
138
|
+
title = str(tree["title"])
|
|
139
|
+
defs[title] = tree
|
|
140
|
+
properties: dict[str, Any] = {"tree": {"$ref": f"#/$defs/{title}"}}
|
|
141
|
+
wanted: list[str] = []
|
|
142
|
+
if kind.settable:
|
|
143
|
+
names = [f"{c.__name__}Setting" for c in kind.settable]
|
|
144
|
+
wanted += names
|
|
145
|
+
properties["settings"] = {"type": "array", "items": {"oneOf": [{"$ref": f"#/$defs/{n}"} for n in names]}}
|
|
146
|
+
if kind.options:
|
|
147
|
+
properties["options"] = {"type": "array", "uniqueItems": True, "items": {"enum": list(kind.options)}}
|
|
148
|
+
if kind.preamble:
|
|
149
|
+
names = [f"{c.__name__}Entry" for c in kind.preamble]
|
|
150
|
+
wanted += names
|
|
151
|
+
slot = {"type": "array", "items": {"oneOf": [{"$ref": f"#/$defs/{n}"} for n in names]}}
|
|
152
|
+
properties["preamble"] = {
|
|
153
|
+
"type": "object",
|
|
154
|
+
"additionalProperties": False,
|
|
155
|
+
"properties": dict.fromkeys(("before_class", "before_package", "after_package"), slot),
|
|
156
|
+
}
|
|
157
|
+
shared: dict[str, Any] = spec.json_schema(package)["$defs"] if wanted else {}
|
|
158
|
+
return {
|
|
159
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
160
|
+
"title": kind.name,
|
|
161
|
+
"type": "object",
|
|
162
|
+
"additionalProperties": False,
|
|
163
|
+
"properties": properties,
|
|
164
|
+
"required": ["tree"],
|
|
165
|
+
"$defs": dict(sorted((defs | _closure(shared, wanted)).items())),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _fields(node: Command | Environment | Passthrough) -> dict[str, Any]:
|
|
170
|
+
return node.model_dump(mode="json", exclude={"content"})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def tree_state(node: Node) -> dict[str, Any]:
|
|
174
|
+
"""The state of a tree: every value written (V21), and for each node the fields that hold the package's defaults in
|
|
175
|
+
`$defaults` (V24), known from `model_fields_set`, which says what the caller passed (V25)."""
|
|
176
|
+
if isinstance(node, Comment):
|
|
177
|
+
return {"type": type(node).__name__} | {f: getattr(node, f) for f in node.__tm_fields__}
|
|
178
|
+
if isinstance(node, Passthrough):
|
|
179
|
+
return {"type": "Passthrough"} | _fields(node)
|
|
180
|
+
fields = _fields(node)
|
|
181
|
+
out: dict[str, Any] = {"type": type(node).__name__} | fields
|
|
182
|
+
if defaulted := [name for name in fields if name not in node.model_fields_set]:
|
|
183
|
+
out[DEFAULTS] = defaulted
|
|
184
|
+
if isinstance(node, Environment):
|
|
185
|
+
out["content"] = [tree_state(child) for child in node.content]
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def given(state: dict[str, Any]) -> dict[str, Any]:
|
|
190
|
+
"""`state` as the caller would have passed it: the fields marked as defaults left out, so the model fills them with
|
|
191
|
+
its current defaults and `model_fields_set` is what the author set (V27)."""
|
|
192
|
+
out = {k: v for k, v in state.items() if k not in (DEFAULTS, *state.get(DEFAULTS, ()))}
|
|
193
|
+
if "content" in out:
|
|
194
|
+
out["content"] = [given(child) for child in out["content"]]
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def tree_from_state(model: Model, state: dict[str, Any]) -> Node:
|
|
199
|
+
"""The tree a stored state describes; raises an `ExceptionGroup` of texmodel `ValidationError` for one that does not
|
|
200
|
+
fit the model."""
|
|
201
|
+
return spec.from_dict(model, given(state))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def default_paths(node: Node, path: str = "") -> tuple[str, ...]:
|
|
205
|
+
"""The data paths of the fields that hold defaults (`/content/1/accent`), in document order."""
|
|
206
|
+
if isinstance(node, Comment):
|
|
207
|
+
return ()
|
|
208
|
+
own = tuple(f"{path}/{name}" for name in _fields(node) if name not in node.model_fields_set)
|
|
209
|
+
if isinstance(node, Environment):
|
|
210
|
+
return own + tuple(
|
|
211
|
+
p for i, child in enumerate(node.content) for p in default_paths(child, f"{path}/content/{i}")
|
|
212
|
+
)
|
|
213
|
+
return own
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def defaults_table(model: Model) -> dict[str, Any]:
|
|
217
|
+
"""The defaults of the classes a kind's trees are made of, by `Class.field`: what the package would fill in."""
|
|
218
|
+
validation = spec.json_schema(model)
|
|
219
|
+
table: dict[str, Any] = {}
|
|
220
|
+
for node in (validation, *validation.get("$defs", {}).values()):
|
|
221
|
+
for name, schema in node.get("properties", {}).items():
|
|
222
|
+
if "default" in schema:
|
|
223
|
+
table[f"{node['title']}.{name}"] = schema["default"]
|
|
224
|
+
return dict(sorted(table.items()))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def sty_hashes(sty: Mapping[str, str]) -> dict[str, str]:
|
|
228
|
+
"""The `.sty` files the template state records by hash, not by value (D6)."""
|
|
229
|
+
return {name: hashlib.sha256(text.encode("utf-8")).hexdigest() for name, text in sorted(sty.items())}
|