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.
Files changed (90) hide show
  1. texpkg-0.1.0/LICENSE +21 -0
  2. texpkg-0.1.0/PKG-INFO +30 -0
  3. texpkg-0.1.0/README.md +10 -0
  4. texpkg-0.1.0/pyproject.toml +202 -0
  5. texpkg-0.1.0/src/texpkg/__init__.py +18 -0
  6. texpkg-0.1.0/src/texpkg/_internal/__init__.py +1 -0
  7. texpkg-0.1.0/src/texpkg/_internal/adopt.py +119 -0
  8. texpkg-0.1.0/src/texpkg/_internal/classify.py +190 -0
  9. texpkg-0.1.0/src/texpkg/_internal/directory.py +48 -0
  10. texpkg-0.1.0/src/texpkg/_internal/envelope.py +37 -0
  11. texpkg-0.1.0/src/texpkg/_internal/export.py +49 -0
  12. texpkg-0.1.0/src/texpkg/_internal/handedits.py +33 -0
  13. texpkg-0.1.0/src/texpkg/_internal/history.py +91 -0
  14. texpkg-0.1.0/src/texpkg/_internal/payload.py +118 -0
  15. texpkg-0.1.0/src/texpkg/_internal/provenance_tex.py +65 -0
  16. texpkg-0.1.0/src/texpkg/_internal/release.py +257 -0
  17. texpkg-0.1.0/src/texpkg/_internal/snapshots.py +61 -0
  18. texpkg-0.1.0/src/texpkg/_internal/state.py +113 -0
  19. texpkg-0.1.0/src/texpkg/_internal/versions.py +61 -0
  20. texpkg-0.1.0/src/texpkg/assets.py +56 -0
  21. texpkg-0.1.0/src/texpkg/contracts.py +46 -0
  22. texpkg-0.1.0/src/texpkg/declaration.py +379 -0
  23. texpkg-0.1.0/src/texpkg/document.py +225 -0
  24. texpkg-0.1.0/src/texpkg/errors.py +66 -0
  25. texpkg-0.1.0/src/texpkg/layout.py +48 -0
  26. texpkg-0.1.0/src/texpkg/migration.py +73 -0
  27. texpkg-0.1.0/src/texpkg/protocol.py +61 -0
  28. texpkg-0.1.0/src/texpkg/provenance.py +61 -0
  29. texpkg-0.1.0/src/texpkg/py.typed +0 -0
  30. texpkg-0.1.0/src/texpkg_build/__init__.py +1 -0
  31. texpkg-0.1.0/src/texpkg_build/_internal/__init__.py +1 -0
  32. texpkg-0.1.0/src/texpkg_build/_internal/checks.py +154 -0
  33. texpkg-0.1.0/src/texpkg_build/_internal/embed.py +254 -0
  34. texpkg-0.1.0/src/texpkg_build/_internal/generate.py +254 -0
  35. texpkg-0.1.0/src/texpkg_build/_internal/isolate.py +86 -0
  36. texpkg-0.1.0/src/texpkg_build/_internal/snapshot.py +319 -0
  37. texpkg-0.1.0/src/texpkg_build/cli.py +83 -0
  38. texpkg-0.1.0/src/texpkg_build/hook.py +76 -0
  39. texpkg-0.1.0/src/texpkg_build/py.typed +0 -0
  40. texpkg-0.1.0/src/texpkg_build/testing.py +176 -0
  41. texpkg-0.1.0/tests/__init__.py +0 -0
  42. texpkg-0.1.0/tests/conftest.py +65 -0
  43. texpkg-0.1.0/tests/consumers/acme-brand-tex/pdm_build.py +3 -0
  44. texpkg-0.1.0/tests/consumers/acme-brand-tex/pyproject.toml +19 -0
  45. texpkg-0.1.0/tests/consumers/acme-deck-tex/pdm_build.py +3 -0
  46. texpkg-0.1.0/tests/consumers/acme-deck-tex/pyproject.toml +19 -0
  47. texpkg-0.1.0/tests/fixtures/acme_brand/__init__.py +90 -0
  48. texpkg-0.1.0/tests/fixtures/acme_brand/acme.tex +8 -0
  49. texpkg-0.1.0/tests/fixtures/acme_brand/assets/NOTICE.txt +2 -0
  50. texpkg-0.1.0/tests/fixtures/acme_brand/assets/lmroman10-regular.otf +0 -0
  51. texpkg-0.1.0/tests/fixtures/acme_brand/assets/lockup.pdf +0 -0
  52. texpkg-0.1.0/tests/fixtures/acme_brand/generated/README-expert.md +33 -0
  53. texpkg-0.1.0/tests/fixtures/acme_brand/generated/acme-brand-provenance.sty +8 -0
  54. texpkg-0.1.0/tests/fixtures/acme_brand/generated/acme-brand.sty +88 -0
  55. texpkg-0.1.0/tests/fixtures/acme_brand/generated/deck-compact.tex +21 -0
  56. texpkg-0.1.0/tests/fixtures/acme_brand/generated/deck-wide.tex +21 -0
  57. texpkg-0.1.0/tests/fixtures/acme_brand/generated/deck.tex +20 -0
  58. texpkg-0.1.0/tests/fixtures/acme_brand/generated/expert-template.tex +20 -0
  59. texpkg-0.1.0/tests/fixtures/acme_brand/generated/glossary.tex +12 -0
  60. texpkg-0.1.0/tests/fixtures/acme_brand/generated/plan.json +132 -0
  61. texpkg-0.1.0/tests/fixtures/acme_brand/generated/state.json +52 -0
  62. texpkg-0.1.0/tests/fixtures/acme_brand/model.py +125 -0
  63. texpkg-0.1.0/tests/fixtures/acme_brand/other/lockup.pdf +1 -0
  64. texpkg-0.1.0/tests/fixtures/acme_brand/tokens.json +3 -0
  65. texpkg-0.1.0/tests/fixtures/acme_deck/__init__.py +44 -0
  66. texpkg-0.1.0/tests/fixtures/acme_deck/assets/DECK.txt +1 -0
  67. texpkg-0.1.0/tests/fixtures/acme_deck/generated/brief.tex +17 -0
  68. texpkg-0.1.0/tests/fixtures/acme_deck/model.py +76 -0
  69. texpkg-0.1.0/tests/fixtures/acme_sheet/__init__.py +39 -0
  70. texpkg-0.1.0/tests/fixtures/acme_sheet/model.py +45 -0
  71. texpkg-0.1.0/tests/helpers.py +18 -0
  72. texpkg-0.1.0/tests/projects.py +57 -0
  73. texpkg-0.1.0/tests/spawn.py +27 -0
  74. texpkg-0.1.0/tests/test_build.py +206 -0
  75. texpkg-0.1.0/tests/test_compile.py +162 -0
  76. texpkg-0.1.0/tests/test_contracts.py +541 -0
  77. texpkg-0.1.0/tests/test_declaration.py +210 -0
  78. texpkg-0.1.0/tests/test_document.py +508 -0
  79. texpkg-0.1.0/tests/test_embed.py +404 -0
  80. texpkg-0.1.0/tests/test_end_to_end.py +156 -0
  81. texpkg-0.1.0/tests/test_export.py +80 -0
  82. texpkg-0.1.0/tests/test_isolated.py +83 -0
  83. texpkg-0.1.0/tests/test_migration.py +287 -0
  84. texpkg-0.1.0/tests/test_module.py +163 -0
  85. texpkg-0.1.0/tests/test_payload.py +168 -0
  86. texpkg-0.1.0/tests/test_profile.py +191 -0
  87. texpkg-0.1.0/tests/test_provenance.py +125 -0
  88. texpkg-0.1.0/tests/test_public_api.py +51 -0
  89. texpkg-0.1.0/tests/test_typing.py +79 -0
  90. 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,10 @@
1
+ # texpkg
2
+
3
+ LaTeX package management.
4
+
5
+ ```zsh
6
+ pdm install
7
+ git config core.hooksPath .githooks # pre-commit runs ruff and pyright
8
+ bin/autoformat.sh
9
+ bin/test.sh # pytest, 100% coverage gate
10
+ ```
@@ -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