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.
Files changed (87) hide show
  1. {pydifftools-0.1.23/pyDiffTools.egg-info → pydifftools-0.1.25}/PKG-INFO +1 -1
  2. {pydifftools-0.1.23 → pydifftools-0.1.25/pyDiffTools.egg-info}/PKG-INFO +1 -1
  3. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/browser_lifecycle.py +2 -1
  4. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/command_line.py +41 -1
  5. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/continuous.py +2 -1
  6. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/graph.py +19 -24
  7. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/watch_graph.py +286 -45
  8. {pydifftools-0.1.23 → pydifftools-0.1.25}/pyproject.toml +1 -1
  9. {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_browser_lifecycle.py +1 -12
  10. {pydifftools-0.1.23 → pydifftools-0.1.25}/LICENSE.md +0 -0
  11. {pydifftools-0.1.23 → pydifftools-0.1.25}/MANIFEST.in +0 -0
  12. {pydifftools-0.1.23 → pydifftools-0.1.25}/README.rst +0 -0
  13. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/_quarto.yml +0 -0
  14. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  15. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  16. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  17. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  18. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  19. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  20. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  21. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  22. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  23. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  24. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  25. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  26. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  27. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  28. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  29. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  30. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  31. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  32. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  33. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  34. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  35. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  36. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/example.qmd +0 -0
  37. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/example.tex +0 -0
  38. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/index.qmd +0 -0
  39. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  40. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  41. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  42. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  43. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/independent.qmd +0 -0
  44. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/index.qmd +0 -0
  45. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  46. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  47. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  48. {pydifftools-0.1.23 → pydifftools-0.1.25}/example_notebook/project1/test_include.qmd +0 -0
  49. {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/SOURCES.txt +0 -0
  50. {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  51. {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/entry_points.txt +0 -0
  52. {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/requires.txt +0 -0
  53. {pydifftools-0.1.23 → pydifftools-0.1.25}/pyDiffTools.egg-info/top_level.txt +0 -0
  54. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/__init__.py +0 -0
  55. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/check_numbers.py +0 -0
  56. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/command_registry.py +0 -0
  57. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comment_functions.py +0 -0
  58. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comment_tags.lua +0 -0
  59. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comment_toggle.js +0 -0
  60. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/comments.css +0 -0
  61. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/copy_files.py +0 -0
  62. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/diff-doc.js +0 -0
  63. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/doc_contents.py +0 -0
  64. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/__init__.py +0 -0
  65. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  66. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/html_comments.py +0 -0
  67. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/html_uncomments.py +0 -0
  68. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/match_spaces.py +0 -0
  69. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/notebook/__init__.py +0 -0
  70. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/notebook/fast_build.py +0 -0
  71. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/notebook/tex_to_qmd.py +0 -0
  72. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/onewordify.py +0 -0
  73. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/onewordify_undo.py +0 -0
  74. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/outline.py +0 -0
  75. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/rearrange_tex.py +0 -0
  76. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/searchacro.py +0 -0
  77. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/separate_comments.py +0 -0
  78. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/split_conflict.py +0 -0
  79. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/unseparate_comments.py +0 -0
  80. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/update_check.py +0 -0
  81. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/wrap_sentences.py +0 -0
  82. {pydifftools-0.1.23 → pydifftools-0.1.25}/pydifftools/xml2xlsx.vbs +0 -0
  83. {pydifftools-0.1.23 → pydifftools-0.1.25}/setup.cfg +0 -0
  84. {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_continuous_shutdown.py +0 -0
  85. {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_rrng.py +0 -0
  86. {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_tex_to_qmd.py +0 -0
  87. {pydifftools-0.1.23 → pydifftools-0.1.25}/tests/test_update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -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 = argparse.ArgumentParser()
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, restore hidden comments and scroll position
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
- # Add a node line with an optional sort hint so Graphviz keeps date order.
382
- if node_name in data["nodes"]:
383
- label = _node_label(
384
- _node_text_with_due(data["nodes"][node_name]), wrap_width
385
- )
386
- else:
387
- label = ""
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
- if order_by_date:
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
- if order_by_date:
398
- lines.append(
399
- f"{indent}{node_name} [sortv={sort_order[node_name]}];"
400
- )
401
- else:
402
- lines.append(f"{indent}{node_name};")
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, svg_file: Path) -> None:
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
- svg_uri = svg_file.resolve().as_uri() + f"?t={time.time()}"
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, html_file):
46
- # Launch Chrome and display the local SVG preview HTML file.
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(html_file.resolve().as_uri())
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
- svg_tree.write(str(svg_file), encoding="utf-8", xml_declaration=True)
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
- html_file=None,
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
- order_by_date=False,
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.html_file = None if html_file is None else Path(html_file)
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
- self.order_by_date = order_by_date
482
- self.target_task = target_task
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.html_file is not None
614
+ and self.preview_url is not None
519
615
  ):
520
616
  self.driver = start_chrome(
521
- self.webdriver, self.options, self.html_file
617
+ self.webdriver, self.options, self.preview_url
522
618
  )
523
619
  else:
524
- # Allow legacy/test usage without a live driver.
525
- _reload_svg(self.driver, self.svg_file)
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": ("Task name to focus on (show incomplete ancestor tasks only)"),
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
- html_file = yaml_file.with_suffix(".html")
563
-
564
- # Use date ordering when requested so boxes appear in calendar order.
565
- # Render the initial graph, optionally restricting to incomplete ancestors
566
- # of a target task.
567
- data = build_graph(yaml_file, dot_file, svg_file, wrap_width, d, None, t)
568
- html_file.write_text(
569
- "<html><body style='margin:0'><embed id='svg-view'"
570
- " type='image/svg+xml'"
571
- f" src='{svg_file.name}?t={time.time()}'/></body></html>"
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
- html_file,
580
- driver,
794
+ None,
795
+ None,
796
+ None,
581
797
  options,
582
798
  webdriver,
583
799
  wrap_width,
584
800
  data,
585
- d,
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
- # Exit the watcher when the browser window is closed so the CLI
594
- # process does not stay alive in the background.
595
- if not browser_window_is_alive(event_handler.driver):
596
- break
597
- time.sleep(1)
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()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyDiffTools"
7
- version = "0.1.23"
7
+ version = "0.1.25"
8
8
  authors = [{ name = "J M Franck" }]
9
9
  description = "Diff tools"
10
10
  readme = "README.rst"
@@ -2,17 +2,11 @@ from pydifftools import browser_lifecycle
2
2
 
3
3
 
4
4
  class FakeBrowser:
5
- def __init__(self, handles=None, script_error=False, quit_error=False):
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