pyDiffTools 0.1.23__tar.gz → 0.1.25__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.
- {pydifftools-0.1.23/pyDiffTools.egg-info → pydifftools-0.1.25}/PKG-INFO +1 -1
- {pydifftools-0.1.23 → pydifftools-0.1.25/pyDiffTools.egg-info}/PKG-INFO +1 -1
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/browser_lifecycle.py +2 -1
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/command_line.py +41 -1
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/continuous.py +2 -1
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/graph.py +19 -24
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/watch_graph.py +286 -45
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pyproject.toml +1 -1
- {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_browser_lifecycle.py +1 -12
- {pydifftools-0.1.23 → pydifftools-0.1.25}/LICENSE.md +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/MANIFEST.in +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/README.rst +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/_quarto.yml +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/global.db +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/example.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/example.tex +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/index.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/independent.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/index.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tasks.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/test_include.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/test_include.qmd +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/SOURCES.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/dependency_links.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/entry_points.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/requires.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/top_level.txt +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/__init__.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/check_numbers.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/command_registry.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comment_functions.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comment_tags.lua +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comment_toggle.js +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comments.css +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/copy_files.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/diff-doc.js +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/doc_contents.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/__init__.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/dot_to_yaml.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/html_comments.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/html_uncomments.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/match_spaces.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/notebook/__init__.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/notebook/fast_build.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/notebook/tex_to_qmd.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/onewordify.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/onewordify_undo.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/outline.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/rearrange_tex.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/searchacro.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/separate_comments.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/split_conflict.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/unseparate_comments.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/update_check.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/wrap_sentences.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/xml2xlsx.vbs +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/setup.cfg +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_continuous_shutdown.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_rrng.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_tex_to_qmd.py +0 -0
- {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_update_check.py +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
def browser_window_is_alive(browser):
|
|
2
2
|
# Keep all browser liveness checks in one place so watch commands share
|
|
3
3
|
# the same shutdown behavior when a user closes the browser window.
|
|
4
|
+
# Do not probe with execute_script here: page navigations can briefly
|
|
5
|
+
# interrupt script execution even while the window is still open.
|
|
4
6
|
if browser is None:
|
|
5
7
|
return False
|
|
6
8
|
try:
|
|
7
9
|
handles = browser.window_handles
|
|
8
10
|
if not handles:
|
|
9
11
|
return False
|
|
10
|
-
browser.execute_script("return 1")
|
|
11
12
|
return True
|
|
12
13
|
except Exception:
|
|
13
14
|
return False
|
|
@@ -796,8 +796,35 @@ def pmd(arguments):
|
|
|
796
796
|
p1.wait()
|
|
797
797
|
|
|
798
798
|
|
|
799
|
+
def _subcommand_help_hint(prog):
|
|
800
|
+
return (
|
|
801
|
+
f"*** Run '{prog} --help <subcommand>' to learn about "
|
|
802
|
+
"subcommand options. ***"
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class PyDiffArgumentParser(argparse.ArgumentParser):
|
|
807
|
+
"""ArgumentParser with a clearer root-level missing-subcommand hint."""
|
|
808
|
+
|
|
809
|
+
def __init__(self, *args, is_root_parser=False, **kwargs):
|
|
810
|
+
super().__init__(*args, **kwargs)
|
|
811
|
+
self._pydifft_is_root_parser = is_root_parser
|
|
812
|
+
|
|
813
|
+
def error(self, message):
|
|
814
|
+
if self._pydifft_is_root_parser:
|
|
815
|
+
self.print_usage(sys.stderr)
|
|
816
|
+
hint = _subcommand_help_hint(self.prog)
|
|
817
|
+
self.exit(2, f"{self.prog}: error: {message}\n{hint}\n")
|
|
818
|
+
super().error(message)
|
|
819
|
+
|
|
820
|
+
|
|
799
821
|
def build_parser():
|
|
800
|
-
parser =
|
|
822
|
+
parser = PyDiffArgumentParser(
|
|
823
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
824
|
+
is_root_parser=True,
|
|
825
|
+
)
|
|
826
|
+
parser.epilog = _subcommand_help_hint(parser.prog)
|
|
827
|
+
parser._pydifft_subparsers = {}
|
|
801
828
|
subparsers = parser.add_subparsers(dest="command")
|
|
802
829
|
subparsers.required = True
|
|
803
830
|
for name, spec in _COMMAND_SPECS.items():
|
|
@@ -823,10 +850,18 @@ def build_parser():
|
|
|
823
850
|
action.completer = FilesCompleter(
|
|
824
851
|
allowednames=["*.yaml", "*.yml"]
|
|
825
852
|
)
|
|
853
|
+
if (
|
|
854
|
+
FilesCompleter is not None
|
|
855
|
+
and name == "cpb"
|
|
856
|
+
and action.dest == "filename"
|
|
857
|
+
):
|
|
858
|
+
# Provide Markdown-only completions for continuous pandoc build.
|
|
859
|
+
action.completer = FilesCompleter(allowednames=["*.md"])
|
|
826
860
|
if name == "wgrph" and action.dest == "t":
|
|
827
861
|
# Offer case-insensitive completions for incomplete task names.
|
|
828
862
|
action.completer = wgrph_task_completer
|
|
829
863
|
subparser.set_defaults(_handler=spec["handler"])
|
|
864
|
+
parser._pydifft_subparsers[name] = subparser
|
|
830
865
|
return parser
|
|
831
866
|
|
|
832
867
|
|
|
@@ -859,6 +894,11 @@ def main(argv=None):
|
|
|
859
894
|
if not argv:
|
|
860
895
|
parser.print_help()
|
|
861
896
|
return
|
|
897
|
+
if argv[0] in ("-h", "--help") and len(argv) > 1:
|
|
898
|
+
subcommand = argv[1]
|
|
899
|
+
if subcommand in parser._pydifft_subparsers:
|
|
900
|
+
parser._pydifft_subparsers[subcommand].print_help()
|
|
901
|
+
return
|
|
862
902
|
namespace = parser.parse_args(argv)
|
|
863
903
|
handler = namespace._handler
|
|
864
904
|
handler_kwargs = dict(vars(namespace))
|
|
@@ -312,7 +312,8 @@ position
|
|
|
312
312
|
);
|
|
313
313
|
});
|
|
314
314
|
|
|
315
|
-
// When the page has loaded,
|
|
315
|
+
// When the page has loaded,
|
|
316
|
+
// restore hidden comments and scroll position
|
|
316
317
|
window.addEventListener('load', function() {
|
|
317
318
|
var hiddenCommentIndexes = sessionStorage.getItem(
|
|
318
319
|
'commentHiddenBubbleIndexes'
|
|
@@ -378,28 +378,26 @@ def _normalize_graph_dates(data):
|
|
|
378
378
|
def _append_node(
|
|
379
379
|
lines, indent, node_name, data, wrap_width, order_by_date, sort_order
|
|
380
380
|
):
|
|
381
|
-
#
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
381
|
+
# Every rendered DOT node corresponds to a real YAML node, so build the
|
|
382
|
+
# label directly from that node and prepend the task-link marker line.
|
|
383
|
+
label = _node_label(
|
|
384
|
+
_node_text_with_due(data["nodes"][node_name]), wrap_width
|
|
385
|
+
)
|
|
386
|
+
task_link_line = (
|
|
387
|
+
f'<font point-size="7">__WGRPH_TASK_LINK__:{node_name}</font>'
|
|
388
|
+
)
|
|
388
389
|
if label:
|
|
389
|
-
|
|
390
|
-
lines.append(
|
|
391
|
-
f"{indent}{node_name} [label={label},"
|
|
392
|
-
f" sortv={sort_order[node_name]}];"
|
|
393
|
-
)
|
|
394
|
-
else:
|
|
395
|
-
lines.append(f"{indent}{node_name} [label={label}];")
|
|
390
|
+
label = "<" + task_link_line + '<br align="left"/>' + label[1:]
|
|
396
391
|
else:
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
392
|
+
label = "<" + task_link_line + '<br align="left"/>' + ">"
|
|
393
|
+
|
|
394
|
+
if order_by_date:
|
|
395
|
+
lines.append(
|
|
396
|
+
f"{indent}{node_name} [label={label},"
|
|
397
|
+
f" sortv={sort_order[node_name]}];"
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
lines.append(f"{indent}{node_name} [label={label}];")
|
|
403
401
|
|
|
404
402
|
|
|
405
403
|
def yaml_to_dot(data, wrap_width=55, order_by_date=False):
|
|
@@ -648,10 +646,7 @@ def write_dot_from_yaml(
|
|
|
648
646
|
if parent in ancestors:
|
|
649
647
|
continue
|
|
650
648
|
ancestors.add(parent)
|
|
651
|
-
if
|
|
652
|
-
parent in data["nodes"]
|
|
653
|
-
and "parents" in data["nodes"][parent]
|
|
654
|
-
):
|
|
649
|
+
if parent in data["nodes"] and "parents" in data["nodes"][parent]:
|
|
655
650
|
for grandparent in data["nodes"][parent]["parents"]:
|
|
656
651
|
parents_to_check.append(grandparent)
|
|
657
652
|
incomplete_ancestors = set()
|
|
@@ -2,6 +2,9 @@ import subprocess
|
|
|
2
2
|
import time
|
|
3
3
|
import shutil
|
|
4
4
|
import math
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import http.server
|
|
5
8
|
import xml.etree.ElementTree as ET
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
|
|
@@ -24,12 +27,19 @@ from pydifftools.browser_lifecycle import (
|
|
|
24
27
|
from .graph import write_dot_from_yaml
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def _reload_svg(driver,
|
|
30
|
+
def _reload_svg(driver, svg_src) -> None:
|
|
28
31
|
"""Refresh the embedded SVG while preserving zoom and scroll."""
|
|
29
32
|
zoom = driver.execute_script("return window.visualViewport.scale")
|
|
30
33
|
scroll_x = driver.execute_script("return window.scrollX")
|
|
31
34
|
scroll_y = driver.execute_script("return window.scrollY")
|
|
32
|
-
|
|
35
|
+
if isinstance(svg_src, Path):
|
|
36
|
+
svg_uri = svg_src.resolve().as_uri()
|
|
37
|
+
else:
|
|
38
|
+
svg_uri = str(svg_src)
|
|
39
|
+
if "?" in svg_uri:
|
|
40
|
+
svg_uri = svg_uri + f"&ts={time.time()}"
|
|
41
|
+
else:
|
|
42
|
+
svg_uri = svg_uri + f"?ts={time.time()}"
|
|
33
43
|
driver.execute_async_script(
|
|
34
44
|
"const [src,z,x,y,done]=arguments;const"
|
|
35
45
|
" s=document.getElementById('svg-view');s.onload=function()"
|
|
@@ -42,10 +52,10 @@ def _reload_svg(driver, svg_file: Path) -> None:
|
|
|
42
52
|
)
|
|
43
53
|
|
|
44
54
|
|
|
45
|
-
def start_chrome(webdriver, options,
|
|
46
|
-
# Launch Chrome and display the local SVG preview
|
|
55
|
+
def start_chrome(webdriver, options, preview_url):
|
|
56
|
+
# Launch Chrome and display the local SVG preview page from the server.
|
|
47
57
|
driver = webdriver.Chrome(options=options)
|
|
48
|
-
driver.get(
|
|
58
|
+
driver.get(preview_url)
|
|
49
59
|
return driver
|
|
50
60
|
|
|
51
61
|
|
|
@@ -185,6 +195,60 @@ def _svg_shape_bounds(shape, namespace):
|
|
|
185
195
|
return None
|
|
186
196
|
|
|
187
197
|
|
|
198
|
+
def _svg_parse_viewbox(svg_root):
|
|
199
|
+
viewbox = svg_root.attrib.get("viewBox")
|
|
200
|
+
if viewbox is None:
|
|
201
|
+
return None
|
|
202
|
+
parts = viewbox.replace(",", " ").split()
|
|
203
|
+
if len(parts) != 4:
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
return tuple(float(part) for part in parts)
|
|
207
|
+
except ValueError:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _svg_add_canvas_padding(svg_root, padding=8.0):
|
|
212
|
+
# Graphviz emits a tight canvas; add small fixed padding so post-processed
|
|
213
|
+
# outlines and anti-aliased strokes are never clipped at edges.
|
|
214
|
+
viewbox = _svg_parse_viewbox(svg_root)
|
|
215
|
+
if viewbox is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
view_x, view_y, view_w, view_h = viewbox
|
|
219
|
+
if view_w <= 0.0 or view_h <= 0.0:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
new_view_x = view_x - padding
|
|
223
|
+
new_view_y = view_y - padding
|
|
224
|
+
new_view_w = view_w + 2.0 * padding
|
|
225
|
+
new_view_h = view_h + 2.0 * padding
|
|
226
|
+
svg_root.set(
|
|
227
|
+
"viewBox",
|
|
228
|
+
f"{new_view_x:.2f} {new_view_y:.2f} {new_view_w:.2f} {new_view_h:.2f}",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _watch_html(svg_url, order_by_date):
|
|
233
|
+
# Keep the SVG as the page's main content so browser zoom behavior matches
|
|
234
|
+
# the original watcher experience (the graph scales, not just footer text).
|
|
235
|
+
# The footer link toggles between dependency/date views depending on mode.
|
|
236
|
+
footer_label = "date-ordered"
|
|
237
|
+
footer_url = "/?d=1"
|
|
238
|
+
if order_by_date:
|
|
239
|
+
footer_label = "dependency-ordered"
|
|
240
|
+
footer_url = "/"
|
|
241
|
+
return (
|
|
242
|
+
"<html><body style='margin:0'>"
|
|
243
|
+
"<embed id='svg-view' style='display:block;' type='image/svg+xml'"
|
|
244
|
+
f" src='{svg_url}'/>"
|
|
245
|
+
"<p style='margin:0.4em 0.8em;font-family:sans-serif;font-size:13px;'>"
|
|
246
|
+
f"<a href='{footer_url}'>{footer_label}</a>"
|
|
247
|
+
"</p>"
|
|
248
|
+
"</body></html>"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
188
252
|
def _svg_expanded_outline(
|
|
189
253
|
shape, namespace, expand, stroke_color, stroke_width
|
|
190
254
|
):
|
|
@@ -221,6 +285,32 @@ def _svg_expanded_outline(
|
|
|
221
285
|
)
|
|
222
286
|
|
|
223
287
|
|
|
288
|
+
def _svg_add_task_links(svg_root, namespace):
|
|
289
|
+
# Replace marker text emitted in DOT labels with clickable links. Graphviz
|
|
290
|
+
# generates one <text> item per line, so the marker occupies its own row.
|
|
291
|
+
xlink_ns = "http://www.w3.org/1999/xlink"
|
|
292
|
+
ET.register_namespace("xlink", xlink_ns)
|
|
293
|
+
link_marker = "__WGRPH_TASK_LINK__:"
|
|
294
|
+
for group in svg_root.iter(f"{namespace}g"):
|
|
295
|
+
if "class" not in group.attrib or group.attrib["class"] != "node":
|
|
296
|
+
continue
|
|
297
|
+
for index, child in enumerate(list(group)):
|
|
298
|
+
if child.tag != f"{namespace}text" or child.text is None:
|
|
299
|
+
continue
|
|
300
|
+
if not child.text.startswith(link_marker):
|
|
301
|
+
continue
|
|
302
|
+
task_name = child.text[len(link_marker) :]
|
|
303
|
+
child.text = task_name
|
|
304
|
+
link = ET.Element(f"{namespace}a")
|
|
305
|
+
link.set(
|
|
306
|
+
f"{{{xlink_ns}}}href", f"/?t={urllib.parse.quote(task_name)}"
|
|
307
|
+
)
|
|
308
|
+
link.set("target", "_top")
|
|
309
|
+
link.append(child)
|
|
310
|
+
group.remove(child)
|
|
311
|
+
group.insert(index, link)
|
|
312
|
+
|
|
313
|
+
|
|
224
314
|
def build_graph(
|
|
225
315
|
yaml_file,
|
|
226
316
|
dot_file,
|
|
@@ -249,6 +339,14 @@ def build_graph(
|
|
|
249
339
|
["dot", "-Tsvg", str(dot_file), "-o", str(svg_file)],
|
|
250
340
|
check=True,
|
|
251
341
|
)
|
|
342
|
+
svg_tree = ET.parse(str(svg_file))
|
|
343
|
+
svg_root = svg_tree.getroot()
|
|
344
|
+
namespace = ""
|
|
345
|
+
if svg_root.tag.startswith("{"):
|
|
346
|
+
namespace = svg_root.tag[: svg_root.tag.find("}") + 1]
|
|
347
|
+
|
|
348
|
+
_svg_add_task_links(svg_root, namespace)
|
|
349
|
+
|
|
252
350
|
if not order_by_date:
|
|
253
351
|
# In dependency view mode, each node explicitly tagged with
|
|
254
352
|
# ``style: endpoint`` defines a project color. A project includes the
|
|
@@ -282,12 +380,6 @@ def build_graph(
|
|
|
282
380
|
for parent in data["nodes"][ancestor]["parents"]:
|
|
283
381
|
ancestors_to_visit.append(parent)
|
|
284
382
|
|
|
285
|
-
svg_tree = ET.parse(str(svg_file))
|
|
286
|
-
svg_root = svg_tree.getroot()
|
|
287
|
-
namespace = ""
|
|
288
|
-
if svg_root.tag.startswith("{"):
|
|
289
|
-
namespace = svg_root.tag[: svg_root.tag.find("}") + 1]
|
|
290
|
-
|
|
291
383
|
title_to_group = {}
|
|
292
384
|
node_title_to_group = {}
|
|
293
385
|
for group in svg_root.iter(f"{namespace}g"):
|
|
@@ -449,7 +541,8 @@ def build_graph(
|
|
|
449
541
|
for insert_index, outline in reversed(inserts):
|
|
450
542
|
group.insert(insert_index, outline)
|
|
451
543
|
|
|
452
|
-
|
|
544
|
+
_svg_add_canvas_padding(svg_root, padding=24.0)
|
|
545
|
+
svg_tree.write(str(svg_file), encoding="utf-8", xml_declaration=True)
|
|
453
546
|
return data
|
|
454
547
|
|
|
455
548
|
|
|
@@ -459,27 +552,30 @@ class GraphEventHandler(FileSystemEventHandler):
|
|
|
459
552
|
yaml_file,
|
|
460
553
|
dot_file,
|
|
461
554
|
svg_file,
|
|
462
|
-
|
|
555
|
+
preview_url=None,
|
|
556
|
+
svg_url=None,
|
|
463
557
|
driver=None,
|
|
464
558
|
options=None,
|
|
465
559
|
webdriver=None,
|
|
466
560
|
wrap_width=55,
|
|
467
561
|
data=None,
|
|
468
|
-
|
|
469
|
-
target_task=None,
|
|
562
|
+
state=None,
|
|
470
563
|
debounce=0.25,
|
|
471
564
|
):
|
|
472
565
|
self.yaml_file = Path(yaml_file)
|
|
473
566
|
self.dot_file = Path(dot_file)
|
|
474
567
|
self.svg_file = Path(svg_file)
|
|
475
|
-
self.
|
|
568
|
+
self.preview_url = preview_url
|
|
569
|
+
self.svg_url = svg_url
|
|
476
570
|
self.driver = driver
|
|
477
571
|
self.options = options
|
|
478
572
|
self.webdriver = webdriver
|
|
479
573
|
self.wrap_width = wrap_width
|
|
480
574
|
self.data = data
|
|
481
|
-
|
|
482
|
-
|
|
575
|
+
if state is None:
|
|
576
|
+
self.state = {"order_by_date": False, "target_task": None}
|
|
577
|
+
else:
|
|
578
|
+
self.state = state
|
|
483
579
|
self.debounce = debounce
|
|
484
580
|
self._last_handled = 0.0
|
|
485
581
|
self._last_mtime = None
|
|
@@ -499,9 +595,9 @@ class GraphEventHandler(FileSystemEventHandler):
|
|
|
499
595
|
self.dot_file,
|
|
500
596
|
self.svg_file,
|
|
501
597
|
self.wrap_width,
|
|
502
|
-
self.order_by_date,
|
|
598
|
+
self.state["order_by_date"],
|
|
503
599
|
self.data,
|
|
504
|
-
self.target_task,
|
|
600
|
+
self.state["target_task"],
|
|
505
601
|
)
|
|
506
602
|
except Exception:
|
|
507
603
|
# If the graph fails to build (e.g. invalid date), close the
|
|
@@ -515,21 +611,135 @@ class GraphEventHandler(FileSystemEventHandler):
|
|
|
515
611
|
if (
|
|
516
612
|
self.webdriver is not None
|
|
517
613
|
and self.options is not None
|
|
518
|
-
and self.
|
|
614
|
+
and self.preview_url is not None
|
|
519
615
|
):
|
|
520
616
|
self.driver = start_chrome(
|
|
521
|
-
self.webdriver, self.options, self.
|
|
617
|
+
self.webdriver, self.options, self.preview_url
|
|
522
618
|
)
|
|
523
619
|
else:
|
|
524
|
-
# Allow legacy
|
|
525
|
-
|
|
620
|
+
# Allow test/legacy usage where no browser driver exists.
|
|
621
|
+
if self.svg_url is not None:
|
|
622
|
+
_reload_svg(self.driver, self.svg_url)
|
|
623
|
+
else:
|
|
624
|
+
_reload_svg(self.driver, self.svg_file)
|
|
526
625
|
self._last_mtime = self.yaml_file.stat().st_mtime
|
|
527
626
|
return
|
|
627
|
+
if self.svg_url is not None:
|
|
628
|
+
_reload_svg(self.driver, self.svg_url)
|
|
528
629
|
else:
|
|
529
630
|
_reload_svg(self.driver, self.svg_file)
|
|
530
631
|
self._last_mtime = self.yaml_file.stat().st_mtime
|
|
531
632
|
|
|
532
633
|
|
|
634
|
+
class FlowchartPreviewServer:
|
|
635
|
+
def __init__(self, event_handler, host="127.0.0.1"):
|
|
636
|
+
self.event_handler = event_handler
|
|
637
|
+
self.host = host
|
|
638
|
+
self.httpd = None
|
|
639
|
+
self.server_thread = None
|
|
640
|
+
self.base_url = None
|
|
641
|
+
self.svg_url = None
|
|
642
|
+
|
|
643
|
+
def start(self):
|
|
644
|
+
event_handler = self.event_handler
|
|
645
|
+
|
|
646
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
647
|
+
def do_GET(self):
|
|
648
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
649
|
+
if parsed.path == "/graph.svg":
|
|
650
|
+
svg_bytes = event_handler.svg_file.read_bytes()
|
|
651
|
+
self.send_response(200)
|
|
652
|
+
self.send_header(
|
|
653
|
+
"Content-Type", "image/svg+xml; charset=utf-8"
|
|
654
|
+
)
|
|
655
|
+
self.send_header("Cache-Control", "no-store")
|
|
656
|
+
self.end_headers()
|
|
657
|
+
self.wfile.write(svg_bytes)
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
if parsed.path != "/" and parsed.path != "/index.html":
|
|
661
|
+
self.send_error(404)
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
# Parse query args so GET requests control graph mode.
|
|
665
|
+
params = urllib.parse.parse_qs(
|
|
666
|
+
parsed.query, keep_blank_values=True
|
|
667
|
+
)
|
|
668
|
+
order_by_date = event_handler.state["order_by_date"]
|
|
669
|
+
target_task = event_handler.state["target_task"]
|
|
670
|
+
if "d" in params:
|
|
671
|
+
d_value = params["d"][-1]
|
|
672
|
+
order_by_date = d_value in (
|
|
673
|
+
"1",
|
|
674
|
+
"true",
|
|
675
|
+
"yes",
|
|
676
|
+
"on",
|
|
677
|
+
"",
|
|
678
|
+
)
|
|
679
|
+
if "t" in params:
|
|
680
|
+
t_value = params["t"][-1].strip()
|
|
681
|
+
if t_value:
|
|
682
|
+
target_task = t_value
|
|
683
|
+
order_by_date = False
|
|
684
|
+
else:
|
|
685
|
+
target_task = None
|
|
686
|
+
|
|
687
|
+
if (
|
|
688
|
+
order_by_date != event_handler.state["order_by_date"]
|
|
689
|
+
or target_task != event_handler.state["target_task"]
|
|
690
|
+
):
|
|
691
|
+
event_handler.state["order_by_date"] = order_by_date
|
|
692
|
+
event_handler.state["target_task"] = target_task
|
|
693
|
+
event_handler.data = build_graph(
|
|
694
|
+
event_handler.yaml_file,
|
|
695
|
+
event_handler.dot_file,
|
|
696
|
+
event_handler.svg_file,
|
|
697
|
+
event_handler.wrap_width,
|
|
698
|
+
event_handler.state["order_by_date"],
|
|
699
|
+
event_handler.data,
|
|
700
|
+
event_handler.state["target_task"],
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
body = _watch_html(
|
|
704
|
+
"/graph.svg", event_handler.state["order_by_date"]
|
|
705
|
+
)
|
|
706
|
+
body_bytes = body.encode("utf-8")
|
|
707
|
+
self.send_response(200)
|
|
708
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
709
|
+
self.send_header("Content-Length", str(len(body_bytes)))
|
|
710
|
+
self.send_header("Cache-Control", "no-store")
|
|
711
|
+
self.end_headers()
|
|
712
|
+
self.wfile.write(body_bytes)
|
|
713
|
+
|
|
714
|
+
def log_message(self, format, *args):
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
self.httpd = http.server.ThreadingHTTPServer((self.host, 0), Handler)
|
|
718
|
+
self.httpd.daemon_threads = True
|
|
719
|
+
port = self.httpd.server_address[1]
|
|
720
|
+
self.base_url = f"http://{self.host}:{port}/"
|
|
721
|
+
self.svg_url = f"http://{self.host}:{port}/graph.svg"
|
|
722
|
+
# Start serving immediately so the first browser navigation does not
|
|
723
|
+
# block waiting for the watcher loop to call handle_request.
|
|
724
|
+
self.server_thread = threading.Thread(
|
|
725
|
+
target=self.httpd.serve_forever,
|
|
726
|
+
daemon=True,
|
|
727
|
+
)
|
|
728
|
+
self.server_thread.start()
|
|
729
|
+
|
|
730
|
+
def serve_pending_request(self):
|
|
731
|
+
# The server runs in a background thread; this method remains for
|
|
732
|
+
# compatibility with the watcher loop call site.
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
def stop(self):
|
|
736
|
+
if self.httpd is not None:
|
|
737
|
+
self.httpd.shutdown()
|
|
738
|
+
self.httpd.server_close()
|
|
739
|
+
if self.server_thread is not None:
|
|
740
|
+
self.server_thread.join(timeout=1.0)
|
|
741
|
+
|
|
742
|
+
|
|
533
743
|
@register_command(
|
|
534
744
|
"Watch a flowchart YAML file, rebuild DOT/SVG output, and open the"
|
|
535
745
|
" preview",
|
|
@@ -537,7 +747,7 @@ class GraphEventHandler(FileSystemEventHandler):
|
|
|
537
747
|
"yaml": "Path to the flowchart YAML file",
|
|
538
748
|
"wrap_width": "Line wrap width used when generating node labels",
|
|
539
749
|
"d": "Render nodes by date without showing connections",
|
|
540
|
-
"t":
|
|
750
|
+
"t": "Task name to focus on (show incomplete ancestor tasks only)",
|
|
541
751
|
},
|
|
542
752
|
)
|
|
543
753
|
def wgrph(yaml, wrap_width=55, d=False, t=None):
|
|
@@ -559,45 +769,76 @@ def wgrph(yaml, wrap_width=55, d=False, t=None):
|
|
|
559
769
|
|
|
560
770
|
dot_file = yaml_file.with_suffix(".dot")
|
|
561
771
|
svg_file = yaml_file.with_suffix(".svg")
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
#
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
772
|
+
|
|
773
|
+
# The browser now drives filtering/date-mode by GET query parameters
|
|
774
|
+
# handled by the local preview server. Keep Python state in sync there.
|
|
775
|
+
initial_state = {"order_by_date": False, "target_task": None}
|
|
776
|
+
|
|
777
|
+
# Build the default dependency graph first. Optional -t / -d args are then
|
|
778
|
+
# applied by requesting server URLs with query parameters.
|
|
779
|
+
data = build_graph(
|
|
780
|
+
yaml_file,
|
|
781
|
+
dot_file,
|
|
782
|
+
svg_file,
|
|
783
|
+
wrap_width,
|
|
784
|
+
initial_state["order_by_date"],
|
|
785
|
+
None,
|
|
786
|
+
initial_state["target_task"],
|
|
572
787
|
)
|
|
788
|
+
|
|
573
789
|
options = Options()
|
|
574
|
-
driver = start_chrome(webdriver, options, html_file)
|
|
575
790
|
event_handler = GraphEventHandler(
|
|
576
791
|
yaml_file,
|
|
577
792
|
dot_file,
|
|
578
793
|
svg_file,
|
|
579
|
-
|
|
580
|
-
|
|
794
|
+
None,
|
|
795
|
+
None,
|
|
796
|
+
None,
|
|
581
797
|
options,
|
|
582
798
|
webdriver,
|
|
583
799
|
wrap_width,
|
|
584
800
|
data,
|
|
585
|
-
|
|
586
|
-
t,
|
|
801
|
+
initial_state,
|
|
587
802
|
)
|
|
803
|
+
preview_server = FlowchartPreviewServer(event_handler)
|
|
804
|
+
preview_server.start()
|
|
805
|
+
event_handler.preview_url = preview_server.base_url
|
|
806
|
+
event_handler.svg_url = preview_server.svg_url
|
|
807
|
+
|
|
808
|
+
driver = start_chrome(webdriver, options, preview_server.base_url)
|
|
809
|
+
event_handler.driver = driver
|
|
810
|
+
|
|
811
|
+
if t is not None and str(t).strip():
|
|
812
|
+
driver.get(
|
|
813
|
+
preview_server.base_url
|
|
814
|
+
+ "?t="
|
|
815
|
+
+ urllib.parse.quote(str(t).strip())
|
|
816
|
+
)
|
|
817
|
+
elif d:
|
|
818
|
+
driver.get(preview_server.base_url + "?d=1")
|
|
819
|
+
|
|
588
820
|
observer = Observer()
|
|
589
821
|
observer.schedule(event_handler, yaml_file.parent, recursive=False)
|
|
590
822
|
observer.start()
|
|
823
|
+
dead_since = None
|
|
591
824
|
try:
|
|
592
825
|
while True:
|
|
593
|
-
|
|
594
|
-
#
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
826
|
+
preview_server.serve_pending_request()
|
|
827
|
+
# Exit the watcher when the browser window is really closed, but
|
|
828
|
+
# tolerate short Selenium liveness errors during top-level
|
|
829
|
+
# navigation (for example when opening /?t=... from the links).
|
|
830
|
+
if browser_window_is_alive(event_handler.driver):
|
|
831
|
+
dead_since = None
|
|
832
|
+
else:
|
|
833
|
+
if dead_since is None:
|
|
834
|
+
dead_since = time.time()
|
|
835
|
+
elif time.time() - dead_since >= 1.0:
|
|
836
|
+
break
|
|
837
|
+
time.sleep(0.1)
|
|
598
838
|
except KeyboardInterrupt:
|
|
599
839
|
pass
|
|
600
840
|
finally:
|
|
601
841
|
observer.stop()
|
|
602
842
|
observer.join()
|
|
603
843
|
close_chrome(event_handler.driver)
|
|
844
|
+
preview_server.stop()
|
|
@@ -2,17 +2,11 @@ from pydifftools import browser_lifecycle
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class FakeBrowser:
|
|
5
|
-
def __init__(self, handles=None,
|
|
5
|
+
def __init__(self, handles=None, quit_error=False):
|
|
6
6
|
self.window_handles = handles if handles is not None else ["main"]
|
|
7
|
-
self.script_error = script_error
|
|
8
7
|
self.quit_error = quit_error
|
|
9
8
|
self.quit_calls = 0
|
|
10
9
|
|
|
11
|
-
def execute_script(self, _code):
|
|
12
|
-
if self.script_error:
|
|
13
|
-
raise RuntimeError("window closed")
|
|
14
|
-
return 1
|
|
15
|
-
|
|
16
10
|
def quit(self):
|
|
17
11
|
self.quit_calls += 1
|
|
18
12
|
if self.quit_error:
|
|
@@ -29,11 +23,6 @@ def test_browser_window_is_alive_false_when_handles_missing():
|
|
|
29
23
|
assert not browser_lifecycle.browser_window_is_alive(browser)
|
|
30
24
|
|
|
31
25
|
|
|
32
|
-
def test_browser_window_is_alive_false_when_script_errors():
|
|
33
|
-
browser = FakeBrowser(script_error=True)
|
|
34
|
-
assert not browser_lifecycle.browser_window_is_alive(browser)
|
|
35
|
-
|
|
36
|
-
|
|
37
26
|
def test_close_browser_window_quits_and_swallows_errors():
|
|
38
27
|
browser = FakeBrowser(quit_error=True)
|
|
39
28
|
browser_lifecycle.close_browser_window(browser)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/__version__.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/global.db
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg
RENAMED
|
File without changes
|
{pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/independent.qmd
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/test_include.qmd
RENAMED
|
File without changes
|
{pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tryforerror.qmd
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|