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/document.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""`Document[T]`: a kind's tree and the directory it lives in (P27, P28)."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime
|
|
5
|
+
import enum
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from texmodel import drift
|
|
12
|
+
from texmodel.components import Command, Environment
|
|
13
|
+
|
|
14
|
+
from . import provenance
|
|
15
|
+
from ._internal import directory, handedits, state
|
|
16
|
+
from .contracts import Pair
|
|
17
|
+
from .declaration import Kind
|
|
18
|
+
from .errors import HandEditError, NotADocumentError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ._internal.release import Release
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@enum.unique
|
|
25
|
+
class FileState(enum.StrEnum):
|
|
26
|
+
UNCHANGED = "unchanged"
|
|
27
|
+
CHANGED = "changed"
|
|
28
|
+
MISSING = "missing"
|
|
29
|
+
EXTRA = "extra" # in a place the payload writes to, and not written by it: a retired `.sty` (A4)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@enum.unique
|
|
33
|
+
class HandEditKind(enum.StrEnum):
|
|
34
|
+
ADDED = "added" # in the file, not in the plan
|
|
35
|
+
REMOVED = "removed" # in the plan, not in the file
|
|
36
|
+
CHANGED = "changed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass(frozen=True)
|
|
40
|
+
class HandEdit:
|
|
41
|
+
"""One line of a `.tex` that is not what `save` wrote (D8). `path` is the node's data path (`/content/1/content/0`),
|
|
42
|
+
or the plan's own for a line no node wrote; `value` is the typed value when texmodel can read the edit back (D9),
|
|
43
|
+
else `None`: it is never guessed."""
|
|
44
|
+
|
|
45
|
+
file: pathlib.Path
|
|
46
|
+
path: str
|
|
47
|
+
expected: str
|
|
48
|
+
found: str
|
|
49
|
+
kind: HandEditKind
|
|
50
|
+
value: object = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclasses.dataclass(frozen=True)
|
|
54
|
+
class TemplateChange:
|
|
55
|
+
"""One thing a new release changes for a saved document (D4): a setting a `.tex` is written with
|
|
56
|
+
(`setting deck.tex FactGap`), a default (`default Fact.accent`) or a `.sty` (`sty acme-brand.sty`, by hash).
|
|
57
|
+
`old` and `new` are the recorded and the current value; `None` is absent."""
|
|
58
|
+
|
|
59
|
+
setting: str
|
|
60
|
+
old: object
|
|
61
|
+
new: object
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass(frozen=True)
|
|
65
|
+
class Check:
|
|
66
|
+
"""What a `save` would change, and nothing changed to find out (P29, A4). A file that is `EXTRA` is reported, but a
|
|
67
|
+
save removes nothing, so it does not make the document not current."""
|
|
68
|
+
|
|
69
|
+
files: Mapping[pathlib.Path, FileState]
|
|
70
|
+
edits: tuple[HandEdit, ...] = ()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def current(self) -> bool:
|
|
74
|
+
return not self.edits and all(s in (FileState.UNCHANGED, FileState.EXTRA) for s in self.files.values())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclasses.dataclass(frozen=True)
|
|
78
|
+
class StackEntry:
|
|
79
|
+
package: str
|
|
80
|
+
release: str
|
|
81
|
+
output: Pair # what the package rendered with, `current:revision` (V18)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclasses.dataclass(frozen=True)
|
|
85
|
+
class Stack:
|
|
86
|
+
"""The packages a document was rendered through, the top one first: its lockfile (V18)."""
|
|
87
|
+
|
|
88
|
+
entries: tuple[StackEntry, ...]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Document[T: Command | Environment]:
|
|
92
|
+
"""A texmodel tree of a kind's classes (`doc.tree`) with texpkg's interface as methods (P28). Made by a kind's
|
|
93
|
+
`load` and `create`, never by hand; a tree is frozen, so `replace` gives another document."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, tree: T, path: pathlib.Path, release: "Release", kind: Kind) -> None:
|
|
96
|
+
self.tree = tree
|
|
97
|
+
self.path = path
|
|
98
|
+
self._release, self._kind = release, kind
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def kind(self) -> str:
|
|
102
|
+
return self._kind.name
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def defaults(self) -> tuple[str, ...]:
|
|
106
|
+
"""The data paths of the values that are still the package's defaults (V21)."""
|
|
107
|
+
return state.default_paths(self.tree)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def stack(self) -> Stack:
|
|
111
|
+
"""The packages the document was last saved through; the release's own when nothing is saved yet."""
|
|
112
|
+
saved = directory.read(self.path, directory.TEMPLATE)
|
|
113
|
+
template = json.loads(saved) if saved else self._release.template(self._kind)
|
|
114
|
+
return Stack(tuple(StackEntry(e["package"], e["release"], Pair(*e["output"])) for e in template["stack"]))
|
|
115
|
+
|
|
116
|
+
def render(self, variant: str | None = None) -> str:
|
|
117
|
+
"""The `.tex` of the document, complete: pure, nothing is written. Raises an `ExceptionGroup` of texmodel
|
|
118
|
+
`ValidationError` when the tree breaks a rule."""
|
|
119
|
+
return self._release.plan(self._kind, self.tree, variant).text
|
|
120
|
+
|
|
121
|
+
def replace(self, tree: T) -> "Document[T]":
|
|
122
|
+
"""The same document, in the same directory, with another tree."""
|
|
123
|
+
return Document(tree, self.path, self._release, self._kind)
|
|
124
|
+
|
|
125
|
+
def _expected(self, stamp: str, time: str) -> dict[str, str | bytes]:
|
|
126
|
+
"""Every file a save writes, by path in the directory."""
|
|
127
|
+
files = provenance.files(self._release, stamp, time)
|
|
128
|
+
payload = self._release.payload(self._kind, files)
|
|
129
|
+
return (
|
|
130
|
+
payload.expected()
|
|
131
|
+
| self._release.rendered(self._kind, self.tree)
|
|
132
|
+
| self._release.states(self._kind, self.tree)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _data_path(self, plan_path: str) -> str:
|
|
136
|
+
"""A profile's tree is expanded before it is written, so a plan path names a node of the base's tree and not a
|
|
137
|
+
path in this document's data: it is given as it is."""
|
|
138
|
+
return plan_path if self._kind.expand else handedits.data_path(plan_path)
|
|
139
|
+
|
|
140
|
+
def hand_edits(self) -> tuple[HandEdit, ...]:
|
|
141
|
+
"""The lines of the `.tex` files that differ from the plan `save` kept of them (D8)."""
|
|
142
|
+
return tuple(
|
|
143
|
+
HandEdit(
|
|
144
|
+
self.path / name,
|
|
145
|
+
self._data_path(edit.path),
|
|
146
|
+
edit.expected,
|
|
147
|
+
edit.found,
|
|
148
|
+
HandEditKind(edit.kind.value),
|
|
149
|
+
)
|
|
150
|
+
for name, edit in handedits.edits(self.path, self._kind.documents())
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def save(self, *, now: datetime.datetime | None = None, stamp: str | None = None, force: bool = False) -> None:
|
|
154
|
+
"""Write the states, the `.tex` of every variant with its plan, and the payload of the kind: only what changed,
|
|
155
|
+
and a history entry only when the states did (P29). Raises `HandEditError`, writing nothing, when a `.tex` was
|
|
156
|
+
edited by hand, unless `force`. Nothing is written if the tree or the payload is not whole.
|
|
157
|
+
|
|
158
|
+
The provenance files carry `stamp` (default: the release's version, V4) and the time of `now` (default: the
|
|
159
|
+
clock, N4). Their time is kept while the stamp and the rest of the payload are as they were."""
|
|
160
|
+
rendered = self._release.rendered(self._kind, self.tree)
|
|
161
|
+
states = self._release.states(self._kind, self.tree)
|
|
162
|
+
wanted = provenance.stamp_of(stamp, self._release)
|
|
163
|
+
bare = self._release.payload(self._kind)
|
|
164
|
+
bare.complete()
|
|
165
|
+
saved = provenance.saved(self.path, self._release)
|
|
166
|
+
unchanged = saved is not None and saved.stamp == wanted and not bare.pending(self.path)
|
|
167
|
+
time = saved.time if unchanged and saved else provenance.time_of(now)
|
|
168
|
+
payload = self._release.payload(self._kind, provenance.files(self._release, wanted, time))
|
|
169
|
+
if not force and (edits := self.hand_edits()):
|
|
170
|
+
files = ", ".join(sorted({e.file.name for e in edits}))
|
|
171
|
+
raise HandEditError(
|
|
172
|
+
f"{len(edits)} hand-edited line(s) in {files}: not overwritten",
|
|
173
|
+
edits=edits,
|
|
174
|
+
hint="doc.hand_edits() lists them; save(force=True) overwrites them",
|
|
175
|
+
)
|
|
176
|
+
payload.write(self.path)
|
|
177
|
+
directory.write(self.path, directory.differing(self.path, rendered))
|
|
178
|
+
if changed := directory.differing(self.path, states):
|
|
179
|
+
directory.rotate_history(self.path)
|
|
180
|
+
directory.write(self.path, changed)
|
|
181
|
+
|
|
182
|
+
def check(self, *, stamp: str | None = None) -> Check:
|
|
183
|
+
"""What a save would change; writes nothing. A hand-edited `.tex` is reported as changed and in `edits`. The
|
|
184
|
+
provenance files are compared by stamp (A4): `stamp` is the one the document was saved with (default: the
|
|
185
|
+
release's version), and the time they hold is not a difference."""
|
|
186
|
+
wanted = provenance.stamp_of(stamp, self._release)
|
|
187
|
+
saved = provenance.saved(self.path, self._release)
|
|
188
|
+
expected = self._expected(wanted, saved.time if saved else provenance.time_of(None))
|
|
189
|
+
report = drift.check_drift(expected, self.path, self._release.dist.layout.owned())
|
|
190
|
+
files: dict[pathlib.Path, FileState] = {}
|
|
191
|
+
for rel in expected:
|
|
192
|
+
files[self.path / rel] = (
|
|
193
|
+
FileState.MISSING
|
|
194
|
+
if rel in report.missing
|
|
195
|
+
else FileState.CHANGED
|
|
196
|
+
if rel in report.stale
|
|
197
|
+
else FileState.UNCHANGED
|
|
198
|
+
)
|
|
199
|
+
files |= {self.path / rel: FileState.EXTRA for rel in report.orphaned}
|
|
200
|
+
return Check(files, self.hand_edits())
|
|
201
|
+
|
|
202
|
+
def template_changes(self) -> tuple[TemplateChange, ...]:
|
|
203
|
+
"""What the installed release changes for this document against the template state it was saved with (D4)."""
|
|
204
|
+
saved = directory.read(self.path, directory.TEMPLATE)
|
|
205
|
+
if saved is None:
|
|
206
|
+
raise NotADocumentError(
|
|
207
|
+
f"{self.path}: no {directory.TEMPLATE}", code="TP-D001", path=str(self.path), hint="save the document"
|
|
208
|
+
)
|
|
209
|
+
old, new = _flat(json.loads(saved)), _flat(self._release.template(self._kind))
|
|
210
|
+
return tuple(
|
|
211
|
+
TemplateChange(key, old.get(key), new.get(key)) for key in sorted(old | new) if old.get(key) != new.get(key)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _flat(template: dict[str, Any]) -> dict[str, object]:
|
|
216
|
+
"""The parts of a template state a release can change for a document, one entry each."""
|
|
217
|
+
return (
|
|
218
|
+
{
|
|
219
|
+
f"setting {file} {value}": line
|
|
220
|
+
for file, lines in template["settings"].items()
|
|
221
|
+
for value, line in lines.items()
|
|
222
|
+
}
|
|
223
|
+
| {f"default {name}": value for name, value in template["defaults"].items()}
|
|
224
|
+
| {f"sty {name}": digest for name, digest in template["sty"].items()}
|
|
225
|
+
)
|
texpkg/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The errors of a package: a built-in base and a stable `.code` each (B11).
|
|
2
|
+
|
|
3
|
+
No class is shared between packages (each wheel embeds its own copy of this module), so a consumer catches the built-in
|
|
4
|
+
base (`ValueError`, `FileNotFoundError`) and reads `.code`, which is `TP-<area><nnn>` and never reused.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .document import HandEdit
|
|
11
|
+
from .migration import Unresolved
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TexpkgError(Exception):
|
|
15
|
+
"""What every texpkg error carries: a stable code, where it happened and the fix."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, code: str, path: str = "", hint: str = "") -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.code = code
|
|
20
|
+
self.path = path
|
|
21
|
+
self.hint = hint
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeclarationError(TexpkgError, ValueError):
|
|
25
|
+
"""A declaration (an asset, a layout, a kind, a distribution) is invalid."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PayloadError(TexpkgError, FileNotFoundError):
|
|
29
|
+
"""A payload that cannot be written whole: a source file is absent (A3)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotADocumentError(TexpkgError, FileNotFoundError):
|
|
33
|
+
"""The directory holds no readable document state (P27)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DocumentExistsError(TexpkgError, FileExistsError):
|
|
37
|
+
"""`create` into a directory that already holds a document."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class KindMismatchError(TexpkgError, ValueError):
|
|
41
|
+
"""A kind's `load` of a directory that holds another kind's document, or another package's (K3)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, found: str, expected: str, path: str = "", hint: str = "") -> None:
|
|
44
|
+
super().__init__(message, code="TP-D003", path=path, hint=hint)
|
|
45
|
+
self.found = found
|
|
46
|
+
self.expected = expected
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NewerDataError(TexpkgError, ValueError):
|
|
50
|
+
"""Data written for a newer `data` version than this release reads (V14)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MigrationError(TexpkgError, ValueError):
|
|
54
|
+
"""Adopting a release failed; nothing was returned or written (V31). `unresolved` lists what no migration placed."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, *, code: str, unresolved: "tuple[Unresolved, ...]" = (), hint: str = "") -> None:
|
|
57
|
+
super().__init__(message, code=code, hint=hint)
|
|
58
|
+
self.unresolved = unresolved
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HandEditError(TexpkgError, ValueError):
|
|
62
|
+
"""`save` found a `.tex` that differs from the plan of what was last written (D8). `edits` lists them."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, message: str, *, edits: "tuple[HandEdit, ...]", hint: str = "") -> None:
|
|
65
|
+
super().__init__(message, code="TP-D005", hint=hint)
|
|
66
|
+
self.edits = edits
|
texpkg/layout.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The layout (A2): where a payload file goes under a document directory, and the preamble lines that find it (A5).
|
|
2
|
+
|
|
3
|
+
Policy over `texmodel.bundle`, which decides nothing about placement. There is one layout, `LAYOUT`, for every
|
|
4
|
+
distribution: a package built on another writes into the same directory, which the one set of preamble lines finds.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
10
|
+
from texmodel.package import PreambleSlots
|
|
11
|
+
from texmodel.preamble import GraphicsPath, InputPath
|
|
12
|
+
|
|
13
|
+
from .assets import Role
|
|
14
|
+
from .errors import DeclarationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass(frozen=True)
|
|
18
|
+
class Project:
|
|
19
|
+
"""`.sty`, graphics and notes in `dir`; fonts at the root, since luaotfload reads no document-side search path;
|
|
20
|
+
`BY_PATH` at its own path."""
|
|
21
|
+
|
|
22
|
+
dir: pathlib.PurePosixPath
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if not self.dir.parts or self.dir.is_absolute() or ".." in self.dir.parts or "." in self.dir.parts:
|
|
26
|
+
raise DeclarationError(
|
|
27
|
+
f"layout directory {str(self.dir)!r}: not a relative directory inside the document",
|
|
28
|
+
code="TP-A003",
|
|
29
|
+
hint="e.g. Project(PurePosixPath('res/acme'))",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def place(self, role: Role, path: pathlib.PurePosixPath) -> pathlib.PurePosixPath:
|
|
33
|
+
match role:
|
|
34
|
+
case Role.BY_PATH:
|
|
35
|
+
return path
|
|
36
|
+
case Role.FONT:
|
|
37
|
+
return pathlib.PurePosixPath(path.name)
|
|
38
|
+
case Role.PACKAGE | Role.GRAPHIC | Role.NOTE:
|
|
39
|
+
return self.dir / path.name
|
|
40
|
+
|
|
41
|
+
def preamble(self) -> PreambleSlots:
|
|
42
|
+
return PreambleSlots(before_package=(InputPath(f"{self.dir}/"),), after_package=(GraphicsPath(f"{self.dir}/"),))
|
|
43
|
+
|
|
44
|
+
def owned(self) -> tuple[str, ...]:
|
|
45
|
+
return (f"{self.dir}/**/*",)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
LAYOUT = Project(pathlib.PurePosixPath("res/texpkg"))
|
texpkg/migration.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Migrations: one API whoever writes them (V32): the build derives some, a kind lists some, a consumer passes some.
|
|
2
|
+
|
|
3
|
+
A migration is a function over plain data, from the stored state of `data` version `to - 1` to `to` (V28); what it
|
|
4
|
+
cannot place it leaves unresolved, never guessed (V15).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import enum
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import DeclarationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@enum.unique
|
|
16
|
+
class DefaultChange(enum.StrEnum):
|
|
17
|
+
"""What a consumer does when a release changes a default that a stored document still marks as default (V23). A
|
|
18
|
+
`StrEnum`, so one package's value equals another's (B11)."""
|
|
19
|
+
|
|
20
|
+
FOLLOW = "follow" # the marked value moves to the new default
|
|
21
|
+
FREEZE = "freeze" # it keeps the old value, which stops being marked
|
|
22
|
+
ASK = "ask" # adoption aborts with each changed default as an unresolved item
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class Unresolved:
|
|
27
|
+
"""What no migration placed: the data path, why, and the old value. texpkg fills `schema` (the new schema at that
|
|
28
|
+
path) and `boundary` (`"4 -> 5"`) when it reports."""
|
|
29
|
+
|
|
30
|
+
path: str
|
|
31
|
+
reason: str
|
|
32
|
+
value: object = None
|
|
33
|
+
schema: dict[str, Any] = dataclasses.field(default_factory=dict[str, Any])
|
|
34
|
+
boundary: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclasses.dataclass(frozen=True)
|
|
38
|
+
class Migrated:
|
|
39
|
+
"""A migration's result: the state, and what it left unresolved."""
|
|
40
|
+
|
|
41
|
+
state: dict[str, Any]
|
|
42
|
+
unresolved: Sequence[Unresolved] = ()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
type MigrationFunction = Callable[[dict[str, Any]], Migrated]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclasses.dataclass(frozen=True)
|
|
49
|
+
class Migration:
|
|
50
|
+
"""A function from `data` version `to - 1` to `to`; make one with `@migration(to=N)`."""
|
|
51
|
+
|
|
52
|
+
to: int
|
|
53
|
+
function: MigrationFunction
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.to < 2:
|
|
57
|
+
raise DeclarationError(
|
|
58
|
+
f"migration(to={self.to}): `data` version 1 is the first, so a migration goes to 2 or later",
|
|
59
|
+
code="TP-M001",
|
|
60
|
+
hint="@migration(to=N) migrates from `data` version N - 1 to N",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __call__(self, state: dict[str, Any]) -> Migrated:
|
|
64
|
+
return self.function(state)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def migration(*, to: int) -> Callable[[MigrationFunction], Migration]:
|
|
68
|
+
"""`@migration(to=5)`: from `data` version 4 to 5."""
|
|
69
|
+
|
|
70
|
+
def wrap(function: MigrationFunction) -> Migration:
|
|
71
|
+
return Migration(to, function)
|
|
72
|
+
|
|
73
|
+
return wrap
|
texpkg/protocol.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The fixed surface of a built package (P6), as protocols: the build generates a module to satisfy them and checks it
|
|
2
|
+
does, and a consumer's type checker reads the generated module's own types."""
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import pathlib
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from .contracts import Changes, Contracts
|
|
10
|
+
from .document import Document
|
|
11
|
+
from .migration import DefaultChange, Migration
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class KindModule(Protocol):
|
|
16
|
+
"""`acme_tex.kinds.expert`: besides these, the kind's own classes (P24)."""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def NAME(self) -> str: ... # noqa: N802
|
|
20
|
+
def load(
|
|
21
|
+
self,
|
|
22
|
+
path: pathlib.Path | str,
|
|
23
|
+
*,
|
|
24
|
+
migrations: Sequence[Migration] = (),
|
|
25
|
+
default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
|
|
26
|
+
) -> Document[Any]: ...
|
|
27
|
+
def create(self, path: pathlib.Path | str, tree: Any) -> Document[Any]: ... # noqa: ANN401 # the kind's root class
|
|
28
|
+
def schema(self, mode: Literal["validation", "serialization"] = "validation") -> dict[str, Any]: ...
|
|
29
|
+
def example(self) -> Any: ... # noqa: ANN401 # the kind's root class
|
|
30
|
+
def variants(self) -> tuple[str, ...]: ...
|
|
31
|
+
def changes(self, old: int, new: int) -> Changes: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class KindsModule(Protocol):
|
|
36
|
+
"""`acme_tex.kinds`."""
|
|
37
|
+
|
|
38
|
+
def names(self) -> tuple[str, ...]: ...
|
|
39
|
+
def modules(self) -> Mapping[str, KindModule]: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class PackageModule(Protocol):
|
|
44
|
+
"""`acme_tex`."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def __version__(self) -> str: ...
|
|
48
|
+
@property
|
|
49
|
+
def CONTRACTS(self) -> Contracts: ... # noqa: N802
|
|
50
|
+
@property
|
|
51
|
+
def kinds(self) -> KindsModule: ...
|
|
52
|
+
def export(
|
|
53
|
+
self, dest: pathlib.Path | str, *, now: datetime.datetime | None = None, stamp: str | None = None
|
|
54
|
+
) -> tuple[pathlib.Path, ...]: ...
|
|
55
|
+
def load(
|
|
56
|
+
self,
|
|
57
|
+
path: pathlib.Path | str,
|
|
58
|
+
*,
|
|
59
|
+
migrations: Sequence[Migration] = (),
|
|
60
|
+
default_changes: DefaultChange | Mapping[str, DefaultChange] = DefaultChange.FOLLOW,
|
|
61
|
+
) -> Document[Any]: ...
|
texpkg/provenance.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The provenance file: what a document directory was materialised from and when (V2, V34).
|
|
2
|
+
|
|
3
|
+
`save` writes `<package>-provenance.sty` for every package of the stack, the only payload file that holds a time. A
|
|
4
|
+
document can print what it was built from with `\\<prefix>ProvenanceInfo`, and the PDF carries the same text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import datetime
|
|
9
|
+
import pathlib
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from ._internal import directory, provenance_tex
|
|
13
|
+
from .errors import DeclarationError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._internal.release import Release
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclasses.dataclass(frozen=True)
|
|
20
|
+
class Provenance:
|
|
21
|
+
stamp: str
|
|
22
|
+
time: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def stamp_of(stamp: str | None, release: "Release") -> str:
|
|
26
|
+
"""The stamp a save or a check uses: the consumer's (V4, e.g. `1.2.0-3f9c2ab-dirty`), else the release's version."""
|
|
27
|
+
chosen = release.version if stamp is None else stamp
|
|
28
|
+
if not provenance_tex.STAMP.fullmatch(chosen):
|
|
29
|
+
raise DeclarationError(
|
|
30
|
+
f"stamp {chosen!r}: letters, digits and . + : / @ - and space only",
|
|
31
|
+
code="TP-D006",
|
|
32
|
+
hint="it is printed on the page and written into the PDF's metadata, so it has to be the same text in both",
|
|
33
|
+
)
|
|
34
|
+
return chosen
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def time_of(now: datetime.datetime | None) -> str:
|
|
38
|
+
"""`now` in UTC, or the clock's. This is the one place texpkg reads the clock (N4): a caller that wants the
|
|
39
|
+
time pinned passes `now`."""
|
|
40
|
+
moment = now or datetime.datetime.now(datetime.UTC)
|
|
41
|
+
if moment.tzinfo is not None:
|
|
42
|
+
moment = moment.astimezone(datetime.UTC)
|
|
43
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def files(release: "Release", stamp: str, time: str) -> dict[str, str]:
|
|
47
|
+
"""The provenance file of every package in the stack, by file name."""
|
|
48
|
+
return {
|
|
49
|
+
provenance_tex.file_name(r.dist.package.name): provenance_tex.companion(
|
|
50
|
+
r.dist.package.prefix, r.dist.package.name, r.version, stamp, time
|
|
51
|
+
)
|
|
52
|
+
for r in release.releases()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def saved(root: pathlib.Path, release: "Release") -> Provenance | None:
|
|
57
|
+
"""The stamp and time of the top package's provenance file in `root`; `None` if it is not there."""
|
|
58
|
+
text = directory.read(root, release.layout_path(provenance_tex.file_name(release.dist.package.name)))
|
|
59
|
+
if text is None:
|
|
60
|
+
return None
|
|
61
|
+
return Provenance(*provenance_tex.read(text, release.dist.package.prefix))
|
texpkg/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: texpkg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LaTeX package management.
|
|
5
|
+
Author-Email: Ilja Orlovs <ilja@release.art>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Project-URL: Homepage, https://github.com/release-art/texpkg
|
|
12
|
+
Project-URL: Repository, https://github.com/release-art/texpkg
|
|
13
|
+
Requires-Python: <3.16,>=3.15
|
|
14
|
+
Requires-Dist: texmodel>=0.4.0
|
|
15
|
+
Requires-Dist: pydantic>=2.14.1
|
|
16
|
+
Provides-Extra: build
|
|
17
|
+
Requires-Dist: typer>=0.27.3; extra == "build"
|
|
18
|
+
Requires-Dist: pdm-backend>=2.4; extra == "build"
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# texpkg
|
|
22
|
+
|
|
23
|
+
LaTeX package management.
|
|
24
|
+
|
|
25
|
+
```zsh
|
|
26
|
+
pdm install
|
|
27
|
+
git config core.hooksPath .githooks # pre-commit runs ruff and pyright
|
|
28
|
+
bin/autoformat.sh
|
|
29
|
+
bin/test.sh # pytest, 100% coverage gate
|
|
30
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
texpkg-0.1.0.dist-info/METADATA,sha256=acTlQL8FfOd65nsz51ps3DQfndOYpkKIIMrXuJ97HuM,894
|
|
2
|
+
texpkg-0.1.0.dist-info/WHEEL,sha256=IuIk3wbaEnFH1cx75iM2R1CmliRI3dFLXTbRakgvgFQ,90
|
|
3
|
+
texpkg-0.1.0.dist-info/entry_points.txt,sha256=oUqhd04qxoHQRBgbkHgEIQj5UnlKkvyrTbsBMpS-s-8,70
|
|
4
|
+
texpkg-0.1.0.dist-info/licenses/LICENSE,sha256=URgqTzVBt4gXkRH2QdXRAmSR42oS1Vziq6C60LxzAoc,1068
|
|
5
|
+
texpkg/__init__.py,sha256=ERXQOzGTJDCMgjbB-2arlapJhxkN5xv7A7HCk2SsNZM,434
|
|
6
|
+
texpkg/_internal/__init__.py,sha256=wuskjiFnpPCgptbkhCFXj0_z2Dl6TWm8mhNLbuFoWMw,60
|
|
7
|
+
texpkg/_internal/adopt.py,sha256=En46yS0EDSW8Br4pgA_7g-o5ATeSp8x7qcEX_HQDVrM,5378
|
|
8
|
+
texpkg/_internal/classify.py,sha256=Uxs-2D4C0KiKGuYofEEs1QltopSi80TZcAqqBQwrCQU,9147
|
|
9
|
+
texpkg/_internal/directory.py,sha256=ewOgbesT2Dt6Ery_AvKj8gfDS2EP3xPYnJuFgpfQT_0,2015
|
|
10
|
+
texpkg/_internal/envelope.py,sha256=SLj0leS-RGuDgl7KmD7xpTfvT6xf6Ux25sqRpwzucBQ,1243
|
|
11
|
+
texpkg/_internal/export.py,sha256=chzZYnM9Pn1eUemaN883AGAczvd5YU8c5yLXFjlJ3sw,2313
|
|
12
|
+
texpkg/_internal/handedits.py,sha256=YALsA0bdUhjE56_zI3Jh2YZy4owoKtHVvfAEIn8tJwo,1413
|
|
13
|
+
texpkg/_internal/history.py,sha256=gsxIOcC83wcV-Wea11EAz02rFcQU6ITSt1E7or-a8xs,3829
|
|
14
|
+
texpkg/_internal/payload.py,sha256=rjjp-uT62ElSTyvmAJ_uvIw6SaE7Vaf-_qknaIJu5sY,5114
|
|
15
|
+
texpkg/_internal/provenance_tex.py,sha256=bWidYrA7XrVL8ozYqz08p1rPfuhlmpWUkxHjAghpAx8,3007
|
|
16
|
+
texpkg/_internal/release.py,sha256=NFDnXXG922HEqa4pSlvI3dIR5gwDbm1inIhdlTaGevY,11960
|
|
17
|
+
texpkg/_internal/snapshots.py,sha256=p0LYdyRcDm-mwhqqsGRczk36UKajwoowzPpGvq6EX0A,2580
|
|
18
|
+
texpkg/_internal/state.py,sha256=37vchgRcVF2KyLx1ds4btiF692sGdSroTTSKRp_W5_Y,4942
|
|
19
|
+
texpkg/_internal/versions.py,sha256=r3F6B76IdtQpGOUFm56i-lJJA2nIXEAsIA93tWpC34Y,2970
|
|
20
|
+
texpkg/assets.py,sha256=Qsv1P5cuBeT7CWdDfNCR9qv99pLIO-6MxZBh1ckqNFg,2105
|
|
21
|
+
texpkg/contracts.py,sha256=3owxcK7FP2G7AZxx-ZozpeaEAnnWNzJrfhng4sG6D9g,1578
|
|
22
|
+
texpkg/declaration.py,sha256=M0ihNSPhNqnYQDArEyFdrzy8PXFOhE8sp5HJZTFRxWU,17475
|
|
23
|
+
texpkg/document.py,sha256=VK5_5R7o5OLLyOQb3f6undOOXtDVQNU9Ihpk7t7y45s,9718
|
|
24
|
+
texpkg/errors.py,sha256=MZWkM8MeNo8-hPlOO4yeagPhpafWMQkTnI4lkO_l-hg,2523
|
|
25
|
+
texpkg/layout.py,sha256=-7nymRjX9kUvcbWSRdwn1-83zkHcOGHnvMy_j5RTZic,1822
|
|
26
|
+
texpkg/migration.py,sha256=m3OY6DYFtY9smB5G03iZxZ9uPCGznCSDjIHrNRm6s84,2406
|
|
27
|
+
texpkg/protocol.py,sha256=1zJiMPvTjxk9C_PhgdYrUYueX4PV1OFWUOl5LIb9Bfw,2124
|
|
28
|
+
texpkg/provenance.py,sha256=9uAIcSQA124HuihuD0iuMJX-aGfvcxctZQoq-gGvCK4,2386
|
|
29
|
+
texpkg/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
|
+
texpkg_build/__init__.py,sha256=XUHwnmcgvNpiN9bcsaTgnxieq0SJ-APAReLLU_1KYpA,69
|
|
31
|
+
texpkg_build/_internal/__init__.py,sha256=EOIj7Sj8W9wJiKcplrtfUeyM-olcY6ctjvZzXwQlwbg,57
|
|
32
|
+
texpkg_build/_internal/checks.py,sha256=1fHGHJZiRgYTrp-h-6gn9QaQpt7AZvZoMl9gyPGvDaU,7000
|
|
33
|
+
texpkg_build/_internal/embed.py,sha256=AFholRjHQQDX4ivERetaR3Y1uFWFxBXPyCwdqy8ZbbI,12161
|
|
34
|
+
texpkg_build/_internal/generate.py,sha256=Vm87ZITxCV3gjIvKYKgeOnVwtHIUCOU-n-GRyWXtyoQ,10000
|
|
35
|
+
texpkg_build/_internal/isolate.py,sha256=BLxYvUbZ3UzACCEkPDo6oilxV-mbUug158ri7zajNK8,3659
|
|
36
|
+
texpkg_build/_internal/snapshot.py,sha256=sy0e6e48jT5HV3onjvZcCs-vehY0cCokznqHgPlQAsk,14085
|
|
37
|
+
texpkg_build/cli.py,sha256=C_vW1kZTrpQWSY-ihUYdW5GXFICrQVKczW1tGPbUL-Q,3917
|
|
38
|
+
texpkg_build/hook.py,sha256=M4Pj1Jsln4WyugyOiJ0miWqXXgR0qPn0FXZCOD0_Tyw,3415
|
|
39
|
+
texpkg_build/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
40
|
+
texpkg_build/testing.py,sha256=R4yZdB31nrNMo7jFDSPr34hdZQupt6ay_62WsgazNtY,8563
|
|
41
|
+
texpkg-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ilja Orlovs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
texpkg_build/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The build of a texpkg package (never embedded in a wheel, B8)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The internal modules of the build: not public API."""
|