pyDiffTools 0.1.30__tar.gz → 0.1.31__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 (105) hide show
  1. {pydifftools-0.1.30/pyDiffTools.egg-info → pydifftools-0.1.31}/PKG-INFO +10 -1
  2. {pydifftools-0.1.30 → pydifftools-0.1.31}/README.rst +9 -0
  3. {pydifftools-0.1.30 → pydifftools-0.1.31/pyDiffTools.egg-info}/PKG-INFO +10 -1
  4. {pydifftools-0.1.30 → pydifftools-0.1.31}/pyDiffTools.egg-info/SOURCES.txt +1 -0
  5. pydifftools-0.1.31/pydifftools/flowchart/comparison.py +331 -0
  6. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/flowchart/graph.py +15 -18
  7. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/flowchart/watch_graph.py +195 -229
  8. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/git_gd_history.py +34 -16
  9. {pydifftools-0.1.30 → pydifftools-0.1.31}/pyproject.toml +1 -1
  10. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_git_gd_history.py +2 -0
  11. {pydifftools-0.1.30 → pydifftools-0.1.31}/LICENSE.md +0 -0
  12. {pydifftools-0.1.30 → pydifftools-0.1.31}/MANIFEST.in +0 -0
  13. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/_quarto.yml +0 -0
  14. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  15. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  16. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  17. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  18. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  19. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  20. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  21. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  22. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  23. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  24. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  25. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  26. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  27. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  28. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  29. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  30. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  31. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  32. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  33. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  34. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  35. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  36. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/example.qmd +0 -0
  37. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/example.tex +0 -0
  38. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/index.qmd +0 -0
  39. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  40. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  41. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  42. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  43. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/independent.qmd +0 -0
  44. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/index.qmd +0 -0
  45. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  46. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  47. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  48. {pydifftools-0.1.30 → pydifftools-0.1.31}/example_notebook/project1/test_include.qmd +0 -0
  49. {pydifftools-0.1.30 → pydifftools-0.1.31}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  50. {pydifftools-0.1.30 → pydifftools-0.1.31}/pyDiffTools.egg-info/entry_points.txt +0 -0
  51. {pydifftools-0.1.30 → pydifftools-0.1.31}/pyDiffTools.egg-info/requires.txt +0 -0
  52. {pydifftools-0.1.30 → pydifftools-0.1.31}/pyDiffTools.egg-info/top_level.txt +0 -0
  53. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/__init__.py +0 -0
  54. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/browser_lifecycle.py +0 -0
  55. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/check_numbers.py +0 -0
  56. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/command_line.py +0 -0
  57. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/command_registry.py +0 -0
  58. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/comment_functions.py +0 -0
  59. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/comment_tags.lua +0 -0
  60. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/comment_tags_margin.lua +0 -0
  61. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/comment_tags_no_comments.lua +0 -0
  62. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/comment_toggle.js +0 -0
  63. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/comments.css +0 -0
  64. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/continuous.py +0 -0
  65. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/copy_files.py +0 -0
  66. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/diff-doc.js +0 -0
  67. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/doc_contents.py +0 -0
  68. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/flowchart/__init__.py +0 -0
  69. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  70. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/forward_search.py +0 -0
  71. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/git_gd.py +0 -0
  72. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/git_gd_image.py +0 -0
  73. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/git_gd_qt.py +0 -0
  74. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/html_comments.py +0 -0
  75. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/html_uncomments.py +0 -0
  76. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/log_example.py +0 -0
  77. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/match_spaces.py +0 -0
  78. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/notebook/__init__.py +0 -0
  79. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/notebook/display.py +0 -0
  80. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/notebook/fast_build.py +0 -0
  81. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/notebook/tex_to_qmd.py +0 -0
  82. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/onewordify.py +0 -0
  83. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/onewordify_undo.py +0 -0
  84. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/outline.py +0 -0
  85. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/rearrange_tex.py +0 -0
  86. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/searchacro.py +0 -0
  87. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/separate_comments.py +0 -0
  88. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/split_conflict.py +0 -0
  89. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/unseparate_comments.py +0 -0
  90. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/update_check.py +0 -0
  91. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/wrap_sentences.py +0 -0
  92. {pydifftools-0.1.30 → pydifftools-0.1.31}/pydifftools/xml2xlsx.vbs +0 -0
  93. {pydifftools-0.1.30 → pydifftools-0.1.31}/setup.cfg +0 -0
  94. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_browser_lifecycle.py +0 -0
  95. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_check_single_use.py +0 -0
  96. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_command_line_help.py +0 -0
  97. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_continuous_shutdown.py +0 -0
  98. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_forward_search.py +0 -0
  99. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_git_gd_image.py +0 -0
  100. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_git_gd_qt.py +0 -0
  101. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_rrng.py +0 -0
  102. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_tex_to_qmd.py +0 -0
  103. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_update_check.py +0 -0
  104. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_wrap_check.py +0 -0
  105. {pydifftools-0.1.30 → pydifftools-0.1.31}/tests/test_wrapping.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.30
3
+ Version: 0.1.31
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -101,6 +101,15 @@ included are (listed in order of fun/utility):
101
101
  refreshed as you edit the file. This wraps the former
102
102
  ``flowchart/watch_graph.py`` script so all of its functionality is now
103
103
  available through the main ``pydifft`` entry point.
104
+ Add ``--diff-base HEAD`` (or a branch, tag, commit, or quoted reflog
105
+ reference) to compare against that Git snapshot throughout the preview
106
+ session. Additions are green, deletions are red and struck through, and
107
+ unchanged content is muted. Renames share one box; date, status, and
108
+ dependency changes are annotated. Navigation, filters, and reloads retain
109
+ the baseline, whose reference and commit appear below the graph.
110
+ Comparison uses the same repository-relative YAML path; a file absent
111
+ from the revision is treated as an empty plan. Historical file renames
112
+ are not followed, and comparison markup is never written to YAML.
104
113
  - `pydifft tex2qmd file.tex` converts LaTeX sources to Quarto markdown.
105
114
  The converter preserves custom observation blocks and errata tags while
106
115
  translating verbatim/python environments into fenced code blocks so the
@@ -46,6 +46,15 @@ included are (listed in order of fun/utility):
46
46
  refreshed as you edit the file. This wraps the former
47
47
  ``flowchart/watch_graph.py`` script so all of its functionality is now
48
48
  available through the main ``pydifft`` entry point.
49
+ Add ``--diff-base HEAD`` (or a branch, tag, commit, or quoted reflog
50
+ reference) to compare against that Git snapshot throughout the preview
51
+ session. Additions are green, deletions are red and struck through, and
52
+ unchanged content is muted. Renames share one box; date, status, and
53
+ dependency changes are annotated. Navigation, filters, and reloads retain
54
+ the baseline, whose reference and commit appear below the graph.
55
+ Comparison uses the same repository-relative YAML path; a file absent
56
+ from the revision is treated as an empty plan. Historical file renames
57
+ are not followed, and comparison markup is never written to YAML.
49
58
  - `pydifft tex2qmd file.tex` converts LaTeX sources to Quarto markdown.
50
59
  The converter preserves custom observation blocks and errata tags while
51
60
  translating verbatim/python environments into fenced code blocks so the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.30
3
+ Version: 0.1.31
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -101,6 +101,15 @@ included are (listed in order of fun/utility):
101
101
  refreshed as you edit the file. This wraps the former
102
102
  ``flowchart/watch_graph.py`` script so all of its functionality is now
103
103
  available through the main ``pydifft`` entry point.
104
+ Add ``--diff-base HEAD`` (or a branch, tag, commit, or quoted reflog
105
+ reference) to compare against that Git snapshot throughout the preview
106
+ session. Additions are green, deletions are red and struck through, and
107
+ unchanged content is muted. Renames share one box; date, status, and
108
+ dependency changes are annotated. Navigation, filters, and reloads retain
109
+ the baseline, whose reference and commit appear below the graph.
110
+ Comparison uses the same repository-relative YAML path; a file absent
111
+ from the revision is treated as an empty plan. Historical file renames
112
+ are not followed, and comparison markup is never written to YAML.
104
113
  - `pydifft tex2qmd file.tex` converts LaTeX sources to Quarto markdown.
105
114
  The converter preserves custom observation blocks and errata tags while
106
115
  translating verbatim/python environments into fenced code blocks so the
@@ -80,6 +80,7 @@ pydifftools/update_check.py
80
80
  pydifftools/wrap_sentences.py
81
81
  pydifftools/xml2xlsx.vbs
82
82
  pydifftools/flowchart/__init__.py
83
+ pydifftools/flowchart/comparison.py
83
84
  pydifftools/flowchart/dot_to_yaml.py
84
85
  pydifftools/flowchart/graph.py
85
86
  pydifftools/flowchart/watch_graph.py
@@ -0,0 +1,331 @@
1
+ """An immutable Git snapshot and a disposable plan comparison render model."""
2
+
3
+ from copy import deepcopy
4
+ from difflib import SequenceMatcher
5
+ import html
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import tempfile
10
+
11
+ from .graph import (
12
+ _filter_nodes_for_dot,
13
+ _format_label,
14
+ _normalize_graph_dates,
15
+ _node_text_with_due,
16
+ load_graph_yaml,
17
+ trace_ancestors,
18
+ )
19
+
20
+
21
+ class PlanComparison:
22
+ def __init__(self, path, revision):
23
+ path = Path(path).resolve()
24
+ try:
25
+ root = Path(
26
+ subprocess.check_output(
27
+ [
28
+ "git",
29
+ "-C",
30
+ str(path.parent),
31
+ "rev-parse",
32
+ "--show-toplevel",
33
+ ],
34
+ text=True,
35
+ stderr=subprocess.PIPE,
36
+ ).strip()
37
+ ).resolve()
38
+ relative = path.relative_to(root).as_posix()
39
+ self.commit = subprocess.check_output(
40
+ [
41
+ "git",
42
+ "-C",
43
+ str(root),
44
+ "rev-parse",
45
+ "--verify",
46
+ "--end-of-options",
47
+ f"{revision}^{{commit}}",
48
+ ],
49
+ text=True,
50
+ stderr=subprocess.PIPE,
51
+ ).strip()
52
+ listing = subprocess.check_output(
53
+ [
54
+ "git",
55
+ "-C",
56
+ str(root),
57
+ "ls-tree",
58
+ self.commit,
59
+ "--",
60
+ relative,
61
+ ],
62
+ stderr=subprocess.PIPE,
63
+ )
64
+ self.reference = revision
65
+ self.data = {"nodes": {}}
66
+ if listing:
67
+ content = subprocess.check_output(
68
+ [
69
+ "git",
70
+ "-C",
71
+ str(root),
72
+ "show",
73
+ f"{self.commit}:{relative}",
74
+ ],
75
+ stderr=subprocess.PIPE,
76
+ )
77
+ with tempfile.NamedTemporaryFile(suffix=".yaml") as snapshot:
78
+ snapshot.write(content)
79
+ snapshot.flush()
80
+ self.data = load_graph_yaml(snapshot.name)
81
+ _normalize_graph_dates(self.data)
82
+ except Exception as exc:
83
+ detail = getattr(exc, "stderr", None) or str(exc)
84
+ if isinstance(detail, bytes):
85
+ detail = detail.decode(errors="replace")
86
+ raise ValueError(
87
+ f"Cannot compare {path} against Git revision {revision!r}: "
88
+ f"{detail}"
89
+ ) from exc
90
+
91
+ def correspondence(self, current):
92
+ old = self.data["nodes"]
93
+ new = current["nodes"]
94
+ matches = {name: name for name in old.keys() & new.keys()}
95
+ old_text = {
96
+ k: " ".join(str(v.get("text", "")).split()) for k, v in old.items()
97
+ }
98
+ new_text = {
99
+ k: " ".join(str(v.get("text", "")).split()) for k, v in new.items()
100
+ }
101
+ # {{{ Match unique exact content before conservative edited renames
102
+ remaining_old = set(old) - matches.keys()
103
+ remaining_new = set(new) - set(matches.values())
104
+ for name in sorted(remaining_old):
105
+ candidates = [
106
+ k for k in remaining_new if new_text[k] == old_text[name]
107
+ ]
108
+ peers = [k for k in remaining_old if old_text[k] == old_text[name]]
109
+ if len(candidates) == len(peers) == 1:
110
+ matches[name] = candidates[0]
111
+ remaining_old -= matches.keys()
112
+ remaining_new -= set(matches.values())
113
+ scores = {}
114
+ for a in remaining_old:
115
+ for b in remaining_new:
116
+ scores[a, b] = SequenceMatcher(
117
+ None, old_text[a], new_text[b], autojunk=False
118
+ ).ratio()
119
+ anchors = dict(matches)
120
+ for (a, b), score in sorted(scores.items()):
121
+ if score < 0.9:
122
+ continue
123
+ alternatives = [
124
+ v
125
+ for (x, y), v in scores.items()
126
+ if (x == a or y == b) and (x, y) != (a, b)
127
+ ]
128
+ if alternatives and score - max(alternatives) < 0.1 - 1e-9:
129
+ continue
130
+ agrees = True
131
+ evidence = False
132
+ for relation in ("parents", "children"):
133
+ left = {
134
+ anchors[k]
135
+ for k in old[a].get(relation, [])
136
+ if k in anchors
137
+ }
138
+ right = set(new[b].get(relation, [])) & set(anchors.values())
139
+ evidence |= bool(left or right)
140
+ agrees &= left == right
141
+ if agrees and evidence:
142
+ matches[a] = b
143
+ # }}}
144
+ return matches
145
+
146
+ def label(self, before, after, width):
147
+ """Diff visible words while retaining the current label's markup."""
148
+ deleting = bool(before) and not after
149
+ if deleting:
150
+ after = before
151
+ old = _format_label(before or "", width)[1:-1]
152
+ new = _format_label(after or "", width)[1:-1]
153
+ old_words = re.findall(r"[^\s<>]+", re.sub(r"<[^>]*>", " ", old))
154
+ pieces = re.findall(r"<[^>]*>|\s+|[^\s<]+", new)
155
+ positions = [
156
+ i
157
+ for i, part in enumerate(pieces)
158
+ if not part.startswith("<") and not part.isspace()
159
+ ]
160
+ words = [pieces[i] for i in positions]
161
+ for op, a, b, c, d in SequenceMatcher(
162
+ None, old_words, words, autojunk=False
163
+ ).get_opcodes():
164
+ for index in range(c, d):
165
+ color = "#16803c" if op in ("insert", "replace") else "#888888"
166
+ pos = positions[index]
167
+ if deleting:
168
+ pieces[pos] = "<s>" + pieces[pos] + "</s>"
169
+ color = "#c62828"
170
+ pieces[pos] = f'<font color="{color}">{pieces[pos]}</font>'
171
+ if op in ("delete", "replace"):
172
+ removed = " ".join(old_words[a:b])
173
+ removed = f'<font color="#c62828"><s>{removed}</s></font> '
174
+ if c < len(positions):
175
+ pieces[positions[c]] = removed + pieces[positions[c]]
176
+ else:
177
+ pieces.append(removed)
178
+ return "".join(pieces)
179
+
180
+ def render(self, current, width, target=None, completed=False):
181
+ matches = self.correspondence(current)
182
+ old = self.data["nodes"]
183
+ new = current["nodes"]
184
+ reverse = {b: a for a, b in matches.items()}
185
+ if target is not None:
186
+ candidates = set(old) | set(new)
187
+ if target not in candidates:
188
+ candidates = [
189
+ k for k in candidates if k.lower() == target.lower()
190
+ ]
191
+ if len(candidates) != 1:
192
+ raise ValueError(
193
+ f"Task {target!r} not found or ambiguous."
194
+ )
195
+ target = candidates[0]
196
+ visible = []
197
+ views = []
198
+ for graph, focus in (
199
+ (current, matches.get(target, target)),
200
+ (self.data, reverse.get(target, target)),
201
+ ):
202
+ if target is not None:
203
+ names = [focus] + trace_ancestors(graph, focus)
204
+ view = _filter_nodes_for_dot(graph, names)
205
+ elif completed:
206
+ view = _filter_nodes_for_dot(graph, graph["nodes"], True)
207
+ else:
208
+ view = graph
209
+ visible.append(set(view["nodes"]))
210
+ views.append(view)
211
+ names = visible[0] | {matches.get(k, k) for k in visible[1]}
212
+ model = {"nodes": {}, "styles": deepcopy(current.get("styles", {}))}
213
+ model["styles"] = {
214
+ **deepcopy(self.data.get("styles", {})),
215
+ **model["styles"],
216
+ }
217
+ self.node_states = {}
218
+ self.edge_states = {}
219
+ for name in sorted(names):
220
+ previous = reverse.get(name, name if name in old else None)
221
+ node = deepcopy(new[name] if name in new else old[name])
222
+ before = old.get(previous, {})
223
+ after = new.get(name, {})
224
+ state = "same"
225
+ if name not in new:
226
+ state = "removed"
227
+ elif previous is None:
228
+ state = "added"
229
+ body = self.label(
230
+ before.get("text", ""), after.get("text", ""), width
231
+ )
232
+ if state in ("added", "removed"):
233
+ prefix = "added:" if state == "added" else "deleted:"
234
+ body = f"<b>{prefix}</b><br/>{body}"
235
+ if previous is not None and previous != name:
236
+ body = (
237
+ f'<font color="#c62828"><s>{html.escape(previous)}</s>'
238
+ "</font><br/>" + body
239
+ )
240
+ state = "changed"
241
+ # {{{ Explicit metadata changes, including date-only view edges
242
+ for key in ("due", "orig_due", "style", "parents", "children"):
243
+ left = before.get(key, "")
244
+ right = after.get(key, "")
245
+ if key in ("parents", "children"):
246
+ equal = {matches.get(k, k) for k in left} == set(right)
247
+ else:
248
+ equal = left == right
249
+ if equal:
250
+ if key == "due" and right:
251
+ dated = dict(after)
252
+ dated.pop("text", None)
253
+ body += (
254
+ "<br/>"
255
+ + _format_label(_node_text_with_due(dated), width)[
256
+ 1:-1
257
+ ]
258
+ )
259
+ continue
260
+ body += "<br/>" + self.label(
261
+ html.escape(f"{key}: {left or '(none)'}"),
262
+ html.escape(f"{key}: {right or '(none)'}"),
263
+ width,
264
+ )
265
+ # }}}
266
+ if state == "same" and body.count("#16803c") + body.count(
267
+ "#c62828"
268
+ ):
269
+ state = "changed"
270
+ node["_comparison_label"] = body
271
+ node["children"] = []
272
+ node["parents"] = []
273
+ model["nodes"][name] = node
274
+ self.node_states[name] = state
275
+ edges = []
276
+ for graph, mapping in ((self.data, matches), (current, {})):
277
+ edges.append(
278
+ {
279
+ (mapping.get(a, a), mapping.get(b, b))
280
+ for a, node in graph["nodes"].items()
281
+ for b in node.get("children", [])
282
+ }
283
+ )
284
+ visible_edges = {
285
+ (mapping.get(a, a), mapping.get(b, b))
286
+ for graph, mapping in ((views[1], matches), (views[0], {}))
287
+ for a, node in graph["nodes"].items()
288
+ for b in node.get("children", [])
289
+ }
290
+ for a, b in sorted(visible_edges):
291
+ if a not in names or b not in names:
292
+ continue
293
+ model["nodes"][a]["children"].append(b)
294
+ model["nodes"][b]["parents"].append(a)
295
+ self.edge_states[f"{a}->{b}"] = (
296
+ "same"
297
+ if (a, b) in edges[0] & edges[1]
298
+ else "added" if (a, b) in edges[1] else "removed"
299
+ )
300
+ return model
301
+
302
+ def style_svg(self, root, namespace):
303
+ for group in root.iter(f"{namespace}g"):
304
+ kind = group.get("class")
305
+ if kind not in ("node", "edge"):
306
+ continue
307
+ title = group.find(f"{namespace}title")
308
+ states = self.node_states if kind == "node" else self.edge_states
309
+ state = states.get(title.text if title is not None else "", "same")
310
+ color = {"added": "#16803c", "removed": "#c62828"}.get(
311
+ state, "#888888"
312
+ )
313
+ for item in group.iter():
314
+ tag = item.tag.split("}")[-1]
315
+ if tag in ("polygon", "path", "ellipse", "rect"):
316
+ item.set("stroke", color)
317
+ if kind == "node" and item.get("fill") != "none":
318
+ item.set("fill", "#f5f5f5")
319
+ if kind == "edge" and tag == "polygon":
320
+ item.set("fill", color)
321
+ if state == "removed":
322
+ item.set("stroke-dasharray", "5,3")
323
+ elif tag == "text":
324
+ if state in ("added", "removed"):
325
+ item.set("fill", color)
326
+ elif item.get("fill") not in ("#16803c", "#c62828"):
327
+ item.set("fill", "#888888")
328
+ if state == "removed":
329
+ item.set("text-decoration", "line-through")
330
+ elif state == "changed" and item.text in self.node_states:
331
+ item.set("fill", "#16803c")
@@ -42,15 +42,11 @@ def _str_presenter(dumper, data: str):
42
42
  return dumper.represent_scalar("tag:yaml.org,2002:str", data)
43
43
 
44
44
 
45
- def _register_block_str_presenter() -> None:
46
- """Register the multiline string presenter on all dumpers we use."""
47
-
48
- for dumper in (yaml.Dumper, yaml.SafeDumper, IndentDumper):
49
- if getattr(dumper, "yaml_representers", None) is not None:
50
- dumper.add_representer(str, _str_presenter)
51
-
52
-
53
- _register_block_str_presenter()
45
+ # {{{ Register multiline strings with each YAML dumper
46
+ for dumper in (yaml.Dumper, yaml.SafeDumper, IndentDumper):
47
+ if getattr(dumper, "yaml_representers", None) is not None:
48
+ dumper.add_representer(str, _str_presenter)
49
+ # }}}
54
50
 
55
51
 
56
52
  class EmptyGraphYamlError(ValueError):
@@ -428,12 +424,6 @@ def endpoint_projects(data):
428
424
  return projects
429
425
 
430
426
 
431
- def _node_label(text, wrap_width=55):
432
- if text is None:
433
- return ""
434
- return _format_label(text, wrap_width)
435
-
436
-
437
427
  def _normalize_graph_dates(data):
438
428
  # Normalize due dates to mm/dd/yy so the YAML is consistent across years.
439
429
  if "nodes" not in data:
@@ -482,9 +472,11 @@ def _append_node(
482
472
  if parent_due is None or not str(parent_due).strip():
483
473
  depends_on_undated_parent = True
484
474
  break
485
- label = _node_label(
486
- _node_text_with_due(node, depends_on_undated_parent), wrap_width
475
+ label = _format_label(
476
+ _node_text_with_due(node, depends_on_undated_parent) or "", wrap_width
487
477
  )
478
+ if "_comparison_label" in node:
479
+ label = "<" + node["_comparison_label"] + ">"
488
480
  task_link_line = (
489
481
  f'<font point-size="9">__WGRPH_TASK_LINK__:{node_name}</font>'
490
482
  )
@@ -734,6 +726,7 @@ def write_dot_from_yaml(
734
726
  filter_task=None,
735
727
  filter_completed=False,
736
728
  resolve_due_date_conflict=None,
729
+ comparison=None,
737
730
  ):
738
731
  data = load_graph_yaml(str(yaml_path), old_data=old_data)
739
732
  _normalize_graph_dates(data)
@@ -847,7 +840,11 @@ def write_dot_from_yaml(
847
840
  adjust_due_after_parents(name)
848
841
  # }}}
849
842
  data_for_dot = data
850
- if filter_task is not None:
843
+ if comparison is not None:
844
+ data_for_dot = comparison.render(
845
+ data, wrap_width, filter_task, filter_completed
846
+ )
847
+ elif filter_task is not None:
851
848
  # Limit the rendered graph to incomplete ancestors of the target task.
852
849
  if "nodes" not in data or filter_task not in data["nodes"]:
853
850
  matches = [