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,154 @@
1
+ """What the build refuses (B4): a defect fails `pdm build`, not a consumer's import."""
2
+
3
+ import importlib
4
+ import pathlib
5
+ import re
6
+ from types import ModuleType
7
+ from typing import Any
8
+
9
+ from texmodel.components import control_name
10
+ from texmodel.errors import DeclarationError as TexmodelDeclarationError
11
+
12
+ from texpkg._internal import classify, payload
13
+ from texpkg._internal.release import Release
14
+ from texpkg.contracts import Contracts
15
+ from texpkg.declaration import Distribution
16
+ from texpkg.errors import DeclarationError
17
+ from texpkg.protocol import KindModule, KindsModule, PackageModule
18
+
19
+ from . import snapshot
20
+
21
+
22
+ def declaration_directory(dist: Distribution) -> pathlib.Path:
23
+ """The directory of the declaration package, which the build copies whole (P22)."""
24
+ path = pathlib.Path(str(dist.files))
25
+ if not (path / "__init__.py").is_file():
26
+ raise DeclarationError(
27
+ f"{dist.declared_in}: the declaration must be a package (a directory with an __init__.py)",
28
+ code="TP-B004",
29
+ path=dist.declared_in,
30
+ hint="move the declaration into a package; its assets and templates live beside it",
31
+ )
32
+ return path
33
+
34
+
35
+ def assets_are_there(dist: Distribution) -> None:
36
+ """Every asset of the chain is a file (A3), and no two land on one payload path (P11, `TP-A002`)."""
37
+ if gone := [r.asset.path for r in dist.resolved_assets() if not r.source.is_file()]:
38
+ raise DeclarationError(
39
+ f"missing assets: {', '.join(gone)}",
40
+ code="TP-B005",
41
+ path=gone[0],
42
+ hint=f"they are read from the package {dist.declared_in}",
43
+ )
44
+ payload.build({f"{d.package.name}.sty": "" for d in dist.chain()}, tuple(dist.resolved_assets()), dist.layout)
45
+
46
+
47
+ def module_satisfies_the_protocols(module: ModuleType) -> None:
48
+ """The generated module has the fixed surface (P6)."""
49
+ name = module.__name__
50
+ candidate: object = module
51
+ kinds: object = getattr(module, "kinds", None)
52
+ problems = [] if isinstance(candidate, PackageModule) else ["the package module"]
53
+ if isinstance(kinds, KindsModule):
54
+ problems += [
55
+ f"kinds.{n}" for n, m in dict[str, object](kinds.modules()).items() if not isinstance(m, KindModule)
56
+ ]
57
+ else:
58
+ problems.append("kinds")
59
+ if problems:
60
+ raise DeclarationError(
61
+ f"the generated module {name} does not satisfy the protocol: {', '.join(problems)}",
62
+ code="TP-B006",
63
+ path=name,
64
+ hint="this is a texpkg defect: texpkg.protocol lists the surface",
65
+ )
66
+
67
+
68
+ def tex_names_are_unique(dist: Distribution) -> None:
69
+ """No two packages of the chain define one control sequence (P11): the later `.sty` would redefine the earlier."""
70
+ seen: dict[str, str] = {}
71
+ for member in reversed(dist.chain()):
72
+ for cls in member.package.registered:
73
+ try:
74
+ name = control_name(cls)
75
+ except TexmodelDeclarationError:
76
+ continue # a colour has no control sequence
77
+ if seen.setdefault(name, member.name) != member.name:
78
+ raise DeclarationError(
79
+ f"\\{name} is defined by {seen[name]} and by {member.name}",
80
+ code="TP-B009",
81
+ path=name,
82
+ hint="a package built on another must not reuse its names: choose another prefix or name",
83
+ )
84
+
85
+
86
+ def design_is_valid(dist: Distribution) -> None:
87
+ """The embedded data validates against its model (L1)."""
88
+ if dist.design is None:
89
+ return
90
+ try:
91
+ dist.design.load(dist.files)
92
+ except (OSError, ValueError) as exc: # a missing file, bad JSON and pydantic's ValidationError are all of these
93
+ raise DeclarationError(
94
+ f"{dist.name}: the design data {dist.design.path} is not a valid {dist.design.model.__name__}: {exc}",
95
+ code="TP-B010",
96
+ path=dist.design.path,
97
+ hint="fix the file, or the model it is validated against",
98
+ ) from exc
99
+
100
+
101
+ _RELEASE = re.compile(r"\d+\.\d+\.\d+")
102
+
103
+
104
+ def contract_gate(
105
+ root: pathlib.Path, dist: Distribution, release: Release, version: str
106
+ ) -> tuple[Contracts, list[pathlib.Path]]:
107
+ """The contract versions this build carries, and the frozen snapshots that ship (V12, V13, V17, V37). Without a
108
+ `contracts` directory a package has the first versions and ships none. A development build is held to `next`: it
109
+ must be current and an output change must be classified. A release build must have its own newest frozen
110
+ directory, and its number must not understate the contracts."""
111
+ if not (root / snapshot.CONTRACTS).is_dir():
112
+ return Contracts(), []
113
+ held = snapshot.frozen(root)
114
+ current = snapshot.base_current(dist)
115
+ proposal = snapshot.propose(root, dist, release, snapshot.sty_of(dist), base_current=current)
116
+ if _RELEASE.fullmatch(version):
117
+ if not held or held[-1].name != version:
118
+ raise DeclarationError(
119
+ f"release {version}: contracts/{version} is not the newest frozen snapshot",
120
+ code="TP-C003",
121
+ hint="freeze it first: texpkg-build contracts --freeze",
122
+ )
123
+ required = snapshot.required_for(held[-2] if len(held) > 1 else None, held[-1])
124
+ if classify.parse_version(version) < classify.parse_version(required):
125
+ raise DeclarationError(
126
+ f"release {version} understates the contracts: they need {required}",
127
+ code="TP-C004",
128
+ hint=f"tag {required} or later",
129
+ )
130
+ versions = snapshot.read_versions(held[-1])
131
+ else:
132
+ if stale := snapshot.stale(root, proposal.files):
133
+ raise DeclarationError(
134
+ f"contracts/next is not current: {', '.join(stale)}",
135
+ code="TP-C001",
136
+ hint="run texpkg-build contracts and review the files",
137
+ )
138
+ if proposal.output and not proposal.versions.output_change:
139
+ raise DeclarationError(
140
+ f"the output changed ({', '.join(proposal.output)}) and contracts/next/versions.toml does not say how",
141
+ code="TP-C005",
142
+ hint='under [output] write change = "current" (visible: proofs need approving again) or "revision"',
143
+ )
144
+ versions = snapshot.read_versions(root / snapshot.CONTRACTS / snapshot.NEXT)
145
+ assert versions is not None
146
+ if dist.base is not None and versions.base_interface:
147
+ base: Any = importlib.import_module(dist.base.module).CONTRACTS
148
+ if versions.base_interface < base.interface.current - base.interface.age:
149
+ raise DeclarationError(
150
+ f"the base no longer supports the interface {versions.base_interface} this package relies on",
151
+ code="TP-C006",
152
+ hint="adapt to the new base, then set [base] interface in contracts/next/versions.toml",
153
+ )
154
+ return versions.contracts(), held
@@ -0,0 +1,254 @@
1
+ """Copying embedded packages and rewriting their imports (P13-P19, B7, B10, 1.5).
2
+
3
+ A wheel holds one copy of texmodel, of texpkg's run time and of each package in its base chain, side by side under
4
+ `<module>/_embedded/`. Every import of an embedded name, absolute or relative into another embedded copy, is found with
5
+ `ast` and rewritten as a relative import of the private copy; nothing is matched by regular expression.
6
+ """
7
+
8
+ import ast
9
+ import pathlib
10
+ import shutil
11
+ import sys
12
+ from collections.abc import Callable, Iterator, Sequence
13
+
14
+ from texpkg.declaration import Distribution
15
+ from texpkg.errors import DeclarationError
16
+
17
+ EMBEDDED = "_embedded"
18
+ RUN_TIME = ("texmodel", "texpkg")
19
+ ALLOWED = frozenset({"pydantic", "pydantic_core", "annotated_types", "typing_extensions", "typing_inspection"})
20
+ _SKIP = shutil.ignore_patterns("__pycache__", "*.pyc")
21
+
22
+ type Mapping = Callable[[str], str | None]
23
+
24
+
25
+ def relative(package: tuple[str, ...], target: tuple[str, ...]) -> tuple[int, str]:
26
+ """The level and module of the relative import that reaches `target` from a module in `package`."""
27
+ common = 0
28
+ while common < min(len(package), len(target)) and package[common] == target[common]:
29
+ common += 1
30
+ return len(package) - common + 1, ".".join(target[common:])
31
+
32
+
33
+ def _absolute(node: ast.ImportFrom, package: tuple[str, ...]) -> tuple[str, ...]:
34
+ base = package[: len(package) - (node.level - 1)] if node.level else ()
35
+ return (*base, *(node.module.split(".") if node.module else ()))
36
+
37
+
38
+ def _fail(code: str, message: str, where: str, hint: str) -> DeclarationError:
39
+ return DeclarationError(message, code=code, path=where, hint=hint)
40
+
41
+
42
+ def _names(aliases: list[ast.alias]) -> str:
43
+ return ", ".join(a.name + (f" as {a.asname}" if a.asname else "") for a in aliases)
44
+
45
+
46
+ def _from_statement(node: ast.ImportFrom, old: tuple[str, ...], new: tuple[str, ...], mapping: Mapping) -> str | None:
47
+ mapped = mapping(".".join(_absolute(node, old)))
48
+ if mapped is None:
49
+ return None
50
+ level, module = relative(new, tuple(mapped.split(".")))
51
+ if (level, module) == (node.level, node.module or ""):
52
+ return None # a relative import inside what moves rigidly stays as it is
53
+ return f"from {'.' * level}{module} import {_names(node.names)}"
54
+
55
+
56
+ def _import_statement(node: ast.Import, new: tuple[str, ...], mapping: Mapping, where: str) -> str | None:
57
+ if not any(mapping(a.name) is not None for a in node.names):
58
+ return None
59
+ lines: list[str] = []
60
+ for alias in node.names:
61
+ mapped = mapping(alias.name)
62
+ if mapped is None:
63
+ lines.append(f"import {_names([alias])}")
64
+ continue
65
+ if "." in alias.name and not alias.asname:
66
+ parent, _, child = alias.name.rpartition(".")
67
+ raise _fail(
68
+ "TP-E001",
69
+ f"{where}: `import {alias.name}` binds the top package, which cannot be pointed at the private copy",
70
+ where,
71
+ f"write `from {parent} import {child}`",
72
+ )
73
+ *parent, last = mapped.split(".")
74
+ level, module = relative(new, tuple(parent))
75
+ lines.append(f"from {'.' * level}{module} import {last}" + (f" as {alias.asname}" if alias.asname else ""))
76
+ return ("\n" + " " * node.col_offset).join(lines)
77
+
78
+
79
+ def rewrite(source: str, old: tuple[str, ...], new: tuple[str, ...], mapping: Mapping, where: str) -> str:
80
+ """`source`, a module of package `old` that is moved to package `new`, with each import whose absolute name
81
+ `mapping` changes written as a relative import from `new`. Other imports are left as they are."""
82
+ raw = source.encode("utf-8")
83
+ starts = [0]
84
+ for line in raw.splitlines(keepends=True):
85
+ starts.append(starts[-1] + len(line))
86
+ edits: list[tuple[int, int, str]] = []
87
+ for node in ast.walk(ast.parse(source)):
88
+ if isinstance(node, ast.ImportFrom):
89
+ text = _from_statement(node, old, new, mapping)
90
+ elif isinstance(node, ast.Import):
91
+ text = _import_statement(node, new, mapping, where)
92
+ else:
93
+ continue
94
+ if text is not None and node.end_lineno is not None and node.end_col_offset is not None:
95
+ edits.append(
96
+ (starts[node.lineno - 1] + node.col_offset, starts[node.end_lineno - 1] + node.end_col_offset, text)
97
+ )
98
+ for start, end, text in sorted(edits, reverse=True):
99
+ raw = raw[:start] + text.encode("utf-8") + raw[end:]
100
+ return raw.decode("utf-8")
101
+
102
+
103
+ def imported_names(source: str) -> Iterator[tuple[str, ast.Import | ast.ImportFrom]]:
104
+ """Every absolute module name `source` imports, with its node: `import a.b` and `from a.b import c` give `a.b`."""
105
+ for node in ast.walk(ast.parse(source)):
106
+ if isinstance(node, ast.Import):
107
+ for alias in node.names:
108
+ yield alias.name, node
109
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
110
+ yield node.module, node
111
+
112
+
113
+ def scan_declaration(source: str, where: str, own: set[str], embedded: set[str]) -> None:
114
+ """What the build refuses in the author's code (B4): an absolute import of the package itself (P19), a read of the
115
+ distribution's metadata (B10), a dynamic import of an embedded name, and a module the wheel does not carry."""
116
+ for name, node in imported_names(source):
117
+ top = name.split(".")[0]
118
+ if top in own:
119
+ raise _fail(
120
+ "TP-E002",
121
+ f"{where}:{node.lineno}: absolute import of {name}, which is this package's own name",
122
+ where,
123
+ "import the package's own modules relatively (from .model import X), so a copy works anywhere",
124
+ )
125
+ if name.startswith("importlib.metadata") or (
126
+ name == "importlib" and isinstance(node, ast.ImportFrom) and any(a.name == "metadata" for a in node.names)
127
+ ):
128
+ raise _fail(
129
+ "TP-E003",
130
+ f"{where}: reads distribution metadata ({name}); an embedded copy has none of its own",
131
+ where,
132
+ "take the version from the constant the build writes (the module's __version__)",
133
+ )
134
+ if top not in sys.stdlib_module_names and top not in ALLOWED and top not in embedded:
135
+ raise _fail(
136
+ "TP-E004",
137
+ f"{where}: imports {name}, which the wheel does not carry",
138
+ where,
139
+ "a wheel needs only the standard library, pydantic, texmodel, texpkg and its bases",
140
+ )
141
+ for node in ast.walk(ast.parse(source)):
142
+ if isinstance(node, ast.Call) and node.args and isinstance(node.args[0], ast.Constant):
143
+ called = node.func.attr if isinstance(node.func, ast.Attribute) else getattr(node.func, "id", "")
144
+ value = node.args[0].value
145
+ if (
146
+ called in ("import_module", "__import__")
147
+ and isinstance(value, str)
148
+ and value.split(".")[0] in own | embedded
149
+ ):
150
+ raise _fail(
151
+ "TP-E005",
152
+ f"{where}:{node.lineno}: dynamic import of {value}",
153
+ where,
154
+ "import it with a plain import statement, which the build can point at the private copy",
155
+ )
156
+
157
+
158
+ def set_generator_version(source: str, version: str) -> str:
159
+ """texmodel's `GENERATOR_VERSION` set to `version` (B10): a copy has no installed metadata to ask."""
160
+ unset = "GENERATOR_VERSION: str | None = None\n"
161
+ if source.count(unset) != 1:
162
+ raise _fail(
163
+ "TP-E006",
164
+ "texmodel.package no longer declares GENERATOR_VERSION as None",
165
+ "texmodel/package.py",
166
+ "this texpkg needs a texmodel that reads GENERATOR_VERSION when it is set (texmodel>=0.4.0)",
167
+ )
168
+ return source.replace(unset, f"GENERATOR_VERSION: str | None = {version!r}\n")
169
+
170
+
171
+ def copy_tree(source: pathlib.Path, dest: pathlib.Path, *, skip: tuple[str, ...] = ()) -> list[pathlib.Path]:
172
+ """Copy a package directory, leaving out caches and `skip`; the `.py` files copied, for rewriting."""
173
+ shutil.copytree(source, dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", *skip), dirs_exist_ok=True)
174
+ return sorted(dest.rglob("*.py"))
175
+
176
+
177
+ def rewrite_tree(root: pathlib.Path, old: tuple[str, ...], new: tuple[str, ...], mapping: Mapping) -> None:
178
+ """Rewrite every `.py` under `root`, a package that stands at `old` and is moved to `new`."""
179
+ for path in sorted(root.rglob("*.py")):
180
+ rel = path.relative_to(root)
181
+ package_old = (*old, *rel.parts[:-1])
182
+ package_new = (*new, *rel.parts[:-1])
183
+ text = path.read_text(encoding="utf-8")
184
+ changed = rewrite(text, package_old, package_new, mapping, str(path))
185
+ if changed != text:
186
+ path.write_text(changed, encoding="utf-8")
187
+
188
+
189
+ def _package_dir(name: str) -> pathlib.Path:
190
+ """Where the importable package `name` is installed in this build environment."""
191
+ module = sys.modules.get(name) or __import__(name)
192
+ return pathlib.Path(next(iter(module.__path__)))
193
+
194
+
195
+ def embed(
196
+ out: pathlib.Path,
197
+ dist: Distribution,
198
+ declaration: pathlib.Path,
199
+ generated: dict[str, str],
200
+ frozen: Sequence[pathlib.Path] = (),
201
+ ) -> None:
202
+ """Write the module of `dist` into `out` (`<build dir>/<module>`): the generated files, the declaration copied to
203
+ `_declaration`, and under `_embedded` one copy each of texmodel, texpkg's run time and the base chain (P19), all
204
+ with their imports of one another rewritten (1.5)."""
205
+ from texmodel.package import generator_version
206
+
207
+ module = dist.module
208
+ embedded = {*RUN_TIME, *(d.module for d in dist.chain()[1:])}
209
+ own = {dist.declared_in.split(".")[0], module}
210
+
211
+ def to_copy(name: str) -> str | None:
212
+ return f"{module}._embedded.{name}" if name.split(".")[0] in embedded else None
213
+
214
+ copy_tree(declaration, out / "_declaration")
215
+ for snapshot in frozen:
216
+ copy_tree(snapshot, out / "_contracts" / snapshot.name)
217
+ for path in sorted((out / "_declaration").rglob("*.py")):
218
+ scan_declaration(path.read_text(encoding="utf-8"), str(path.relative_to(out)), own, embedded)
219
+ for rel, text in generated.items():
220
+ (out / rel).parent.mkdir(parents=True, exist_ok=True)
221
+ (out / rel).write_text(text, encoding="utf-8")
222
+ rewrite_tree(out, (module,), (module,), to_copy)
223
+ base = out / EMBEDDED
224
+ base.mkdir()
225
+ (base / "__init__.py").write_text("", encoding="utf-8")
226
+ for name, skip in (("texmodel", ("cli.py",)), ("texpkg", ())):
227
+ copy_tree(_package_dir(name), base / name, skip=skip)
228
+ rewrite_tree(base / name, (name,), (module, EMBEDDED, name), to_copy)
229
+ patched = base / "texmodel" / "package.py"
230
+ patched.write_text(
231
+ set_generator_version(patched.read_text(encoding="utf-8"), generator_version()), encoding="utf-8"
232
+ )
233
+ if dist.base is not None:
234
+ _embed_base(base, module, dist.base.module)
235
+
236
+
237
+ def _embed_base(base: pathlib.Path, module: str, base_module: str) -> None:
238
+ """The installed base, flattened: its own files, then each package of its `_embedded` that is not texmodel or texpkg
239
+ (the dependent has its own copy of those, and the base's code is pointed at it)."""
240
+ source = _package_dir(base_module)
241
+ prefix = f"{base_module}.{EMBEDDED}."
242
+
243
+ def moved(name: str) -> str | None:
244
+ if name == prefix.removesuffix("."):
245
+ return f"{module}.{EMBEDDED}" # `from .._embedded import x`: the bases are now beside this one
246
+ return f"{module}.{EMBEDDED}.{name.removeprefix(prefix)}" if name.startswith(prefix) else None
247
+
248
+ copy_tree(source, base / base_module, skip=(EMBEDDED,))
249
+ rewrite_tree(base / base_module, (base_module,), (module, EMBEDDED, base_module), moved)
250
+ lifted = source / EMBEDDED
251
+ for path in sorted(lifted.iterdir()) if lifted.is_dir() else ():
252
+ if path.is_dir() and path.name not in RUN_TIME:
253
+ copy_tree(path, base / path.name)
254
+ rewrite_tree(base / path.name, (base_module, EMBEDDED, path.name), (module, EMBEDDED, path.name), moved)
@@ -0,0 +1,254 @@
1
+ """The generated module's source (P6, K4, V1): plain Python, deterministic (B5), importing the run time by its absolute
2
+ name (`texpkg`), which the embedding step later points at the private copy (1.5).
3
+
4
+ The module is `acme_tex/{__init__,_bound,_version,errors}.py`, `kinds/{__init__,<kind>}.py` and `_sty/<name>.sty`; the
5
+ declaration is imported from `acme_tex._declaration`, so its classes are real classes a type checker reads (K4).
6
+ """
7
+
8
+ import copy
9
+ import inspect
10
+ import textwrap
11
+ import typing
12
+ from collections.abc import Iterator
13
+ from typing import Annotated
14
+
15
+ from texpkg import errors
16
+ from texpkg._internal import provenance_tex
17
+ from texpkg.contracts import Contracts
18
+ from texpkg.declaration import Distribution, Kind, reachable
19
+ from texpkg.errors import DeclarationError
20
+
21
+ _HEADER = '"""Generated by texpkg-build from the declaration. Do not edit."""\n'
22
+ _FOREIGN = ("builtins", "typing", "types", "collections", "texmodel", "enum", "pydantic")
23
+
24
+
25
+ def render_sty(dist: Distribution, version: str) -> dict[str, str]:
26
+ """The `.sty` of the distribution's own package, rendered at `version` (P4, V1). The author's object is left as it
27
+ is. A base's was rendered when the base was built."""
28
+ package = copy.deepcopy(dist.package) # a copy that shares no list with the author's: the block is added to it
29
+ if not package.prefix:
30
+ raise DeclarationError(
31
+ f"package {package.name} has no prefix, so it cannot declare its provenance command",
32
+ code="TP-B011",
33
+ path=package.name,
34
+ hint='give the Package a prefix: Package("acme", prefix="ac")',
35
+ )
36
+ package.version = version
37
+ package.library("texpkg-provenance", provenance_tex.declarations(package.prefix, package.name, version))
38
+ return {f"{package.name}.sty": package.render()}
39
+
40
+
41
+ def error_names() -> tuple[str, ...]:
42
+ return tuple(
43
+ sorted(name for name, cls in inspect.getmembers(errors, inspect.isclass) if issubclass(cls, errors.TexpkgError))
44
+ )
45
+
46
+
47
+ def _types(hint: object) -> Iterator[type]:
48
+ """The classes a field's annotation names that the declaration defines: a `Choice`, a bounded length (P24)."""
49
+ if typing.get_origin(hint) is Annotated:
50
+ yield from _types(typing.get_args(hint)[0])
51
+ elif args := typing.get_args(hint):
52
+ for arg in args:
53
+ yield from _types(arg)
54
+ elif isinstance(hint, type) and hint.__module__.split(".")[0] not in _FOREIGN:
55
+ yield hint
56
+
57
+
58
+ def exported(kind: Kind) -> tuple[type, ...]:
59
+ """The classes a kind module exports: the components its trees are made of, and the value types they use, once each
60
+ and in the order met."""
61
+ found: dict[type, None] = {}
62
+ for cls in reachable(kind.model):
63
+ found[cls] = None
64
+ for arg in cls.__tm_args__:
65
+ found.update(dict.fromkeys(_types(arg.hint)))
66
+ return tuple(found)
67
+
68
+
69
+ def _import_from(dist: Distribution, cls: type) -> str:
70
+ """`from .._declaration.model import Deck as Deck` for `cls`, relative to `kinds/<kind>.py`, wherever in the chain
71
+ it is declared."""
72
+ for owner in dist.chain():
73
+ prefix = owner.declared_in
74
+ if cls.__module__ == prefix or cls.__module__.startswith(prefix + "."):
75
+ if "." in cls.__qualname__:
76
+ break
77
+ where = "_declaration" if owner is dist else f"_embedded.{owner.module}._declaration"
78
+ return f"from ..{where}{cls.__module__.removeprefix(prefix)} import {cls.__name__} as {cls.__name__}"
79
+ raise DeclarationError(
80
+ f"{cls.__module__}.{cls.__qualname__} is not a top-level class of the declaration package or of a base",
81
+ code="TP-B002",
82
+ path=cls.__qualname__,
83
+ hint="declare it at module level inside the package named by declared_in, so the module can export it",
84
+ )
85
+
86
+
87
+ def _module(body: str) -> str:
88
+ return _HEADER + "\n" + textwrap.dedent(body).strip() + "\n"
89
+
90
+
91
+ def _kind_source(dist: Distribution, kind: Kind) -> str:
92
+ imports = "\n ".join(sorted({_import_from(dist, cls) for cls in exported(kind)})) # the template's indent
93
+ root = kind.model.__name__
94
+ return _module(f"""
95
+ from collections.abc import Mapping, Sequence
96
+ from pathlib import Path
97
+ from typing import Any, Final, Literal
98
+
99
+ from texpkg.contracts import Changes
100
+ from texpkg.document import Document
101
+ from texpkg.migration import DefaultChange, Migration
102
+
103
+ from .. import _bound
104
+ {imports}
105
+
106
+ NAME: Final = {kind.name!r}
107
+ _api = _bound.RELEASE.kind(NAME, {root})
108
+
109
+
110
+ def load(
111
+ path: Path | str,
112
+ *,
113
+ migrations: Sequence[Migration] = (),
114
+ default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
115
+ ) -> Document[{root}]:
116
+ return _api.load(path, migrations, default_changes)
117
+
118
+
119
+ def create(path: Path | str, tree: {root}) -> Document[{root}]:
120
+ return _api.create(path, tree)
121
+
122
+
123
+ def schema(mode: Literal["validation", "serialization"] = "validation") -> dict[str, Any]:
124
+ return _api.schema(mode)
125
+
126
+
127
+ def example() -> {root}:
128
+ return _api.example()
129
+
130
+
131
+ def variants() -> tuple[str, ...]:
132
+ return _api.variants()
133
+
134
+
135
+ def changes(old: int, new: int) -> Changes:
136
+ return _api.changes(old, new)
137
+ """)
138
+
139
+
140
+ def _tuple(items: list[str]) -> str:
141
+ return "(" + "".join(f"{item!r}, " for item in items).strip() + ")"
142
+
143
+
144
+ def sources(dist: Distribution, pointer_attr: str, version: str, contracts: Contracts) -> dict[str, str]:
145
+ """The generated files by path inside the module, for `dist` released at `version`. `pointer_attr` is the name
146
+ `[tool.texpkg] distribution` gives the declaration inside its package."""
147
+ offered = dist.kinds_offered()
148
+ names = [k.name for k in offered]
149
+ sty = render_sty(dist, version)
150
+ c = contracts
151
+ imports = f"from . import {', '.join(names)}" if names else ""
152
+ base = dist.base
153
+ base_import = f"from ._embedded.{base.module} import _bound as _base" if base else ""
154
+ base_sty = "**_base.STY, " if base else ""
155
+ base_release = "(_base.RELEASE,), " if base else "(), "
156
+ return (
157
+ {f"_sty/{name}": text for name, text in sty.items()}
158
+ | {
159
+ "__init__.py": _module("""
160
+ import datetime
161
+ from collections.abc import Mapping, Sequence
162
+ from pathlib import Path
163
+ from typing import Any, Final
164
+
165
+ from texpkg.document import Document
166
+ from texpkg.migration import DefaultChange, Migrated, Migration, Unresolved, migration
167
+
168
+ from . import errors, kinds
169
+ from ._bound import RELEASE
170
+ from ._version import CONTRACTS, VERSION
171
+
172
+ __version__: Final = VERSION
173
+ dist: Final = RELEASE.dist # for a package built on this one (P14)
174
+ __all__ = [
175
+ "CONTRACTS",
176
+ "DefaultChange",
177
+ "Migrated",
178
+ "Unresolved",
179
+ "__version__",
180
+ "dist",
181
+ "errors",
182
+ "export",
183
+ "kinds",
184
+ "load",
185
+ "migration",
186
+ ]
187
+
188
+
189
+ def export(
190
+ dest: Path | str, *, now: datetime.datetime | None = None, stamp: str | None = None
191
+ ) -> tuple[Path, ...]:
192
+ return RELEASE.export(dest, now, stamp)
193
+
194
+
195
+ def load(
196
+ path: Path | str,
197
+ *,
198
+ migrations: Sequence[Migration] = (),
199
+ default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
200
+ ) -> Document[Any]:
201
+ return RELEASE.load(path, migrations, default_changes)
202
+ """),
203
+ "_version.py": _module(f"""
204
+ from typing import Final
205
+
206
+ from texpkg.contracts import Contracts, Pair, Triple
207
+
208
+ VERSION: Final = {version!r}
209
+ CONTRACTS: Final = Contracts(
210
+ Triple({c.data.current}, {c.data.revision}, {c.data.age}),
211
+ Triple({c.interface.current}, {c.interface.revision}, {c.interface.age}),
212
+ Pair({c.output.current}, {c.output.revision}),
213
+ {c.base_data!r},
214
+ )
215
+ """),
216
+ "_bound.py": _module(f"""
217
+ from importlib.resources import files
218
+ from typing import Final
219
+
220
+ from texpkg._internal.release import Release
221
+
222
+ from ._declaration import {pointer_attr}
223
+ from ._version import CONTRACTS, VERSION
224
+ {base_import}
225
+
226
+ _NAMES: Final = {_tuple(list(sty))}
227
+ _SNAPSHOTS: Final = files(__package__).joinpath("_contracts")
228
+ _OWN: Final = {{
229
+ name: files(__package__).joinpath("_sty", name).read_text(encoding="utf-8") for name in _NAMES
230
+ }}
231
+ STY: Final = {{{base_sty}**_OWN}}
232
+ RELEASE: Final = Release({pointer_attr}, VERSION, CONTRACTS, STY, {base_release}_SNAPSHOTS)
233
+ """),
234
+ "errors.py": _module(
235
+ "from texpkg.errors import (\n" + "".join(f" {name} as {name},\n" for name in error_names()) + ")\n"
236
+ ),
237
+ "kinds/__init__.py": _module(f"""
238
+ from collections.abc import Mapping
239
+
240
+ from texpkg.protocol import KindModule
241
+
242
+ {imports}
243
+
244
+
245
+ def names() -> tuple[str, ...]:
246
+ return {_tuple(names)}
247
+
248
+
249
+ def modules() -> Mapping[str, KindModule]:
250
+ return {{{", ".join(f"{n!r}: {n}" for n in names)}}}
251
+ """),
252
+ }
253
+ | {f"kinds/{kind.name}.py": _kind_source(dist, kind) for kind in offered}
254
+ )