texpkg 0.2.1__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 (104) hide show
  1. {texpkg-0.2.1 → texpkg-0.2.2}/PKG-INFO +1 -1
  2. {texpkg-0.2.1 → texpkg-0.2.2}/pyproject.toml +1 -1
  3. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/adopt.py +39 -0
  4. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/release.py +17 -2
  5. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/declaration.py +34 -3
  6. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/hook.py +4 -9
  7. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_deck/__init__.py +5 -2
  8. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_build.py +7 -4
  9. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_declaration.py +9 -0
  10. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_embed.py +2 -0
  11. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_end_to_end.py +2 -2
  12. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_profile.py +100 -2
  13. {texpkg-0.2.1 → texpkg-0.2.2}/LICENSE +0 -0
  14. {texpkg-0.2.1 → texpkg-0.2.2}/README.md +0 -0
  15. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/__init__.py +0 -0
  16. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/__init__.py +0 -0
  17. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/classify.py +0 -0
  18. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/directory.py +0 -0
  19. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/envelope.py +0 -0
  20. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/export.py +0 -0
  21. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/handedits.py +0 -0
  22. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/history.py +0 -0
  23. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/payload.py +0 -0
  24. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/provenance_tex.py +0 -0
  25. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/snapshots.py +0 -0
  26. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/state.py +0 -0
  27. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/_internal/versions.py +0 -0
  28. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/assets.py +0 -0
  29. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/contracts.py +0 -0
  30. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/document.py +0 -0
  31. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/errors.py +0 -0
  32. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/layout.py +0 -0
  33. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/migration.py +0 -0
  34. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/protocol.py +0 -0
  35. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/provenance.py +0 -0
  36. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg/py.typed +0 -0
  37. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/__init__.py +0 -0
  38. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/__init__.py +0 -0
  39. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/checks.py +0 -0
  40. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/embed.py +0 -0
  41. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/generate.py +0 -0
  42. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/generated.py +0 -0
  43. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/isolate.py +0 -0
  44. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/_internal/snapshot.py +0 -0
  45. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/cli.py +0 -0
  46. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/py.typed +0 -0
  47. {texpkg-0.2.1 → texpkg-0.2.2}/src/texpkg_build/testing.py +0 -0
  48. {texpkg-0.2.1 → texpkg-0.2.2}/tests/__init__.py +0 -0
  49. {texpkg-0.2.1 → texpkg-0.2.2}/tests/conftest.py +0 -0
  50. {texpkg-0.2.1 → texpkg-0.2.2}/tests/consumers/acme-brand-tex/pdm_build.py +0 -0
  51. {texpkg-0.2.1 → texpkg-0.2.2}/tests/consumers/acme-brand-tex/pyproject.toml +0 -0
  52. {texpkg-0.2.1 → texpkg-0.2.2}/tests/consumers/acme-deck-tex/pdm_build.py +0 -0
  53. {texpkg-0.2.1 → texpkg-0.2.2}/tests/consumers/acme-deck-tex/pyproject.toml +0 -0
  54. {texpkg-0.2.1 → texpkg-0.2.2}/tests/consumers/acme-series-tex/pdm_build.py +0 -0
  55. {texpkg-0.2.1 → texpkg-0.2.2}/tests/consumers/acme-series-tex/pyproject.toml +0 -0
  56. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/__init__.py +0 -0
  57. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/acme.tex +0 -0
  58. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/assets/NOTICE.txt +0 -0
  59. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
  60. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
  61. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/README-expert.md +0 -0
  62. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +0 -0
  63. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/acme-brand.sty +0 -0
  64. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/deck-compact.tex +0 -0
  65. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/deck-wide.tex +0 -0
  66. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/deck.tex +0 -0
  67. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/expert-template.tex +0 -0
  68. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/glossary.tex +0 -0
  69. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/plan.json +0 -0
  70. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/generated/state.json +0 -0
  71. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/model.py +0 -0
  72. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/other/lockup.pdf +0 -0
  73. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_brand/tokens.json +0 -0
  74. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_deck/assets/DECK.txt +0 -0
  75. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_deck/generated/brief.tex +0 -0
  76. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_deck/model.py +0 -0
  77. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/__init__.py +0 -0
  78. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/_kinds/__init__.py +0 -0
  79. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/_kinds/long.py +0 -0
  80. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/_kinds/short.py +0 -0
  81. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/assets/lmroman10-regular.otf +0 -0
  82. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/model.py +0 -0
  83. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/series.json +0 -0
  84. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series/series.tex +0 -0
  85. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_series_gen/__init__.py +0 -0
  86. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_sheet/__init__.py +0 -0
  87. {texpkg-0.2.1 → texpkg-0.2.2}/tests/fixtures/acme_sheet/model.py +0 -0
  88. {texpkg-0.2.1 → texpkg-0.2.2}/tests/helpers.py +0 -0
  89. {texpkg-0.2.1 → texpkg-0.2.2}/tests/projects.py +0 -0
  90. {texpkg-0.2.1 → texpkg-0.2.2}/tests/spawn.py +0 -0
  91. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_compile.py +0 -0
  92. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_contracts.py +0 -0
  93. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_document.py +0 -0
  94. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_export.py +0 -0
  95. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_extras.py +0 -0
  96. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_generated.py +0 -0
  97. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_isolated.py +0 -0
  98. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_migration.py +0 -0
  99. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_module.py +0 -0
  100. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_payload.py +0 -0
  101. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_provenance.py +0 -0
  102. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_public_api.py +0 -0
  103. {texpkg-0.2.1 → texpkg-0.2.2}/tests/test_typing.py +0 -0
  104. {texpkg-0.2.1 → 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.1
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.1"
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:
@@ -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 []))
@@ -101,7 +113,7 @@ class Release:
101
113
  return package.plan(
102
114
  TexDocument(
103
115
  body,
104
- options=extras.options,
116
+ options=kind.class_options(extras.options, variant),
105
117
  settings=(*self.settings(kind, variant), *extras.settings),
106
118
  preamble=PreambleSlots(
107
119
  before_class=(*found.before_class, *own.before_class),
@@ -163,6 +175,7 @@ class Release:
163
175
  for variant, name in kind.documents()
164
176
  },
165
177
  "defaults": state.defaults_table(kind.model),
178
+ "kind_defaults": self.kind_defaults_state(kind),
166
179
  "sty": state.sty_hashes(self.sty),
167
180
  "stack": self.stack(),
168
181
  }
@@ -300,6 +313,8 @@ class KindApi[T: Command | Environment]:
300
313
  consumer=migrations,
301
314
  policy=default_changes,
302
315
  saved_defaults=template.get("defaults"),
316
+ saved_kind_defaults=template.get("kind_defaults"),
317
+ kind_defaults=self.release.kind_defaults_state(self.kind),
303
318
  base_data=dict(self.release.contracts.base_data) if self.kind.name in self.release.dist.expose else None,
304
319
  )
305
320
  return Document(cast("T", tree), path, self.release, self.kind, extras)
@@ -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,9 @@ 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. `locked_preamble` holds entries every document is written with, in the slots given, after
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
109
126
  the layout's lines and before the document's own: a document may not carry an entry class that is locked."""
110
127
 
111
128
  name: str
@@ -122,9 +139,10 @@ class Kind:
122
139
  options: tuple[str, ...] = ()
123
140
  preamble: tuple[type[Entry], ...] = ()
124
141
  locked_preamble: PreambleSlots = dataclasses.field(default_factory=PreambleSlots)
142
+ defaults: tuple[AnyValue, ...] = ()
125
143
 
126
144
  def __post_init__(self) -> None:
127
- for field in ("presets", "migrations", "allow_raw", "variants", "settable", "options", "preamble"):
145
+ for field in ("presets", "migrations", "allow_raw", "variants", "settable", "options", "preamble", "defaults"):
128
146
  object.__setattr__(self, field, tuple(getattr(self, field)))
129
147
  if not _KIND.fullmatch(self.name) or keyword.iskeyword(self.name):
130
148
  raise DeclarationError(
@@ -192,6 +210,14 @@ class Kind:
192
210
  path=self.name,
193
211
  hint="a value is written for every document or set by a document, not both",
194
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
+ )
195
221
  object.__setattr__(self, "stem", self.stem or self.model.__name__.lower())
196
222
  if not _STEM.fullmatch(self.stem):
197
223
  raise DeclarationError(
@@ -239,6 +265,11 @@ class Kind:
239
265
  """The `.tex` a save writes, as `(variant, file name)`: the kind's own, then one per variant (K7)."""
240
266
  return ((None, f"{self.stem}.tex"), *((v.name, f"{self.stem}-{v.name}.tex") for v in self.variants))
241
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
+
242
273
  def settings(self, variant: str | None = None) -> tuple[AnyValue, ...]:
243
274
  """What a document of the kind is written with (L8): the presets, then the settings of `variant`, if named."""
244
275
  if variant is None:
@@ -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:
@@ -7,9 +7,9 @@ import pathlib
7
7
  import acme_brand_tex # pyright: ignore[reportMissingImports]
8
8
  from acme_brand_tex._declaration.model import FactGap, Ink # pyright: ignore[reportMissingImports]
9
9
  from texmodel.preamble import PassOption, PreambleSlots, RequireMetadata
10
- from texmodel.types import Length
10
+ from texmodel.types import CMYK, Length
11
11
 
12
- from texpkg import Asset, Distribution, Kind, Role
12
+ from texpkg import Asset, Distribution, Kind, Role, Variant
13
13
 
14
14
  from .model import Brief, Heading, Headline, Point, Slide, Tint, expand, pkg
15
15
 
@@ -34,6 +34,9 @@ dist = Distribution(
34
34
  presets=(),
35
35
  example=slide_example,
36
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",)),),
37
40
  locked_preamble=PreambleSlots(before_class=(RequireMetadata(pdfversion="2.0"),)), # every slide has it
38
41
  ),
39
42
  Kind(
@@ -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):
@@ -272,6 +272,15 @@ def test_what_a_kind_lets_a_document_carry_is_checked(changes, code):
272
272
  assert code_of(dataclasses.replace, dist.kind("expert"), **changes) == code
273
273
 
274
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
+
275
284
  def test_a_kind_locks_entries_of_classes_it_does_not_also_allow():
276
285
  locked = PreambleSlots(before_class=(PassOption("xcolor", "table"),), before_package=(PassOption("a", "b"),))
277
286
  assert code_of(dataclasses.replace, dist.kind("expert"), locked_preamble=locked) == "TP-K020" # twice, in two slots
@@ -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
@@ -15,7 +15,7 @@ from hypothesis import (
15
15
  from texmodel.preamble import PassOption, PreambleSlots, RequireMetadata
16
16
  from texmodel.types import CMYK
17
17
 
18
- from texpkg import Design, Kind
18
+ from texpkg import DefaultChange, Design, Kind
19
19
  from texpkg.errors import DeclarationError, MigrationError, NotAllowedError
20
20
  from texpkg_build._internal import checks
21
21
  from texpkg_build.testing import check_expansion, load_declaration
@@ -223,6 +223,15 @@ def test_a_kind_writes_its_locked_entries_before_the_documents_own(deck, tmp_pat
223
223
  assert at == sorted(at) and at[1] < next(i for i, line in enumerate(lines) if "\\documentclass" in line)
224
224
 
225
225
 
226
+ def test_a_variant_writes_its_class_options_into_its_own_tex(deck, tmp_path):
227
+ slide = deck.kinds.modules()["slide"]
228
+ doc = slide.create(tmp_path, slide.example())
229
+ assert "\\documentclass{" in doc.render() and "\\documentclass[draft]{" in doc.render("review")
230
+ doc.save()
231
+ assert "\\documentclass[draft]{" in (tmp_path / "slide-review.tex").read_text()
232
+ assert "\\documentclass{" in (tmp_path / "slide.tex").read_text()
233
+
234
+
226
235
  def test_a_document_may_not_carry_an_entry_class_the_kind_locks(deck, tmp_path):
227
236
  slide = deck.kinds.modules()["slide"]
228
237
  with pytest.raises(NotAllowedError) as raised:
@@ -261,7 +270,8 @@ def test_a_hand_edit_of_the_documents_own_entry_is_found_at_its_data_path(deck,
261
270
  def test_a_document_with_a_locked_entry_compiles(deck, compiler, tmp_path):
262
271
  slide = deck.kinds.modules()["slide"]
263
272
  slide.create(tmp_path / "doc", slide.example(), preamble=OWN).save()
264
- compiler.compile(tmp_path / "doc/slide.tex", tmp_path / "out").raise_for_status()
273
+ for name in ("slide", "slide-review"): # the variant passes `draft` to the class
274
+ compiler.compile(tmp_path / f"doc/{name}.tex", tmp_path / "out" / name).raise_for_status()
265
275
 
266
276
 
267
277
  def locked_deck(deck, **changes): # noqa: ANN001, ANN003, ANN201
@@ -289,3 +299,91 @@ def test_a_profile_may_not_lock_or_allow_what_its_base_kind_locks(deck):
289
299
  with pytest.raises(DeclarationError) as raised:
290
300
  locked_deck(deck, preamble=(PassOption,))
291
301
  assert raised.value.code == "TP-K020" and "locked by the base kind" in str(raised.value)
302
+
303
+
304
+ # -- a kind's own defaults for what a document may set (V21) -------------------------------------------------------
305
+
306
+ OLD_INK = {"type": "Ink", "value": {"c": 0.0, "m": 0.0, "y": 0.0, "k": 0.9}}
307
+ GREY = "\\definecolor{ab-ink}{cmyk}{0,0,0,0.3}"
308
+ BLACK = "\\definecolor{ab-ink}{cmyk}{0,0,0,0.9}"
309
+
310
+
311
+ def slide_saved_with(deck, tmp_path, *, settings=(), old=OLD_INK): # noqa: ANN001, ANN002, ANN201
312
+ """A slide document whose saved template says the kind's default for `Ink` used to be `old`."""
313
+ slide = deck.kinds.modules()["slide"]
314
+ slide.create(tmp_path, slide.example(), settings=settings).save()
315
+ path = tmp_path / "_texpkg/template.json"
316
+ template = json.loads(path.read_text())
317
+ assert template["kind_defaults"] == {"Ink": {"type": "Ink", "value": {"c": 0.0, "m": 0.0, "y": 0.0, "k": 0.3}}}
318
+ template["kind_defaults"]["Ink"] = old
319
+ path.write_text(json.dumps(template))
320
+ return slide
321
+
322
+
323
+ def test_a_kinds_default_is_written_before_what_the_document_sets(deck, tmp_path):
324
+ slide = deck.kinds.modules()["slide"]
325
+ lines = slide.create(tmp_path / "a", slide.example()).render().splitlines()
326
+ assert GREY in lines
327
+ own = slide.create(tmp_path / "b", slide.example(), settings=[Ink(CMYK(0, 0, 0, 0.6))])
328
+ lines = own.render().splitlines()
329
+ assert lines.index(GREY) < lines.index(
330
+ "\\definecolor{ab-ink}{cmyk}{0,0,0,0.6}"
331
+ ) # the document's comes last: it wins
332
+ own.save()
333
+ (tmp_path / "b/slide.tex").write_text((tmp_path / "b/slide.tex").read_text().replace("{0,0,0,0.6}", "{0,0,0,0.7}"))
334
+ assert {e.path for e in own.hand_edits()} == {"/settings/0"} # the default is not mistaken for it
335
+
336
+
337
+ def test_a_changed_default_is_followed_frozen_or_asked_for_when_the_document_sets_nothing(deck, tmp_path):
338
+ slide = slide_saved_with(deck, tmp_path)
339
+ followed = slide.load(tmp_path)
340
+ assert followed.settings == () and GREY in followed.render()
341
+ frozen = slide.load(tmp_path, default_changes=DefaultChange.FREEZE)
342
+ assert [type(v) for v in frozen.settings] == [Ink]
343
+ lines = frozen.render().splitlines()
344
+ assert lines.index(GREY) < lines.index(BLACK) # the old value is written after the new default, so it wins
345
+ by_class = slide.load(tmp_path, default_changes={"Ink": DefaultChange.FREEZE})
346
+ assert [v.value for v in by_class.settings] == [v.value for v in frozen.settings]
347
+ with pytest.raises(MigrationError) as raised:
348
+ slide.load(tmp_path, default_changes=DefaultChange.ASK)
349
+ (item,) = raised.value.unresolved
350
+ assert item.path == "/defaults/Ink" and "changed from" in item.reason and item.boundary == "defaults"
351
+
352
+
353
+ def test_a_value_the_document_sets_is_not_a_changed_default(deck, tmp_path):
354
+ slide = slide_saved_with(deck, tmp_path, settings=[Ink(CMYK(0, 0, 0, 0.6))])
355
+ kept = slide.load(tmp_path, default_changes=DefaultChange.ASK)
356
+ assert BLACK not in kept.render() and "{0,0,0,0.6}" in kept.render()
357
+ again = slide.load(tmp_path, default_changes=DefaultChange.FREEZE)
358
+ assert [v.value for v in again.settings] == [v.value for v in kept.settings]
359
+
360
+
361
+ def test_a_default_that_did_not_change_or_was_not_saved_asks_nothing(deck, tmp_path):
362
+ slide = slide_saved_with(deck, tmp_path, old={"type": "Ink", "value": {"c": 0.0, "m": 0.0, "y": 0.0, "k": 0.3}})
363
+ assert slide.load(tmp_path, default_changes=DefaultChange.ASK).settings == ()
364
+ path = tmp_path / "_texpkg/template.json"
365
+ template = json.loads(path.read_text())
366
+ del template["kind_defaults"] # saved before the kind had a default
367
+ path.write_text(json.dumps(template))
368
+ assert slide.load(tmp_path, default_changes=DefaultChange.ASK).settings == ()
369
+
370
+
371
+ def test_a_frozen_default_the_class_no_longer_reads_is_unresolved(deck, tmp_path):
372
+ slide = slide_saved_with(deck, tmp_path, old={"type": "Ink", "value": "not a colour"})
373
+ with pytest.raises(MigrationError) as raised:
374
+ slide.load(tmp_path, default_changes=DefaultChange.FREEZE)
375
+ (item,) = raised.value.unresolved
376
+ assert item.path == "/defaults" and item.boundary == "defaults"
377
+
378
+
379
+ def test_a_default_is_for_a_settable_value_once(acme_brand_tex):
380
+ from acme_brand import Compact, Ink # noqa: PLC0415
381
+
382
+ expert = acme_brand.dist.kind("expert")
383
+ twice = (Ink(CMYK(0, 0, 0, 0.1)), Ink(CMYK(0, 0, 0, 0.2)))
384
+ with pytest.raises(DeclarationError) as raised:
385
+ dataclasses.replace(expert, defaults=twice)
386
+ assert raised.value.code == "TP-K002"
387
+ with pytest.raises(DeclarationError) as raised:
388
+ dataclasses.replace(expert, defaults=(Compact(True),))
389
+ assert raised.value.code == "TP-K021" and "Compact" in str(raised.value)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes