throughline 3.3.0__tar.gz → 3.4.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 (41) hide show
  1. {throughline-3.3.0/src/throughline.egg-info → throughline-3.4.0}/PKG-INFO +2 -2
  2. {throughline-3.3.0 → throughline-3.4.0}/README.md +1 -1
  3. {throughline-3.3.0 → throughline-3.4.0}/pyproject.toml +1 -1
  4. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/cli.py +16 -1
  5. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/ratification.py +72 -34
  6. {throughline-3.3.0 → throughline-3.4.0/src/throughline.egg-info}/PKG-INFO +2 -2
  7. {throughline-3.3.0 → throughline-3.4.0}/tests/test_engine.py +30 -0
  8. {throughline-3.3.0 → throughline-3.4.0}/tests/test_ratification_change.py +63 -0
  9. {throughline-3.3.0 → throughline-3.4.0}/LICENSE +0 -0
  10. {throughline-3.3.0 → throughline-3.4.0}/NOTICE +0 -0
  11. {throughline-3.3.0 → throughline-3.4.0}/setup.cfg +0 -0
  12. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/__init__.py +0 -0
  13. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/dump.py +0 -0
  14. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/filters.py +0 -0
  15. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/fingerprint.py +0 -0
  16. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/graph.py +0 -0
  17. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/grounding.py +0 -0
  18. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/identity.py +0 -0
  19. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/inject.py +0 -0
  20. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/model.py +0 -0
  21. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/schema.py +0 -0
  22. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/schema_ops.py +0 -0
  23. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/storage.py +0 -0
  24. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/tomledit.py +0 -0
  25. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/uid.py +0 -0
  26. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/validate.py +0 -0
  27. {throughline-3.3.0 → throughline-3.4.0}/src/throughline/version.py +0 -0
  28. {throughline-3.3.0 → throughline-3.4.0}/src/throughline.egg-info/SOURCES.txt +0 -0
  29. {throughline-3.3.0 → throughline-3.4.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  30. {throughline-3.3.0 → throughline-3.4.0}/src/throughline.egg-info/entry_points.txt +0 -0
  31. {throughline-3.3.0 → throughline-3.4.0}/src/throughline.egg-info/requires.txt +0 -0
  32. {throughline-3.3.0 → throughline-3.4.0}/src/throughline.egg-info/top_level.txt +0 -0
  33. {throughline-3.3.0 → throughline-3.4.0}/tests/test_amend.py +0 -0
  34. {throughline-3.3.0 → throughline-3.4.0}/tests/test_doctor.py +0 -0
  35. {throughline-3.3.0 → throughline-3.4.0}/tests/test_filters.py +0 -0
  36. {throughline-3.3.0 → throughline-3.4.0}/tests/test_ratification_withdrawal.py +0 -0
  37. {throughline-3.3.0 → throughline-3.4.0}/tests/test_ratifier_correction.py +0 -0
  38. {throughline-3.3.0 → throughline-3.4.0}/tests/test_ratify_several.py +0 -0
  39. {throughline-3.3.0 → throughline-3.4.0}/tests/test_ratify_status.py +0 -0
  40. {throughline-3.3.0 → throughline-3.4.0}/tests/test_schema_ops.py +0 -0
  41. {throughline-3.3.0 → throughline-3.4.0}/tests/test_yaml_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 3.3.0
3
+ Version: 3.4.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,7 +36,7 @@ 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
- 165
39
+ 166
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
42
  29
@@ -8,7 +8,7 @@ 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
- 165
11
+ 166
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
14
  29
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "3.3.0"
7
+ version = "3.4.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"
@@ -629,6 +629,21 @@ def cmd_link(args) -> int:
629
629
  print(f"retyped {src_uid} {dst_uid}: --{old_type}--> is now --{ltype}-->"
630
630
  + (" (stamped)" if args.stamp else ""))
631
631
  return OK
632
+ # The same edge twice is never wanted: with --stamp it is the re-confirm
633
+ # command SR-0034 promises, refreshing the stored fingerprint in place;
634
+ # without it there is nothing to add, and a parallel duplicate would hide
635
+ # a stale stamp behind a fresh one.
636
+ same = [ln for ln in src.links if ln.target == dst_uid and ln.type == ltype]
637
+ if same:
638
+ if not args.stamp:
639
+ return _err(f"{src_uid} --{ltype}--> {dst_uid} already exists "
640
+ f"(add --stamp to refresh its stamp, or --retype to "
641
+ f"change its type)")
642
+ for ln in same:
643
+ ln.stamp = stamp
644
+ write_item(src, project.register_of(src.uid))
645
+ print(f"restamped {src_uid} --{ltype}--> {dst_uid}")
646
+ return OK
632
647
  src.links.append(Link(target=dst_uid, type=ltype, stamp=stamp))
633
648
  write_item(src, project.register_of(src.uid))
634
649
  print(f"linked {src_uid} --{ltype}--> {dst_uid}"
@@ -2229,7 +2244,7 @@ def build_parser() -> argparse.ArgumentParser:
2229
2244
  s.add_argument("--type", default=None,
2230
2245
  help="link type (omit on a terminal to choose one)")
2231
2246
  s.add_argument("--stamp", action="store_true",
2232
- help="record target fingerprint (suspect tracking)")
2247
+ help="record the target's fingerprint, or refresh it on an edge that already exists (suspect tracking)")
2233
2248
  s.add_argument("--retype", action="store_true",
2234
2249
  help="change the type of the existing SRC -> DST link in "
2235
2250
  "place instead of adding a new one")
@@ -26,7 +26,9 @@ and each of those deserves to be named rather than smoothed over.
26
26
  Results are returned as structured data, with rendering kept separate
27
27
  (:func:`render_change`). Every other surface that must show this — a composing
28
28
  consumer, a review cockpit — would otherwise re-parse formatted text, which is a
29
- second copy of the rule, drifting from the first.
29
+ second copy of the rule, drifting from the first. The same holds one level down:
30
+ a changed prose field's diff is data too (:func:`diff_prose`, SR-0200), and the
31
+ terminal rendering here is one reader of it among others.
30
32
  """
31
33
  from __future__ import annotations
32
34
 
@@ -400,20 +402,46 @@ def _units(value: str) -> list[str]:
400
402
  return _SENTENCE_END.split(value) if value else [""]
401
403
 
402
404
 
403
- def _prose(was: object, now: object) -> bool:
405
+ def is_prose(was: object, now: object) -> bool:
406
+ """Whether a changed field is shown as a diff (SR-0198): both values are
407
+ strings and at least one holds whitespace. A token — a priority, a flag — is
408
+ shown as before and after on one line instead."""
404
409
  return (isinstance(was, str) and isinstance(now, str)
405
410
  and any(ch.isspace() for ch in was + now))
406
411
 
407
412
 
408
413
  #: A word of a unit and whether it is among the words that moved.
409
- _Word = tuple[str, bool]
414
+ Word = tuple[str, bool]
410
415
 
416
+ #: The marks a unit of a prose diff carries, as a git diff marks its lines.
417
+ KEPT, REMOVED, ADDED = " ", "-", "+"
411
418
 
412
- def _plain(unit: str) -> list[_Word]:
419
+
420
+ @dataclass(frozen=True)
421
+ class DiffUnit:
422
+ """One unit of a prose diff (SR-0200): a sentence, or a line where the value
423
+ has lines, marked :data:`KEPT`, :data:`REMOVED` or :data:`ADDED`, with each
424
+ word and whether it is among the words that moved. Words are marked only
425
+ between a removed sentence and the added one it plainly became; a unit
426
+ deleted, added or rewritten outright carries no marks."""
427
+ mark: str
428
+ words: tuple[Word, ...]
429
+
430
+ @property
431
+ def text(self) -> str:
432
+ return " ".join(w for w, _moved in self.words)
433
+
434
+ @property
435
+ def marked(self) -> bool:
436
+ """Whether any word of the unit is among the words that moved."""
437
+ return any(moved for _w, moved in self.words)
438
+
439
+
440
+ def _plain(unit: str) -> list[Word]:
413
441
  return [(w, False) for w in unit.split()]
414
442
 
415
443
 
416
- def _word_marks(removed: str, added: str) -> tuple[list[_Word], list[_Word]]:
444
+ def _word_marks(removed: str, added: str) -> tuple[list[Word], list[Word]]:
417
445
  """Both sides of a replaced sentence with the words that differ marked."""
418
446
  a, b = removed.split(), added.split()
419
447
  keep_a, keep_b = [False] * len(a), [False] * len(b)
@@ -433,7 +461,7 @@ _SAME_SENTENCE = 0.5
433
461
 
434
462
 
435
463
  def _pair_words(removed: list[str],
436
- added: list[str]) -> tuple[list[list[_Word]], list[list[_Word]]]:
464
+ added: list[str]) -> tuple[list[list[Word]], list[list[Word]]]:
437
465
  """The units of one hunk with the words that moved marked, wherever a removed
438
466
  sentence has a successor it plainly became. Each removed sentence takes the
439
467
  most similar added one still free; a sentence with no counterpart — deleted
@@ -456,12 +484,36 @@ def _pair_words(removed: list[str],
456
484
  return marks_r, marks_a
457
485
 
458
486
 
459
- def _wrap(words: list[_Word], *, first: str, rest: str,
460
- width: int) -> list[list[_Word]]:
461
- """Greedy word wrap that never breaks a word, measured on the words alone
487
+ def diff_prose(was: str, now: str) -> tuple[DiffUnit, ...]:
488
+ """The difference between two prose values as data (SR-0200). Kept units stay
489
+ in place, so a removed sentence sits above its replacement inside the
490
+ paragraph it belongs to, and removed units precede added ones within a hunk,
491
+ as they do in a git diff (SR-0198).
492
+
493
+ This is the one place the comparison is made. :func:`render_change` reads it
494
+ to paint a terminal, and a consumer with a screen of its own reads it too,
495
+ so the two can never disagree about what moved."""
496
+ a, b = _units(was), _units(now)
497
+ units: list[DiffUnit] = []
498
+ matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
499
+ for op, i1, i2, j1, j2 in matcher.get_opcodes():
500
+ if op == "equal":
501
+ units.extend(DiffUnit(KEPT, tuple(_plain(u))) for u in a[i1:i2])
502
+ continue
503
+ marks_r, marks_a = _pair_words(a[i1:i2], b[j1:j2])
504
+ units.extend(DiffUnit(REMOVED, tuple(w)) for w in marks_r)
505
+ units.extend(DiffUnit(ADDED, tuple(w)) for w in marks_a)
506
+ return tuple(units)
507
+
508
+
509
+ def wrap_words(words: list[Word] | tuple[Word, ...], *, first: str, rest: str,
510
+ width: int) -> list[list[Word]]:
511
+ """Greedy word wrap of a unit's words that never breaks a word and keeps each
512
+ word's mark. ``first`` and ``rest`` are the prefixes the caller will draw
513
+ before the first and the later lines; the wrap is measured on the words alone
462
514
  so that painting a line afterwards cannot lengthen it past ``width``."""
463
- lines: list[list[_Word]] = []
464
- line: list[_Word] = []
515
+ lines: list[list[Word]] = []
516
+ line: list[Word] = []
465
517
  used = len(first)
466
518
  for word, moved in words:
467
519
  need = len(word) + (1 if line else 0)
@@ -478,14 +530,13 @@ def _wrap(words: list[_Word], *, first: str, rest: str,
478
530
  def _render_prose(field: str, was: str, now: str, *, pad: str,
479
531
  columns: int, colour: bool) -> list[str]:
480
532
  """One field's change as its kept, removed and added units, each wrapped to
481
- the terminal (SR-0198). Kept units stay in place so the reader sees the
482
- removed sentence above its replacement, inside the paragraph it belongs to.
483
- Removed units precede added ones within a hunk, as they do in a git diff."""
533
+ the terminal (SR-0198). Built from :func:`diff_prose` and nothing else
534
+ (SR-0200): what is painted here is exactly what the data says."""
484
535
  lines = [f"{pad}{field}:"]
485
536
  indent = pad * 2
486
537
  width = max(columns, 40)
487
538
 
488
- def painted(wrapped: list[_Word]) -> str:
539
+ def painted(wrapped: list[Word]) -> str:
489
540
  # Consecutive moved words share one run, spaces included, so a reworded
490
541
  # phrase reads as a phrase and not as a row of blinking words.
491
542
  out: list[str] = []
@@ -506,27 +557,14 @@ def _render_prose(field: str, was: str, now: str, *, pad: str,
506
557
  out.append(_UNEMPHASIS)
507
558
  return "".join(out)
508
559
 
509
- def emit(mark: str, words: list[_Word]) -> None:
510
- first, rest = f"{indent}{mark} ", f"{indent} "
511
- paint = _PAINT[mark] if colour else ""
512
- for n, wrapped in enumerate(_wrap(words, first=first, rest=rest,
513
- width=width)):
560
+ for unit in diff_prose(was, now):
561
+ first, rest = f"{indent}{unit.mark} ", f"{indent} "
562
+ paint = _PAINT[unit.mark] if colour else ""
563
+ for n, wrapped in enumerate(wrap_words(unit.words, first=first, rest=rest,
564
+ width=width)):
514
565
  head = first if n == 0 else rest
515
566
  line = f"{head}{painted(wrapped)}".rstrip()
516
567
  lines.append(f"{paint}{line}{_RESET}" if paint else line)
517
-
518
- a, b = _units(was), _units(now)
519
- matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
520
- for op, i1, i2, j1, j2 in matcher.get_opcodes():
521
- if op == "equal":
522
- for unit in a[i1:i2]:
523
- emit(" ", _plain(unit))
524
- continue
525
- marks_r, marks_a = _pair_words(a[i1:i2], b[j1:j2])
526
- for words in marks_r:
527
- emit("-", words)
528
- for words in marks_a:
529
- emit("+", words)
530
568
  return lines
531
569
 
532
570
 
@@ -555,7 +593,7 @@ def render_change(change: RatificationChange, *, ratifier: str | None = None,
555
593
  f"(stamp {change.stamp}, ratified content at {change.revision[:9]}"
556
594
  f"{', cached' if change.cached else ''}):"]
557
595
  for c in change.changes:
558
- if _prose(c.was, c.now):
596
+ if is_prose(c.was, c.now):
559
597
  lines.extend(_render_prose(c.field, c.was, c.now, pad=pad,
560
598
  columns=columns, colour=colour))
561
599
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 3.3.0
3
+ Version: 3.4.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,7 +36,7 @@ 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
- 165
39
+ 166
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
42
  29
@@ -4376,6 +4376,36 @@ def _linked_pair(tmp_path):
4376
4376
  return root
4377
4377
 
4378
4378
 
4379
+ def test_link_same_edge_twice_is_an_error(tmp_path, capsys):
4380
+ """Adding an edge that already exists with the same type is refused rather
4381
+ than duplicated (SR-0143 names the single matching edge; SR-0034)."""
4382
+ root = _linked_pair(tmp_path)
4383
+ assert _cli(["-C", str(root), "link", "FR-0002", "FR-0001",
4384
+ "--type", "relates"]) != 0
4385
+ assert "already exists" in capsys.readouterr().err
4386
+ links = [ln for ln in load_project(str(root)).get("FR-0002").links
4387
+ if ln.target == "FR-0001"]
4388
+ assert len(links) == 1
4389
+
4390
+
4391
+ def test_link_stamp_refreshes_existing_edge_in_place(tmp_path):
4392
+ """`tl link --stamp` on an existing edge is the re-confirm command SR-0034
4393
+ promises: the stamp is refreshed on that edge and no second edge appears."""
4394
+ root = _linked_pair(tmp_path)
4395
+ assert _cli(["-C", str(root), "link", "FR-0002", "FR-0001",
4396
+ "--type", "relates", "--stamp"]) == 0
4397
+ links = [ln for ln in load_project(str(root)).get("FR-0002").links
4398
+ if ln.target == "FR-0001"]
4399
+ assert len(links) == 1 and links[0].stamp
4400
+ first = links[0].stamp
4401
+ assert _cli(["-C", str(root), "amend", "FR-0001", "--text", "changed"]) == 0
4402
+ assert _cli(["-C", str(root), "link", "FR-0002", "FR-0001",
4403
+ "--type", "relates", "--stamp"]) == 0
4404
+ links = [ln for ln in load_project(str(root)).get("FR-0002").links
4405
+ if ln.target == "FR-0001"]
4406
+ assert len(links) == 1 and links[0].stamp and links[0].stamp != first
4407
+
4408
+
4379
4409
  def test_link_retype_changes_type_in_place(tmp_path):
4380
4410
  """`tl link --retype` changes an existing edge's type rather than adding a
4381
4411
  parallel one, so a semantic-link review needs no YAML hand-editing (SR-0143)."""
@@ -16,7 +16,10 @@ import pytest
16
16
 
17
17
  from throughline.cli import main as cli_main
18
18
  from throughline.ratification import (
19
+ ADDED,
19
20
  CHANGED,
21
+ KEPT,
22
+ REMOVED,
20
23
  REVISION_ATTR,
21
24
  UNCHANGED,
22
25
  UNRATIFIED,
@@ -24,6 +27,9 @@ from throughline.ratification import (
24
27
  change_since_ratification,
25
28
  render_change,
26
29
  resolve_revision,
30
+ diff_prose,
31
+ is_prose,
32
+ wrap_words,
27
33
  )
28
34
  from throughline.storage import load_project
29
35
 
@@ -332,6 +338,63 @@ def test_a_sentence_replaced_by_two_pairs_with_the_one_it_became(graph):
332
338
  assert wholly_new.startswith("\033[32m") and "\033[7m" not in wholly_new
333
339
 
334
340
 
341
+ # --------------------------------------------- the diff offered as data (SR-0200)
342
+
343
+ def test_the_diff_is_offered_as_units_with_the_moved_words_marked():
344
+ """A consumer with a screen of its own reads the kept, removed and added units
345
+ in order, each word carrying whether it moved, and derives nothing (SR-0200)."""
346
+ units = diff_prose(PARAGRAPH, REWORDED)
347
+ assert [u.mark for u in units] == [REMOVED, ADDED, KEPT, KEPT]
348
+ removed, added, kept, _last = units
349
+ assert removed.text == "The Tool shall evict a cached widget after 3600 seconds."
350
+ assert added.text == "The Tool shall evict a cached widget when it is an hour old."
351
+ assert [w for w, moved in removed.words if moved] == ["after", "3600", "seconds."]
352
+ assert [w for w, moved in added.words if moved] == ["when", "it", "is", "an", "hour", "old."]
353
+ assert kept.text.startswith("It shall record") and not kept.marked
354
+
355
+
356
+ def test_the_terminal_rendering_is_built_from_the_same_data(graph):
357
+ """What tl ratify paints and what the data says are one account: every unit
358
+ the rendering shows is a unit the data names, in the same order with the same
359
+ words, and the words it highlights are the words the data marks (SR-0200)."""
360
+ _ratified_paragraph(graph)
361
+ project = load_project(graph)
362
+ change = change_since_ratification(project, project.get("REQ-0002"))
363
+ (field,) = change.changes
364
+ units = diff_prose(field.was, field.now)
365
+
366
+ plain = render_change(change, columns=400)[2:] # one line per unit
367
+ assert [ln.strip()[0] if ln.strip()[0] in "-+" else " " for ln in plain] \
368
+ == [u.mark for u in units]
369
+ assert [ln.strip().lstrip("-+ ") for ln in plain] == [u.text for u in units]
370
+
371
+ painted = render_change(change, columns=400, colour=True)[2:]
372
+ for line, unit in zip(painted, units):
373
+ marked_words = " ".join(w for w, moved in unit.words if moved)
374
+ assert _marked(line) == ([marked_words] if marked_words else [])
375
+
376
+
377
+ def test_only_prose_is_diffed():
378
+ """A token — a priority, a flag — is before and after on one line, so the
379
+ consumer asks the same question the renderer does before drawing a diff."""
380
+ assert not is_prose("should", "must")
381
+ assert not is_prose(None, "must")
382
+ assert is_prose("The Tool shall.", "The Tool must.")
383
+ assert is_prose("", "Two words.")
384
+
385
+
386
+ def test_a_unit_wraps_without_losing_a_word_or_its_mark():
387
+ """The wrap a consumer draws with keeps every word once, keeps each word's
388
+ mark, and never runs past the width once the prefixes are counted (SR-0200)."""
389
+ (removed, *_rest) = diff_prose(PARAGRAPH, REWORDED)
390
+ lines = wrap_words(removed.words, first=" - ", rest=" ", width=30)
391
+ assert len(lines) > 1
392
+ assert [w for ln in lines for w in ln] == list(removed.words)
393
+ for n, ln in enumerate(lines):
394
+ prefix = " - " if n == 0 else " "
395
+ assert len(prefix) + len(" ".join(w for w, _m in ln)) <= 30
396
+
397
+
335
398
  # ------------------------------------------------------ the revision cache (SR-0166)
336
399
 
337
400
  def test_migrate_caches_the_resolved_revision(graph):
File without changes
File without changes
File without changes