texpkg 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
texpkg/assets.py ADDED
@@ -0,0 +1,56 @@
1
+ """Assets and their roles (A1): the files the TeX needs that texmodel does not render."""
2
+
3
+ import dataclasses
4
+ import enum
5
+ import importlib.resources.abc
6
+ import pathlib
7
+ from collections.abc import Collection
8
+ from typing import NamedTuple
9
+
10
+ from .errors import DeclarationError
11
+
12
+
13
+ @enum.unique
14
+ class Role(enum.StrEnum):
15
+ """What a file is, which decides where a layout puts it (A1): never inferred from a suffix. A `StrEnum`, so one
16
+ package's value equals another's (B11)."""
17
+
18
+ PACKAGE = "package" # a `.sty`
19
+ GRAPHIC = "graphic"
20
+ FONT = "font" # luaotfload reads no search path, so a layout puts it at the document's root
21
+ BY_PATH = "by-path" # named by path in a preamble: it keeps that path
22
+ NOTE = "note" # notes, licences
23
+
24
+
25
+ @dataclasses.dataclass(frozen=True)
26
+ class Asset:
27
+ """A file the package ships. `path` is relative to the declaring package's root (`Distribution.files`); `kinds`
28
+ names the kinds that need it, none meaning every kind (A6)."""
29
+
30
+ path: str
31
+ role: Role
32
+ why: str
33
+ kinds: tuple[str, ...] = ()
34
+
35
+ def __post_init__(self) -> None:
36
+ parts = pathlib.PurePosixPath(self.path).parts
37
+ if not self.path or "\\" in self.path or pathlib.PurePosixPath(self.path).is_absolute() or ".." in parts:
38
+ raise DeclarationError(
39
+ f"asset path {self.path!r}: not a relative path inside the package",
40
+ code="TP-A001",
41
+ path=self.path,
42
+ hint="a posix path under the declaring package, without '..'",
43
+ )
44
+ object.__setattr__(self, "kinds", tuple(self.kinds))
45
+
46
+ def needed_by(self, kinds: Collection[str] | None) -> bool:
47
+ """Whether a document that is one of `kinds` (a profile kind and the base kind it expands to) needs this asset;
48
+ `None` is every kind (an export, E1)."""
49
+ return kinds is None or not self.kinds or bool(set(self.kinds) & set(kinds))
50
+
51
+
52
+ class Resolved(NamedTuple):
53
+ """An asset with the file it stands for, read with `importlib.resources` (P5)."""
54
+
55
+ asset: Asset
56
+ source: importlib.resources.abc.Traversable
texpkg/contracts.py ADDED
@@ -0,0 +1,46 @@
1
+ """Contract versions (V10): what a consumer relies on, versioned on its own, libtool's `current:revision:age`."""
2
+
3
+ import dataclasses
4
+ from collections.abc import Mapping
5
+
6
+
7
+ @dataclasses.dataclass(frozen=True)
8
+ class Triple:
9
+ """`current` numbers the contract, `revision` counts implementation changes within it, `age` how many earlier
10
+ `current`s are still supported (`current - age` ... `current`)."""
11
+
12
+ current: int
13
+ revision: int = 0
14
+ age: int = 0
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class Pair:
19
+ """`output` has no age: `current` when the appearance changed, `revision` when bytes changed and nothing visible
20
+ should have."""
21
+
22
+ current: int
23
+ revision: int = 0
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class Contracts:
28
+ """`data`: what a stored document may say. `interface`: the public TeX names and signatures. `output`: what the same
29
+ data renders to."""
30
+
31
+ data: Triple = Triple(1)
32
+ interface: Triple = Triple(1)
33
+ output: Pair = Pair(1)
34
+ base_data: tuple[tuple[int, int], ...] = () # this package's `data` version -> the base's it corresponds to (P30)
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class Changes:
39
+ """What changed between two `data` versions of a kind (V33): fields added, removed and tightened (by data path), the
40
+ author's notes, and the release that introduced each `data` version crossed."""
41
+
42
+ added: tuple[str, ...] = ()
43
+ removed: tuple[str, ...] = ()
44
+ tightened: tuple[str, ...] = ()
45
+ notes: tuple[str, ...] = ()
46
+ releases: Mapping[int, str] = dataclasses.field(default_factory=dict[int, str])
texpkg/declaration.py ADDED
@@ -0,0 +1,379 @@
1
+ """The declaration an author writes: `Distribution`, `Kind`, `Variant`, `Design` (P8, K1).
2
+
3
+ Checked when it is built, so a mistake fails at import, not at a consumer's `load`.
4
+ """
5
+
6
+ import dataclasses
7
+ import importlib.resources
8
+ import importlib.resources.abc
9
+ import inspect
10
+ import keyword
11
+ import os
12
+ import pathlib
13
+ import re
14
+ from collections.abc import Callable, Iterator
15
+ from typing import Any
16
+
17
+ import pydantic
18
+ from texmodel.components import AnyValue, Command, Environment, Node, Passthrough
19
+ from texmodel.interface import Body, BodyKind
20
+ from texmodel.package import Package
21
+
22
+ from .assets import Asset, Resolved
23
+ from .errors import DeclarationError
24
+ from .layout import LAYOUT, Project
25
+ from .migration import Migration
26
+
27
+ _STEM = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*") # a `.tex` file name without its suffix
28
+ _KIND = re.compile(r"[a-z][a-z0-9_]*") # a kind is a module, `acme_tex.kinds.<name>`
29
+ _VARIANT = re.compile(r"[a-z][a-z0-9-]*") # a variant is part of a file name
30
+ _DISTRIBUTION = re.compile(r"[a-z][a-z0-9-]*")
31
+
32
+ type Model = type[Command] | type[Environment]
33
+
34
+
35
+ @dataclasses.dataclass(frozen=True)
36
+ class Variant:
37
+ """A named set of settings applied to the same document (K6): `doc.render(variant="compact")`."""
38
+
39
+ name: str
40
+ settings: tuple[AnyValue, ...] = ()
41
+
42
+ def __post_init__(self) -> None:
43
+ if not _VARIANT.fullmatch(self.name):
44
+ raise DeclarationError(
45
+ f"variant name {self.name!r} must match [a-z][a-z0-9-]*",
46
+ code="TP-K005",
47
+ path=self.name,
48
+ hint="it is part of a file name: deck-<variant>.tex",
49
+ )
50
+ object.__setattr__(self, "settings", tuple(self.settings))
51
+
52
+
53
+ @dataclasses.dataclass(frozen=True)
54
+ class Design:
55
+ """Typed data a package ships (tokens, palettes), validated at build time and read with `importlib.resources`
56
+ (L1). `path` is relative to the declaring package."""
57
+
58
+ model: type[pydantic.BaseModel]
59
+ path: str
60
+
61
+ def load(self, root: importlib.resources.abc.Traversable) -> pydantic.BaseModel:
62
+ """The data, read from `root` (the declaring package) and validated: JSON for the model."""
63
+ text = root.joinpath(*self.path.split("/")).read_text(encoding="utf-8")
64
+ return self.model.model_validate_json(text)
65
+
66
+
67
+ def _raw(body: Body) -> bool:
68
+ return body.kind is BodyKind.RAW or any(_raw(item) for item in body.items)
69
+
70
+
71
+ def reachable(model: Model) -> tuple[type[Command] | type[Environment], ...]:
72
+ """`model` and every component its grammars name (`Passthrough` excluded), in the order they are first met."""
73
+ found: dict[type[Command] | type[Environment], None] = {model: None}
74
+ todo = [model]
75
+ while todo:
76
+ cls = todo.pop(0)
77
+ if issubclass(cls, Environment):
78
+ for term in cls.__tm_grammar__:
79
+ kind = term.kind
80
+ if kind is not Passthrough and kind not in found and issubclass(kind, Command | Environment):
81
+ found[kind] = None
82
+ todo.append(kind)
83
+ return tuple(found)
84
+
85
+
86
+ def raw_holes(model: Model) -> tuple[str, ...]:
87
+ """The places a tree of `model` can hold TeX written as it is (P25): `Class.field` for a `Raw` field and
88
+ `Class.Passthrough` for a grammar that accepts a `Passthrough`."""
89
+ holes: list[str] = []
90
+ for cls in reachable(model):
91
+ holes += [f"{cls.__name__}.{a.name}" for a in cls.__tm_args__ if _raw(a.body)]
92
+ if issubclass(cls, Environment) and any(t.kind is Passthrough for t in cls.__tm_grammar__):
93
+ holes.append(f"{cls.__name__}.Passthrough")
94
+ return tuple(holes)
95
+
96
+
97
+ @dataclasses.dataclass(frozen=True)
98
+ class Kind:
99
+ """One named document model a package offers (K1): `model` is the root class of its trees, `presets` the settings
100
+ every document of the kind is written with (L8), `example` a valid tree that exercises the whole kind (A7, V11).
101
+ Every `Raw` field and `Passthrough` the kind can hold is named in `allow_raw` (P25). `stem` names the kind's `.tex`
102
+ files (`deck` gives `deck.tex` and `deck-<variant>.tex`); left out, it is the root class's name, lower-cased."""
103
+
104
+ name: str
105
+ model: Model
106
+ presets: tuple[AnyValue, ...]
107
+ example: Callable[[], Node]
108
+ migrations: tuple[Migration, ...] = ()
109
+ allow_raw: tuple[str, ...] = ()
110
+ variants: tuple[Variant, ...] = ()
111
+ expand: Callable[[Any], Node] | None = None
112
+ over: str | None = None
113
+ stem: str = ""
114
+
115
+ def __post_init__(self) -> None:
116
+ for field in ("presets", "migrations", "allow_raw", "variants"):
117
+ object.__setattr__(self, field, tuple(getattr(self, field)))
118
+ if not _KIND.fullmatch(self.name) or keyword.iskeyword(self.name):
119
+ raise DeclarationError(
120
+ f"kind name {self.name!r} must match [a-z][a-z0-9_]* and not be a Python keyword",
121
+ code="TP-K005",
122
+ path=self.name,
123
+ hint="a kind is a module: acme_tex.kinds.<name>",
124
+ )
125
+ self._unique("variant", [v.name for v in self.variants])
126
+ object.__setattr__(self, "stem", self.stem or self.model.__name__.lower())
127
+ if not _STEM.fullmatch(self.stem):
128
+ raise DeclarationError(
129
+ f"kind {self.name}: stem {self.stem!r} is not a file name without its suffix",
130
+ code="TP-K015",
131
+ path=self.name,
132
+ hint="letters, digits, '_' and '-', e.g. stem='brand-deck' for brand-deck.tex",
133
+ )
134
+ if (self.expand is None) != (self.over is None):
135
+ raise DeclarationError(
136
+ f"kind {self.name}: expand and over go together",
137
+ code="TP-K009",
138
+ path=self.name,
139
+ hint="a profile kind names the base kind it expands to (over=) and the function that does it (expand=)",
140
+ )
141
+ holes = raw_holes(self.model)
142
+ if unknown := [a for a in self.allow_raw if a not in holes]:
143
+ raise DeclarationError(
144
+ f"kind {self.name}: allow_raw names {unknown[0]!r}, which is no Raw field or Passthrough of the kind",
145
+ code="TP-K003",
146
+ path=self.name,
147
+ hint=f"the holes are {list(holes) or 'none'}",
148
+ )
149
+ if unlisted := [h for h in holes if h not in self.allow_raw]:
150
+ raise DeclarationError(
151
+ f"kind {self.name}: {unlisted[0]} holds TeX written as it is and is not allowed",
152
+ code="TP-K003",
153
+ path=f"{self.name}.{unlisted[0]}",
154
+ hint=f"narrow the field, or name it: allow_raw=[{unlisted[0]!r}]",
155
+ )
156
+ tree = self.example()
157
+ if not isinstance(tree, self.model):
158
+ raise self._bad_example(f"is not a {self.model.__name__}")
159
+ if problems := tree.violations():
160
+ raise self._bad_example(problems[0].message)
161
+
162
+ def _bad_example(self, what: str) -> DeclarationError:
163
+ return DeclarationError(
164
+ f"kind {self.name}: example() {what}",
165
+ code="TP-K001",
166
+ path=self.name,
167
+ hint="the example is rendered for the output fingerprint and compiled by the author's tests",
168
+ )
169
+
170
+ def _unique(self, what: str, names: list[str]) -> None:
171
+ if twice := next((n for n in names if names.count(n) > 1), None):
172
+ raise DeclarationError(
173
+ f"kind {self.name}: {what} {twice!r} is declared twice", code="TP-K002", path=self.name
174
+ )
175
+
176
+ def documents(self) -> tuple[tuple[str | None, str], ...]:
177
+ """The `.tex` a save writes, as `(variant, file name)`: the kind's own, then one per variant (K7)."""
178
+ return ((None, f"{self.stem}.tex"), *((v.name, f"{self.stem}-{v.name}.tex") for v in self.variants))
179
+
180
+ def settings(self, variant: str | None = None) -> tuple[AnyValue, ...]:
181
+ """What a document of the kind is written with (L8): the presets, then the settings of `variant`, if named."""
182
+ if variant is None:
183
+ return self.presets
184
+ found = next((v for v in self.variants if v.name == variant), None)
185
+ if found is None:
186
+ raise DeclarationError(
187
+ f"kind {self.name} has no variant {variant!r}",
188
+ code="TP-K004",
189
+ path=self.name,
190
+ hint=f"its variants are {[v.name for v in self.variants]}",
191
+ )
192
+ return (*self.presets, *found.settings)
193
+
194
+
195
+ def _declared_in(name: str, root: str) -> tuple[str, str]:
196
+ """The package of the module that called `Distribution(...)` and `root` (a path, relative to that module or
197
+ absolute) made relative to it: past this function, `__post_init__` and the generated `__init__`."""
198
+ frame = inspect.currentframe()
199
+ for _ in range(3):
200
+ frame = frame.f_back if frame else None
201
+ package = frame.f_globals.get("__package__") if frame else None
202
+ if not package:
203
+ raise DeclarationError(
204
+ "the declaring module is not in a package, so the package the assets are read from is unknown",
205
+ code="TP-K012",
206
+ path=name,
207
+ hint="declare the distribution in a module of a package",
208
+ )
209
+ here = pathlib.Path(str(frame.f_globals["__file__"])).parent if frame else pathlib.Path()
210
+ if here.is_dir(): # symlinks and `..` followed, so what is checked is where the files are
211
+ here, target = here.resolve(), (here / root).resolve()
212
+ else: # a package imported from a zip has no directory to resolve
213
+ target = pathlib.Path(os.path.normpath(here / root))
214
+ if not target.is_relative_to(here):
215
+ raise DeclarationError(
216
+ f"root {root!r} is outside the package that declares {name}",
217
+ code="TP-K013",
218
+ path=name,
219
+ hint="the wheel carries the declaring package only: keep the assets inside it",
220
+ )
221
+ relative = target.relative_to(here).as_posix()
222
+ if not importlib.resources.files(str(package)).joinpath(*pathlib.PurePosixPath(relative).parts).is_dir():
223
+ raise DeclarationError(
224
+ f"root {root!r} is not a directory of the package that declares {name}",
225
+ code="TP-K014",
226
+ path=name,
227
+ hint="a path relative to the declaring module, e.g. root='data'",
228
+ )
229
+ return str(package), relative
230
+
231
+
232
+ @dataclasses.dataclass(frozen=True)
233
+ class Distribution:
234
+ """What a package author declares (B1): the texmodel `package` whose `.sty` is shipped, its `kinds`, its `assets`,
235
+ the `base` it is built on (P20) and which of the base's kinds it `expose`s (P21).
236
+
237
+ `root` is the directory an asset's `path` is relative to: a path relative to the module that calls
238
+ `Distribution(...)`, or absolute, and inside the package that holds it (the wheel carries that package, P22).
239
+ `declared_in`, the import name of that package, is read from the call and not passed.
240
+ The payload goes in a directory as `LAYOUT` places it.
241
+ """
242
+
243
+ name: str
244
+ module: str
245
+ package: Package
246
+ root: str = "."
247
+ design: Design | None = None
248
+ assets: tuple[Asset, ...] = ()
249
+ kinds: tuple[Kind, ...] = ()
250
+ base: "Distribution | None" = None
251
+ expose: tuple[str, ...] = ()
252
+ declared_in: str = ""
253
+
254
+ @property
255
+ def layout(self) -> Project:
256
+ return LAYOUT
257
+
258
+ def __post_init__(self) -> None:
259
+ if not self.declared_in: # stored, so `dataclasses.replace` keeps both
260
+ declared_in, root = _declared_in(self.name, self.root)
261
+ object.__setattr__(self, "declared_in", declared_in)
262
+ object.__setattr__(self, "root", root)
263
+ for field in ("assets", "kinds", "expose"):
264
+ object.__setattr__(self, field, tuple(getattr(self, field)))
265
+ if not _DISTRIBUTION.fullmatch(self.name):
266
+ raise DeclarationError(
267
+ f"distribution name {self.name!r} must match [a-z][a-z0-9-]*", code="TP-K006", path=self.name
268
+ )
269
+ if not self.module.isidentifier() or keyword.iskeyword(self.module):
270
+ raise DeclarationError(
271
+ f"module {self.module!r} is not a Python module name",
272
+ code="TP-K006",
273
+ path=self.name,
274
+ hint="the generated module is imported by this name",
275
+ )
276
+ names = [k.name for k in self.kinds]
277
+ if twice := next((n for n in names if names.count(n) > 1), None):
278
+ raise DeclarationError(f"kind {twice!r} is declared twice", code="TP-K002", path=self.name)
279
+ base_offers = [k.name for k in self.base.kinds_offered()] if self.base else []
280
+ if bad := next((n for n in self.expose if n not in base_offers), None):
281
+ raise DeclarationError(
282
+ f"expose names {bad!r}, which is no kind of the base",
283
+ code="TP-K007",
284
+ path=self.name,
285
+ hint=f"the base offers {base_offers}" if self.base else "a distribution without a base exposes nothing",
286
+ )
287
+ if clash := next((n for n in self.expose if n in names or self.expose.count(n) > 1), None):
288
+ raise DeclarationError(
289
+ f"kind {clash!r} is both declared and exposed from the base, or exposed twice",
290
+ code="TP-K007",
291
+ path=self.name,
292
+ )
293
+ for kind in self.kinds:
294
+ self._check_profile(kind)
295
+ if unknown := next((k for a in self.assets for k in a.kinds if k not in (*names, *self.expose)), None):
296
+ raise DeclarationError(
297
+ f"an asset names kind {unknown!r}, which {self.name} does not offer",
298
+ code="TP-K002",
299
+ path=self.name,
300
+ hint=f"the kinds are {[*names, *self.expose]}",
301
+ )
302
+
303
+ def _check_profile(self, kind: Kind) -> None:
304
+ """A profile kind expands to a kind of the base, and its example does so to a valid tree of it (L3, L7)."""
305
+ if kind.over is None or kind.expand is None:
306
+ return
307
+ offered = {k.name: k for k in (self.base.kinds_offered() if self.base else ())}
308
+ if kind.over not in offered:
309
+ raise DeclarationError(
310
+ f"kind {kind.name} expands to {kind.over!r}, which the base does not offer",
311
+ code="TP-K009",
312
+ path=kind.name,
313
+ hint=f"the base offers {list(offered) or 'no kinds'}",
314
+ )
315
+ if problem := self.expansion_problem(kind, kind.example()):
316
+ raise self._bad_expansion(kind, problem)
317
+
318
+ def expansion_problem(self, kind: Kind, tree: Node) -> str | None:
319
+ """Why `tree` of a profile kind does not expand to a valid tree of the base kind (L3); `None` if it does."""
320
+ base = self.base_kind(kind)
321
+ if base is None or kind.expand is None:
322
+ return "is not a profile kind"
323
+ try:
324
+ expanded = kind.expand(tree)
325
+ except (LookupError, ValueError, TypeError, AttributeError) as exc:
326
+ return f"cannot be expanded: {exc!r}"
327
+ if not isinstance(expanded, base.model):
328
+ return "is not a tree of the base kind"
329
+ problems = expanded.violations()
330
+ return problems[0].message if problems else None
331
+
332
+ def _bad_expansion(self, kind: Kind, what: str) -> DeclarationError:
333
+ return DeclarationError(
334
+ f"kind {kind.name}: its example expands to a tree that {what}",
335
+ code="TP-K010",
336
+ path=kind.name,
337
+ hint="every valid document of a profile must expand to a valid base document (L3)",
338
+ )
339
+
340
+ def base_kind(self, kind: Kind) -> Kind | None:
341
+ """The base kind a profile kind expands to; `None` for any other."""
342
+ return self.base.kind(kind.over) if self.base and kind.over else None
343
+
344
+ @property
345
+ def files(self) -> importlib.resources.abc.Traversable:
346
+ """The directory the assets are read from, in the declaring package."""
347
+ return importlib.resources.files(self.declared_in).joinpath(*pathlib.PurePosixPath(self.root).parts)
348
+
349
+ def chain(self) -> tuple["Distribution", ...]:
350
+ """This distribution, then its base, then the base's base (P19, P20)."""
351
+ return (self, *(self.base.chain() if self.base else ()))
352
+
353
+ def kinds_offered(self) -> tuple[Kind, ...]:
354
+ """The kinds of this distribution's module: its own, then the base's that it exposes (P21)."""
355
+ exposed = tuple(k for k in (self.base.kinds_offered() if self.base else ()) if k.name in self.expose)
356
+ return (*self.kinds, *exposed)
357
+
358
+ def owner(self, kind: str) -> "Distribution":
359
+ """The distribution whose package renders `kind`: this one, or the base that declares an exposed kind."""
360
+ if any(k.name == kind for k in self.kinds) or self.base is None:
361
+ return self
362
+ return self.base.owner(kind)
363
+
364
+ def kind(self, name: str) -> Kind:
365
+ found = next((k for k in self.kinds_offered() if k.name == name), None)
366
+ if found is None:
367
+ raise DeclarationError(
368
+ f"{self.name} has no kind {name!r}",
369
+ code="TP-K004",
370
+ path=name,
371
+ hint=f"its kinds are {[k.name for k in self.kinds_offered()]}",
372
+ )
373
+ return found
374
+
375
+ def resolved_assets(self) -> Iterator[Resolved]:
376
+ """Every asset of the chain, with the file it names: this distribution's first (P10)."""
377
+ for dist in self.chain():
378
+ for asset in dist.assets:
379
+ yield Resolved(asset, dist.files.joinpath(*asset.path.split("/")))