texpkg 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {texpkg-0.2.0 → texpkg-0.2.1}/PKG-INFO +1 -1
  2. {texpkg-0.2.0 → texpkg-0.2.1}/pyproject.toml +1 -1
  3. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/handedits.py +4 -3
  4. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/release.py +14 -3
  5. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/state.py +4 -1
  6. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/declaration.py +42 -2
  7. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/document.py +1 -1
  8. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/generate.py +6 -4
  9. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/generated.py +7 -3
  10. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/hook.py +1 -1
  11. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/testing.py +16 -8
  12. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/__init__.py +9 -1
  13. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_declaration.py +12 -1
  14. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_generated.py +25 -0
  15. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_profile.py +85 -1
  16. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_typing.py +1 -1
  17. {texpkg-0.2.0 → texpkg-0.2.1}/LICENSE +0 -0
  18. {texpkg-0.2.0 → texpkg-0.2.1}/README.md +0 -0
  19. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/__init__.py +0 -0
  20. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/__init__.py +0 -0
  21. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/adopt.py +0 -0
  22. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/classify.py +0 -0
  23. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/directory.py +0 -0
  24. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/envelope.py +0 -0
  25. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/export.py +0 -0
  26. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/history.py +0 -0
  27. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/payload.py +0 -0
  28. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/provenance_tex.py +0 -0
  29. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/snapshots.py +0 -0
  30. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/_internal/versions.py +0 -0
  31. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/assets.py +0 -0
  32. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/contracts.py +0 -0
  33. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/errors.py +0 -0
  34. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/layout.py +0 -0
  35. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/migration.py +0 -0
  36. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/protocol.py +0 -0
  37. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/provenance.py +0 -0
  38. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg/py.typed +0 -0
  39. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/__init__.py +0 -0
  40. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/__init__.py +0 -0
  41. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/checks.py +0 -0
  42. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/embed.py +0 -0
  43. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/isolate.py +0 -0
  44. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/_internal/snapshot.py +0 -0
  45. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/cli.py +0 -0
  46. {texpkg-0.2.0 → texpkg-0.2.1}/src/texpkg_build/py.typed +0 -0
  47. {texpkg-0.2.0 → texpkg-0.2.1}/tests/__init__.py +0 -0
  48. {texpkg-0.2.0 → texpkg-0.2.1}/tests/conftest.py +0 -0
  49. {texpkg-0.2.0 → texpkg-0.2.1}/tests/consumers/acme-brand-tex/pdm_build.py +0 -0
  50. {texpkg-0.2.0 → texpkg-0.2.1}/tests/consumers/acme-brand-tex/pyproject.toml +0 -0
  51. {texpkg-0.2.0 → texpkg-0.2.1}/tests/consumers/acme-deck-tex/pdm_build.py +0 -0
  52. {texpkg-0.2.0 → texpkg-0.2.1}/tests/consumers/acme-deck-tex/pyproject.toml +0 -0
  53. {texpkg-0.2.0 → texpkg-0.2.1}/tests/consumers/acme-series-tex/pdm_build.py +0 -0
  54. {texpkg-0.2.0 → texpkg-0.2.1}/tests/consumers/acme-series-tex/pyproject.toml +0 -0
  55. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/__init__.py +0 -0
  56. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/acme.tex +0 -0
  57. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/assets/NOTICE.txt +0 -0
  58. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
  59. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
  60. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/README-expert.md +0 -0
  61. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +0 -0
  62. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/acme-brand.sty +0 -0
  63. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/deck-compact.tex +0 -0
  64. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/deck-wide.tex +0 -0
  65. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/deck.tex +0 -0
  66. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/expert-template.tex +0 -0
  67. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/glossary.tex +0 -0
  68. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/plan.json +0 -0
  69. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/generated/state.json +0 -0
  70. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/model.py +0 -0
  71. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/other/lockup.pdf +0 -0
  72. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_brand/tokens.json +0 -0
  73. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/assets/DECK.txt +0 -0
  74. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/generated/brief.tex +0 -0
  75. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_deck/model.py +0 -0
  76. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/__init__.py +0 -0
  77. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/_kinds/__init__.py +0 -0
  78. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/_kinds/long.py +0 -0
  79. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/_kinds/short.py +0 -0
  80. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/assets/lmroman10-regular.otf +0 -0
  81. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/model.py +0 -0
  82. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/series.json +0 -0
  83. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series/series.tex +0 -0
  84. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_series_gen/__init__.py +0 -0
  85. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_sheet/__init__.py +0 -0
  86. {texpkg-0.2.0 → texpkg-0.2.1}/tests/fixtures/acme_sheet/model.py +0 -0
  87. {texpkg-0.2.0 → texpkg-0.2.1}/tests/helpers.py +0 -0
  88. {texpkg-0.2.0 → texpkg-0.2.1}/tests/projects.py +0 -0
  89. {texpkg-0.2.0 → texpkg-0.2.1}/tests/spawn.py +0 -0
  90. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_build.py +0 -0
  91. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_compile.py +0 -0
  92. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_contracts.py +0 -0
  93. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_document.py +0 -0
  94. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_embed.py +0 -0
  95. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_end_to_end.py +0 -0
  96. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_export.py +0 -0
  97. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_extras.py +0 -0
  98. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_isolated.py +0 -0
  99. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_migration.py +0 -0
  100. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_module.py +0 -0
  101. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_payload.py +0 -0
  102. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_provenance.py +0 -0
  103. {texpkg-0.2.0 → texpkg-0.2.1}/tests/test_public_api.py +0 -0
  104. {texpkg-0.2.0 → texpkg-0.2.1}/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.1
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.1"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  build = [
@@ -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
 
@@ -79,21 +79,32 @@ class Release:
79
79
  """The lines that find the payload, which come before a document's own entries (A5)."""
80
80
  return self.dist.layout.preamble(graphics=self._has_graphics(kind))
81
81
 
82
+ def fixed_preamble(self, kind: Kind) -> PreambleSlots:
83
+ """What leads each preamble slot of every document of `kind`: the layout's lines, then the entries the base kind
84
+ locks (a profile's), then the kind's own (K020)."""
85
+ base = self.dist.base_kind(kind)
86
+ slots = [self.layout_preamble(kind), *([base.locked_preamble] if base else []), kind.locked_preamble]
87
+ return PreambleSlots(
88
+ before_class=tuple(e for s in slots for e in s.before_class),
89
+ before_package=tuple(e for s in slots for e in s.before_package),
90
+ after_package=tuple(e for s in slots for e in s.after_package),
91
+ )
92
+
82
93
  def plan(self, kind: Kind, tree: Node, variant: str | None, extras: Extras = Extras()) -> Plan: # noqa: B008
83
94
  """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).
95
+ document's own settings (D10); in each preamble slot the fixed entries, then the document's own.
85
96
 
86
97
  An exposed base kind is written by the base's package; the directory's layout is this distribution's."""
87
98
  body = tree if kind.expand is None else kind.expand(tree)
88
99
  package = self.dist.owner(kind.name).package
89
- found, own = self.layout_preamble(kind), extras.preamble
100
+ found, own = self.fixed_preamble(kind), extras.preamble
90
101
  return package.plan(
91
102
  TexDocument(
92
103
  body,
93
104
  options=extras.options,
94
105
  settings=(*self.settings(kind, variant), *extras.settings),
95
106
  preamble=PreambleSlots(
96
- before_class=own.before_class,
107
+ before_class=(*found.before_class, *own.before_class),
97
108
  before_package=(*found.before_package, *own.before_package),
98
109
  after_package=(*found.after_package, *own.after_package),
99
110
  ),
@@ -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
@@ -105,7 +105,8 @@ class Kind:
105
105
 
106
106
  A document may carry what the kind allows and nothing else: `settable` (values a document sets, after `presets`,
107
107
  which every document is written with), `options` (class options) and `preamble` (entry classes). A value is a preset
108
- or settable, not both."""
108
+ or settable, not both. `locked_preamble` holds entries every document is written with, in the slots given, after
109
+ the layout's lines and before the document's own: a document may not carry an entry class that is locked."""
109
110
 
110
111
  name: str
111
112
  model: Model
@@ -120,6 +121,7 @@ class Kind:
120
121
  settable: tuple[type[Value[Any]], ...] = ()
121
122
  options: tuple[str, ...] = ()
122
123
  preamble: tuple[type[Entry], ...] = ()
124
+ locked_preamble: PreambleSlots = dataclasses.field(default_factory=PreambleSlots)
123
125
 
124
126
  def __post_init__(self) -> None:
125
127
  for field in ("presets", "migrations", "allow_raw", "variants", "settable", "options", "preamble"):
@@ -173,6 +175,7 @@ class Kind:
173
175
  """What a document may carry (D10): no name twice, real class options, nothing both written and settable."""
174
176
  self._unique("settable value", [c.__name__ for c in self.settable])
175
177
  self._unique("preamble entry", [c.__name__ for c in self.preamble])
178
+ self._check_locked()
176
179
  self._unique("option", list(self.options))
177
180
  if bad := next((o for o in self.options if not OPTION.fullmatch(o)), None):
178
181
  raise DeclarationError(
@@ -198,6 +201,34 @@ class Kind:
198
201
  hint="letters, digits, '_' and '-', e.g. stem='brand-deck' for brand-deck.tex",
199
202
  )
200
203
 
204
+ def locked_classes(self) -> tuple[str, ...]:
205
+ """The names of the entry classes the kind writes into every document."""
206
+ return tuple(type(item).__name__ for _, _, item in self.locked_preamble.items())
207
+
208
+ def _check_locked(self) -> None:
209
+ """Locked entries are typed, in a slot they fit, each class once, and not also allowed to a document (K020)."""
210
+ for slot, _, item in self.locked_preamble.items():
211
+ slots = getattr(item, "slots", ())
212
+ if slot not in slots:
213
+ raise self.locked_error(
214
+ f"{type(item).__name__} cannot sit in {slot}" if slots else "is not a typed entry",
215
+ f"a typed entry (RequireMetadata, PdfInfo, ...) in a slot it fits: {list(slots) or 'none'}",
216
+ item,
217
+ )
218
+ names = self.locked_classes()
219
+ if twice := next((n for n in names if names.count(n) > 1), None):
220
+ raise self.locked_error("is locked twice", "lock each entry class once", twice)
221
+ if both := next((c for c in self.preamble if c.__name__ in names), None):
222
+ raise self.locked_error(
223
+ "is locked and also allowed to a document", "a class is locked or allowed, not both", both.__name__
224
+ )
225
+
226
+ def locked_error(self, what: str, hint: str, entry: object) -> DeclarationError:
227
+ name = entry if isinstance(entry, str) else type(entry).__name__
228
+ return DeclarationError(
229
+ f"kind {self.name}: locked preamble entry {name} {what}", code="TP-K020", path=self.name, hint=hint
230
+ )
231
+
201
232
  def _unique(self, what: str, names: list[str]) -> None:
202
233
  if twice := next((n for n in names if names.count(n) > 1), None):
203
234
  raise DeclarationError(
@@ -347,6 +378,15 @@ class Distribution:
347
378
  )
348
379
  if problem := self.expansion_problem(kind, kind.example()):
349
380
  raise self._bad_expansion(kind, problem)
381
+ base = offered[kind.over]
382
+ if clash := next((n for n in kind.locked_classes() if n in base.locked_classes()), None):
383
+ raise kind.locked_error(
384
+ "is locked by the base kind too", "the base kind's locked entries are written first", clash
385
+ )
386
+ if clash := next((c for c in kind.preamble if c.__name__ in base.locked_classes()), None):
387
+ raise kind.locked_error(
388
+ "is locked by the base kind", "a profile cannot allow what its base locks", clash.__name__
389
+ )
350
390
 
351
391
  def expansion_problem(self, kind: Kind, tree: Node) -> str | None:
352
392
  """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]]:
@@ -73,7 +73,7 @@ def pdm_build_update_files(context: Context, files: dict[str, pathlib.Path]) ->
73
73
  loaded = load_declaration(f"{module}:{attr}", version=version)
74
74
  checks.module_satisfies_the_protocols(loaded)
75
75
  contracts, held = checks.contract_gate(context.root, dist, loaded._bound.RELEASE, version) # pyright: ignore[reportAttributeAccessIssue]
76
- module_files = generate.sources(dist, attr, version, contracts)
76
+ module_files = generate.sources(dist, f"{module}:{attr}", version, contracts)
77
77
  files.clear() # a wheel holds the generated module, not the author's sources
78
78
  embed.embed(
79
79
  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,6 +6,7 @@ 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.preamble import PassOption, PreambleSlots, RequireMetadata
9
10
  from texmodel.types import Length
10
11
 
11
12
  from texpkg import Asset, Distribution, Kind, Role
@@ -27,7 +28,14 @@ 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
+ locked_preamble=PreambleSlots(before_class=(RequireMetadata(pdfversion="2.0"),)), # every slide has it
38
+ ),
31
39
  Kind(
32
40
  "brief",
33
41
  Brief,
@@ -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,23 @@ 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_kind_locks_entries_of_classes_it_does_not_also_allow():
276
+ locked = PreambleSlots(before_class=(PassOption("xcolor", "table"),), before_package=(PassOption("a", "b"),))
277
+ assert code_of(dataclasses.replace, dist.kind("expert"), locked_preamble=locked) == "TP-K020" # twice, in two slots
278
+ kind = dataclasses.replace(dist.kind("expert"), locked_preamble=PreambleSlots(before_class=locked.before_class))
279
+ assert kind.locked_classes() == ("PassOption",) and dist.kind("expert").locked_classes() == ()
280
+
281
+
271
282
  def test_a_settable_value_is_registered_by_the_package_that_reads_it_back():
272
283
  other = Package("other", prefix="ot", version="1.0.0", date="2026/10/22", description="Another")
273
284
 
@@ -165,6 +165,31 @@ def test_generate_check_reports_drift_and_generate_repairs_it(project):
165
165
  assert ".times(1, 4)" in (project / "src/acme_series/_kinds/short.py").read_text()
166
166
 
167
167
 
168
+ def test_the_declaration_may_be_a_module_of_its_package_and_the_package_import_free(project, tmp_path):
169
+ package = project / "src/acme_series"
170
+ (package / "declaration.py").write_text((package / "__init__.py").read_text())
171
+ (package / "__init__.py").write_text("")
172
+ pyproject = project / "pyproject.toml"
173
+ pyproject.write_text(pyproject.read_text().replace("acme_series:dist", "acme_series.declaration:dist"))
174
+ assert run_cli(project, "--check").exit_code == 0
175
+ change_data(project)
176
+ assert run_cli(project, "--check").exit_code == 1 and run_cli(project).exit_code == 0
177
+ assert "acme_series_tex/kinds/short.py" in projects.names(projects.wheel(project, tmp_path / "dist"))
178
+
179
+
180
+ def test_a_base_no_module_holds_is_refused_by_name(tmp_path):
181
+ import dataclasses # noqa: PLC0415
182
+
183
+ import acme_brand # noqa: PLC0415
184
+
185
+ from texpkg_build import testing # noqa: PLC0415
186
+
187
+ copy = dataclasses.replace(acme_brand.dist) # equal, but no module holds this one
188
+ with pytest.raises(DeclarationError) as raised:
189
+ testing._pointer_of(copy) # noqa: SLF001
190
+ assert raised.value.code == "TP-B003" and "acme-brand-tex" in str(raised.value)
191
+
192
+
168
193
  def test_generate_reports_a_bad_pointer_and_a_failing_generator(project):
169
194
  pyproject = project / "pyproject.toml"
170
195
  pyproject.write_text(pyproject.read_text().replace('generate = "acme_series_gen:sources"', 'generate = "nothing"'))
@@ -12,10 +12,11 @@ from hypothesis import (
12
12
  given,
13
13
  strategies as st,
14
14
  )
15
+ from texmodel.preamble import PassOption, PreambleSlots, RequireMetadata
15
16
  from texmodel.types import CMYK
16
17
 
17
18
  from texpkg import Design, Kind
18
- from texpkg.errors import DeclarationError
19
+ from texpkg.errors import DeclarationError, MigrationError, NotAllowedError
19
20
  from texpkg_build._internal import checks
20
21
  from texpkg_build.testing import check_expansion, load_declaration
21
22
 
@@ -205,3 +206,86 @@ def test_a_profile_kind_is_declared_whole(deck):
205
206
  with pytest.raises(DeclarationError) as raised:
206
207
  dataclasses.replace(deck_dist, kinds=(dataclasses.replace(brief, **changes),))
207
208
  assert raised.value.code == code
209
+
210
+
211
+ # -- locked preamble entries (K020) ----------------------------------------------------------------------------------
212
+
213
+ LOCK = PreambleSlots(before_class=(RequireMetadata(lang="en"),))
214
+ BASE_LOCK = PreambleSlots(before_class=(PassOption("graphicx", "draft"),)) # what a base kind locks
215
+ OWN = PreambleSlots(before_class=(PassOption("xcolor", "table"),))
216
+
217
+
218
+ def test_a_kind_writes_its_locked_entries_before_the_documents_own(deck, tmp_path):
219
+ slide = deck.kinds.modules()["slide"]
220
+ lines = slide.create(tmp_path, slide.example(), preamble=OWN).render().splitlines()
221
+ wanted = ["\\DocumentMetadata{pdfversion=2.0}", "\\PassOptionsToPackage{table}{xcolor}"]
222
+ at = [lines.index(line) for line in wanted]
223
+ assert at == sorted(at) and at[1] < next(i for i, line in enumerate(lines) if "\\documentclass" in line)
224
+
225
+
226
+ def test_a_document_may_not_carry_an_entry_class_the_kind_locks(deck, tmp_path):
227
+ slide = deck.kinds.modules()["slide"]
228
+ with pytest.raises(NotAllowedError) as raised:
229
+ slide.create(tmp_path, slide.example(), preamble=LOCK)
230
+ (item,) = raised.value.items
231
+ assert item["path"] == "/preamble/before_class/0" and "locked by kind slide" in item["reason"]
232
+
233
+
234
+ def test_locked_entries_are_in_no_documents_data_and_a_stored_one_is_unresolved(deck, tmp_path):
235
+ slide = deck.kinds.modules()["slide"]
236
+ slide.create(tmp_path, slide.example(), preamble=OWN).save()
237
+ path = tmp_path / "_texpkg/state.json"
238
+ stored = json.loads(path.read_text())
239
+ assert [e["type"] for e in stored["preamble"]["before_class"]] == ["PassOption"] # not the lock
240
+ stored["preamble"]["before_class"].append({"type": "RequireMetadata", "entries": {"lang": "en"}})
241
+ path.write_text(json.dumps(stored))
242
+ with pytest.raises(MigrationError) as raised:
243
+ slide.load(tmp_path)
244
+ assert raised.value.code == "TP-M003" and "locked by kind slide" in str(raised.value)
245
+
246
+
247
+ def test_a_hand_edit_of_the_documents_own_entry_is_found_at_its_data_path(deck, tmp_path):
248
+ slide = deck.kinds.modules()["slide"]
249
+ doc = slide.create(tmp_path, slide.example(), preamble=OWN)
250
+ doc.save()
251
+ tex = tmp_path / "slide.tex"
252
+ tex.write_text(
253
+ tex.read_text().replace("{table}{xcolor}", "{x11names}{xcolor}").replace("pdfversion=2.0", "pdfversion=1.7")
254
+ )
255
+ paths = {e.path for e in doc.hand_edits()}
256
+ assert paths == {"/preamble/before_class/0", "preamble.before_class[0]"} # the own entry, and the locked line
257
+
258
+
259
+ @pytest.mark.tex
260
+ @pytest.mark.timeout(300)
261
+ def test_a_document_with_a_locked_entry_compiles(deck, compiler, tmp_path):
262
+ slide = deck.kinds.modules()["slide"]
263
+ slide.create(tmp_path / "doc", slide.example(), preamble=OWN).save()
264
+ compiler.compile(tmp_path / "doc/slide.tex", tmp_path / "out").raise_for_status()
265
+
266
+
267
+ def locked_deck(deck, **changes): # noqa: ANN001, ANN003, ANN201
268
+ """The deck declaration over a brand whose `expert` kind locks `LOCK`; `changes` go to its `brief` profile."""
269
+ import acme_deck # noqa: PLC0415 # importable once the `deck` fixture has built the brand
270
+
271
+ expert = dataclasses.replace(acme_brand.dist.kinds[0], locked_preamble=BASE_LOCK)
272
+ base = dataclasses.replace(acme_brand.dist, kinds=(expert, *acme_brand.dist.kinds[1:]))
273
+ brief = dataclasses.replace(acme_deck.dist.kind("brief"), **changes)
274
+ return dataclasses.replace(acme_deck.dist, base=base, kinds=(acme_deck.dist.kinds[0], brief))
275
+
276
+
277
+ def test_a_profile_writes_the_locked_entries_of_its_base_kind(deck):
278
+ wide = locked_deck(deck)
279
+ release = dataclasses.replace(deck._bound.RELEASE, dist=wide) # noqa: SLF001
280
+ brief = wide.kind("brief")
281
+ text = release.plan(brief, brief.example(), None).text
282
+ assert text.index("\\PassOptionsToPackage{draft}{graphicx}") < text.index("\\documentclass")
283
+
284
+
285
+ def test_a_profile_may_not_lock_or_allow_what_its_base_kind_locks(deck):
286
+ with pytest.raises(DeclarationError) as raised:
287
+ locked_deck(deck, locked_preamble=BASE_LOCK)
288
+ assert raised.value.code == "TP-K020" and "locked by the base kind too" in str(raised.value)
289
+ with pytest.raises(DeclarationError) as raised:
290
+ locked_deck(deck, preamble=(PassOption,))
291
+ assert raised.value.code == "TP-K020" and "locked by the base kind" in str(raised.value)
@@ -64,7 +64,7 @@ nothing_settable = vocab.create("v", vocab.example(), settings=[expert.Ink(CMYK(
64
64
  def wheel_like(tmp_path_factory: pytest.TempPathFactory) -> pathlib.Path:
65
65
  root = tmp_path_factory.mktemp("typing")
66
66
  module = root / dist.module
67
- for path, text in generate.sources(dist, "dist", "1.2.0", Contracts()).items():
67
+ for path, text in generate.sources(dist, "acme_brand:dist", "1.2.0", Contracts()).items():
68
68
  (module / path).parent.mkdir(parents=True, exist_ok=True)
69
69
  (module / path).write_text(text, encoding="utf-8")
70
70
  shutil.copytree(FIXTURES / "acme_brand", module / "_declaration", ignore=shutil.ignore_patterns("__pycache__"))
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