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/__init__.py +18 -0
- texpkg/_internal/__init__.py +1 -0
- texpkg/_internal/adopt.py +119 -0
- texpkg/_internal/classify.py +190 -0
- texpkg/_internal/directory.py +48 -0
- texpkg/_internal/envelope.py +37 -0
- texpkg/_internal/export.py +49 -0
- texpkg/_internal/handedits.py +33 -0
- texpkg/_internal/history.py +91 -0
- texpkg/_internal/payload.py +118 -0
- texpkg/_internal/provenance_tex.py +65 -0
- texpkg/_internal/release.py +257 -0
- texpkg/_internal/snapshots.py +61 -0
- texpkg/_internal/state.py +113 -0
- texpkg/_internal/versions.py +61 -0
- texpkg/assets.py +56 -0
- texpkg/contracts.py +46 -0
- texpkg/declaration.py +379 -0
- texpkg/document.py +225 -0
- texpkg/errors.py +66 -0
- texpkg/layout.py +48 -0
- texpkg/migration.py +73 -0
- texpkg/protocol.py +61 -0
- texpkg/provenance.py +61 -0
- texpkg/py.typed +0 -0
- texpkg-0.1.0.dist-info/METADATA +30 -0
- texpkg-0.1.0.dist-info/RECORD +41 -0
- texpkg-0.1.0.dist-info/WHEEL +4 -0
- texpkg-0.1.0.dist-info/entry_points.txt +5 -0
- texpkg-0.1.0.dist-info/licenses/LICENSE +21 -0
- texpkg_build/__init__.py +1 -0
- texpkg_build/_internal/__init__.py +1 -0
- texpkg_build/_internal/checks.py +154 -0
- texpkg_build/_internal/embed.py +254 -0
- texpkg_build/_internal/generate.py +254 -0
- texpkg_build/_internal/isolate.py +86 -0
- texpkg_build/_internal/snapshot.py +319 -0
- texpkg_build/cli.py +83 -0
- texpkg_build/hook.py +76 -0
- texpkg_build/py.typed +0 -0
- texpkg_build/testing.py +176 -0
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("/")))
|