throughline 4.2.0__tar.gz → 4.3.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.
- {throughline-4.2.0/src/throughline.egg-info → throughline-4.3.0}/PKG-INFO +16 -4
- {throughline-4.2.0 → throughline-4.3.0}/README.md +15 -3
- {throughline-4.2.0 → throughline-4.3.0}/pyproject.toml +1 -1
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/apply.py +136 -11
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/brief.py +47 -10
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/cli.py +42 -2
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/composition.py +1 -1
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/dump.py +13 -1
- throughline-4.3.0/src/throughline/inherit.py +217 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/items.py +33 -1
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/model.py +36 -2
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/parser.py +11 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/schema_ops.py +79 -9
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/sources.py +36 -2
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/storage.py +197 -10
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/tomledit.py +4 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/union.py +2 -2
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/validate.py +16 -2
- {throughline-4.2.0 → throughline-4.3.0/src/throughline.egg-info}/PKG-INFO +16 -4
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/SOURCES.txt +3 -0
- throughline-4.3.0/tests/test_apply_once.py +197 -0
- throughline-4.3.0/tests/test_schema_source.py +316 -0
- {throughline-4.2.0 → throughline-4.3.0}/LICENSE +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/NOTICE +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/setup.cfg +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/__init__.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/collision.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/diagrams.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/filters.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/fingerprint.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/graph.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/grounding.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/identity.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/inject.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/links.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/ratification.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/resolvers.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/schema.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/seam.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/signatures.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/signed_links.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/uid.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/version.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/views.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline/worklist.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_amend.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_apply.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_baseline.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_clarify.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_deprecated_names.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_diagram_cli.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_doctor.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_engine.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_filters.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_flag.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_library_api.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_link_guard.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_migrate_withdrawn.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_normative_by_type.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_own_tree.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratification_change.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratification_withdrawal.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratified_content.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratified_deletion.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratifier_correction.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratify_several.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratify_status.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratify_transition_refused_first.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_register_type.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_resolve_collision.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_resolve_cross_graph.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_schema_ops.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_signatures.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_signed_links.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_spec_lists.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_tombstone_record.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_unset_attrs.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_worklist.py +0 -0
- {throughline-4.2.0 → throughline-4.3.0}/tests/test_yaml_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: throughline
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
|
|
5
5
|
Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,10 +36,10 @@ version control; a `check` command validates the whole graph and gates CI.
|
|
|
36
36
|
|
|
37
37
|
**Dogfooded:** throughline's own spec is itself a throughline project —
|
|
38
38
|
<!-- tl:count type == 'system_requirement' -->
|
|
39
|
-
|
|
39
|
+
247
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
42
|
+
39
|
|
43
43
|
<!-- tl:end --> user requirements, and
|
|
44
44
|
<!-- tl:count type == 'nfr' -->
|
|
45
45
|
21
|
|
@@ -164,7 +164,7 @@ detect items that "violate" a non-goal — that judgement stays with a human.
|
|
|
164
164
|
```
|
|
165
165
|
tl init [--name NAME] # scaffold a project
|
|
166
166
|
tl register new <PREFIX> <dir> [--parent P] # add a register
|
|
167
|
-
tl new <PREFIX> [--uid U] [--type T] [--ground UID] # allocate + create (grounded at birth)
|
|
167
|
+
tl new <PREFIX> [--uid U] [--type T] [--ground UID] [--key UUID4] # allocate + create (grounded at birth); a held key creates nothing
|
|
168
168
|
tl link <SRC> <DST> --type <kind> [--stamp] # add a typed link
|
|
169
169
|
tl delete <UID> --reason "…" # propose a deletion: a tombstone a human ratifies (never erased)
|
|
170
170
|
tl restore <UID> # decline a proposed deletion
|
|
@@ -241,6 +241,18 @@ reports every finding in `namespace:UID` vocabulary. `query`, `dump`, `docs`,
|
|
|
241
241
|
answer over the union too; everything else reads your graph alone, and a project
|
|
242
242
|
that declares no sources is untouched by any of this.
|
|
243
243
|
|
|
244
|
+
A graph whose own items follow a shared model — every deck in a series, every plan
|
|
245
|
+
written to one standard — can take its schema from a source instead of copying it.
|
|
246
|
+
Mark that one source `schema = true`; the project is then checked by the source's
|
|
247
|
+
item types, attributes, link types, statuses, transitions, roles and grounding,
|
|
248
|
+
plus whatever it adds itself. It may add an attribute, a type, a status or a link
|
|
249
|
+
type, but never redefine one the source declares: a widened enum or a changed
|
|
250
|
+
attribute is refused when the configuration is read, naming both values.
|
|
251
|
+
`tl migrate` removes a copy the source makes redundant, `tl context`,
|
|
252
|
+
`tl schema show` and `tl dump` mark what was inherited, and `tl schema` refuses
|
|
253
|
+
to change it. Every operation then reads that one source; the rest wait for the
|
|
254
|
+
union.
|
|
255
|
+
|
|
244
256
|
Sources are read-only, fetched once into a per-user cache and reached again only
|
|
245
257
|
when a tag or branch has moved; `TL_OFFLINE=1` composes from the cache alone. A
|
|
246
258
|
program embedding the Tool reaches a source through the `Resolver` it supplies, so
|
|
@@ -8,10 +8,10 @@ version control; a `check` command validates the whole graph and gates CI.
|
|
|
8
8
|
|
|
9
9
|
**Dogfooded:** throughline's own spec is itself a throughline project —
|
|
10
10
|
<!-- tl:count type == 'system_requirement' -->
|
|
11
|
-
|
|
11
|
+
247
|
|
12
12
|
<!-- tl:end --> system requirements,
|
|
13
13
|
<!-- tl:count type == 'user_requirement' -->
|
|
14
|
-
|
|
14
|
+
39
|
|
15
15
|
<!-- tl:end --> user requirements, and
|
|
16
16
|
<!-- tl:count type == 'nfr' -->
|
|
17
17
|
21
|
|
@@ -136,7 +136,7 @@ detect items that "violate" a non-goal — that judgement stays with a human.
|
|
|
136
136
|
```
|
|
137
137
|
tl init [--name NAME] # scaffold a project
|
|
138
138
|
tl register new <PREFIX> <dir> [--parent P] # add a register
|
|
139
|
-
tl new <PREFIX> [--uid U] [--type T] [--ground UID] # allocate + create (grounded at birth)
|
|
139
|
+
tl new <PREFIX> [--uid U] [--type T] [--ground UID] [--key UUID4] # allocate + create (grounded at birth); a held key creates nothing
|
|
140
140
|
tl link <SRC> <DST> --type <kind> [--stamp] # add a typed link
|
|
141
141
|
tl delete <UID> --reason "…" # propose a deletion: a tombstone a human ratifies (never erased)
|
|
142
142
|
tl restore <UID> # decline a proposed deletion
|
|
@@ -213,6 +213,18 @@ reports every finding in `namespace:UID` vocabulary. `query`, `dump`, `docs`,
|
|
|
213
213
|
answer over the union too; everything else reads your graph alone, and a project
|
|
214
214
|
that declares no sources is untouched by any of this.
|
|
215
215
|
|
|
216
|
+
A graph whose own items follow a shared model — every deck in a series, every plan
|
|
217
|
+
written to one standard — can take its schema from a source instead of copying it.
|
|
218
|
+
Mark that one source `schema = true`; the project is then checked by the source's
|
|
219
|
+
item types, attributes, link types, statuses, transitions, roles and grounding,
|
|
220
|
+
plus whatever it adds itself. It may add an attribute, a type, a status or a link
|
|
221
|
+
type, but never redefine one the source declares: a widened enum or a changed
|
|
222
|
+
attribute is refused when the configuration is read, naming both values.
|
|
223
|
+
`tl migrate` removes a copy the source makes redundant, `tl context`,
|
|
224
|
+
`tl schema show` and `tl dump` mark what was inherited, and `tl schema` refuses
|
|
225
|
+
to change it. Every operation then reads that one source; the rest wait for the
|
|
226
|
+
union.
|
|
227
|
+
|
|
216
228
|
Sources are read-only, fetched once into a per-user cache and reached again only
|
|
217
229
|
when a tag or branch has moved; `TL_OFFLINE=1` composes from the cache alone. A
|
|
218
230
|
program embedding the Tool reaches a source through the `Resolver` it supplies, so
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "throughline"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.3.0"
|
|
8
8
|
description = "A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -27,6 +27,14 @@ file the set had touched. Everything the set creates or changes is recorded
|
|
|
27
27
|
with a machine origin, so it enters the lifecycle as proposed and a person still
|
|
28
28
|
decides (SR-0092).
|
|
29
29
|
|
|
30
|
+
A set may be applied more than once and changes the graph once (SR-0280, SR-0281).
|
|
31
|
+
A ``new`` operation may carry a ``key``, a UUID version 4 recorded on the item it
|
|
32
|
+
creates; where a live item already carries that key the operation creates nothing,
|
|
33
|
+
and its label names that item. Any other operation whose result the graph already
|
|
34
|
+
holds — a link already there, an unlink of one that is not, an amend whose fields
|
|
35
|
+
already read so, a delete of a tombstone — is skipped and reported as already
|
|
36
|
+
applied, whatever revision it quotes, since nothing would be written.
|
|
37
|
+
|
|
30
38
|
These are the functions `tl apply` is made of; the command line is one caller
|
|
31
39
|
(SR-0224, SR-0225). A program embedding the Tool reaches them by name.
|
|
32
40
|
"""
|
|
@@ -40,7 +48,8 @@ from typing import Callable
|
|
|
40
48
|
|
|
41
49
|
from .fingerprint import fingerprint, revision
|
|
42
50
|
from .grounding import GroundingError, set_status
|
|
43
|
-
from .items import amend_item, delete_item, item_type_for,
|
|
51
|
+
from .items import (amend_item, check_key, delete_item, item_type_for, keyed_item,
|
|
52
|
+
new_item, parse_attrs)
|
|
44
53
|
from .links import GroundingView, LinkError, add_link, remove_link
|
|
45
54
|
from .model import Item, Link
|
|
46
55
|
from .schema import SchemaError
|
|
@@ -74,7 +83,7 @@ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
|
74
83
|
_KEYS: dict[str, tuple[set[str], set[str]]] = {
|
|
75
84
|
"new": ({"register"},
|
|
76
85
|
{"op", "register", "type", "title", "text", "rationale", "attrs",
|
|
77
|
-
"links", "label"}),
|
|
86
|
+
"links", "label", "key"}),
|
|
78
87
|
"amend": ({"uid"},
|
|
79
88
|
{"op", "uid", "title", "text", "rationale", "attrs",
|
|
80
89
|
"unset"}),
|
|
@@ -117,7 +126,10 @@ The five operations:
|
|
|
117
126
|
"links": [{{"target": "UR-0003", "type": "implements"}}], "label": "sr1"}}
|
|
118
127
|
creates an item in the register with that prefix. "type" may be left out
|
|
119
128
|
where the register declares one. "links" ground the item: a non-root item
|
|
120
|
-
must reach a root through them. "label" is optional.
|
|
129
|
+
must reach a root through them. "label" is optional. "key" is optional and
|
|
130
|
+
recommended: a fresh UUID version 4 (e.g. "3f0c1b7e-8a52-4c4e-9d61-2b7f5a9c0e14")
|
|
131
|
+
recorded on the item. Where an item already carries it, nothing is created
|
|
132
|
+
and the label names that item, so the set can be applied again safely.
|
|
121
133
|
|
|
122
134
|
{{"op": "amend", "uid": "SR-0012", "revision": "sha256:...", "title": "...",
|
|
123
135
|
"text": "...", "rationale": "...", "attrs": {{"priority": "must"}},
|
|
@@ -138,6 +150,10 @@ The five operations:
|
|
|
138
150
|
"reason": "..."}}
|
|
139
151
|
tombstones the item: its file stays and its UID is never reused.
|
|
140
152
|
|
|
153
|
+
Applying a set twice changes the graph once: an operation whose result the
|
|
154
|
+
graph already holds (a link already there, an amend whose fields already read
|
|
155
|
+
so, a delete of a deleted item) is skipped and reported as already applied.
|
|
156
|
+
|
|
141
157
|
Rules: no other keys; a "uid", "target" or "label" is written exactly; every
|
|
142
158
|
value is a string except "attrs" (strings, numbers or booleans), "stamp"
|
|
143
159
|
(boolean), and "unset" and "links" (lists). The list may also be sent as
|
|
@@ -229,6 +245,7 @@ def parse_change_set(text: str) -> list[dict]:
|
|
|
229
245
|
problems: list[str] = []
|
|
230
246
|
ops: list[dict] = []
|
|
231
247
|
labels: set[str] = set()
|
|
248
|
+
keys: set[str] = set()
|
|
232
249
|
for i, op in enumerate(data, start=1):
|
|
233
250
|
where = f"#{i}"
|
|
234
251
|
if not isinstance(op, dict):
|
|
@@ -255,7 +272,7 @@ def parse_change_set(text: str) -> list[dict]:
|
|
|
255
272
|
if missing:
|
|
256
273
|
problems.append(f"{name}: missing {', '.join(missing)}")
|
|
257
274
|
for key in ("uid", "target", "register", "type", "title", "text",
|
|
258
|
-
"rationale", "label", "reason", quoted):
|
|
275
|
+
"rationale", "label", "reason", "key", quoted):
|
|
259
276
|
if key in op and not isinstance(op[key], str):
|
|
260
277
|
problems.append(f"{name}: \"{key}\" must be a string")
|
|
261
278
|
if "stamp" in op and not isinstance(op["stamp"], bool):
|
|
@@ -289,6 +306,14 @@ def parse_change_set(text: str) -> list[dict]:
|
|
|
289
306
|
elif isinstance(op[quoted], str) and not _DIGEST_RE.match(op[quoted]):
|
|
290
307
|
problems.append(f"{name} {uid}: \"{quoted}\" must be the sha256:… "
|
|
291
308
|
"value the export gave")
|
|
309
|
+
if kind == "new" and isinstance(op.get("key"), str):
|
|
310
|
+
try:
|
|
311
|
+
check_key(op["key"])
|
|
312
|
+
except ValueError as e:
|
|
313
|
+
problems.append(f"{name}: {e}")
|
|
314
|
+
if op["key"] in keys:
|
|
315
|
+
problems.append(f"{name}: key {op['key']} is already used in this set")
|
|
316
|
+
keys.add(op["key"])
|
|
292
317
|
if kind == "new":
|
|
293
318
|
label = op.get("label")
|
|
294
319
|
if isinstance(label, str):
|
|
@@ -361,25 +386,83 @@ def apply_change_set(project, operations: list[dict], *,
|
|
|
361
386
|
ai = _ai_origin(schema)
|
|
362
387
|
problems: list[str] = []
|
|
363
388
|
labels: dict[str, str] = {}
|
|
364
|
-
|
|
365
|
-
|
|
389
|
+
# The operations whose result the graph already holds (SR-0280, SR-0281), by
|
|
390
|
+
# position: each is skipped rather than carried out.
|
|
391
|
+
held: set[int] = set()
|
|
392
|
+
# Held operations whose quoted version is no longer the item's: the mark of a
|
|
393
|
+
# set that has run before, as (position, name, uid, key, quoted, now).
|
|
394
|
+
stale: list[tuple[int, str, str, str, str, str]] = []
|
|
395
|
+
|
|
396
|
+
def version(op: dict, item: Item) -> tuple[str, str]:
|
|
397
|
+
key = "revision" if "revision" in op else "fingerprint"
|
|
398
|
+
return key, (revision(item) if key == "revision" else fingerprint(item, schema))
|
|
399
|
+
|
|
400
|
+
def resolved(ref: str) -> str | None:
|
|
401
|
+
"""A reference as it stands before anything is written: a UID, or the
|
|
402
|
+
existing item a label names, or ``None`` for an item still to be made."""
|
|
403
|
+
if not _is_label(ref):
|
|
404
|
+
return ref
|
|
405
|
+
return labels.get(ref[1:]) or None
|
|
406
|
+
|
|
407
|
+
def existing(name: str, op: dict, i: int) -> Item | None:
|
|
366
408
|
uid = op["uid"]
|
|
367
409
|
if _is_label(uid):
|
|
410
|
+
# A label naming an item the set found under its key is that item; an
|
|
411
|
+
# operation on it is judged as on any item created in the set, with
|
|
412
|
+
# no revision, but may already be held.
|
|
413
|
+
known = resolved(uid)
|
|
414
|
+
item = project.get(known) if known else None
|
|
415
|
+
if item is not None and holds(op, item):
|
|
416
|
+
held.add(i)
|
|
368
417
|
return None
|
|
369
418
|
item = project.get(uid)
|
|
370
419
|
if item is None:
|
|
371
420
|
problems.append(f"{name}: {uid} does not exist")
|
|
421
|
+
elif op["op"] == "delete" and item.is_deleted:
|
|
422
|
+
held.add(i)
|
|
372
423
|
elif item.is_deleted:
|
|
373
424
|
problems.append(f"{name}: {uid} is deleted — a tombstone is permanent")
|
|
425
|
+
elif holds(op, item):
|
|
426
|
+
held.add(i)
|
|
427
|
+
key, now = version(op, item)
|
|
428
|
+
if op.get(key) != now:
|
|
429
|
+
stale.append((i, name, uid, key, op.get(key), now))
|
|
374
430
|
else:
|
|
375
|
-
key =
|
|
376
|
-
now = revision(item) if key == "revision" else fingerprint(item, schema)
|
|
431
|
+
key, now = version(op, item)
|
|
377
432
|
if op.get(key) != now:
|
|
378
433
|
problems.append(f"{name}: {uid} changed since the export — the set "
|
|
379
434
|
f"quotes {key} {op.get(key)} but the item now "
|
|
380
435
|
f"carries {now}")
|
|
381
436
|
return item
|
|
382
437
|
|
|
438
|
+
def holds(op: dict, item: Item) -> bool:
|
|
439
|
+
"""Whether ``item`` already shows what ``op`` would make it (SR-0281)."""
|
|
440
|
+
kind = op["op"]
|
|
441
|
+
if kind == "delete":
|
|
442
|
+
return item.is_deleted
|
|
443
|
+
if kind in ("link", "unlink"):
|
|
444
|
+
target = resolved(op["target"])
|
|
445
|
+
if target is None:
|
|
446
|
+
return False
|
|
447
|
+
there = any(l.target == target and (op.get("type") is None
|
|
448
|
+
or l.type == op["type"])
|
|
449
|
+
for l in item.links)
|
|
450
|
+
return there if kind == "link" else not there
|
|
451
|
+
if kind == "amend":
|
|
452
|
+
for field_name in ("title", "text", "rationale"):
|
|
453
|
+
if field_name in op and getattr(item, field_name) != op[field_name]:
|
|
454
|
+
return False
|
|
455
|
+
try:
|
|
456
|
+
pairs = [f"{k}={v}" for k, v in (op.get("attrs") or {}).items()]
|
|
457
|
+
wanted = parse_attrs(schema, item.type, pairs, command="apply",
|
|
458
|
+
declared_only=True)
|
|
459
|
+
except (SchemaError, ValueError):
|
|
460
|
+
return False
|
|
461
|
+
if any(item.attrs.get(k) != v for k, v in wanted.items()):
|
|
462
|
+
return False
|
|
463
|
+
return not any(k in item.attrs for k in op.get("unset", ()))
|
|
464
|
+
return False
|
|
465
|
+
|
|
383
466
|
def target_known(name: str, ref: str) -> None:
|
|
384
467
|
if _is_label(ref) or find(ref) is not None:
|
|
385
468
|
return
|
|
@@ -398,15 +481,32 @@ def apply_change_set(project, operations: list[dict], *,
|
|
|
398
481
|
if schema.link_types and l["type"] not in schema.link_types:
|
|
399
482
|
problems.append(f"{name}: '{l['type']}' is not a link type this "
|
|
400
483
|
f"project declares ({', '.join(sorted(schema.link_types))})")
|
|
484
|
+
found = keyed_item(project, op["key"]) if "key" in op else None
|
|
485
|
+
if found is not None:
|
|
486
|
+
held.add(i)
|
|
401
487
|
if "label" in op:
|
|
402
|
-
labels[op["label"]] = ""
|
|
488
|
+
labels[op["label"]] = found.uid if found is not None else ""
|
|
403
489
|
continue
|
|
404
|
-
existing(name, op)
|
|
490
|
+
existing(name, op, i)
|
|
405
491
|
if kind in ("link", "unlink"):
|
|
406
492
|
target_known(name, op["target"])
|
|
407
493
|
if kind == "link" and schema.link_types and op["type"] not in schema.link_types:
|
|
408
494
|
problems.append(f"{name}: '{op['type']}' is not a link type this project "
|
|
409
495
|
f"declares ({', '.join(sorted(schema.link_types))})")
|
|
496
|
+
# A set that has run before can be applied again only where every item it
|
|
497
|
+
# creates carries a key (SR-0280): an unkeyed new operation cannot tell that
|
|
498
|
+
# it already ran, and would make its item a second time.
|
|
499
|
+
unkeyed = [f"#{i}" for i, op in enumerate(operations, start=1)
|
|
500
|
+
if op["op"] == "new" and "key" not in op]
|
|
501
|
+
if stale and unkeyed:
|
|
502
|
+
for _i, name, uid, key, quoted, now in stale:
|
|
503
|
+
problems.append(
|
|
504
|
+
f"{name}: {uid} changed since the export — the set quotes {key} "
|
|
505
|
+
f"{quoted} but the item now carries {now}. It already shows what "
|
|
506
|
+
f"this operation asks, so the set looks applied, and its new "
|
|
507
|
+
f"operation(s) {', '.join(unkeyed)} carry no key: applying it again "
|
|
508
|
+
"would create them twice. Nothing was changed. A set whose new "
|
|
509
|
+
"operations carry keys can be applied again safely (SR-0280)")
|
|
410
510
|
if problems:
|
|
411
511
|
raise ChangeSetError(problems)
|
|
412
512
|
|
|
@@ -445,6 +545,31 @@ def apply_change_set(project, operations: list[dict], *,
|
|
|
445
545
|
try:
|
|
446
546
|
for i, op in enumerate(operations, start=1):
|
|
447
547
|
kind = op["op"]
|
|
548
|
+
if i in held:
|
|
549
|
+
# Held when judged, and skipped if held still. Where an earlier
|
|
550
|
+
# operation in this set undid it, it is carried out as asked if the
|
|
551
|
+
# version it quoted was current, and otherwise refused: a set
|
|
552
|
+
# applied twice cannot tell that apart from a conflict.
|
|
553
|
+
if kind == "new":
|
|
554
|
+
found = keyed_item(project, op["key"])
|
|
555
|
+
if found is None:
|
|
556
|
+
raise ValueError(f"key {op['key']} no longer names an item")
|
|
557
|
+
if "label" in op:
|
|
558
|
+
labels[op["label"]] = found.uid
|
|
559
|
+
result.operations.append(OperationResult(
|
|
560
|
+
i, kind, found.uid, "already applied", f"key {op['key']}"))
|
|
561
|
+
continue
|
|
562
|
+
item = project.get(resolve(op["uid"]))
|
|
563
|
+
if item is not None and holds(op, item):
|
|
564
|
+
result.operations.append(OperationResult(
|
|
565
|
+
i, kind, item.uid, "already applied", ""))
|
|
566
|
+
continue
|
|
567
|
+
if i in {pos for pos, *_ in stale}:
|
|
568
|
+
raise ValueError("an earlier operation in this set undid what "
|
|
569
|
+
"this one found already done, and the "
|
|
570
|
+
"revision it quotes is not the item's")
|
|
571
|
+
# An earlier operation in the set undid it, and the version it
|
|
572
|
+
# quoted was current: it is carried out as asked.
|
|
448
573
|
if kind == "new":
|
|
449
574
|
prefix = op["register"]
|
|
450
575
|
item_type = item_type_for(project, prefix, op.get("type"))
|
|
@@ -452,7 +577,7 @@ def apply_change_set(project, operations: list[dict], *,
|
|
|
452
577
|
attrs = parse_attrs(schema, item_type, pairs, command="apply")
|
|
453
578
|
item = new_item(project, prefix, item_type=item_type,
|
|
454
579
|
title=op.get("title", ""), text=op.get("text", ""),
|
|
455
|
-
origin=ai, attrs=attrs)
|
|
580
|
+
origin=ai, attrs=attrs, key=op.get("key"))
|
|
456
581
|
if op.get("rationale"):
|
|
457
582
|
item.rationale = op["rationale"]
|
|
458
583
|
for l in op.get("links", []):
|
|
@@ -106,7 +106,27 @@ def _ctx_working(schema) -> str:
|
|
|
106
106
|
)
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def
|
|
109
|
+
def _from(inheritance, *path: str) -> str:
|
|
110
|
+
"""The mark on a declaration the schema source makes (SR-0279)."""
|
|
111
|
+
if inheritance is None or not inheritance.owns(*path):
|
|
112
|
+
return ""
|
|
113
|
+
return f" (from `{inheritance.namespace}`)"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ctx_schema_source(inheritance) -> str:
|
|
117
|
+
"""Where this project's schema comes from (SR-0279), so an agent changes a rule
|
|
118
|
+
where it is declared rather than in a file that only inherits it."""
|
|
119
|
+
ns = inheritance.namespace
|
|
120
|
+
return (
|
|
121
|
+
"## Schema source\n\n"
|
|
122
|
+
f"This project takes its schema from the source `{ns}` (SR-0275) and declares "
|
|
123
|
+
"only what it adds. Below, every declaration marked _(from "
|
|
124
|
+
f"`{ns}`)_ is the source's: it cannot be redefined here (SR-0276), and "
|
|
125
|
+
"`tl schema` refuses to change it (SR-0279). Change it in the source's own "
|
|
126
|
+
"graph and move this project's pin to take it.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ctx_types(schema, inheritance=None) -> str:
|
|
110
130
|
out = ["## Item types and their attributes\n"]
|
|
111
131
|
if not schema.types:
|
|
112
132
|
out.append("_This project declares no per-type attribute schema; item "
|
|
@@ -120,6 +140,8 @@ def _ctx_types(schema) -> str:
|
|
|
120
140
|
tags.append("delivery-root")
|
|
121
141
|
if not schema.is_normative(tname):
|
|
122
142
|
tags.append("non-normative")
|
|
143
|
+
if _from(inheritance, "type", tname):
|
|
144
|
+
tags.append(f"from `{inheritance.namespace}`")
|
|
123
145
|
tag = f" _({', '.join(tags)})_" if tags else ""
|
|
124
146
|
out.append(f"### `{tname}`{tag}\n")
|
|
125
147
|
specs = schema.attrs_for(tname)
|
|
@@ -132,19 +154,30 @@ def _ctx_types(schema) -> str:
|
|
|
132
154
|
s = specs[aname]
|
|
133
155
|
vals = ", ".join(f"`{v}`" for v in s.values) if s.values else "—"
|
|
134
156
|
out.append(
|
|
135
|
-
f"| `{aname}`
|
|
157
|
+
f"| `{aname}`{_from(inheritance, 'attr', tname, aname)} | "
|
|
158
|
+
f"{s.kind or 'free'} | "
|
|
136
159
|
f"{'yes' if s.required else 'no'} | "
|
|
137
160
|
f"{'yes' if s.normative else 'no'} | {vals} |")
|
|
138
161
|
out.append("")
|
|
139
162
|
return "\n".join(out).rstrip()
|
|
140
163
|
|
|
141
164
|
|
|
142
|
-
def
|
|
165
|
+
def _inherited_of(inheritance, kind: str, names) -> str:
|
|
166
|
+
"""The members of ``names`` the schema source declares, as a brief line."""
|
|
167
|
+
if inheritance is None:
|
|
168
|
+
return ""
|
|
169
|
+
theirs = [n for n in names if inheritance.owns(kind, n)]
|
|
170
|
+
return (f" Of these, {_fmt_set(theirs)} come from the schema source "
|
|
171
|
+
f"`{inheritance.namespace}`." if theirs else "")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _ctx_links(schema, inheritance=None) -> str:
|
|
143
175
|
out = ["## Links\n"]
|
|
144
176
|
if schema.link_types is None:
|
|
145
177
|
out.append("- **Link vocabulary:** unconstrained (any link type is legal).")
|
|
146
178
|
else:
|
|
147
|
-
out.append(f"- **Link vocabulary:** {_fmt_set(schema.link_types)}."
|
|
179
|
+
out.append(f"- **Link vocabulary:** {_fmt_set(schema.link_types)}."
|
|
180
|
+
+ _inherited_of(inheritance, "link", schema.link_types))
|
|
148
181
|
out.append(f"- **Grounding links** (these carry justification): "
|
|
149
182
|
f"{_fmt_set(schema.ground_link_types)}.")
|
|
150
183
|
if schema.link_rules:
|
|
@@ -164,18 +197,20 @@ def _ctx_links(schema) -> str:
|
|
|
164
197
|
return "\n".join(out)
|
|
165
198
|
|
|
166
199
|
|
|
167
|
-
def _ctx_status(schema) -> str:
|
|
200
|
+
def _ctx_status(schema, inheritance=None) -> str:
|
|
168
201
|
out = ["## Status and lifecycle\n"]
|
|
169
202
|
if schema.statuses is None:
|
|
170
203
|
out.append("- **Statuses:** unconstrained (any status string is legal).")
|
|
171
204
|
else:
|
|
172
|
-
out.append(f"- **Statuses:** {_fmt_set(schema.statuses)}."
|
|
205
|
+
out.append(f"- **Statuses:** {_fmt_set(schema.statuses)}."
|
|
206
|
+
+ _inherited_of(inheritance, "status", schema.statuses))
|
|
173
207
|
if schema.transitions:
|
|
174
208
|
out.append("- **Allowed transitions** (a status may only move along "
|
|
175
209
|
"these edges; staying put is always allowed):\n")
|
|
176
210
|
for frm in sorted(schema.transitions):
|
|
177
211
|
tos = schema.transitions[frm]
|
|
178
|
-
out.append(f" - `{frm}` → {_fmt_set(tos)}"
|
|
212
|
+
out.append(f" - `{frm}` → {_fmt_set(tos)}"
|
|
213
|
+
+ _from(inheritance, "transition", frm))
|
|
179
214
|
out.append("\n Render this as a diagram with "
|
|
180
215
|
"`tl diagram transitions`.")
|
|
181
216
|
else:
|
|
@@ -471,13 +506,15 @@ def context_markdown(project) -> str:
|
|
|
471
506
|
"configuration changes, regenerate it with `tl context`. "
|
|
472
507
|
"If a capability isn't described here, it isn't in the tool."
|
|
473
508
|
)
|
|
509
|
+
inheritance = project.inheritance
|
|
474
510
|
sections = [
|
|
475
511
|
header,
|
|
476
512
|
_ctx_idd(schema),
|
|
477
513
|
_ctx_working(schema),
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
514
|
+
*([_ctx_schema_source(inheritance)] if inheritance is not None else []),
|
|
515
|
+
_ctx_types(schema, inheritance),
|
|
516
|
+
_ctx_links(schema, inheritance),
|
|
517
|
+
_ctx_status(schema, inheritance),
|
|
481
518
|
_ctx_grounding(schema),
|
|
482
519
|
_ctx_coverage(schema),
|
|
483
520
|
_CTX_FORMAT,
|
|
@@ -83,10 +83,12 @@ from .items import (
|
|
|
83
83
|
Amendment,
|
|
84
84
|
amend_item,
|
|
85
85
|
birth_item,
|
|
86
|
+
check_key,
|
|
86
87
|
coerce_attr,
|
|
87
88
|
delete_item,
|
|
88
89
|
restore_item,
|
|
89
90
|
item_type_for,
|
|
91
|
+
keyed_item,
|
|
90
92
|
new_item,
|
|
91
93
|
newly_suspect,
|
|
92
94
|
parse_attrs,
|
|
@@ -286,6 +288,7 @@ def cmd_migrate(args) -> int:
|
|
|
286
288
|
or result.cached or result.normative or result.contents
|
|
287
289
|
or result.unprovable or result.registers or result.lifted
|
|
288
290
|
or result.tombstones or result.unattributed or result.withdrawn
|
|
291
|
+
or result.inherited
|
|
289
292
|
else " — nothing to migrate"))
|
|
290
293
|
else:
|
|
291
294
|
# Already at this major, but missing configuration the major requires —
|
|
@@ -388,6 +391,13 @@ def cmd_migrate(args) -> int:
|
|
|
388
391
|
"declared source that carries it:")
|
|
389
392
|
for ns in result.withdrawn:
|
|
390
393
|
print(f" {ns}")
|
|
394
|
+
# The copy of the schema source's declarations removed (SR-0277), each named
|
|
395
|
+
# so the keeper can read the diff against this list.
|
|
396
|
+
if result.inherited:
|
|
397
|
+
print(f"removed {len(result.inherited)} declaration(s) the schema source "
|
|
398
|
+
"already makes identically:")
|
|
399
|
+
for what in result.inherited:
|
|
400
|
+
print(f" {what}")
|
|
391
401
|
if result.registers:
|
|
392
402
|
print(f"declared the item type of {len(result.registers)} register(s), "
|
|
393
403
|
"which `tl new` now gives items born there (SR-0241):")
|
|
@@ -453,6 +463,24 @@ def cmd_schema(args) -> int:
|
|
|
453
463
|
return OK
|
|
454
464
|
|
|
455
465
|
|
|
466
|
+
def cmd_schema_show(args) -> int:
|
|
467
|
+
"""List the project's schema as formed, each declaration marked with the schema
|
|
468
|
+
source it came from (SR-0279)."""
|
|
469
|
+
try:
|
|
470
|
+
project = schema_ops.load(Path(args.path))
|
|
471
|
+
except schema_ops.SchemaOpError as e:
|
|
472
|
+
return _err(str(e))
|
|
473
|
+
rows = schema_ops.show(project)
|
|
474
|
+
if args.format == "json":
|
|
475
|
+
print(json.dumps(rows, indent=2, ensure_ascii=False))
|
|
476
|
+
return OK
|
|
477
|
+
for row in rows:
|
|
478
|
+
value = "" if row["value"] is True else f" = {json.dumps(row['value'])}"
|
|
479
|
+
origin = f" (from {row['from']})" if row["from"] else ""
|
|
480
|
+
print(f"{row['declaration']}{value}{origin}")
|
|
481
|
+
return OK
|
|
482
|
+
|
|
483
|
+
|
|
456
484
|
def cmd_schema_attr_remove(args) -> int:
|
|
457
485
|
"""Withdraw an attribute from an item type, naming the items that still carry
|
|
458
486
|
it, and with --unset remove it from them as well (SR-0207).
|
|
@@ -466,7 +494,7 @@ def cmd_schema_attr_remove(args) -> int:
|
|
|
466
494
|
project = schema_ops.load(root)
|
|
467
495
|
declared = project.schema.attr(args.itype, args.name) is not None
|
|
468
496
|
carriers = schema_ops.attr_carriers(project, args.itype, args.name)
|
|
469
|
-
type_declared = args.itype in (project.
|
|
497
|
+
type_declared = args.itype in (project.formed_config.get("types") or {})
|
|
470
498
|
if declared:
|
|
471
499
|
change = schema_ops.attr_remove(project, args.itype, args.name)
|
|
472
500
|
elif args.unset and carriers and type_declared:
|
|
@@ -689,11 +717,23 @@ def cmd_new(args) -> int:
|
|
|
689
717
|
# honoured, including for a root type (SR-0091).
|
|
690
718
|
default_type = args.ground_type or "derives_from"
|
|
691
719
|
named = list(args.ground or ())
|
|
720
|
+
# The same creation asked for again (SR-0280): the item made under this key
|
|
721
|
+
# is named, and nothing is created or written.
|
|
722
|
+
key = getattr(args, "key", None)
|
|
723
|
+
if key is not None:
|
|
724
|
+
try:
|
|
725
|
+
check_key(key)
|
|
726
|
+
except ValueError as e:
|
|
727
|
+
return _err(str(e))
|
|
728
|
+
held = keyed_item(project, key)
|
|
729
|
+
if held is not None:
|
|
730
|
+
print(f"exists {held.uid} -> {held._path} (key {key}) — nothing created")
|
|
731
|
+
return OK
|
|
692
732
|
try:
|
|
693
733
|
item = new_item(project, args.prefix, item_type=item_type, uid=args.uid,
|
|
694
734
|
title=args.title or "", text=args.text or "",
|
|
695
735
|
status=args.status, origin=args.origin, attrs=attrs,
|
|
696
|
-
ground=named, ground_type=default_type)
|
|
736
|
+
ground=named, ground_type=default_type, key=key)
|
|
697
737
|
except (ProjectError, GroundingError, SchemaError, UidError) as e:
|
|
698
738
|
return _err(str(e))
|
|
699
739
|
grounds = [(target, default_type) for target in named]
|
|
@@ -478,7 +478,7 @@ def local_view(view: Project, consumer: Project) -> Project:
|
|
|
478
478
|
SR-0040). Ownership is read from the consumer's own register set rather than
|
|
479
479
|
inferred from the shape of a displayed name, so narrowing cannot disagree with
|
|
480
480
|
what was loaded."""
|
|
481
|
-
out =
|
|
481
|
+
out = view.shell()
|
|
482
482
|
for prefix, reg in view.registers.items():
|
|
483
483
|
if prefix in consumer.registers:
|
|
484
484
|
out.registers[prefix] = reg
|
|
@@ -56,7 +56,7 @@ def build_dump(project: Project, tool_version: str) -> dict:
|
|
|
56
56
|
d["revision"] = revision(it)
|
|
57
57
|
return d
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
out = {
|
|
60
60
|
"throughline_dump": {
|
|
61
61
|
"dump_schema_version": DUMP_SCHEMA_VERSION,
|
|
62
62
|
"format_version": FORMAT_VERSION,
|
|
@@ -66,3 +66,15 @@ def build_dump(project: Project, tool_version: str) -> dict:
|
|
|
66
66
|
"registers": registers,
|
|
67
67
|
"items": [entry(it) for it in items],
|
|
68
68
|
}
|
|
69
|
+
# A project that takes its schema from a source (SR-0275) says so, with the
|
|
70
|
+
# schema as formed and every declaration it inherited (SR-0279). `config`
|
|
71
|
+
# stays the project's own file. Absent for every other project, whose dump is
|
|
72
|
+
# unchanged.
|
|
73
|
+
inheritance = project.inheritance
|
|
74
|
+
if inheritance is not None:
|
|
75
|
+
out["schema_source"] = {
|
|
76
|
+
"namespace": inheritance.namespace,
|
|
77
|
+
"formed": project.formed_config,
|
|
78
|
+
"inherited": sorted(list(p) for p in inheritance.paths),
|
|
79
|
+
}
|
|
80
|
+
return out
|