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,176 @@
1
+ """Helpers for an author's tests: the declaration loaded in memory, the L3 law, the isolated compile (1.4, L7, A7)."""
2
+
3
+ import importlib
4
+ import importlib.abc
5
+ import importlib.machinery
6
+ import importlib.util
7
+ import pathlib
8
+ import pkgutil
9
+ import sys
10
+ import tempfile
11
+ from collections.abc import Iterable
12
+ from types import ModuleType
13
+
14
+ from texmodel.components import Node
15
+ from texmodel_build import fonts
16
+ from texmodel_build.compiler import Compiler
17
+
18
+ from texpkg.contracts import Contracts
19
+ from texpkg.declaration import Distribution
20
+ from texpkg.errors import DeclarationError
21
+
22
+ from ._internal import generate
23
+
24
+
25
+ def _is_under(name: str, root: str) -> bool:
26
+ return name == root or name.startswith(root + ".")
27
+
28
+
29
+ def _declared_modules(declared_in: str) -> dict[str, ModuleType]:
30
+ """The declaration package and every module under it, imported."""
31
+ package = importlib.import_module(declared_in)
32
+ modules = {declared_in: package}
33
+ for info in pkgutil.walk_packages(package.__path__, f"{declared_in}."):
34
+ modules[info.name] = importlib.import_module(info.name)
35
+ return modules
36
+
37
+
38
+ def load_declaration(pointer: str, *, version: str = "0.0.0", contracts: Contracts | None = None) -> ModuleType:
39
+ """The module a wheel built from `pointer` (`"acme_decl:dist"`, as `[tool.texpkg] distribution` writes it) would
40
+ contain, made from the same generated source (1.4): no wheel, no embedding. The files are written to a temporary
41
+ directory that lives as long as the module; the declaration is the one already imported, shown as
42
+ `<module>._declaration`, so its classes are the ones the author's own tests use. Loading again replaces the
43
+ module."""
44
+ module_name, _, attr = pointer.partition(":")
45
+ dist = getattr(importlib.import_module(module_name), attr, None)
46
+ if not isinstance(dist, Distribution):
47
+ raise DeclarationError(
48
+ f"{pointer} is not a Distribution",
49
+ code="TP-B003",
50
+ path=pointer,
51
+ hint='write [tool.texpkg] distribution = "module:attribute"',
52
+ )
53
+ scratch = tempfile.TemporaryDirectory(prefix="texpkg-")
54
+ root = pathlib.Path(scratch.name) / dist.module
55
+ for loaded in [n for n in sys.modules if _is_under(n, dist.module)]:
56
+ del sys.modules[loaded]
57
+ _write(dist, attr, version, contracts or Contracts(), root)
58
+ spec = importlib.util.spec_from_file_location(
59
+ dist.module, root / "__init__.py", submodule_search_locations=[str(root)]
60
+ )
61
+ assert spec is not None and spec.loader is not None # a file we just wrote
62
+ module = importlib.util.module_from_spec(spec)
63
+ sys.modules[dist.module] = module
64
+ place, aliases = dist.module, dict[str, str]()
65
+ for member in dist.chain():
66
+ for name, real in _declared_modules(member.declared_in).items():
67
+ sys.modules[f"{place}._declaration{name.removeprefix(member.declared_in)}"] = real
68
+ if member.base:
69
+ nested = f"{place}._embedded.{member.base.module}"
70
+ if place != dist.module:
71
+ aliases[f"{dist.module}._embedded.{member.base.module}"] = nested
72
+ place = nested
73
+ module._scratch = scratch # pyright: ignore[reportAttributeAccessIssue] # the files go when the module does
74
+ finder = _Flatten(aliases)
75
+ sys.meta_path.insert(0, finder)
76
+ try:
77
+ spec.loader.exec_module(module)
78
+ finally:
79
+ sys.meta_path.remove(finder)
80
+ return module
81
+
82
+
83
+ class _Flatten(importlib.abc.MetaPathFinder, importlib.abc.Loader):
84
+ """In a wheel the bases of a chain are siblings under `_embedded` (P19); written in memory each sits inside the one
85
+ above it. This makes `<module>._embedded.<grand base>` the same module as the nested one."""
86
+
87
+ def __init__(self, aliases: dict[str, str]) -> None:
88
+ self.aliases = aliases
89
+
90
+ def find_spec(
91
+ self, fullname: str, path: object = None, target: object = None
92
+ ) -> importlib.machinery.ModuleSpec | None:
93
+ if any(fullname == flat or fullname.startswith(flat + ".") for flat in self.aliases):
94
+ return importlib.util.spec_from_loader(fullname, self)
95
+ return None
96
+
97
+ def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType:
98
+ flat = next(f for f in self.aliases if spec.name == f or spec.name.startswith(f + "."))
99
+ return importlib.import_module(self.aliases[flat] + spec.name.removeprefix(flat))
100
+
101
+ def exec_module(self, module: ModuleType) -> None:
102
+ """The module is the nested one, already running."""
103
+
104
+
105
+ def _attribute_of(dist: Distribution) -> str:
106
+ """The name the declaration package gives `dist`, which a base's generated module imports it by."""
107
+ namespace = vars(importlib.import_module(dist.declared_in))
108
+ return next(name for name, value in namespace.items() if value is dist)
109
+
110
+
111
+ def _write(dist: Distribution, attr: str, version: str, contracts: Contracts, root: pathlib.Path) -> None:
112
+ """The generated module of `dist` in `root`, and in `root/_embedded/<base>` that of each base: embedding without
113
+ copying, since the declarations are the modules already imported."""
114
+ for path, text in generate.sources(dist, attr, version, contracts).items():
115
+ (root / path).parent.mkdir(parents=True, exist_ok=True)
116
+ (root / path).write_text(text, encoding="utf-8")
117
+ if dist.base:
118
+ (root / "_embedded").mkdir(exist_ok=True)
119
+ (root / "_embedded" / "__init__.py").write_text("", encoding="utf-8")
120
+ _write(
121
+ dist.base,
122
+ _attribute_of(dist.base),
123
+ dist.base.package.version,
124
+ contracts,
125
+ root / "_embedded" / dist.base.module,
126
+ )
127
+
128
+
129
+ def check_expansion(dist: Distribution, kind: str, examples: Iterable[Node] = ()) -> None:
130
+ """The law of a profile kind (L3): every valid document of it expands to a valid document of the kind it expands to.
131
+ Checked on the kind's own example and on `examples`: run it over trees a hypothesis strategy builds (L6, L7), where
132
+ a profile that widens its base (a default or a value the base's type does not take) fails."""
133
+ profile = dist.kind(kind)
134
+ base = dist.base_kind(profile)
135
+ if profile.expand is None or base is None:
136
+ raise DeclarationError(f"kind {kind} is not a profile kind", code="TP-K009", path=kind)
137
+ for tree in (profile.example(), *examples):
138
+ if problems := tree.violations():
139
+ raise DeclarationError(f"kind {kind}: {problems[0].message}", code="TP-K010", path=kind)
140
+ if problem := dist.expansion_problem(profile, tree):
141
+ raise DeclarationError(
142
+ f"kind {kind}: {tree!r} expands to a tree that {problem}",
143
+ code="TP-K010",
144
+ path=kind,
145
+ hint="a profile narrows its base and never widens it (L6)",
146
+ )
147
+
148
+
149
+ def check_isolated(module: ModuleType, compiler: Compiler, workdir: pathlib.Path) -> None:
150
+ """Each kind's example, in each of its variants, saved into a fresh directory that holds only that kind's payload
151
+ and compiled with no search path (no `TEXINPUTS`, no font directory): it fails on a file the payload lacks, and on a
152
+ font embedded from outside the document directory (A7). It proves what the example uses, so an example uses the
153
+ whole kind."""
154
+ problems: list[str] = []
155
+ for kind in module._bound.RELEASE.dist.kinds_offered(): # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
156
+ kind_module = module.kinds.modules()[kind.name]
157
+ root = workdir / kind.name
158
+ kind_module.create(root, kind_module.example()).save()
159
+ for _, name in kind.documents():
160
+ result = compiler.compile(root / name, workdir / "out" / kind.name / name.removesuffix(".tex"))
161
+ problems += [f"{kind.name}/{name}: {e.code} {e.message}" for e in result.errors]
162
+ if result.log is not None:
163
+ problems += [
164
+ f"{kind.name}/{name}: font read from outside the document directory: {path}"
165
+ for path in fonts.outside(
166
+ result.log.read_text(encoding="utf-8", errors="replace"),
167
+ [pathlib.Path("/work/src"), root.resolve()],
168
+ root.resolve(),
169
+ )
170
+ ]
171
+ if problems:
172
+ raise DeclarationError(
173
+ "a kind's payload is not whole:\n " + "\n ".join(problems),
174
+ code="TP-K011",
175
+ hint="an asset names every kind that needs it (Asset(kinds=)); fonts are assets, not system fonts",
176
+ )