texpkg 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- texpkg-0.1.0/PKG-INFO +30 -0
- texpkg-0.1.0/README.md +10 -0
- texpkg-0.1.0/pyproject.toml +202 -0
- texpkg-0.1.0/src/texpkg/__init__.py +18 -0
- texpkg-0.1.0/src/texpkg/_internal/__init__.py +1 -0
- texpkg-0.1.0/src/texpkg/_internal/adopt.py +119 -0
- texpkg-0.1.0/src/texpkg/_internal/classify.py +190 -0
- texpkg-0.1.0/src/texpkg/_internal/directory.py +48 -0
- texpkg-0.1.0/src/texpkg/_internal/envelope.py +37 -0
- texpkg-0.1.0/src/texpkg/_internal/export.py +49 -0
- texpkg-0.1.0/src/texpkg/_internal/handedits.py +33 -0
- texpkg-0.1.0/src/texpkg/_internal/history.py +91 -0
- texpkg-0.1.0/src/texpkg/_internal/payload.py +118 -0
- texpkg-0.1.0/src/texpkg/_internal/provenance_tex.py +65 -0
- texpkg-0.1.0/src/texpkg/_internal/release.py +257 -0
- texpkg-0.1.0/src/texpkg/_internal/snapshots.py +61 -0
- texpkg-0.1.0/src/texpkg/_internal/state.py +113 -0
- texpkg-0.1.0/src/texpkg/_internal/versions.py +61 -0
- texpkg-0.1.0/src/texpkg/assets.py +56 -0
- texpkg-0.1.0/src/texpkg/contracts.py +46 -0
- texpkg-0.1.0/src/texpkg/declaration.py +379 -0
- texpkg-0.1.0/src/texpkg/document.py +225 -0
- texpkg-0.1.0/src/texpkg/errors.py +66 -0
- texpkg-0.1.0/src/texpkg/layout.py +48 -0
- texpkg-0.1.0/src/texpkg/migration.py +73 -0
- texpkg-0.1.0/src/texpkg/protocol.py +61 -0
- texpkg-0.1.0/src/texpkg/provenance.py +61 -0
- texpkg-0.1.0/src/texpkg/py.typed +0 -0
- texpkg-0.1.0/src/texpkg_build/__init__.py +1 -0
- texpkg-0.1.0/src/texpkg_build/_internal/__init__.py +1 -0
- texpkg-0.1.0/src/texpkg_build/_internal/checks.py +154 -0
- texpkg-0.1.0/src/texpkg_build/_internal/embed.py +254 -0
- texpkg-0.1.0/src/texpkg_build/_internal/generate.py +254 -0
- texpkg-0.1.0/src/texpkg_build/_internal/isolate.py +86 -0
- texpkg-0.1.0/src/texpkg_build/_internal/snapshot.py +319 -0
- texpkg-0.1.0/src/texpkg_build/cli.py +83 -0
- texpkg-0.1.0/src/texpkg_build/hook.py +76 -0
- texpkg-0.1.0/src/texpkg_build/py.typed +0 -0
- texpkg-0.1.0/src/texpkg_build/testing.py +176 -0
- texpkg-0.1.0/tests/__init__.py +0 -0
- texpkg-0.1.0/tests/conftest.py +65 -0
- texpkg-0.1.0/tests/consumers/acme-brand-tex/pdm_build.py +3 -0
- texpkg-0.1.0/tests/consumers/acme-brand-tex/pyproject.toml +19 -0
- texpkg-0.1.0/tests/consumers/acme-deck-tex/pdm_build.py +3 -0
- texpkg-0.1.0/tests/consumers/acme-deck-tex/pyproject.toml +19 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/__init__.py +90 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/acme.tex +8 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/assets/NOTICE.txt +2 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/README-expert.md +33 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +8 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/acme-brand.sty +88 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/deck-compact.tex +21 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/deck-wide.tex +21 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/deck.tex +20 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/expert-template.tex +20 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/glossary.tex +12 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/plan.json +132 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/generated/state.json +52 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/model.py +125 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/other/lockup.pdf +1 -0
- texpkg-0.1.0/tests/fixtures/acme_brand/tokens.json +3 -0
- texpkg-0.1.0/tests/fixtures/acme_deck/__init__.py +44 -0
- texpkg-0.1.0/tests/fixtures/acme_deck/assets/DECK.txt +1 -0
- texpkg-0.1.0/tests/fixtures/acme_deck/generated/brief.tex +17 -0
- texpkg-0.1.0/tests/fixtures/acme_deck/model.py +76 -0
- texpkg-0.1.0/tests/fixtures/acme_sheet/__init__.py +39 -0
- texpkg-0.1.0/tests/fixtures/acme_sheet/model.py +45 -0
- texpkg-0.1.0/tests/helpers.py +18 -0
- texpkg-0.1.0/tests/projects.py +57 -0
- texpkg-0.1.0/tests/spawn.py +27 -0
- texpkg-0.1.0/tests/test_build.py +206 -0
- texpkg-0.1.0/tests/test_compile.py +162 -0
- texpkg-0.1.0/tests/test_contracts.py +541 -0
- texpkg-0.1.0/tests/test_declaration.py +210 -0
- texpkg-0.1.0/tests/test_document.py +508 -0
- texpkg-0.1.0/tests/test_embed.py +404 -0
- texpkg-0.1.0/tests/test_end_to_end.py +156 -0
- texpkg-0.1.0/tests/test_export.py +80 -0
- texpkg-0.1.0/tests/test_isolated.py +83 -0
- texpkg-0.1.0/tests/test_migration.py +287 -0
- texpkg-0.1.0/tests/test_module.py +163 -0
- texpkg-0.1.0/tests/test_payload.py +168 -0
- texpkg-0.1.0/tests/test_profile.py +191 -0
- texpkg-0.1.0/tests/test_provenance.py +125 -0
- texpkg-0.1.0/tests/test_public_api.py +51 -0
- texpkg-0.1.0/tests/test_typing.py +79 -0
- texpkg-0.1.0/tests/venvs.py +40 -0
texpkg-0.1.0/LICENSE
ADDED
|
@@ -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-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
texpkg-0.1.0/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "texpkg"
|
|
3
|
+
dynamic = []
|
|
4
|
+
description = "LaTeX package management."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Ilja Orlovs", email = "ilja@release.art" },
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Programming Language :: Python :: 3.15",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
requires-python = ">=3.15,<3.16"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"texmodel>=0.4.0",
|
|
21
|
+
"pydantic>=2.14.1",
|
|
22
|
+
]
|
|
23
|
+
version = "0.1.0"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
build = [
|
|
27
|
+
"typer>=0.27.3",
|
|
28
|
+
"pdm-backend>=2.4",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
texpkg-build = "texpkg_build.cli:app"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/release-art/texpkg"
|
|
36
|
+
Repository = "https://github.com/release-art/texpkg"
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
target-version = "py315"
|
|
40
|
+
line-length = 120
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = [
|
|
44
|
+
"A",
|
|
45
|
+
"B",
|
|
46
|
+
"C",
|
|
47
|
+
"E",
|
|
48
|
+
"F",
|
|
49
|
+
"I",
|
|
50
|
+
"W",
|
|
51
|
+
"N",
|
|
52
|
+
"C4",
|
|
53
|
+
"T20",
|
|
54
|
+
"PTH",
|
|
55
|
+
]
|
|
56
|
+
ignore = [
|
|
57
|
+
"N802",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint.per-file-ignores]
|
|
61
|
+
"__init__.py" = [
|
|
62
|
+
"F401",
|
|
63
|
+
"E402",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.isort]
|
|
67
|
+
order-by-type = true
|
|
68
|
+
combine-as-imports = true
|
|
69
|
+
force-wrap-aliases = true
|
|
70
|
+
known-first-party = [
|
|
71
|
+
"texpkg",
|
|
72
|
+
]
|
|
73
|
+
forced-separate = [
|
|
74
|
+
"tests",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.format]
|
|
78
|
+
line-ending = "lf"
|
|
79
|
+
|
|
80
|
+
[tool.pyright]
|
|
81
|
+
include = [
|
|
82
|
+
"src",
|
|
83
|
+
"tests",
|
|
84
|
+
]
|
|
85
|
+
strict = [
|
|
86
|
+
"src",
|
|
87
|
+
]
|
|
88
|
+
pythonVersion = "3.15"
|
|
89
|
+
extraPaths = [
|
|
90
|
+
"src",
|
|
91
|
+
"tests/fixtures",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.pytest.ini_options]
|
|
95
|
+
addopts = "-v -l --color=yes --cov=texpkg --cov=texpkg_build --cov-report term-missing --cov-fail-under=100 --no-cov-on-fail"
|
|
96
|
+
pythonpath = [
|
|
97
|
+
"src",
|
|
98
|
+
"tests/fixtures",
|
|
99
|
+
]
|
|
100
|
+
testpaths = [
|
|
101
|
+
"tests",
|
|
102
|
+
]
|
|
103
|
+
timeout = 15
|
|
104
|
+
markers = [
|
|
105
|
+
"tex: needs the reference image with latexmk (skipped without it; TEXPKG_REQUIRE_TEX=1 makes that a failure)",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.importlinter]
|
|
109
|
+
root_packages = [
|
|
110
|
+
"texpkg",
|
|
111
|
+
"texpkg_build",
|
|
112
|
+
]
|
|
113
|
+
include_external_packages = true
|
|
114
|
+
|
|
115
|
+
[[tool.importlinter.contracts]]
|
|
116
|
+
name = "the run time does not know the build"
|
|
117
|
+
type = "forbidden"
|
|
118
|
+
source_modules = [
|
|
119
|
+
"texpkg",
|
|
120
|
+
]
|
|
121
|
+
forbidden_modules = [
|
|
122
|
+
"texpkg_build",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[[tool.importlinter.contracts]]
|
|
126
|
+
name = "the run time imports only the standard library, texmodel and pydantic"
|
|
127
|
+
type = "forbidden"
|
|
128
|
+
source_modules = [
|
|
129
|
+
"texpkg",
|
|
130
|
+
]
|
|
131
|
+
forbidden_modules = [
|
|
132
|
+
"typer",
|
|
133
|
+
"pdm",
|
|
134
|
+
"jsonschema",
|
|
135
|
+
"hypothesis",
|
|
136
|
+
"pytest",
|
|
137
|
+
"texmodel_build",
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
[[tool.importlinter.contracts]]
|
|
141
|
+
name = "only cli prints and formats"
|
|
142
|
+
type = "forbidden"
|
|
143
|
+
source_modules = [
|
|
144
|
+
"texpkg",
|
|
145
|
+
"texpkg_build.hook",
|
|
146
|
+
"texpkg_build.testing",
|
|
147
|
+
"texpkg_build._internal",
|
|
148
|
+
]
|
|
149
|
+
forbidden_modules = [
|
|
150
|
+
"typer",
|
|
151
|
+
"texpkg_build.cli",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
[[tool.importlinter.contracts]]
|
|
155
|
+
name = "nothing starts a process"
|
|
156
|
+
type = "forbidden"
|
|
157
|
+
source_modules = [
|
|
158
|
+
"texpkg",
|
|
159
|
+
"texpkg_build",
|
|
160
|
+
]
|
|
161
|
+
forbidden_modules = [
|
|
162
|
+
"subprocess",
|
|
163
|
+
]
|
|
164
|
+
allow_indirect_imports = true
|
|
165
|
+
|
|
166
|
+
[tool.pdm]
|
|
167
|
+
distribution = true
|
|
168
|
+
|
|
169
|
+
[tool.pdm.version]
|
|
170
|
+
source = "scm"
|
|
171
|
+
tag_regex = "^v(?P<version>\\d+\\.\\d+\\.\\d+.*)$"
|
|
172
|
+
fallback_version = "0.0.0"
|
|
173
|
+
|
|
174
|
+
[tool.pdm.build]
|
|
175
|
+
package-dir = "src"
|
|
176
|
+
includes = [
|
|
177
|
+
"src/texpkg",
|
|
178
|
+
"src/texpkg_build",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
[build-system]
|
|
182
|
+
requires = [
|
|
183
|
+
"pdm-backend",
|
|
184
|
+
]
|
|
185
|
+
build-backend = "pdm.backend"
|
|
186
|
+
|
|
187
|
+
[dependency-groups]
|
|
188
|
+
dev = [
|
|
189
|
+
"pytest>=9.1.1",
|
|
190
|
+
"pytest-cov>=7.1.0",
|
|
191
|
+
"pytest-mock>=3.16.0",
|
|
192
|
+
"pytest-timeout>=2.4.0",
|
|
193
|
+
"ruff>=0.17.1",
|
|
194
|
+
"pyright>=1.1.414",
|
|
195
|
+
"pip-audit>=2.7",
|
|
196
|
+
"hypothesis>=6",
|
|
197
|
+
"import-linter>=2.15",
|
|
198
|
+
"jsonschema>=4.26.0",
|
|
199
|
+
"pdm-backend>=2.5.0",
|
|
200
|
+
"pikepdf>=10.17.0",
|
|
201
|
+
"proof-pdf>=0.3.3",
|
|
202
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""texpkg: TeX packages published as Python packages. The declaration types an author writes."""
|
|
2
|
+
|
|
3
|
+
from .assets import Asset, Role
|
|
4
|
+
from .declaration import Design, Distribution, Kind, Variant
|
|
5
|
+
from .migration import DefaultChange, Migrated, Unresolved, migration
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Asset",
|
|
9
|
+
"DefaultChange",
|
|
10
|
+
"Design",
|
|
11
|
+
"Distribution",
|
|
12
|
+
"Kind",
|
|
13
|
+
"Migrated",
|
|
14
|
+
"Role",
|
|
15
|
+
"Unresolved",
|
|
16
|
+
"Variant",
|
|
17
|
+
"migration",
|
|
18
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The internal modules of the run time: not public API."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Adopting a release: the stored state of a document becomes a valid tree of this release, or nothing does (V14, V22,
|
|
2
|
+
V23, V29, V31, V32).
|
|
3
|
+
|
|
4
|
+
Every migration across the `data` versions crossed runs, in the order the build's, the kind's, the consumer's; the
|
|
5
|
+
result is validated whole; any unresolved item, any validation error and any stored field the model no longer has
|
|
6
|
+
left aborts with the report.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from texmodel.components import Node
|
|
14
|
+
|
|
15
|
+
from ..declaration import Kind
|
|
16
|
+
from ..errors import MigrationError
|
|
17
|
+
from ..migration import DefaultChange, Migration, Unresolved
|
|
18
|
+
from . import state
|
|
19
|
+
from .envelope import Envelope
|
|
20
|
+
from .history import problems, unplaced
|
|
21
|
+
|
|
22
|
+
UNRESOLVED = (
|
|
23
|
+
"$unresolved" # in the state a migration gets: what the earlier ones left, as `{"path", "reason", "value"}`
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _policy(changes: DefaultChange | Mapping[str, DefaultChange], path: str, key: str) -> DefaultChange:
|
|
28
|
+
"""The policy for a default: the one named by the field's data path, else by `Class.field`, else `FOLLOW` (V23)."""
|
|
29
|
+
if isinstance(changes, DefaultChange):
|
|
30
|
+
return changes
|
|
31
|
+
return changes.get(path, changes.get(key, DefaultChange.FOLLOW))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _walk(node: dict[str, Any], path: str = "") -> Iterator[tuple[str, dict[str, Any]]]:
|
|
35
|
+
yield path, node
|
|
36
|
+
for i, child in enumerate(node.get("content", ())):
|
|
37
|
+
yield from _walk(child, f"{path}/content/{i}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def default_changes(
|
|
41
|
+
tree: dict[str, Any],
|
|
42
|
+
old: Mapping[str, Any],
|
|
43
|
+
new: Mapping[str, Any],
|
|
44
|
+
policy: DefaultChange | Mapping[str, DefaultChange],
|
|
45
|
+
) -> list[dict[str, Any]]:
|
|
46
|
+
"""Apply the consumer's policy to every value still marked as a default whose default this release changed (V22): a
|
|
47
|
+
value that follows needs nothing (the model fills the new default); one that is frozen keeps the old value and stops
|
|
48
|
+
being marked; one that asks is returned as unresolved."""
|
|
49
|
+
changed = {key for key in old.keys() & new.keys() if old[key] != new[key]}
|
|
50
|
+
asked: list[dict[str, Any]] = []
|
|
51
|
+
for path, node in _walk(tree):
|
|
52
|
+
for field in list(node.get(state.DEFAULTS, ())):
|
|
53
|
+
key = f"{node.get('type')}.{field}"
|
|
54
|
+
if key not in changed:
|
|
55
|
+
continue
|
|
56
|
+
action = _policy(policy, f"{path}/{field}", key)
|
|
57
|
+
if action is DefaultChange.FREEZE:
|
|
58
|
+
node[field] = old[key]
|
|
59
|
+
node[state.DEFAULTS].remove(field)
|
|
60
|
+
elif action is DefaultChange.ASK:
|
|
61
|
+
reason = f"the default of {key} changed from {old[key]!r} to {new[key]!r}"
|
|
62
|
+
asked.append(
|
|
63
|
+
{"path": f"{path}/{field}", "reason": reason, "value": node.get(field), "boundary": "defaults"}
|
|
64
|
+
)
|
|
65
|
+
if not node.get(state.DEFAULTS, True):
|
|
66
|
+
del node[state.DEFAULTS]
|
|
67
|
+
return asked
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _kind_migrations(kind: Kind, boundary: int, base_data: Mapping[int, int] | None) -> list[Migration]:
|
|
71
|
+
"""The kind's own migrations for `boundary`. An exposed base kind's are numbered by the base: the ones for the base
|
|
72
|
+
versions that this package's `boundary` stands for run at it (P30)."""
|
|
73
|
+
if base_data is None:
|
|
74
|
+
return [m for m in kind.migrations if m.to == boundary]
|
|
75
|
+
low, high = base_data.get(boundary - 1, 0), base_data.get(boundary, 0)
|
|
76
|
+
return [m for m in kind.migrations if low < m.to <= high]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def adopt(
|
|
80
|
+
*,
|
|
81
|
+
kind: Kind,
|
|
82
|
+
envelope: Envelope,
|
|
83
|
+
current: int,
|
|
84
|
+
age: int,
|
|
85
|
+
consumer: Sequence[Migration],
|
|
86
|
+
policy: DefaultChange | Mapping[str, DefaultChange],
|
|
87
|
+
saved_defaults: Mapping[str, Any] | None,
|
|
88
|
+
base_data: Mapping[int, int] | None = None,
|
|
89
|
+
) -> Node:
|
|
90
|
+
"""The tree of `kind` that `envelope` describes at `data` version `current`, or a `MigrationError` naming everything
|
|
91
|
+
that no migration placed. Nothing is written."""
|
|
92
|
+
tree = copy.deepcopy(envelope.tree)
|
|
93
|
+
left: list[dict[str, Any]] = []
|
|
94
|
+
if saved_defaults is not None:
|
|
95
|
+
left += default_changes(tree, saved_defaults, state.defaults_table(kind.model), policy)
|
|
96
|
+
if envelope.data < current - age:
|
|
97
|
+
for boundary in range(envelope.data + 1, current + 1):
|
|
98
|
+
label = f"{boundary - 1} -> {boundary}"
|
|
99
|
+
for migration in (*_kind_migrations(kind, boundary, base_data), *(m for m in consumer if m.to == boundary)):
|
|
100
|
+
tree[UNRESOLVED] = left
|
|
101
|
+
result = migration(tree)
|
|
102
|
+
tree = result.state
|
|
103
|
+
left = [*tree.pop(UNRESOLVED, []), *({**vars(u), "boundary": label} for u in result.unresolved)]
|
|
104
|
+
schema = state.schema(kind.model, "validation")
|
|
105
|
+
left += [{**p, "boundary": "validation"} for p in unplaced(tree, schema)]
|
|
106
|
+
left += [{**p, "boundary": "validation"} for p in problems(kind.model, state.given(tree), schema)]
|
|
107
|
+
if left:
|
|
108
|
+
items = tuple(
|
|
109
|
+
Unresolved(i["path"], i["reason"], i.get("value"), i.get("schema") or {}, i.get("boundary", ""))
|
|
110
|
+
for i in left
|
|
111
|
+
)
|
|
112
|
+
shown = "; ".join(f"{u.path or '/'} ({u.boundary}): {u.reason}" for u in items)
|
|
113
|
+
raise MigrationError(
|
|
114
|
+
f"{len(items)} unresolved: {shown}",
|
|
115
|
+
code="TP-M003",
|
|
116
|
+
unresolved=items,
|
|
117
|
+
hint="extend the process with a migration of your own: load(path, migrations=[...])",
|
|
118
|
+
)
|
|
119
|
+
return state.tree_from_state(kind.model, tree)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Diffing two snapshots into bumps (V12, V13): Avro-style rules over schemas, names and signatures over the interface.
|
|
2
|
+
|
|
3
|
+
In the run time because `changes(old, new)` diffs two shipped snapshots. Written to move into texmodel (V9, X5): it
|
|
4
|
+
reads
|
|
5
|
+
JSON Schema and plain dicts, and only the triples of texpkg.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..contracts import Pair, Triple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@enum.unique
|
|
16
|
+
class Level(enum.IntEnum):
|
|
17
|
+
"""How much a change asks of a contract's consumers; the largest of a contract's changes decides its bump (V10)."""
|
|
18
|
+
|
|
19
|
+
NONE = 0
|
|
20
|
+
REVISION = 1 # implementation only: a description, a default, a doc
|
|
21
|
+
COMPATIBLE = 2 # an addition: old data and old callers still work
|
|
22
|
+
BREAKING = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class Change:
|
|
27
|
+
level: Level
|
|
28
|
+
where: str # `Class.field`, `kind expert`, `component Fact`
|
|
29
|
+
what: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_UPPER = ("maxLength", "maximum", "maxItems", "exclusiveMaximum") # lowering one tightens it
|
|
33
|
+
_LOWER = ("minLength", "minimum", "minItems", "exclusiveMinimum") # raising one tightens it
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compare_property(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
37
|
+
out: list[Change] = []
|
|
38
|
+
for key in ("type", "$ref", "const", "format", "pattern"):
|
|
39
|
+
if old.get(key) != new.get(key):
|
|
40
|
+
out.append(Change(Level.BREAKING, where, f"{key} changed from {old.get(key)!r} to {new.get(key)!r}"))
|
|
41
|
+
if (gone := [v for v in old.get("enum", ()) if v not in new.get("enum", ())]) or ("enum" in old) != ("enum" in new):
|
|
42
|
+
out.append(Change(Level.BREAKING, where, f"values no longer allowed: {gone or 'any'}"))
|
|
43
|
+
elif added := [v for v in new.get("enum", ()) if v not in old.get("enum", ())]:
|
|
44
|
+
out.append(Change(Level.COMPATIBLE, where, f"values added: {added}"))
|
|
45
|
+
for key in _UPPER + _LOWER:
|
|
46
|
+
was, now = old.get(key), new.get(key)
|
|
47
|
+
if was == now:
|
|
48
|
+
continue
|
|
49
|
+
tighter = now is not None and (was is None or (now < was if key in _UPPER else now > was))
|
|
50
|
+
out.append(Change(Level.BREAKING if tighter else Level.COMPATIBLE, where, f"{key} {was} -> {now}"))
|
|
51
|
+
if old.get("items") != new.get("items") and "items" in old:
|
|
52
|
+
out += _compare_items(where, old["items"], new.get("items", {}))
|
|
53
|
+
for key in ("default", "description", "title"):
|
|
54
|
+
if old.get(key) != new.get(key):
|
|
55
|
+
out.append(Change(Level.REVISION, where, f"{key} changed"))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _compare_items(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
60
|
+
"""The element schema of a list: a branch of a union that went is breaking, one that came is compatible."""
|
|
61
|
+
|
|
62
|
+
def refs(items: dict[str, Any]) -> set[str]:
|
|
63
|
+
branches = items.get("oneOf") or [items]
|
|
64
|
+
return {b.get("$ref", b.get("type", "")) for b in branches}
|
|
65
|
+
|
|
66
|
+
gone, added = refs(old) - refs(new), refs(new) - refs(old)
|
|
67
|
+
out = [Change(Level.BREAKING, where, f"{g} is no longer allowed here") for g in sorted(gone)]
|
|
68
|
+
out += [Change(Level.COMPATIBLE, where, f"{a} is allowed here") for a in sorted(added)]
|
|
69
|
+
return out or ([] if old == new else _compare_property(where, old, new))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _classes(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
73
|
+
out = {str(schema.get("title")): schema}
|
|
74
|
+
out |= {str(node.get("title", name)): node for name, node in schema.get("$defs", {}).items()}
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def schema_changes(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
79
|
+
"""What changed in the data a kind accepts (the input schema): the changes, each with its level (V12)."""
|
|
80
|
+
out: list[Change] = []
|
|
81
|
+
was, now = _classes(old), _classes(new)
|
|
82
|
+
for title in sorted(was.keys() - now.keys()):
|
|
83
|
+
out.append(Change(Level.BREAKING, title, "no longer part of the kind"))
|
|
84
|
+
for title in sorted(now.keys() - was.keys()):
|
|
85
|
+
out.append(Change(Level.COMPATIBLE, title, "added to the kind"))
|
|
86
|
+
for title in sorted(was.keys() & now.keys()):
|
|
87
|
+
before, after = was[title].get("properties", {}), now[title].get("properties", {})
|
|
88
|
+
renamed = {p.get("x-renamed-from"): name for name, p in after.items() if p.get("x-renamed-from")}
|
|
89
|
+
for name in before:
|
|
90
|
+
if name in after:
|
|
91
|
+
out += _compare_property(f"{title}.{name}", before[name], after[name])
|
|
92
|
+
elif name in renamed:
|
|
93
|
+
out.append(
|
|
94
|
+
Change(Level.COMPATIBLE, f"{title}.{name}", f"renamed {renamed[name]} (old name still read)")
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
out.append(Change(Level.BREAKING, f"{title}.{name}", "removed"))
|
|
98
|
+
now_required, was_required = set(now[title].get("required", ())), set(was[title].get("required", ()))
|
|
99
|
+
for name in sorted((now_required - was_required) & before.keys() & after.keys()):
|
|
100
|
+
out.append(Change(Level.BREAKING, f"{title}.{name}", "became required"))
|
|
101
|
+
for name in sorted(after.keys() - before.keys() - set(renamed.values())):
|
|
102
|
+
level = Level.BREAKING if name in now_required else Level.COMPATIBLE
|
|
103
|
+
out.append(Change(level, f"{title}.{name}", "added" + (" and required" if name in now_required else "")))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def interface_changes(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
108
|
+
"""What changed in the public names and their signatures, and in the kinds and variants (V12)."""
|
|
109
|
+
out: list[Change] = []
|
|
110
|
+
for section, label in (("components", "component"), ("kinds", "kind")):
|
|
111
|
+
was, now = old.get(section, {}), new.get(section, {})
|
|
112
|
+
out += [Change(Level.BREAKING, f"{label} {n}", "removed") for n in sorted(was.keys() - now.keys())]
|
|
113
|
+
out += [Change(Level.COMPATIBLE, f"{label} {n}", "added") for n in sorted(now.keys() - was.keys())]
|
|
114
|
+
for name in sorted(was.keys() & now.keys()):
|
|
115
|
+
out += _compare_entry(f"{label} {name}", was[name], now[name])
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _compare_entry(where: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
120
|
+
out: list[Change] = []
|
|
121
|
+
for key in sorted(old.keys() | new.keys()):
|
|
122
|
+
if old.get(key) == new.get(key):
|
|
123
|
+
continue
|
|
124
|
+
if key == "variants":
|
|
125
|
+
gone, added = set(old.get(key, ())) - set(new.get(key, ())), set(new.get(key, ())) - set(old.get(key, ()))
|
|
126
|
+
out += [Change(Level.BREAKING, where, f"variant {v} removed") for v in sorted(gone)]
|
|
127
|
+
out += [Change(Level.COMPATIBLE, where, f"variant {v} added") for v in sorted(added)]
|
|
128
|
+
elif key == "doc":
|
|
129
|
+
out.append(Change(Level.REVISION, where, "documentation changed"))
|
|
130
|
+
elif key == "signature":
|
|
131
|
+
added = new.get(key, [])[len(old.get(key, [])) :]
|
|
132
|
+
keeps = new.get(key, [])[: len(old.get(key, []))] == old.get(key, [])
|
|
133
|
+
if keeps and all(arg[2] for arg in added): # trailing optional arguments: old calls still work
|
|
134
|
+
out.append(Change(Level.COMPATIBLE, where, f"optional arguments added: {[a[0] for a in added]}"))
|
|
135
|
+
else:
|
|
136
|
+
out.append(Change(Level.BREAKING, where, "signature changed"))
|
|
137
|
+
else:
|
|
138
|
+
out.append(Change(Level.BREAKING, where, f"{key} changed from {old.get(key)!r} to {new.get(key)!r}"))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def worst(changes: list[Change]) -> Level:
|
|
143
|
+
return max((c.level for c in changes), default=Level.NONE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def bump(previous: Triple, level: Level) -> Triple:
|
|
147
|
+
"""libtool's rules (V10): none keeps it, a revision adds one, an addition raises `current` and `age`, a break
|
|
148
|
+
resets them."""
|
|
149
|
+
match level:
|
|
150
|
+
case Level.NONE:
|
|
151
|
+
return previous
|
|
152
|
+
case Level.REVISION:
|
|
153
|
+
return Triple(previous.current, previous.revision + 1, previous.age)
|
|
154
|
+
case Level.COMPATIBLE:
|
|
155
|
+
return Triple(previous.current + 1, 0, previous.age + 1)
|
|
156
|
+
case Level.BREAKING:
|
|
157
|
+
return Triple(previous.current + 1, 0, 0)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def bump_output(previous: Pair, change: str) -> Pair:
|
|
161
|
+
"""`output` has no age: `current` when the appearance changed, `revision` when only bytes did (V10). `change` is the
|
|
162
|
+
author's statement; an empty one means nothing changed."""
|
|
163
|
+
match change:
|
|
164
|
+
case "current":
|
|
165
|
+
return Pair(previous.current + 1, 0)
|
|
166
|
+
case "revision":
|
|
167
|
+
return Pair(previous.current, previous.revision + 1)
|
|
168
|
+
case _:
|
|
169
|
+
return previous
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
Version = tuple[int, int, int]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_version(text: str) -> Version:
|
|
176
|
+
major, minor, patch = (int(p) for p in text.split(".")[:3])
|
|
177
|
+
return major, minor, patch
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def required_release(previous: str, data: Level, interface: Level, output_changed: bool) -> str:
|
|
181
|
+
"""The smallest release number that does not understate the contracts (V13): a break is major, any other change
|
|
182
|
+
minor, no contract change a patch; each one place lower before 1.0."""
|
|
183
|
+
major, minor, patch = parse_version(previous)
|
|
184
|
+
breaking = Level.BREAKING in (data, interface)
|
|
185
|
+
changed = bool(data or interface) or output_changed
|
|
186
|
+
if major == 0:
|
|
187
|
+
return f"0.{minor + 1}.0" if breaking else f"0.{minor}.{patch + 1}"
|
|
188
|
+
if breaking:
|
|
189
|
+
return f"{major + 1}.0.0"
|
|
190
|
+
return f"{major}.{minor + 1}.0" if changed else f"{major}.{minor}.{patch + 1}"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The document directory (P27, D5): one visible `_texpkg/` folder beside the `.tex`, the payload and the consumer's own
|
|
2
|
+
files. Everything here takes the directory and text; nothing knows what a kind is."""
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
DIR = "_texpkg"
|
|
9
|
+
STATE = f"{DIR}/state.json" # the user's state (D1)
|
|
10
|
+
TEMPLATE = f"{DIR}/template.json" # the package as it was when the document was saved (D1, D6)
|
|
11
|
+
HISTORY = 3 # the last saves kept (D2)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plan_file(variant: str | None) -> str:
|
|
15
|
+
"""The saved plan of the `.tex` of `variant` (D7)."""
|
|
16
|
+
return f"{DIR}/plan.json" if variant is None else f"{DIR}/plan-{variant}.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read(root: pathlib.Path, rel: str) -> str | None:
|
|
20
|
+
path = root / rel
|
|
21
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def differing(root: pathlib.Path, files: Mapping[str, str]) -> dict[str, str]:
|
|
25
|
+
"""The files whose text on disk is not `files`' (absent counts): content, never time (A4)."""
|
|
26
|
+
return {rel: text for rel, text in files.items() if read(root, rel) != text}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write(root: pathlib.Path, files: Mapping[str, str]) -> None:
|
|
30
|
+
for rel, text in files.items():
|
|
31
|
+
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
(root / rel).write_text(text, encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rotate_history(root: pathlib.Path) -> None:
|
|
36
|
+
"""Keep the pair of states on disk as `history/1`, the earlier ones one further, and drop what is beyond `HISTORY`
|
|
37
|
+
(D2, D3). Nothing happens when there is no pair yet."""
|
|
38
|
+
pair = [rel for rel in (STATE, TEMPLATE) if (root / rel).is_file()]
|
|
39
|
+
if not pair:
|
|
40
|
+
return
|
|
41
|
+
history = root / DIR / "history"
|
|
42
|
+
shutil.rmtree(history / str(HISTORY), ignore_errors=True)
|
|
43
|
+
for n in range(HISTORY - 1, 0, -1):
|
|
44
|
+
if (history / str(n)).is_dir():
|
|
45
|
+
(history / str(n)).rename(history / str(n + 1))
|
|
46
|
+
(history / "1").mkdir(parents=True)
|
|
47
|
+
for rel in pair:
|
|
48
|
+
shutil.copyfile(root / rel, history / "1" / pathlib.PurePosixPath(rel).name)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The envelope of a stored document: `{package, kind, data, tree}` (K3). It names the package and kind the tree belongs
|
|
2
|
+
to and the `data` version it was written for (V14)."""
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import NotADocumentError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dumps(value: object) -> str:
|
|
12
|
+
"""The text of a state file: stable, readable, one trailing newline."""
|
|
13
|
+
return json.dumps(value, indent=2, ensure_ascii=False) + "\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass(frozen=True)
|
|
17
|
+
class Envelope:
|
|
18
|
+
package: str
|
|
19
|
+
kind: str
|
|
20
|
+
data: int
|
|
21
|
+
tree: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
def to_text(self) -> str:
|
|
24
|
+
return dumps({"package": self.package, "kind": self.kind, "data": self.data, "tree": self.tree})
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def parse(cls, text: str, where: str) -> "Envelope":
|
|
28
|
+
try:
|
|
29
|
+
found = json.loads(text)
|
|
30
|
+
return cls(str(found["package"]), str(found["kind"]), int(found["data"]), dict(found["tree"]))
|
|
31
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
32
|
+
raise NotADocumentError(
|
|
33
|
+
f"{where}: not a document state ({exc!r})",
|
|
34
|
+
code="TP-D001",
|
|
35
|
+
path=where,
|
|
36
|
+
hint="it is written by a kind's create and save",
|
|
37
|
+
) from exc
|