pyDiffTools 0.1.24__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.24/pyDiffTools.egg-info → pydifftools-0.1.25}/PKG-INFO +1 -1
  2. {pydifftools-0.1.24 → pydifftools-0.1.25/pyDiffTools.egg-info}/PKG-INFO +1 -1
  3. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/browser_lifecycle.py +2 -1
  4. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/continuous.py +2 -1
  5. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/flowchart/graph.py +19 -24
  6. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/flowchart/watch_graph.py +240 -38
  7. {pydifftools-0.1.24 → pydifftools-0.1.25}/pyproject.toml +1 -1
  8. {pydifftools-0.1.24 → pydifftools-0.1.25}/tests/test_browser_lifecycle.py +1 -12
  9. {pydifftools-0.1.24 → pydifftools-0.1.25}/LICENSE.md +0 -0
  10. {pydifftools-0.1.24 → pydifftools-0.1.25}/MANIFEST.in +0 -0
  11. {pydifftools-0.1.24 → pydifftools-0.1.25}/README.rst +0 -0
  12. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/_quarto.yml +0 -0
  13. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  14. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  15. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  16. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  17. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  18. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  19. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  20. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  21. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  22. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  23. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  24. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  25. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  26. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  27. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  28. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  29. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  30. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  31. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  32. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  33. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  34. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  35. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/example.qmd +0 -0
  36. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/example.tex +0 -0
  37. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/index.qmd +0 -0
  38. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  39. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  40. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  41. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  42. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/independent.qmd +0 -0
  43. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/index.qmd +0 -0
  44. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  45. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  46. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  47. {pydifftools-0.1.24 → pydifftools-0.1.25}/example_notebook/project1/test_include.qmd +0 -0
  48. {pydifftools-0.1.24 → pydifftools-0.1.25}/pyDiffTools.egg-info/SOURCES.txt +0 -0
  49. {pydifftools-0.1.24 → pydifftools-0.1.25}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  50. {pydifftools-0.1.24 → pydifftools-0.1.25}/pyDiffTools.egg-info/entry_points.txt +0 -0
  51. {pydifftools-0.1.24 → pydifftools-0.1.25}/pyDiffTools.egg-info/requires.txt +0 -0
  52. {pydifftools-0.1.24 → pydifftools-0.1.25}/pyDiffTools.egg-info/top_level.txt +0 -0
  53. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/__init__.py +0 -0
  54. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/check_numbers.py +0 -0
  55. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/command_line.py +0 -0
  56. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/command_registry.py +0 -0
  57. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/comment_functions.py +0 -0
  58. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/comment_tags.lua +0 -0
  59. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/comment_toggle.js +0 -0
  60. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/comments.css +0 -0
  61. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/copy_files.py +0 -0
  62. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/diff-doc.js +0 -0
  63. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/doc_contents.py +0 -0
  64. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/flowchart/__init__.py +0 -0
  65. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  66. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/html_comments.py +0 -0
  67. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/html_uncomments.py +0 -0
  68. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/match_spaces.py +0 -0
  69. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/notebook/__init__.py +0 -0
  70. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/notebook/fast_build.py +0 -0
  71. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/notebook/tex_to_qmd.py +0 -0
  72. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/onewordify.py +0 -0
  73. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/onewordify_undo.py +0 -0
  74. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/outline.py +0 -0
  75. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/rearrange_tex.py +0 -0
  76. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/searchacro.py +0 -0
  77. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/separate_comments.py +0 -0
  78. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/split_conflict.py +0 -0
  79. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/unseparate_comments.py +0 -0
  80. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/update_check.py +0 -0
  81. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/wrap_sentences.py +0 -0
  82. {pydifftools-0.1.24 → pydifftools-0.1.25}/pydifftools/xml2xlsx.vbs +0 -0
  83. {pydifftools-0.1.24 → pydifftools-0.1.25}/setup.cfg +0 -0
  84. {pydifftools-0.1.24 → pydifftools-0.1.25}/tests/test_continuous_shutdown.py +0 -0
  85. {pydifftools-0.1.24 → pydifftools-0.1.25}/tests/test_rrng.py +0 -0
  86. {pydifftools-0.1.24 → pydifftools-0.1.25}/tests/test_tex_to_qmd.py +0 -0
  87. {pydifftools-0.1.24 → 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.24
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.24
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
@@ -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
 
@@ -219,11 +229,23 @@ def _svg_add_canvas_padding(svg_root, padding=8.0):
219
229
  )
220
230
 
221
231
 
222
- def _watch_html(svg_file):
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 = "/"
223
241
  return (
224
- "<html><body style='margin:0'><embed id='svg-view'"
225
- " style='display:block;' type='image/svg+xml'"
226
- f" src='{svg_file.name}?t={time.time()}'/></body></html>"
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>"
227
249
  )
228
250
 
229
251
 
@@ -263,6 +285,32 @@ def _svg_expanded_outline(
263
285
  )
264
286
 
265
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
+
266
314
  def build_graph(
267
315
  yaml_file,
268
316
  dot_file,
@@ -297,6 +345,8 @@ def build_graph(
297
345
  if svg_root.tag.startswith("{"):
298
346
  namespace = svg_root.tag[: svg_root.tag.find("}") + 1]
299
347
 
348
+ _svg_add_task_links(svg_root, namespace)
349
+
300
350
  if not order_by_date:
301
351
  # In dependency view mode, each node explicitly tagged with
302
352
  # ``style: endpoint`` defines a project color. A project includes the
@@ -502,27 +552,30 @@ class GraphEventHandler(FileSystemEventHandler):
502
552
  yaml_file,
503
553
  dot_file,
504
554
  svg_file,
505
- html_file=None,
555
+ preview_url=None,
556
+ svg_url=None,
506
557
  driver=None,
507
558
  options=None,
508
559
  webdriver=None,
509
560
  wrap_width=55,
510
561
  data=None,
511
- order_by_date=False,
512
- target_task=None,
562
+ state=None,
513
563
  debounce=0.25,
514
564
  ):
515
565
  self.yaml_file = Path(yaml_file)
516
566
  self.dot_file = Path(dot_file)
517
567
  self.svg_file = Path(svg_file)
518
- 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
519
570
  self.driver = driver
520
571
  self.options = options
521
572
  self.webdriver = webdriver
522
573
  self.wrap_width = wrap_width
523
574
  self.data = data
524
- self.order_by_date = order_by_date
525
- 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
526
579
  self.debounce = debounce
527
580
  self._last_handled = 0.0
528
581
  self._last_mtime = None
@@ -542,9 +595,9 @@ class GraphEventHandler(FileSystemEventHandler):
542
595
  self.dot_file,
543
596
  self.svg_file,
544
597
  self.wrap_width,
545
- self.order_by_date,
598
+ self.state["order_by_date"],
546
599
  self.data,
547
- self.target_task,
600
+ self.state["target_task"],
548
601
  )
549
602
  except Exception:
550
603
  # If the graph fails to build (e.g. invalid date), close the
@@ -558,21 +611,135 @@ class GraphEventHandler(FileSystemEventHandler):
558
611
  if (
559
612
  self.webdriver is not None
560
613
  and self.options is not None
561
- and self.html_file is not None
614
+ and self.preview_url is not None
562
615
  ):
563
616
  self.driver = start_chrome(
564
- self.webdriver, self.options, self.html_file
617
+ self.webdriver, self.options, self.preview_url
565
618
  )
566
619
  else:
567
- # Allow legacy/test usage without a live driver.
568
- _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)
569
625
  self._last_mtime = self.yaml_file.stat().st_mtime
570
626
  return
627
+ if self.svg_url is not None:
628
+ _reload_svg(self.driver, self.svg_url)
571
629
  else:
572
630
  _reload_svg(self.driver, self.svg_file)
573
631
  self._last_mtime = self.yaml_file.stat().st_mtime
574
632
 
575
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
+
576
743
  @register_command(
577
744
  "Watch a flowchart YAML file, rebuild DOT/SVG output, and open the"
578
745
  " preview",
@@ -580,7 +747,7 @@ class GraphEventHandler(FileSystemEventHandler):
580
747
  "yaml": "Path to the flowchart YAML file",
581
748
  "wrap_width": "Line wrap width used when generating node labels",
582
749
  "d": "Render nodes by date without showing connections",
583
- "t": ("Task name to focus on (show incomplete ancestor tasks only)"),
750
+ "t": "Task name to focus on (show incomplete ancestor tasks only)",
584
751
  },
585
752
  )
586
753
  def wgrph(yaml, wrap_width=55, d=False, t=None):
@@ -602,41 +769,76 @@ def wgrph(yaml, wrap_width=55, d=False, t=None):
602
769
 
603
770
  dot_file = yaml_file.with_suffix(".dot")
604
771
  svg_file = yaml_file.with_suffix(".svg")
605
- html_file = yaml_file.with_suffix(".html")
606
772
 
607
- # Use date ordering when requested so boxes appear in calendar order.
608
- # Render the initial graph, optionally restricting to incomplete ancestors
609
- # of a target task.
610
- data = build_graph(yaml_file, dot_file, svg_file, wrap_width, d, None, t)
611
- html_file.write_text(_watch_html(svg_file))
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"],
787
+ )
788
+
612
789
  options = Options()
613
- driver = start_chrome(webdriver, options, html_file)
614
790
  event_handler = GraphEventHandler(
615
791
  yaml_file,
616
792
  dot_file,
617
793
  svg_file,
618
- html_file,
619
- driver,
794
+ None,
795
+ None,
796
+ None,
620
797
  options,
621
798
  webdriver,
622
799
  wrap_width,
623
800
  data,
624
- d,
625
- t,
801
+ initial_state,
626
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
+
627
820
  observer = Observer()
628
821
  observer.schedule(event_handler, yaml_file.parent, recursive=False)
629
822
  observer.start()
823
+ dead_since = None
630
824
  try:
631
825
  while True:
632
- # Exit the watcher when the browser window is closed so the CLI
633
- # process does not stay alive in the background.
634
- if not browser_window_is_alive(event_handler.driver):
635
- break
636
- 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)
637
838
  except KeyboardInterrupt:
638
839
  pass
639
840
  finally:
640
841
  observer.stop()
641
842
  observer.join()
642
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.24"
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