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.
Files changed (107) hide show
  1. texpkg-0.2.0/PKG-INFO +37 -0
  2. texpkg-0.2.0/README.md +17 -0
  3. {texpkg-0.1.0 → texpkg-0.2.0}/pyproject.toml +2 -2
  4. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/adopt.py +46 -7
  5. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/classify.py +12 -0
  6. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/envelope.py +10 -4
  7. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/handedits.py +16 -0
  8. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/release.py +62 -13
  9. texpkg-0.2.0/src/texpkg/_internal/state.py +229 -0
  10. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/declaration.py +75 -11
  11. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/document.py +56 -17
  12. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/errors.py +9 -1
  13. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/layout.py +8 -4
  14. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/checks.py +4 -1
  15. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/generate.py +18 -4
  16. texpkg-0.2.0/src/texpkg_build/_internal/generated.py +117 -0
  17. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/snapshot.py +6 -2
  18. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/cli.py +30 -1
  19. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/hook.py +8 -4
  20. texpkg-0.2.0/tests/consumers/acme-series-tex/pdm_build.py +3 -0
  21. texpkg-0.2.0/tests/consumers/acme-series-tex/pyproject.toml +20 -0
  22. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/__init__.py +7 -3
  23. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/glossary.tex +0 -1
  24. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/__init__.py +4 -3
  25. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/model.py +10 -4
  26. texpkg-0.2.0/tests/fixtures/acme_series/__init__.py +19 -0
  27. texpkg-0.2.0/tests/fixtures/acme_series/_kinds/__init__.py +5 -0
  28. texpkg-0.2.0/tests/fixtures/acme_series/_kinds/long.py +67 -0
  29. texpkg-0.2.0/tests/fixtures/acme_series/_kinds/short.py +66 -0
  30. texpkg-0.2.0/tests/fixtures/acme_series/assets/lmroman10-regular.otf +0 -0
  31. texpkg-0.2.0/tests/fixtures/acme_series/model.py +19 -0
  32. texpkg-0.2.0/tests/fixtures/acme_series/series.json +6 -0
  33. texpkg-0.2.0/tests/fixtures/acme_series/series.tex +3 -0
  34. texpkg-0.2.0/tests/fixtures/acme_series_gen/__init__.py +104 -0
  35. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_sheet/__init__.py +1 -1
  36. {texpkg-0.1.0 → texpkg-0.2.0}/tests/projects.py +5 -5
  37. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_build.py +10 -1
  38. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_compile.py +1 -1
  39. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_contracts.py +4 -2
  40. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_declaration.py +79 -8
  41. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_document.py +14 -14
  42. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_embed.py +6 -4
  43. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_end_to_end.py +2 -2
  44. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_export.py +1 -1
  45. texpkg-0.2.0/tests/test_extras.py +209 -0
  46. texpkg-0.2.0/tests/test_generated.py +243 -0
  47. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_isolated.py +13 -1
  48. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_migration.py +1 -1
  49. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_module.py +8 -4
  50. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_payload.py +4 -4
  51. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_profile.py +22 -6
  52. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_provenance.py +2 -2
  53. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_typing.py +13 -3
  54. texpkg-0.1.0/PKG-INFO +0 -30
  55. texpkg-0.1.0/README.md +0 -10
  56. texpkg-0.1.0/src/texpkg/_internal/state.py +0 -113
  57. {texpkg-0.1.0 → texpkg-0.2.0}/LICENSE +0 -0
  58. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/__init__.py +0 -0
  59. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/__init__.py +0 -0
  60. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/directory.py +0 -0
  61. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/export.py +0 -0
  62. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/history.py +0 -0
  63. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/payload.py +0 -0
  64. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/provenance_tex.py +0 -0
  65. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/snapshots.py +0 -0
  66. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/_internal/versions.py +0 -0
  67. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/assets.py +0 -0
  68. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/contracts.py +0 -0
  69. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/migration.py +0 -0
  70. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/protocol.py +0 -0
  71. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/provenance.py +0 -0
  72. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg/py.typed +0 -0
  73. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/__init__.py +0 -0
  74. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/__init__.py +0 -0
  75. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/embed.py +0 -0
  76. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/_internal/isolate.py +0 -0
  77. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/py.typed +0 -0
  78. {texpkg-0.1.0 → texpkg-0.2.0}/src/texpkg_build/testing.py +0 -0
  79. {texpkg-0.1.0 → texpkg-0.2.0}/tests/__init__.py +0 -0
  80. {texpkg-0.1.0 → texpkg-0.2.0}/tests/conftest.py +0 -0
  81. {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-brand-tex/pdm_build.py +0 -0
  82. {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-brand-tex/pyproject.toml +0 -0
  83. {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-deck-tex/pdm_build.py +0 -0
  84. {texpkg-0.1.0 → texpkg-0.2.0}/tests/consumers/acme-deck-tex/pyproject.toml +0 -0
  85. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/acme.tex +0 -0
  86. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/assets/NOTICE.txt +0 -0
  87. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
  88. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
  89. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/README-expert.md +0 -0
  90. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +0 -0
  91. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/acme-brand.sty +0 -0
  92. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/deck-compact.tex +0 -0
  93. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/deck-wide.tex +0 -0
  94. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/deck.tex +0 -0
  95. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/expert-template.tex +0 -0
  96. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/plan.json +0 -0
  97. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/generated/state.json +0 -0
  98. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/model.py +0 -0
  99. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/other/lockup.pdf +0 -0
  100. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_brand/tokens.json +0 -0
  101. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/assets/DECK.txt +0 -0
  102. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_deck/generated/brief.tex +0 -0
  103. {texpkg-0.1.0 → texpkg-0.2.0}/tests/fixtures/acme_sheet/model.py +0 -0
  104. {texpkg-0.1.0 → texpkg-0.2.0}/tests/helpers.py +0 -0
  105. {texpkg-0.1.0 → texpkg-0.2.0}/tests/spawn.py +0 -0
  106. {texpkg-0.1.0 → texpkg-0.2.0}/tests/test_public_api.py +0 -0
  107. {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
+ ```
@@ -17,10 +17,10 @@ classifiers = [
17
17
  ]
18
18
  requires-python = ">=3.15,<3.16"
19
19
  dependencies = [
20
- "texmodel>=0.4.0",
20
+ "texmodel>=0.7.0",
21
21
  "pydantic>=2.14.1",
22
22
  ]
23
- version = "0.1.0"
23
+ version = "0.2.0"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  build = [
@@ -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 everything
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.schema(kind.model, "validation")
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). It names the package and kind the tree belongs
2
- to and the `data` version it was written for (V14)."""
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
- return dumps({"package": self.package, "kind": self.kind, "data": self.data, "tree": self.tree})
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
- return cls(str(found["package"]), str(found["kind"]), int(found["data"]), dict(found["tree"]))
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 plan(self, kind: Kind, tree: Node, variant: str | None) -> Plan:
71
- """The `.tex` of `tree` as lines with provenance: the kind's presets, then the variant's settings (L8).
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
- return package.plan(body, settings=self.settings(kind, variant), preamble=self.dist.layout.preamble())
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(self.dist.name, kind.name, self.contracts.data.current, state.tree_state(tree))
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 create(self, path: pathlib.Path | str, tree: T) -> Document[T]:
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
- directory.write(path, self.release.states(self.kind, tree))
212
- return Document(tree, path, self.release, self.kind)
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.model, mode)
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())}