texpkg 0.2.0__tar.gz → 0.2.2__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 (105) hide show
  1. {texpkg-0.2.0 → texpkg-0.2.2}/PKG-INFO +1 -1
  2. {texpkg-0.2.0 → texpkg-0.2.2}/pyproject.toml +1 -1
  3. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/adopt.py +39 -0
  4. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/handedits.py +4 -3
  5. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/release.py +31 -5
  6. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/state.py +4 -1
  7. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/declaration.py +75 -4
  8. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/document.py +1 -1
  9. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/generate.py +6 -4
  10. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/generated.py +7 -3
  11. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/hook.py +5 -10
  12. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/testing.py +16 -8
  13. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_deck/__init__.py +14 -3
  14. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_build.py +7 -4
  15. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_declaration.py +21 -1
  16. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_embed.py +2 -0
  17. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_end_to_end.py +2 -2
  18. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_generated.py +25 -0
  19. texpkg-0.2.2/tests/test_profile.py +389 -0
  20. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_typing.py +1 -1
  21. texpkg-0.2.0/tests/test_profile.py +0 -207
  22. {texpkg-0.2.0 → texpkg-0.2.2}/LICENSE +0 -0
  23. {texpkg-0.2.0 → texpkg-0.2.2}/README.md +0 -0
  24. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/__init__.py +0 -0
  25. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/__init__.py +0 -0
  26. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/classify.py +0 -0
  27. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/directory.py +0 -0
  28. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/envelope.py +0 -0
  29. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/export.py +0 -0
  30. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/history.py +0 -0
  31. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/payload.py +0 -0
  32. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/provenance_tex.py +0 -0
  33. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/snapshots.py +0 -0
  34. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/_internal/versions.py +0 -0
  35. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/assets.py +0 -0
  36. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/contracts.py +0 -0
  37. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/errors.py +0 -0
  38. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/layout.py +0 -0
  39. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/migration.py +0 -0
  40. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/protocol.py +0 -0
  41. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/provenance.py +0 -0
  42. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg/py.typed +0 -0
  43. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/__init__.py +0 -0
  44. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/__init__.py +0 -0
  45. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/checks.py +0 -0
  46. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/embed.py +0 -0
  47. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/isolate.py +0 -0
  48. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/_internal/snapshot.py +0 -0
  49. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/cli.py +0 -0
  50. {texpkg-0.2.0 → texpkg-0.2.2}/src/texpkg_build/py.typed +0 -0
  51. {texpkg-0.2.0 → texpkg-0.2.2}/tests/__init__.py +0 -0
  52. {texpkg-0.2.0 → texpkg-0.2.2}/tests/conftest.py +0 -0
  53. {texpkg-0.2.0 → texpkg-0.2.2}/tests/consumers/acme-brand-tex/pdm_build.py +0 -0
  54. {texpkg-0.2.0 → texpkg-0.2.2}/tests/consumers/acme-brand-tex/pyproject.toml +0 -0
  55. {texpkg-0.2.0 → texpkg-0.2.2}/tests/consumers/acme-deck-tex/pdm_build.py +0 -0
  56. {texpkg-0.2.0 → texpkg-0.2.2}/tests/consumers/acme-deck-tex/pyproject.toml +0 -0
  57. {texpkg-0.2.0 → texpkg-0.2.2}/tests/consumers/acme-series-tex/pdm_build.py +0 -0
  58. {texpkg-0.2.0 → texpkg-0.2.2}/tests/consumers/acme-series-tex/pyproject.toml +0 -0
  59. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/__init__.py +0 -0
  60. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/acme.tex +0 -0
  61. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/assets/NOTICE.txt +0 -0
  62. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
  63. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
  64. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/README-expert.md +0 -0
  65. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +0 -0
  66. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/acme-brand.sty +0 -0
  67. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/deck-compact.tex +0 -0
  68. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/deck-wide.tex +0 -0
  69. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/deck.tex +0 -0
  70. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/expert-template.tex +0 -0
  71. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/glossary.tex +0 -0
  72. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/plan.json +0 -0
  73. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/state.json +0 -0
  74. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/model.py +0 -0
  75. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/other/lockup.pdf +0 -0
  76. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_brand/tokens.json +0 -0
  77. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_deck/assets/DECK.txt +0 -0
  78. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_deck/generated/brief.tex +0 -0
  79. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_deck/model.py +0 -0
  80. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/__init__.py +0 -0
  81. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/_kinds/__init__.py +0 -0
  82. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/_kinds/long.py +0 -0
  83. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/_kinds/short.py +0 -0
  84. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/assets/lmroman10-regular.otf +0 -0
  85. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/model.py +0 -0
  86. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/series.json +0 -0
  87. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series/series.tex +0 -0
  88. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_series_gen/__init__.py +0 -0
  89. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_sheet/__init__.py +0 -0
  90. {texpkg-0.2.0 → texpkg-0.2.2}/tests/fixtures/acme_sheet/model.py +0 -0
  91. {texpkg-0.2.0 → texpkg-0.2.2}/tests/helpers.py +0 -0
  92. {texpkg-0.2.0 → texpkg-0.2.2}/tests/projects.py +0 -0
  93. {texpkg-0.2.0 → texpkg-0.2.2}/tests/spawn.py +0 -0
  94. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_compile.py +0 -0
  95. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_contracts.py +0 -0
  96. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_document.py +0 -0
  97. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_export.py +0 -0
  98. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_extras.py +0 -0
  99. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_isolated.py +0 -0
  100. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_migration.py +0 -0
  101. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_module.py +0 -0
  102. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_payload.py +0 -0
  103. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_provenance.py +0 -0
  104. {texpkg-0.2.0 → texpkg-0.2.2}/tests/test_public_api.py +0 -0
  105. {texpkg-0.2.0 → texpkg-0.2.2}/tests/venvs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: texpkg
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: LaTeX package management.
5
5
  Author-Email: Ilja Orlovs <ilja@release.art>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "texmodel>=0.7.0",
21
21
  "pydantic>=2.14.1",
22
22
  ]
23
- version = "0.2.0"
23
+ version = "0.2.2"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  build = [
@@ -7,6 +7,7 @@ left aborts with the report.
7
7
  """
8
8
 
9
9
  import copy
10
+ import dataclasses
10
11
  import re
11
12
  from collections.abc import Iterator, Mapping, Sequence
12
13
  from typing import Any, cast
@@ -110,6 +111,39 @@ def _extras_problems(kind: Kind, package: Package, data: dict[str, Any]) -> tupl
110
111
  return extras, state.not_allowed(kind, extras)
111
112
 
112
113
 
114
+ def kind_default_changes(
115
+ package: Package,
116
+ extras: Extras,
117
+ saved: Mapping[str, Any],
118
+ current: Mapping[str, Any],
119
+ policy: DefaultChange | Mapping[str, DefaultChange],
120
+ ) -> tuple[Extras, list[dict[str, Any]]]:
121
+ """Apply the consumer's policy to every kind default this release changed (V22), for a value the document does
122
+ not set itself: following needs nothing (the kind writes the new default); a frozen one becomes a setting of the
123
+ document with the old value; one that asks is returned as unresolved. The key is the value's class name, the path
124
+ `/defaults/<class>`."""
125
+ own = {type(v).__name__ for v in extras.settings}
126
+ frozen: list[Any] = []
127
+ asked: list[dict[str, Any]] = []
128
+ for name in sorted(saved.keys() & current.keys()):
129
+ if saved[name] == current[name] or name in own:
130
+ continue
131
+ action = _policy(policy, f"/defaults/{name}", name)
132
+ if action is DefaultChange.FREEZE:
133
+ frozen.append(saved[name])
134
+ elif action is DefaultChange.ASK:
135
+ reason = f"the default of {name} changed from {saved[name]!r} to {current[name]!r}"
136
+ asked.append({"path": f"/defaults/{name}", "reason": reason, "value": saved[name], "boundary": "defaults"})
137
+ if not frozen:
138
+ return extras, asked
139
+ try:
140
+ kept = state.extras_from(package, {"settings": frozen}).settings
141
+ except ExceptionGroup as group:
142
+ reason = "; ".join(str(e) for e in group.exceptions)
143
+ return extras, [*asked, {"path": "/defaults", "reason": reason, "value": frozen, "boundary": "defaults"}]
144
+ return dataclasses.replace(extras, settings=(*extras.settings, *kept)), asked
145
+
146
+
113
147
  def adopt(
114
148
  *,
115
149
  kind: Kind,
@@ -120,6 +154,8 @@ def adopt(
120
154
  consumer: Sequence[Migration],
121
155
  policy: DefaultChange | Mapping[str, DefaultChange],
122
156
  saved_defaults: Mapping[str, Any] | None,
157
+ saved_kind_defaults: Mapping[str, Any] | None = None,
158
+ kind_defaults: Mapping[str, Any] | None = None,
123
159
  base_data: Mapping[int, int] | None = None,
124
160
  ) -> tuple[Node, Extras]:
125
161
  """The tree and extras of `kind` that `envelope` describes at `data` version `current`, or a `MigrationError` naming
@@ -141,6 +177,9 @@ def adopt(
141
177
  schema = state.tree_schema(kind.model, "validation")
142
178
  extras, found = _extras_problems(kind, package, extras_data)
143
179
  left += [{**p, "boundary": "validation"} for p in found]
180
+ if saved_kind_defaults is not None and not found:
181
+ extras, asked = kind_default_changes(package, extras, saved_kind_defaults, kind_defaults or {}, policy)
182
+ left += asked
144
183
  left += [{**p, "boundary": "validation"} for p in unplaced(tree, schema)]
145
184
  left += [{**p, "boundary": "validation"} for p in problems(kind.model, state.given(tree), schema)]
146
185
  if left:
@@ -19,13 +19,14 @@ _SETTING = re.compile(r"settings\[(\d+)\]")
19
19
  _ENTRY = re.compile(r"preamble\.(before_class|before_package|after_package)\[(\d+)\]")
20
20
 
21
21
 
22
- def extra_path(plan_path: str, *, locked: int, layout: PreambleSlots) -> str:
22
+ def extra_path(plan_path: str, *, locked: int, leading: PreambleSlots) -> str:
23
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."""
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."""
25
26
  if found := _SETTING.fullmatch(plan_path):
26
27
  return f"/settings/{int(found[1]) - locked}" if int(found[1]) >= locked else ""
27
28
  if found := _ENTRY.fullmatch(plan_path):
28
- own = int(found[2]) - len(getattr(layout, found[1]))
29
+ own = int(found[2]) - len(getattr(leading, found[1]))
29
30
  return f"/preamble/{found[1]}/{own}" if own >= 0 else ""
30
31
  return ""
31
32
 
@@ -69,7 +69,19 @@ class Release:
69
69
  """What a document of `kind` is written with (L8): for a profile kind, the base kind's presets first, then
70
70
  its own locks, then the variant's."""
71
71
  base = self.dist.base_kind(kind)
72
- return (*(base.presets if base else ()), *kind.settings(variant))
72
+ return (*(base.presets if base else ()), *kind.settings(variant), *self.kind_defaults(kind))
73
+
74
+ def kind_defaults(self, kind: Kind) -> tuple[AnyValue, ...]:
75
+ """Where a document of `kind` starts for the values it may set: a profile's base kind's, then its own (V21)."""
76
+ base = self.dist.base_kind(kind)
77
+ return (*(base.defaults if base else ()), *kind.defaults)
78
+
79
+ def kind_defaults_state(self, kind: Kind) -> dict[str, Any]:
80
+ """The settable ones among them, by class name, in the data shape of a setting: what the template state keeps
81
+ to tell, at `load`, a default the kind has changed."""
82
+ found = tuple(v for v in self.kind_defaults(kind) if type(v) in kind.settable)
83
+ data = state.extras_state(Extras(settings=found)).get("settings", [])
84
+ return json.loads(dumps({type(v).__name__: d for v, d in zip(found, data, strict=True)}))
73
85
 
74
86
  def _has_graphics(self, kind: Kind) -> bool:
75
87
  kinds = (kind.name, *([kind.over] if kind.over else []))
@@ -79,21 +91,32 @@ class Release:
79
91
  """The lines that find the payload, which come before a document's own entries (A5)."""
80
92
  return self.dist.layout.preamble(graphics=self._has_graphics(kind))
81
93
 
94
+ def fixed_preamble(self, kind: Kind) -> PreambleSlots:
95
+ """What leads each preamble slot of every document of `kind`: the layout's lines, then the entries the base kind
96
+ locks (a profile's), then the kind's own (K020)."""
97
+ base = self.dist.base_kind(kind)
98
+ slots = [self.layout_preamble(kind), *([base.locked_preamble] if base else []), kind.locked_preamble]
99
+ return PreambleSlots(
100
+ before_class=tuple(e for s in slots for e in s.before_class),
101
+ before_package=tuple(e for s in slots for e in s.before_package),
102
+ after_package=tuple(e for s in slots for e in s.after_package),
103
+ )
104
+
82
105
  def plan(self, kind: Kind, tree: Node, variant: str | None, extras: Extras = Extras()) -> Plan: # noqa: B008
83
106
  """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).
107
+ document's own settings (D10); in each preamble slot the fixed entries, then the document's own.
85
108
 
86
109
  An exposed base kind is written by the base's package; the directory's layout is this distribution's."""
87
110
  body = tree if kind.expand is None else kind.expand(tree)
88
111
  package = self.dist.owner(kind.name).package
89
- found, own = self.layout_preamble(kind), extras.preamble
112
+ found, own = self.fixed_preamble(kind), extras.preamble
90
113
  return package.plan(
91
114
  TexDocument(
92
115
  body,
93
- options=extras.options,
116
+ options=kind.class_options(extras.options, variant),
94
117
  settings=(*self.settings(kind, variant), *extras.settings),
95
118
  preamble=PreambleSlots(
96
- before_class=own.before_class,
119
+ before_class=(*found.before_class, *own.before_class),
97
120
  before_package=(*found.before_package, *own.before_package),
98
121
  after_package=(*found.after_package, *own.after_package),
99
122
  ),
@@ -152,6 +175,7 @@ class Release:
152
175
  for variant, name in kind.documents()
153
176
  },
154
177
  "defaults": state.defaults_table(kind.model),
178
+ "kind_defaults": self.kind_defaults_state(kind),
155
179
  "sty": state.sty_hashes(self.sty),
156
180
  "stack": self.stack(),
157
181
  }
@@ -289,6 +313,8 @@ class KindApi[T: Command | Environment]:
289
313
  consumer=migrations,
290
314
  policy=default_changes,
291
315
  saved_defaults=template.get("defaults"),
316
+ saved_kind_defaults=template.get("kind_defaults"),
317
+ kind_defaults=self.release.kind_defaults_state(self.kind),
292
318
  base_data=dict(self.release.contracts.base_data) if self.kind.name in self.release.dist.expose else None,
293
319
  )
294
320
  return Document(cast("T", tree), path, self.release, self.kind, extras)
@@ -84,6 +84,7 @@ def not_allowed(kind: Kind, extras: Extras) -> list[dict[str, Any]]:
84
84
  data = extras_state(extras)
85
85
  settable = {c.__name__ for c in kind.settable}
86
86
  entries = {c.__name__ for c in kind.preamble}
87
+ locked = set(kind.locked_classes())
87
88
  found = [
88
89
  (f"/settings/{i}", f"{type(v).__name__} is not settable in kind {kind.name}", data["settings"][i])
89
90
  for i, v in enumerate(extras.settings)
@@ -97,7 +98,9 @@ def not_allowed(kind: Kind, extras: Extras) -> list[dict[str, Any]]:
97
98
  found += [
98
99
  (
99
100
  f"/preamble/{slot}/{i}",
100
- f"{type(item).__name__} is not an allowed preamble entry in kind {kind.name}",
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}",
101
104
  data["preamble"][slot][i],
102
105
  )
103
106
  for slot, i, item in extras.preamble.items()
@@ -19,7 +19,7 @@ from texmodel.components import AnyValue, Command, Environment, Node, Passthroug
19
19
  from texmodel.document import OPTION
20
20
  from texmodel.interface import Body, BodyKind
21
21
  from texmodel.package import Package
22
- from texmodel.preamble import Entry
22
+ from texmodel.preamble import Entry, PreambleSlots
23
23
 
24
24
  from .assets import Asset, Resolved
25
25
  from .errors import DeclarationError
@@ -36,10 +36,13 @@ type Model = type[Command] | type[Environment]
36
36
 
37
37
  @dataclasses.dataclass(frozen=True)
38
38
  class Variant:
39
- """A named set of settings applied to the same document (K6): `doc.render(variant="compact")`."""
39
+ """A named set of settings and class options applied to the same document (K6): `doc.render(variant="compact")`.
40
+ The options are written after the document's own, and need not be among the kind's `options`: the variant is the
41
+ author's choice, not the document's."""
40
42
 
41
43
  name: str
42
44
  settings: tuple[AnyValue, ...] = ()
45
+ options: tuple[str, ...] = ()
43
46
 
44
47
  def __post_init__(self) -> None:
45
48
  if not _VARIANT.fullmatch(self.name):
@@ -50,6 +53,18 @@ class Variant:
50
53
  hint="it is part of a file name: deck-<variant>.tex",
51
54
  )
52
55
  object.__setattr__(self, "settings", tuple(self.settings))
56
+ object.__setattr__(self, "options", tuple(self.options))
57
+ if bad := next((o for o in self.options if not OPTION.fullmatch(o)), None):
58
+ raise DeclarationError(
59
+ f"variant {self.name}: option {bad!r} is not a class option",
60
+ code="TP-K016",
61
+ path=self.name,
62
+ hint="letters, digits, '=', '.' and '-', starting with a letter or digit",
63
+ )
64
+ if twice := next((o for o in self.options if self.options.count(o) > 1), None):
65
+ raise DeclarationError(
66
+ f"variant {self.name}: option {twice!r} is declared twice", code="TP-K002", path=self.name
67
+ )
53
68
 
54
69
 
55
70
  @dataclasses.dataclass(frozen=True)
@@ -105,7 +120,10 @@ class Kind:
105
120
 
106
121
  A document may carry what the kind allows and nothing else: `settable` (values a document sets, after `presets`,
107
122
  which every document is written with), `options` (class options) and `preamble` (entry classes). A value is a preset
108
- or settable, not both."""
123
+ or settable, not both. `defaults` are the kind's own start values for settable ones: written after the presets and
124
+ the variant's settings and before the document's own, so a document that sets the value overrides them (V21).
125
+ `locked_preamble` holds entries every document is written with, in the slots given, after
126
+ the layout's lines and before the document's own: a document may not carry an entry class that is locked."""
109
127
 
110
128
  name: str
111
129
  model: Model
@@ -120,9 +138,11 @@ class Kind:
120
138
  settable: tuple[type[Value[Any]], ...] = ()
121
139
  options: tuple[str, ...] = ()
122
140
  preamble: tuple[type[Entry], ...] = ()
141
+ locked_preamble: PreambleSlots = dataclasses.field(default_factory=PreambleSlots)
142
+ defaults: tuple[AnyValue, ...] = ()
123
143
 
124
144
  def __post_init__(self) -> None:
125
- for field in ("presets", "migrations", "allow_raw", "variants", "settable", "options", "preamble"):
145
+ for field in ("presets", "migrations", "allow_raw", "variants", "settable", "options", "preamble", "defaults"):
126
146
  object.__setattr__(self, field, tuple(getattr(self, field)))
127
147
  if not _KIND.fullmatch(self.name) or keyword.iskeyword(self.name):
128
148
  raise DeclarationError(
@@ -173,6 +193,7 @@ class Kind:
173
193
  """What a document may carry (D10): no name twice, real class options, nothing both written and settable."""
174
194
  self._unique("settable value", [c.__name__ for c in self.settable])
175
195
  self._unique("preamble entry", [c.__name__ for c in self.preamble])
196
+ self._check_locked()
176
197
  self._unique("option", list(self.options))
177
198
  if bad := next((o for o in self.options if not OPTION.fullmatch(o)), None):
178
199
  raise DeclarationError(
@@ -189,6 +210,14 @@ class Kind:
189
210
  path=self.name,
190
211
  hint="a value is written for every document or set by a document, not both",
191
212
  )
213
+ self._unique("default", [type(v).__name__ for v in self.defaults])
214
+ if loose := next((v for v in self.defaults if type(v) not in self.settable), None):
215
+ raise DeclarationError(
216
+ f"kind {self.name}: a default for {type(loose).__name__}, which is not settable",
217
+ code="TP-K021",
218
+ path=self.name,
219
+ hint="a default is where a document starts for a value it may set: add the class to settable",
220
+ )
192
221
  object.__setattr__(self, "stem", self.stem or self.model.__name__.lower())
193
222
  if not _STEM.fullmatch(self.stem):
194
223
  raise DeclarationError(
@@ -198,6 +227,34 @@ class Kind:
198
227
  hint="letters, digits, '_' and '-', e.g. stem='brand-deck' for brand-deck.tex",
199
228
  )
200
229
 
230
+ def locked_classes(self) -> tuple[str, ...]:
231
+ """The names of the entry classes the kind writes into every document."""
232
+ return tuple(type(item).__name__ for _, _, item in self.locked_preamble.items())
233
+
234
+ def _check_locked(self) -> None:
235
+ """Locked entries are typed, in a slot they fit, each class once, and not also allowed to a document (K020)."""
236
+ for slot, _, item in self.locked_preamble.items():
237
+ slots = getattr(item, "slots", ())
238
+ if slot not in slots:
239
+ raise self.locked_error(
240
+ f"{type(item).__name__} cannot sit in {slot}" if slots else "is not a typed entry",
241
+ f"a typed entry (RequireMetadata, PdfInfo, ...) in a slot it fits: {list(slots) or 'none'}",
242
+ item,
243
+ )
244
+ names = self.locked_classes()
245
+ if twice := next((n for n in names if names.count(n) > 1), None):
246
+ raise self.locked_error("is locked twice", "lock each entry class once", twice)
247
+ if both := next((c for c in self.preamble if c.__name__ in names), None):
248
+ raise self.locked_error(
249
+ "is locked and also allowed to a document", "a class is locked or allowed, not both", both.__name__
250
+ )
251
+
252
+ def locked_error(self, what: str, hint: str, entry: object) -> DeclarationError:
253
+ name = entry if isinstance(entry, str) else type(entry).__name__
254
+ return DeclarationError(
255
+ f"kind {self.name}: locked preamble entry {name} {what}", code="TP-K020", path=self.name, hint=hint
256
+ )
257
+
201
258
  def _unique(self, what: str, names: list[str]) -> None:
202
259
  if twice := next((n for n in names if names.count(n) > 1), None):
203
260
  raise DeclarationError(
@@ -208,6 +265,11 @@ class Kind:
208
265
  """The `.tex` a save writes, as `(variant, file name)`: the kind's own, then one per variant (K7)."""
209
266
  return ((None, f"{self.stem}.tex"), *((v.name, f"{self.stem}-{v.name}.tex") for v in self.variants))
210
267
 
268
+ def class_options(self, own: tuple[str, ...], variant: str | None = None) -> tuple[str, ...]:
269
+ """The class options of a `.tex` (K6): the document's `own`, then the variant's that are not among them."""
270
+ found = next((v for v in self.variants if v.name == variant), None)
271
+ return (*own, *(o for o in (found.options if found else ()) if o not in own))
272
+
211
273
  def settings(self, variant: str | None = None) -> tuple[AnyValue, ...]:
212
274
  """What a document of the kind is written with (L8): the presets, then the settings of `variant`, if named."""
213
275
  if variant is None:
@@ -347,6 +409,15 @@ class Distribution:
347
409
  )
348
410
  if problem := self.expansion_problem(kind, kind.example()):
349
411
  raise self._bad_expansion(kind, problem)
412
+ base = offered[kind.over]
413
+ if clash := next((n for n in kind.locked_classes() if n in base.locked_classes()), None):
414
+ raise kind.locked_error(
415
+ "is locked by the base kind too", "the base kind's locked entries are written first", clash
416
+ )
417
+ if clash := next((c for c in kind.preamble if c.__name__ in base.locked_classes()), None):
418
+ raise kind.locked_error(
419
+ "is locked by the base kind", "a profile cannot allow what its base locks", clash.__name__
420
+ )
350
421
 
351
422
  def expansion_problem(self, kind: Kind, tree: Node) -> str | None:
352
423
  """Why `tree` of a profile kind does not expand to a valid tree of the base kind (L3); `None` if it does."""
@@ -169,7 +169,7 @@ class Document[T: Command | Environment]:
169
169
  own = handedits.extra_path(
170
170
  plan_path,
171
171
  locked=len(self._release.settings(self._kind, variant)),
172
- layout=self._release.layout_preamble(self._kind),
172
+ leading=self._release.fixed_preamble(self._kind),
173
173
  )
174
174
  if own:
175
175
  return own
@@ -155,9 +155,11 @@ def _tuple(items: list[str]) -> str:
155
155
  return "(" + "".join(f"{item!r}, " for item in items).strip() + ")"
156
156
 
157
157
 
158
- def sources(dist: Distribution, pointer_attr: str, version: str, contracts: Contracts) -> dict[str, str]:
159
- """The generated files by path inside the module, for `dist` released at `version`. `pointer_attr` is the name
160
- `[tool.texpkg] distribution` gives the declaration inside its package."""
158
+ def sources(dist: Distribution, pointer: str, version: str, contracts: Contracts) -> dict[str, str]:
159
+ """The generated files by path inside the module, for `dist` released at `version`. `pointer` is the
160
+ `module:attribute` of `[tool.texpkg] distribution`: where the declaring package holds the declaration."""
161
+ module, _, pointer_attr = pointer.partition(":")
162
+ where = module.removeprefix(dist.declared_in) # "" when the declaration is the package's `__init__`
161
163
  offered = dist.kinds_offered()
162
164
  names = [k.name for k in offered]
163
165
  sty = render_sty(dist, version)
@@ -233,7 +235,7 @@ def sources(dist: Distribution, pointer_attr: str, version: str, contracts: Cont
233
235
 
234
236
  from texpkg._internal.release import Release
235
237
 
236
- from ._declaration import {pointer_attr}
238
+ from ._declaration{where} import {pointer_attr}
237
239
  from ._version import CONTRACTS, VERSION
238
240
  {base_import}
239
241
 
@@ -3,7 +3,9 @@
3
3
  `[tool.texpkg] generate = "module:function"` names a pure `function(root) -> {path: source}`, where `root` is the
4
4
  declaring package's directory and each path is relative to it. `texpkg-build generate` writes the files; the build runs
5
5
  the function again and refuses a file that is missing, differs (stale or hand-edited), or is left over from an earlier
6
- run. The function must not import the declaring package, whose `__init__` imports the files it produces.
6
+ run. The function must not import a module that imports the files it produces, as the declaration does: keep the
7
+ declaration in a module of its own (`pkg.declaration:dist`) and `pkg/__init__.py` free of imports, and the function can
8
+ share the package's pure modules.
7
9
  """
8
10
 
9
11
  import importlib
@@ -20,8 +22,10 @@ HEADER = "# Generated by texpkg-build generate. Do not edit.\n# fmt: off\n"
20
22
 
21
23
 
22
24
  def package_directory(source: pathlib.Path, module: str) -> pathlib.Path:
23
- """The directory of the declaring package `module`, found without importing it."""
24
- return source.joinpath(*module.split("."))
25
+ """The directory of the declaring package, found without importing it: `module` itself when it is a package
26
+ (`pkg:dist`), else the package that holds it (`pkg.declaration:dist`), as `Distribution(declared_in)` is."""
27
+ path = source.joinpath(*module.split("."))
28
+ return path if path.is_dir() else path.parent
25
29
 
26
30
 
27
31
  def _function(pointer: str, isolation: Isolation) -> Callable[[pathlib.Path], Mapping[str, str]]:
@@ -36,22 +36,17 @@ def _pointer(context: Context) -> tuple[str, str]:
36
36
 
37
37
 
38
38
  def pdm_build_initialize(context: Context) -> None:
39
- """A wheel needs pydantic and nothing else: texmodel, texpkg and the bases are embedded in it (B7)."""
39
+ """A wheel needs pydantic and nothing else: texmodel, texpkg and the bases are embedded in it (B7). An editable
40
+ install embeds nothing, so it needs texpkg itself, which brings texmodel and pydantic."""
40
41
  metadata = context.config.metadata
41
- metadata["dependencies"] = [PYDANTIC]
42
+ metadata["dependencies"] = ["texpkg"] if context.target == "editable" else [PYDANTIC]
42
43
  if "dynamic" in metadata and "dependencies" in metadata["dynamic"]:
43
44
  metadata["dynamic"].remove("dependencies")
44
45
 
45
46
 
46
47
  def pdm_build_update_files(context: Context, files: dict[str, pathlib.Path]) -> None:
47
- if context.target == "sdist":
48
- return
49
48
  if context.target != "wheel":
50
- raise DeclarationError(
51
- f"a {context.target} build of a texpkg package",
52
- code="TP-B008",
53
- hint="the module is generated from the declaration: test it with texpkg_build.testing.load_declaration",
54
- )
49
+ return # the sdist holds the sources, an editable install links them: the module is made for a wheel (B6)
55
50
  module, attr = _pointer(context)
56
51
  source = context.root / (context.config.build_config.package_dir or ".")
57
52
  with author_source(source) as isolation:
@@ -73,7 +68,7 @@ def pdm_build_update_files(context: Context, files: dict[str, pathlib.Path]) ->
73
68
  loaded = load_declaration(f"{module}:{attr}", version=version)
74
69
  checks.module_satisfies_the_protocols(loaded)
75
70
  contracts, held = checks.contract_gate(context.root, dist, loaded._bound.RELEASE, version) # pyright: ignore[reportAttributeAccessIssue]
76
- module_files = generate.sources(dist, attr, version, contracts)
71
+ module_files = generate.sources(dist, f"{module}:{attr}", version, contracts)
77
72
  files.clear() # a wheel holds the generated module, not the author's sources
78
73
  embed.embed(
79
74
  context.build_dir / dist.module, dist, declaration, module_files, held
@@ -54,7 +54,7 @@ def load_declaration(pointer: str, *, version: str = "0.0.0", contracts: Contrac
54
54
  root = pathlib.Path(scratch.name) / dist.module
55
55
  for loaded in [n for n in sys.modules if _is_under(n, dist.module)]:
56
56
  del sys.modules[loaded]
57
- _write(dist, attr, version, contracts or Contracts(), root)
57
+ _write(dist, pointer, version, contracts or Contracts(), root)
58
58
  spec = importlib.util.spec_from_file_location(
59
59
  dist.module, root / "__init__.py", submodule_search_locations=[str(root)]
60
60
  )
@@ -102,16 +102,24 @@ class _Flatten(importlib.abc.MetaPathFinder, importlib.abc.Loader):
102
102
  """The module is the nested one, already running."""
103
103
 
104
104
 
105
- def _attribute_of(dist: Distribution) -> str:
106
- """The name the declaration package gives `dist`, which a base's generated module imports it by."""
107
- namespace = vars(importlib.import_module(dist.declared_in))
108
- return next(name for name, value in namespace.items() if value is dist)
105
+ def _pointer_of(dist: Distribution) -> str:
106
+ """The `module:attribute` a base's declaration is imported by: the module of its package that holds it."""
107
+ for name, module in sorted(sys.modules.items()):
108
+ if _is_under(name, dist.declared_in):
109
+ if attr := next((n for n, value in vars(module).items() if value is dist), None):
110
+ return f"{name}:{attr}"
111
+ raise DeclarationError(
112
+ f"{dist.name} is not held by a module of {dist.declared_in}",
113
+ code="TP-B003",
114
+ path=dist.name,
115
+ hint="declare the distribution at the top level of a module of its package",
116
+ )
109
117
 
110
118
 
111
- def _write(dist: Distribution, attr: str, version: str, contracts: Contracts, root: pathlib.Path) -> None:
119
+ def _write(dist: Distribution, pointer: str, version: str, contracts: Contracts, root: pathlib.Path) -> None:
112
120
  """The generated module of `dist` in `root`, and in `root/_embedded/<base>` that of each base: embedding without
113
121
  copying, since the declarations are the modules already imported."""
114
- for path, text in generate.sources(dist, attr, version, contracts).items():
122
+ for path, text in generate.sources(dist, pointer, version, contracts).items():
115
123
  (root / path).parent.mkdir(parents=True, exist_ok=True)
116
124
  (root / path).write_text(text, encoding="utf-8")
117
125
  if dist.base:
@@ -119,7 +127,7 @@ def _write(dist: Distribution, attr: str, version: str, contracts: Contracts, ro
119
127
  (root / "_embedded" / "__init__.py").write_text("", encoding="utf-8")
120
128
  _write(
121
129
  dist.base,
122
- _attribute_of(dist.base),
130
+ _pointer_of(dist.base),
123
131
  dist.base.package.version,
124
132
  contracts,
125
133
  root / "_embedded" / dist.base.module,
@@ -6,9 +6,10 @@ import pathlib
6
6
  # exported by its kinds).
7
7
  import acme_brand_tex # pyright: ignore[reportMissingImports]
8
8
  from acme_brand_tex._declaration.model import FactGap, Ink # pyright: ignore[reportMissingImports]
9
- from texmodel.types import Length
9
+ from texmodel.preamble import PassOption, PreambleSlots, RequireMetadata
10
+ from texmodel.types import CMYK, Length
10
11
 
11
- from texpkg import Asset, Distribution, Kind, Role
12
+ from texpkg import Asset, Distribution, Kind, Role, Variant
12
13
 
13
14
  from .model import Brief, Heading, Headline, Point, Slide, Tint, expand, pkg
14
15
 
@@ -27,7 +28,17 @@ dist = Distribution(
27
28
  package=pkg,
28
29
  assets=(Asset("assets/DECK.txt", Role.NOTE, "what the deck package is"),),
29
30
  kinds=(
30
- Kind("slide", Slide, presets=(), example=slide_example),
31
+ Kind(
32
+ "slide",
33
+ Slide,
34
+ presets=(),
35
+ example=slide_example,
36
+ preamble=(PassOption,),
37
+ settable=(Ink,),
38
+ defaults=(Ink(CMYK(0, 0, 0, 0.3)),), # where a slide starts; a slide may set its own
39
+ variants=(Variant("review", options=("draft",)),),
40
+ locked_preamble=PreambleSlots(before_class=(RequireMetadata(pdfversion="2.0"),)), # every slide has it
41
+ ),
31
42
  Kind(
32
43
  "brief",
33
44
  Brief,
@@ -179,11 +179,14 @@ def test_the_declaration_must_be_a_package(tmp_path):
179
179
  assert raised.value.code == "TP-B004"
180
180
 
181
181
 
182
- def test_an_editable_install_is_refused(project, tmp_path, monkeypatch):
182
+ def test_an_editable_install_links_the_sources_and_needs_texpkg(project, tmp_path, monkeypatch):
183
183
  monkeypatch.chdir(project)
184
- with pytest.raises(DeclarationError) as raised:
185
- pdm.backend.build_editable(str(tmp_path / "dist"))
186
- assert raised.value.code == "TP-B008"
184
+ built = tmp_path / "dist" / pdm.backend.build_editable(str(tmp_path / "dist"))
185
+ names = projects.names(built)
186
+ assert not any("acme_brand_tex" in n and n.endswith(".py") for n in names) # no generated module
187
+ with zipfile.ZipFile(built) as z:
188
+ metadata = z.read(next(n for n in names if n.endswith("METADATA"))).decode()
189
+ assert "Requires-Dist: texpkg" in metadata and "pydantic" not in metadata
187
190
 
188
191
 
189
192
  def test_a_generated_module_that_breaks_the_protocol_is_refused(tmp_path, monkeypatch):
@@ -10,7 +10,7 @@ import pytest
10
10
  from acme_brand import Card, Compact, Deck, Fact, FactGap, Glossary, Ink, Mark, Term, Title, dist, expert_example
11
11
  from texmodel.components import Command, Environment, Passthrough, Value
12
12
  from texmodel.package import Package
13
- from texmodel.preamble import PdfInfo
13
+ from texmodel.preamble import PassOption, PdfInfo, PreambleSlots, RequireMetadata
14
14
  from texmodel.types import Length, Raw, Text
15
15
 
16
16
  from texpkg import Asset, DefaultChange, Distribution, Kind, Migrated, Role, Variant, migration
@@ -262,12 +262,32 @@ def test_a_distribution_declared_in_a_zipped_package_checks_its_root_through_the
262
262
  ({"options": ("!",)}, "TP-K016"),
263
263
  ({"settable": (FactGap,)}, "TP-K017"), # a preset of the kind
264
264
  ({"settable": (Compact,)}, "TP-K017"), # a variant's setting
265
+ ({"locked_preamble": PreambleSlots(before_class=(Raw(r"\\x"),))}, "TP-K020"), # not a typed entry
266
+ ({"locked_preamble": PreambleSlots(before_package=(RequireMetadata(lang="en"),))}, "TP-K020"), # wrong slot
267
+ ({"locked_preamble": PreambleSlots(before_class=(PassOption("a", "b"), PassOption("c", "d")))}, "TP-K020"),
268
+ ({"locked_preamble": PreambleSlots(before_class=(RequireMetadata(lang="en"),))}, "TP-K020"), # also allowed
265
269
  ],
266
270
  )
267
271
  def test_what_a_kind_lets_a_document_carry_is_checked(changes, code):
268
272
  assert code_of(dataclasses.replace, dist.kind("expert"), **changes) == code
269
273
 
270
274
 
275
+ def test_a_variants_options_follow_the_documents_without_repeating_them():
276
+ expert = dist.kind("expert")
277
+ review = dataclasses.replace(expert, variants=(Variant("review", options=("draft", "a4paper")),))
278
+ assert review.class_options(("a4paper",), "review") == ("a4paper", "draft")
279
+ assert review.class_options(("a4paper",)) == ("a4paper",) and review.class_options((), "missing") == ()
280
+ assert code_of(Variant, "x", options=("two words",)) == "TP-K016"
281
+ assert code_of(Variant, "x", options=("draft", "draft")) == "TP-K002"
282
+
283
+
284
+ def test_a_kind_locks_entries_of_classes_it_does_not_also_allow():
285
+ locked = PreambleSlots(before_class=(PassOption("xcolor", "table"),), before_package=(PassOption("a", "b"),))
286
+ assert code_of(dataclasses.replace, dist.kind("expert"), locked_preamble=locked) == "TP-K020" # twice, in two slots
287
+ kind = dataclasses.replace(dist.kind("expert"), locked_preamble=PreambleSlots(before_class=locked.before_class))
288
+ assert kind.locked_classes() == ("PassOption",) and dist.kind("expert").locked_classes() == ()
289
+
290
+
271
291
  def test_a_settable_value_is_registered_by_the_package_that_reads_it_back():
272
292
  other = Package("other", prefix="ot", version="1.0.0", date="2026/10/22", description="Another")
273
293
 
@@ -200,6 +200,7 @@ def test_a_wheel_installs_into_a_fresh_environment_that_has_only_pydantic(brand_
200
200
  report = json.loads(venvs.run(python, SCRIPT, tmp_path / "out", cwd=tmp_path))
201
201
  assert report["names"] == ["slide", "brief", "vocab"] and report["version"] == "0.1.0"
202
202
  assert report["slide"] == [
203
+ "_texpkg/plan-review.json",
203
204
  "_texpkg/plan.json",
204
205
  "_texpkg/state.json",
205
206
  "_texpkg/template.json",
@@ -210,6 +211,7 @@ def test_a_wheel_installs_into_a_fresh_environment_that_has_only_pydantic(brand_
210
211
  "res/texpkg/acme-brand.sty",
211
212
  "res/texpkg/acme-deck-provenance.sty",
212
213
  "res/texpkg/acme-deck.sty",
214
+ "slide-review.tex",
213
215
  "slide.tex",
214
216
  ]
215
217
  assert "res/texpkg/acme-brand.sty" in report["vocab"] and "glossary.tex" in report["vocab"]
@@ -122,8 +122,8 @@ def test_two_wheels_are_installed_used_saved_compiled_and_a_new_release_is_adopt
122
122
  ]
123
123
  sources = sorted((tmp_path / "docs").rglob("*.tex"))
124
124
  assert (
125
- len(sources) == 3 + 1 + 1 + 1 + 1
126
- ) # expert and its two variants, vocab, then the deck's brief, slide and vocab
125
+ len(sources) == 3 + 1 + 1 + 2 + 1
126
+ ) # expert and its two variants, vocab, then the deck's brief, slide and its variant, and vocab
127
127
  for source in sources:
128
128
  compiler.compile(
129
129
  source, tmp_path / "out" / source.parent.parent.name / source.parent.name / source.stem