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.
@@ -0,0 +1,86 @@
1
+ """The build imports the author's declaration into the process it runs in, and leaves no trace of it (B2).
2
+
3
+ While it is imported, the author's source is on `sys.path`, the modules of the declaration and of the generated module
4
+ are the build's own copies (a copy that is already loaded is put aside and restored), and
5
+ `<base>._embedded.texmodel` and
6
+ `.texpkg` are the modules this process runs on, as the wheel's single copy will be.
7
+ """
8
+
9
+ import contextlib
10
+ import importlib
11
+ import importlib.abc
12
+ import importlib.machinery
13
+ import importlib.util
14
+ import pathlib
15
+ import sys
16
+ from collections.abc import Generator
17
+ from types import ModuleType
18
+
19
+ from . import embed
20
+
21
+
22
+ class LinkRunTime(importlib.abc.MetaPathFinder, importlib.abc.Loader):
23
+ """Static linking at build time: an installed base carries its own private texmodel and texpkg, but the declaration
24
+ being built must use classes of one texmodel. While it is imported, `<base>._embedded.texmodel` and `.texpkg` are
25
+ the
26
+ modules this build runs on, as the wheel's single copy will be."""
27
+
28
+ def find_spec(
29
+ self, fullname: str, path: object = None, target: object = None
30
+ ) -> importlib.machinery.ModuleSpec | None:
31
+ parts = fullname.split(".")
32
+ if len(parts) > 2 and parts[1] == embed.EMBEDDED and parts[2] in embed.RUN_TIME:
33
+ return importlib.util.spec_from_loader(fullname, self)
34
+ return None
35
+
36
+ def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType:
37
+ return importlib.import_module(spec.name.split(f".{embed.EMBEDDED}.", 1)[1])
38
+
39
+ def exec_module(self, module: ModuleType) -> None:
40
+ """The module is the build's own and already runs."""
41
+
42
+
43
+ class Isolation:
44
+ """The modules of the author's declaration and of the module generated from it, imported for the build and forgotten
45
+ after: the build runs in the author's environment, which need not install the project, and a build must not see (or
46
+ leave behind) another copy of them already imported in the process."""
47
+
48
+ def __init__(self) -> None:
49
+ self._tops: set[str] = set()
50
+ self._stashed: dict[str, ModuleType] = {}
51
+
52
+ def forget(self, top: str) -> None:
53
+ """Take the modules of the package `top` out of the way until the build is over."""
54
+ self._tops.add(top)
55
+ self._stashed |= {n: sys.modules.pop(n) for n in list(sys.modules) if n.split(".")[0] == top}
56
+
57
+ def drop(self, top: str) -> None:
58
+ """Forget what is imported under `top` from now on, keeping what is imported now."""
59
+ self._tops.add(top)
60
+
61
+ def restore(self) -> None:
62
+ for name in [n for n in sys.modules if n.split(".")[0] in self._tops]:
63
+ del sys.modules[name]
64
+ sys.modules.update(self._stashed)
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def author_source(source: pathlib.Path) -> Generator[Isolation]:
69
+ """`source` on `sys.path` for the block, and the modules that were forgotten and imported put right afterwards.
70
+
71
+ Importing `<base>._embedded.texpkg.migration` through the finder binds `migration` on the real `texpkg` package,
72
+ over the function of that name it exports; the packages' namespaces are put back too."""
73
+ where = str(source)
74
+ sys.path.insert(0, where)
75
+ linker = LinkRunTime()
76
+ sys.meta_path.insert(0, linker)
77
+ linked = {name: dict(vars(sys.modules[name])) for name in embed.RUN_TIME if name in sys.modules}
78
+ isolation = Isolation()
79
+ try:
80
+ yield isolation
81
+ finally:
82
+ sys.meta_path.remove(linker)
83
+ sys.path.remove(where)
84
+ isolation.restore()
85
+ for name, namespace in linked.items():
86
+ vars(sys.modules[name]).update(namespace)
@@ -0,0 +1,319 @@
1
+ """Contract snapshots, the versions file and freezing (V11, V35-V37).
2
+
3
+ `contracts/next/` is the working snapshot an author reviews and commits; `contracts/<release>/` is a frozen copy that is
4
+ never rewritten and ships in the wheel. Nothing here needs TeX: the output fingerprint is the hash of what is rendered.
5
+ """
6
+
7
+ import dataclasses
8
+ import hashlib
9
+ import importlib
10
+ import json
11
+ import pathlib
12
+ import re
13
+ import shutil
14
+ from collections.abc import Mapping
15
+ from typing import Any
16
+
17
+ from texmodel.components import Command, Environment, Value, control_name
18
+ from texmodel.errors import DeclarationError as TexmodelDeclarationError
19
+ from texmodel.interface import Body
20
+
21
+ from texpkg import errors
22
+ from texpkg._internal import classify, state
23
+ from texpkg._internal.release import Release
24
+ from texpkg._internal.versions import Versions
25
+ from texpkg.declaration import Distribution
26
+
27
+ from . import generate
28
+
29
+ CONTRACTS = "contracts"
30
+ NEXT = "next"
31
+ VERSIONS = "versions.toml"
32
+ _RELEASE = re.compile(r"\d+\.\d+\.\d+")
33
+
34
+
35
+ def _dumps(value: object) -> str:
36
+ return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
37
+
38
+
39
+ def _sha(data: str | bytes) -> str:
40
+ return hashlib.sha256(data.encode("utf-8") if isinstance(data, str) else data).hexdigest()
41
+
42
+
43
+ def _body(body: Body) -> str:
44
+ """What an argument holds, without a `Choice`'s members (they belong to the data contract, not the interface)."""
45
+ return body.kind.value + (f" {body.choice.__name__}" if body.choice else "")
46
+
47
+
48
+ def _component(cls: type) -> dict[str, Any]:
49
+ plain: type = cls # `cls` is narrowed below; the name lookups want the declared type
50
+ entry: dict[str, Any] = {"doc": str(getattr(cls, "__tm_doc__", ""))}
51
+ if issubclass(cls, Command | Environment):
52
+ entry["kind"] = "environment" if issubclass(cls, Environment) else "command"
53
+ entry["signature"] = [[a.name, _body(a.body), a.optional] for a in cls.__tm_args__]
54
+ elif issubclass(cls, Value):
55
+ entry["kind"] = "value"
56
+ entry["type"] = cls.__tm_kind__.name
57
+ try:
58
+ entry["cs"] = control_name(plain)
59
+ except TexmodelDeclarationError:
60
+ entry["cs"] = str(getattr(plain, "colour_name", None) or "") # a colour is named, not a control sequence
61
+ return entry
62
+
63
+
64
+ def interface_of(dist: Distribution) -> dict[str, Any]:
65
+ """The public TeX names with their signatures, and the kinds with their variants (V11): from the declaration."""
66
+ components = {cls.__name__: _component(cls) for cls in dist.package.registered}
67
+ kinds = {
68
+ k.name: {"variants": [v.name for v in k.variants], "over": k.over or "", "exposed": k.name in dist.expose}
69
+ for k in dist.kinds_offered()
70
+ }
71
+ return {"components": components, "kinds": kinds}
72
+
73
+
74
+ def build(dist: Distribution, release: Release, sty: Mapping[str, str]) -> dict[str, str]:
75
+ """Every file of a snapshot except the versions file, by path: both schemas of every kind, the interface, each
76
+ example's data and rendered `.tex`, and the fingerprint of the output (one hash per file, V35)."""
77
+ files: dict[str, str] = {"interface.json": _dumps(interface_of(dist))}
78
+ examples: dict[str, str] = {}
79
+ for kind in dist.kinds_offered():
80
+ files[f"kinds/{kind.name}.input.schema.json"] = _dumps(state.schema(kind.model, "validation"))
81
+ files[f"kinds/{kind.name}.stored.schema.json"] = _dumps(state.schema(kind.model, "serialization"))
82
+ tree = kind.example()
83
+ files[f"examples/{kind.name}.json"] = _dumps(state.tree_state(tree))
84
+ for variant, name in kind.documents():
85
+ tex = release.plan(kind, tree, variant).text
86
+ files[f"examples/{kind.name}/{name}"] = tex
87
+ examples[f"{kind.name}/{name}"] = _sha(tex)
88
+ output = {
89
+ "sty": {name: _sha(text) for name, text in sorted(sty.items())},
90
+ "assets": {r.asset.path: _sha(r.source.read_bytes()) for r in dist.resolved_assets()},
91
+ "examples": examples,
92
+ }
93
+ files["output.json"] = _dumps(output)
94
+ return files
95
+
96
+
97
+ def frozen(root: pathlib.Path) -> list[pathlib.Path]:
98
+ """The frozen snapshots under `root/contracts`, oldest first."""
99
+ base = root / CONTRACTS
100
+ found = [p for p in base.iterdir() if p.is_dir() and _RELEASE.fullmatch(p.name)] if base.is_dir() else []
101
+ return sorted(found, key=lambda p: classify.parse_version(p.name))
102
+
103
+
104
+ def read_versions(directory: pathlib.Path) -> Versions | None:
105
+ path = directory / VERSIONS
106
+ return Versions.parse(path.read_text(encoding="utf-8")) if path.is_file() else None
107
+
108
+
109
+ def output_changes(
110
+ previous: pathlib.Path, dist: Distribution, release: Release, sty: Mapping[str, str]
111
+ ) -> tuple[list[str], list[str]]:
112
+ """The files whose bytes changed against the previous release, and the examples that could not be compared (V35).
113
+
114
+ The previous release's example data is rendered with this package and compared with the previous release's
115
+ `.tex`, so editing an example is not a change; an example whose data no longer loads is reported, never guessed.
116
+ """
117
+ before = json.loads((previous / "output.json").read_text(encoding="utf-8"))
118
+ now = {
119
+ "sty": {n: _sha(t) for n, t in sty.items()},
120
+ "assets": {r.asset.path: _sha(r.source.read_bytes()) for r in dist.resolved_assets()},
121
+ }
122
+ changed = [
123
+ f"{part}: {name}"
124
+ for part in ("sty", "assets")
125
+ for name in sorted(before[part].keys() | now[part].keys())
126
+ if before[part].get(name) != now[part].get(name)
127
+ ]
128
+ not_compared: list[str] = []
129
+ for kind in dist.kinds_offered():
130
+ saved = previous / "examples" / f"{kind.name}.json"
131
+ if not saved.is_file():
132
+ continue # a kind that is new has no baseline
133
+ try:
134
+ tree = state.tree_from_state(kind.model, json.loads(saved.read_text(encoding="utf-8")))
135
+ except ExceptionGroup, ValueError, KeyError:
136
+ not_compared.append(kind.name)
137
+ continue
138
+ for variant, name in kind.documents():
139
+ was = before["examples"].get(f"{kind.name}/{name}")
140
+ if was is not None and was != _sha(release.plan(kind, tree, variant).text):
141
+ changed.append(f"example: {kind.name}/{name}")
142
+ return changed, not_compared
143
+
144
+
145
+ @dataclasses.dataclass(frozen=True)
146
+ class Proposal:
147
+ files: dict[str, str] # the snapshot, with the versions file as `versions.toml`
148
+ versions: Versions
149
+ data: list[classify.Change]
150
+ interface: list[classify.Change]
151
+ output: list[str]
152
+ not_compared: list[str]
153
+
154
+
155
+ def propose(
156
+ root: pathlib.Path,
157
+ dist: Distribution,
158
+ release: Release,
159
+ sty: Mapping[str, str],
160
+ *,
161
+ first: str = "0.1.0",
162
+ base_current: tuple[int, int] = (0, 0),
163
+ ) -> Proposal:
164
+ """The snapshot of this declaration and the versions it implies against the newest frozen release (V12, V13),
165
+ keeping what the author wrote in `contracts/next/versions.toml`: the `output` classification and the notes."""
166
+ files = build(dist, release, sty)
167
+ authored = read_versions(root / CONTRACTS / NEXT)
168
+ notes = dict(authored.notes) if authored else {}
169
+ change = authored.output_change if authored else ""
170
+ previous = frozen(root)
171
+ if not previous:
172
+ versions = Versions(
173
+ first,
174
+ notes=notes,
175
+ releases={1: first},
176
+ base_data={1: base_current[0]} if dist.base else {},
177
+ base_interface=authored.base_interface if authored else base_current[1],
178
+ )
179
+ return Proposal(files | {VERSIONS: versions.to_text()}, versions, [], [], [], [])
180
+ last = previous[-1]
181
+ old = read_versions(last)
182
+ assert old is not None
183
+ data_changes: list[classify.Change] = []
184
+ for kind in dist.kinds_offered():
185
+ was = last / "kinds" / f"{kind.name}.input.schema.json"
186
+ if was.is_file():
187
+ data_changes += [
188
+ dataclasses.replace(c, where=f"{kind.name}: {c.where}")
189
+ for c in classify.schema_changes(
190
+ json.loads(was.read_text(encoding="utf-8")),
191
+ json.loads(files[f"kinds/{kind.name}.input.schema.json"]),
192
+ )
193
+ ]
194
+ else:
195
+ data_changes.append(classify.Change(classify.Level.COMPATIBLE, f"kind {kind.name}", "added"))
196
+ kinds_before = {p.name.removesuffix(".input.schema.json") for p in (last / "kinds").glob("*.input.schema.json")}
197
+ data_changes += [
198
+ classify.Change(classify.Level.BREAKING, f"kind {n}", "removed")
199
+ for n in sorted(kinds_before - {k.name for k in dist.kinds_offered()})
200
+ ]
201
+ interface_changes = classify.interface_changes(
202
+ json.loads((last / "interface.json").read_text(encoding="utf-8")), json.loads(files["interface.json"])
203
+ )
204
+ changed, not_compared = output_changes(last, dist, release, sty)
205
+ data = classify.bump(old.data, classify.worst(data_changes))
206
+ interface = classify.bump(old.interface, classify.worst(interface_changes))
207
+ output = classify.bump_output(old.output, change if changed else "")
208
+ required = classify.required_release(
209
+ old.release, classify.worst(data_changes), classify.worst(interface_changes), bool(changed)
210
+ )
211
+ releases = dict(old.releases)
212
+ if data.current != old.data.current:
213
+ releases[data.current] = required
214
+ base_data = dict(old.base_data)
215
+ if dist.base is not None and data.current != old.data.current:
216
+ base_data[data.current] = base_current[0]
217
+ versions = Versions(
218
+ required,
219
+ data,
220
+ interface,
221
+ output,
222
+ change if changed else "",
223
+ notes,
224
+ releases,
225
+ base_data,
226
+ authored.base_interface if authored and authored.base_interface else old.base_interface,
227
+ )
228
+ return Proposal(
229
+ files | {VERSIONS: versions.to_text()}, versions, data_changes, interface_changes, changed, not_compared
230
+ )
231
+
232
+
233
+ def write_next(root: pathlib.Path, files: Mapping[str, str]) -> None:
234
+ """Write the working snapshot, replacing the previous one."""
235
+ target = root / CONTRACTS / NEXT
236
+ shutil.rmtree(target, ignore_errors=True)
237
+ for rel, text in files.items():
238
+ (target / rel).parent.mkdir(parents=True, exist_ok=True)
239
+ (target / rel).write_text(text, encoding="utf-8")
240
+
241
+
242
+ def stale(root: pathlib.Path, files: Mapping[str, str]) -> list[str]:
243
+ """The files of `contracts/next` that are not what this declaration gives (V12): none means it is current."""
244
+ target = root / CONTRACTS / NEXT
245
+ held: set[str] = (
246
+ {p.relative_to(target).as_posix() for p in target.rglob("*") if p.is_file()} if target.is_dir() else set()
247
+ )
248
+ return sorted(
249
+ rel
250
+ for rel in held | files.keys()
251
+ if not (target / rel).is_file() or rel not in files or (target / rel).read_text(encoding="utf-8") != files[rel]
252
+ )
253
+
254
+
255
+ def freeze(root: pathlib.Path) -> pathlib.Path:
256
+ """Copy `contracts/next` to `contracts/<release>`, which is never rewritten (V37)."""
257
+ versions = read_versions(root / CONTRACTS / NEXT)
258
+ if versions is None:
259
+ raise errors.DeclarationError("no contracts/next to freeze", code="TP-C001", hint="run texpkg-build contracts")
260
+ target = root / CONTRACTS / versions.release
261
+ if target.exists():
262
+ raise errors.DeclarationError(
263
+ f"contracts/{versions.release} is frozen already",
264
+ code="TP-C002",
265
+ path=str(target),
266
+ hint="a frozen snapshot is never rewritten: the next release has another number",
267
+ )
268
+ shutil.copytree(root / CONTRACTS / NEXT, target)
269
+ return target
270
+
271
+
272
+ def sty_of(dist: Distribution) -> dict[str, str]:
273
+ """The `.sty` files the output fingerprint is taken from: the base chain's as the bases shipped them, this package's
274
+ rendered at a fixed version so that a release number is not an output change."""
275
+ shipped: dict[str, str] = {}
276
+ if dist.base is not None:
277
+ shipped = dict(importlib.import_module(dist.base.module)._bound.STY) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownArgumentType]
278
+ return {**shipped, **generate.render_sty(dist, "0.0.0")}
279
+
280
+
281
+ def base_current(dist: Distribution) -> tuple[int, int]:
282
+ """The `data` and `interface` `current` of the base this is built on (P30, V17); zeros without one."""
283
+ if dist.base is None:
284
+ return (0, 0)
285
+ contracts = importlib.import_module(dist.base.module).CONTRACTS # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
286
+ return (contracts.data.current, contracts.interface.current) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
287
+
288
+
289
+ def required_for(previous: pathlib.Path | None, this: pathlib.Path) -> str:
290
+ """The release number the frozen `this` needs against the one before it (V13); the first needs none."""
291
+ after = read_versions(this)
292
+ assert after is not None
293
+ if previous is None:
294
+ return after.release
295
+ before = read_versions(previous)
296
+ assert before is not None
297
+ data: list[classify.Change] = []
298
+ for path in sorted((this / "kinds").glob("*.input.schema.json")):
299
+ old = previous / "kinds" / path.name
300
+ data += (
301
+ classify.schema_changes(
302
+ json.loads(old.read_text(encoding="utf-8")), json.loads(path.read_text(encoding="utf-8"))
303
+ )
304
+ if old.is_file()
305
+ else [classify.Change(classify.Level.COMPATIBLE, path.name, "added")]
306
+ )
307
+ data += [
308
+ classify.Change(classify.Level.BREAKING, path.name, "removed")
309
+ for path in (previous / "kinds").glob("*.input.schema.json")
310
+ if not (this / "kinds" / path.name).is_file()
311
+ ]
312
+ interface = classify.interface_changes(
313
+ json.loads((previous / "interface.json").read_text(encoding="utf-8")),
314
+ json.loads((this / "interface.json").read_text(encoding="utf-8")),
315
+ )
316
+ changed = (previous / "output.json").read_text(encoding="utf-8") != (this / "output.json").read_text(
317
+ encoding="utf-8"
318
+ )
319
+ return classify.required_release(before.release, classify.worst(data), classify.worst(interface), changed)
texpkg_build/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ """`texpkg-build` (V12, V37). The only module of the build that prints."""
2
+
3
+ import importlib
4
+ import pathlib
5
+ import tomllib
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from texpkg.declaration import Distribution
11
+ from texpkg.errors import TexpkgError
12
+
13
+ from ._internal import snapshot
14
+ from ._internal.isolate import author_source
15
+ from .testing import load_declaration
16
+
17
+ app = typer.Typer(pretty_exceptions_enable=False, help="Build tools for texpkg packages.", no_args_is_help=True)
18
+
19
+
20
+ def _project(root: pathlib.Path) -> tuple[str, pathlib.Path]:
21
+ """The pointer in `[tool.texpkg] distribution` and the directory the project's source is in."""
22
+ config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
23
+ pointer = str(config.get("tool", {}).get("texpkg", {}).get("distribution", ""))
24
+ if not all(pointer.partition(":")[::2]):
25
+ raise typer.BadParameter(f"{root / 'pyproject.toml'}: [tool.texpkg] distribution is not module:attribute")
26
+ return pointer, root / config.get("tool", {}).get("pdm", {}).get("build", {}).get("package-dir", ".")
27
+
28
+
29
+ @app.callback()
30
+ def main() -> None:
31
+ """Build tools for texpkg packages."""
32
+
33
+
34
+ @app.command()
35
+ def contracts(
36
+ root: Annotated[pathlib.Path, typer.Option(help="The project directory.")] = pathlib.Path(),
37
+ freeze: Annotated[bool, typer.Option(help="Freeze contracts/next as contracts/<release>.")] = False,
38
+ first: Annotated[str, typer.Option(help="The release number of a first snapshot.")] = "0.1.0",
39
+ ) -> None:
40
+ """Write contracts/next: the snapshot of the declaration and the versions it implies against the newest frozen
41
+ release. Review the files; with --freeze, keep them as the release."""
42
+ root = root.resolve()
43
+ pointer, source = _project(root)
44
+ module, _, attr = pointer.partition(":")
45
+ try:
46
+ with author_source(source) as isolation:
47
+ isolation.forget(module.split(".")[0])
48
+ dist = getattr(importlib.import_module(module), attr)
49
+ if not isinstance(dist, Distribution):
50
+ raise typer.BadParameter(f"{pointer} is not a Distribution")
51
+ isolation.forget(dist.module)
52
+ for base in dist.chain()[1:]:
53
+ isolation.drop(base.module)
54
+ release = load_declaration(pointer, version="0.0.0")._bound.RELEASE # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
55
+ proposal = snapshot.propose(
56
+ root,
57
+ dist,
58
+ release, # pyright: ignore[reportUnknownArgumentType]
59
+ snapshot.sty_of(dist),
60
+ first=first,
61
+ base_current=snapshot.base_current(dist),
62
+ )
63
+ snapshot.write_next(root, proposal.files)
64
+ for label, found in (("data", proposal.data), ("interface", proposal.interface)):
65
+ for change in found:
66
+ typer.echo(f"{label}: {change.level.name.lower()}: {change.where}: {change.what}")
67
+ for name in proposal.output:
68
+ typer.echo(f"output: changed: {name}")
69
+ for name in proposal.not_compared:
70
+ typer.echo(f"output: not compared: {name} (its example data no longer loads)")
71
+ v = proposal.versions
72
+ typer.echo(
73
+ f"release {v.release}: data {v.data.current}:{v.data.revision}:{v.data.age} "
74
+ f"interface {v.interface.current}:{v.interface.revision}:{v.interface.age} "
75
+ f"output {v.output.current}:{v.output.revision}"
76
+ )
77
+ if proposal.output and not v.output_change:
78
+ typer.echo('output changed: say how in contracts/next/versions.toml, change = "current" or "revision"')
79
+ if freeze:
80
+ typer.echo(f"frozen: {snapshot.freeze(root)}")
81
+ except TexpkgError as exc:
82
+ typer.echo(f"error {exc.code}: {exc}" + (f"\n hint: {exc.hint}" if exc.hint else ""), err=True)
83
+ raise typer.Exit(1) from exc
texpkg_build/hook.py ADDED
@@ -0,0 +1,76 @@
1
+ """The pdm build hook (B2): `pdm_build.py` re-exports these two functions.
2
+
3
+ ```python
4
+ from texpkg_build.hook import pdm_build_initialize, pdm_build_update_files
5
+ ```
6
+
7
+ `[tool.texpkg] distribution = "module:attribute"` names the declaration (B3). The wheel holds the generated module and
8
+ the declaration copied into it; the sdist holds the author's sources and builds the same wheel (B6).
9
+ """
10
+
11
+ import importlib
12
+ import pathlib
13
+
14
+ from pdm.backend.hooks import Context
15
+
16
+ from texpkg.declaration import Distribution
17
+ from texpkg.errors import DeclarationError
18
+
19
+ from ._internal import checks, embed, generate
20
+ from ._internal.isolate import author_source
21
+ from .testing import load_declaration
22
+
23
+ PYDANTIC = "pydantic>=2,<3" # the one thing a wheel cannot embed: it is compiled (B7)
24
+
25
+
26
+ def _pointer(context: Context) -> tuple[str, str]:
27
+ given = context.config.data.get("tool", {}).get("texpkg", {}).get("distribution")
28
+ module, _, attr = str(given).partition(":")
29
+ if not (module and attr):
30
+ raise DeclarationError(
31
+ f"{context.root / 'pyproject.toml'}: [tool.texpkg] distribution is {given!r}",
32
+ code="TP-B007",
33
+ hint='write distribution = "module:attribute", the Distribution the build generates the module from',
34
+ )
35
+ return module, attr
36
+
37
+
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)."""
40
+ metadata = context.config.metadata
41
+ metadata["dependencies"] = [PYDANTIC]
42
+ if "dynamic" in metadata and "dependencies" in metadata["dynamic"]:
43
+ metadata["dynamic"].remove("dependencies")
44
+
45
+
46
+ def pdm_build_update_files(context: Context, files: dict[str, pathlib.Path]) -> None:
47
+ if context.target == "sdist":
48
+ return
49
+ 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
+ )
55
+ module, attr = _pointer(context)
56
+ with author_source(context.root / (context.config.build_config.package_dir or ".")) as isolation:
57
+ isolation.forget(module.split(".")[0])
58
+ dist = getattr(importlib.import_module(module), attr, None)
59
+ if not isinstance(dist, Distribution):
60
+ raise DeclarationError(f"{module}:{attr} is not a Distribution", code="TP-B003", path=f"{module}:{attr}")
61
+ isolation.forget(dist.module)
62
+ for base in dist.chain()[1:]:
63
+ isolation.drop(base.module)
64
+ declaration = checks.declaration_directory(dist)
65
+ checks.assets_are_there(dist)
66
+ checks.tex_names_are_unique(dist)
67
+ checks.design_is_valid(dist)
68
+ version = str(context.config.metadata["version"])
69
+ loaded = load_declaration(f"{module}:{attr}", version=version)
70
+ checks.module_satisfies_the_protocols(loaded)
71
+ contracts, held = checks.contract_gate(context.root, dist, loaded._bound.RELEASE, version) # pyright: ignore[reportAttributeAccessIssue]
72
+ generated = generate.sources(dist, attr, version, contracts)
73
+ files.clear() # a wheel holds the generated module, not the author's sources
74
+ embed.embed(
75
+ context.build_dir / dist.module, dist, declaration, generated, held
76
+ ) # not ensure_build_dir(): its .gitignore
texpkg_build/py.typed ADDED
File without changes