pyDiffTools 0.1.27__tar.gz → 0.1.29__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 (94) hide show
  1. {pydifftools-0.1.27/pyDiffTools.egg-info → pydifftools-0.1.29}/PKG-INFO +3 -1
  2. {pydifftools-0.1.27 → pydifftools-0.1.29/pyDiffTools.egg-info}/PKG-INFO +3 -1
  3. {pydifftools-0.1.27 → pydifftools-0.1.29}/pyDiffTools.egg-info/SOURCES.txt +1 -0
  4. {pydifftools-0.1.27 → pydifftools-0.1.29}/pyDiffTools.egg-info/requires.txt +2 -0
  5. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/command_line.py +72 -1
  6. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/continuous.py +158 -67
  7. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/flowchart/graph.py +133 -36
  8. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/flowchart/watch_graph.py +59 -48
  9. pydifftools-0.1.29/pydifftools/notebook/display.py +67 -0
  10. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/notebook/fast_build.py +340 -22
  11. {pydifftools-0.1.27 → pydifftools-0.1.29}/pyproject.toml +3 -1
  12. {pydifftools-0.1.27 → pydifftools-0.1.29}/tests/test_command_line_help.py +64 -0
  13. {pydifftools-0.1.27 → pydifftools-0.1.29}/LICENSE.md +0 -0
  14. {pydifftools-0.1.27 → pydifftools-0.1.29}/MANIFEST.in +0 -0
  15. {pydifftools-0.1.27 → pydifftools-0.1.29}/README.rst +0 -0
  16. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/_quarto.yml +0 -0
  17. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  18. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  19. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  20. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  21. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  22. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  23. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  24. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  25. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  26. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  27. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  28. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  29. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  30. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  31. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  32. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  33. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  34. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  35. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  36. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  37. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  38. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  39. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/example.qmd +0 -0
  40. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/example.tex +0 -0
  41. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/index.qmd +0 -0
  42. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  43. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  44. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  45. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  46. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/independent.qmd +0 -0
  47. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/index.qmd +0 -0
  48. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  49. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  50. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  51. {pydifftools-0.1.27 → pydifftools-0.1.29}/example_notebook/project1/test_include.qmd +0 -0
  52. {pydifftools-0.1.27 → pydifftools-0.1.29}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  53. {pydifftools-0.1.27 → pydifftools-0.1.29}/pyDiffTools.egg-info/entry_points.txt +0 -0
  54. {pydifftools-0.1.27 → pydifftools-0.1.29}/pyDiffTools.egg-info/top_level.txt +0 -0
  55. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/__init__.py +0 -0
  56. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/browser_lifecycle.py +0 -0
  57. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/check_numbers.py +0 -0
  58. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/command_registry.py +0 -0
  59. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/comment_functions.py +0 -0
  60. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/comment_tags.lua +0 -0
  61. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/comment_tags_margin.lua +0 -0
  62. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/comment_tags_no_comments.lua +0 -0
  63. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/comment_toggle.js +0 -0
  64. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/comments.css +0 -0
  65. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/copy_files.py +0 -0
  66. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/diff-doc.js +0 -0
  67. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/doc_contents.py +0 -0
  68. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/flowchart/__init__.py +0 -0
  69. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  70. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/git_gd.py +0 -0
  71. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/git_gd_qt.py +0 -0
  72. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/html_comments.py +0 -0
  73. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/html_uncomments.py +0 -0
  74. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/log_example.py +0 -0
  75. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/match_spaces.py +0 -0
  76. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/notebook/__init__.py +0 -0
  77. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/notebook/tex_to_qmd.py +0 -0
  78. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/onewordify.py +0 -0
  79. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/onewordify_undo.py +0 -0
  80. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/outline.py +0 -0
  81. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/rearrange_tex.py +0 -0
  82. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/searchacro.py +0 -0
  83. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/separate_comments.py +0 -0
  84. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/split_conflict.py +0 -0
  85. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/unseparate_comments.py +0 -0
  86. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/update_check.py +0 -0
  87. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/wrap_sentences.py +0 -0
  88. {pydifftools-0.1.27 → pydifftools-0.1.29}/pydifftools/xml2xlsx.vbs +0 -0
  89. {pydifftools-0.1.27 → pydifftools-0.1.29}/setup.cfg +0 -0
  90. {pydifftools-0.1.27 → pydifftools-0.1.29}/tests/test_browser_lifecycle.py +0 -0
  91. {pydifftools-0.1.27 → pydifftools-0.1.29}/tests/test_continuous_shutdown.py +0 -0
  92. {pydifftools-0.1.27 → pydifftools-0.1.29}/tests/test_rrng.py +0 -0
  93. {pydifftools-0.1.27 → pydifftools-0.1.29}/tests/test_tex_to_qmd.py +0 -0
  94. {pydifftools-0.1.27 → pydifftools-0.1.29}/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.27
3
+ Version: 0.1.29
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -41,6 +41,8 @@ Requires-Dist: watchdog
41
41
  Requires-Dist: pydot
42
42
  Requires-Dist: python-dateutil
43
43
  Requires-Dist: jinja2
44
+ Requires-Dist: ipykernel
45
+ Requires-Dist: matplotlib
44
46
  Requires-Dist: nbformat
45
47
  Requires-Dist: nbconvert
46
48
  Requires-Dist: pygments
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.27
3
+ Version: 0.1.29
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -41,6 +41,8 @@ Requires-Dist: watchdog
41
41
  Requires-Dist: pydot
42
42
  Requires-Dist: python-dateutil
43
43
  Requires-Dist: jinja2
44
+ Requires-Dist: ipykernel
45
+ Requires-Dist: matplotlib
44
46
  Requires-Dist: nbformat
45
47
  Requires-Dist: nbconvert
46
48
  Requires-Dist: pygments
@@ -81,6 +81,7 @@ pydifftools/flowchart/dot_to_yaml.py
81
81
  pydifftools/flowchart/graph.py
82
82
  pydifftools/flowchart/watch_graph.py
83
83
  pydifftools/notebook/__init__.py
84
+ pydifftools/notebook/display.py
84
85
  pydifftools/notebook/fast_build.py
85
86
  pydifftools/notebook/tex_to_qmd.py
86
87
  tests/test_browser_lifecycle.py
@@ -5,6 +5,8 @@ watchdog
5
5
  pydot
6
6
  python-dateutil
7
7
  jinja2
8
+ ipykernel
9
+ matplotlib
8
10
  nbformat
9
11
  nbconvert
10
12
  pygments
@@ -943,11 +943,82 @@ def main(argv=None):
943
943
  "argument --no-comments: not allowed with argument "
944
944
  "--comments-to-margin"
945
945
  )
946
+ if (
947
+ namespace.command == "qmdb"
948
+ and namespace.always_code
949
+ and namespace.no_code
950
+ ):
951
+ parser._pydifft_subparsers["qmdb"].error(
952
+ "argument --no-code: not allowed with argument --always-code"
953
+ )
946
954
  handler = namespace._handler
947
955
  handler_kwargs = dict(vars(namespace))
948
956
  handler_kwargs.pop("_handler", None)
949
957
  handler_kwargs.pop("command", None)
950
- handler(**handler_kwargs)
958
+ # {{{ explain Chrome and ChromeDriver version mismatches
959
+ from selenium.common.exceptions import SessionNotCreatedException
960
+
961
+ try:
962
+ handler(**handler_kwargs)
963
+ except SessionNotCreatedException as exc:
964
+ details = str(exc)
965
+ driver_match = re.search(
966
+ r"ChromeDriver only supports Chrome version (\d+)",
967
+ details,
968
+ )
969
+ browser_match = re.search(
970
+ r"Current browser version is (\d+)",
971
+ details,
972
+ )
973
+ if driver_match is None or browser_match is None:
974
+ raise
975
+
976
+ driver_major = int(driver_match.group(1))
977
+ browser_major = int(browser_match.group(1))
978
+ print(
979
+ "pydifft: Chrome could not start because ChromeDriver "
980
+ f"{driver_major} and Chrome {browser_major} do not match.",
981
+ file=sys.stderr,
982
+ )
983
+ if shutil.which("apt-get") is not None:
984
+ if browser_major < driver_major:
985
+ binary_match = re.search(
986
+ r"binary path ([^;\n]+)",
987
+ details,
988
+ )
989
+ browser_binary = (
990
+ binary_match.group(1) if binary_match is not None else ""
991
+ )
992
+ browser_package = (
993
+ "chromium"
994
+ if "chromium" in browser_binary
995
+ else "google-chrome-stable"
996
+ )
997
+ packages = browser_package
998
+ elif driver_major < browser_major:
999
+ packages = "chromium-driver"
1000
+ else:
1001
+ packages = "google-chrome-stable chromium-driver"
1002
+ print(
1003
+ "Update the older system package:\n"
1004
+ " sudo apt update\n"
1005
+ f" sudo apt install --only-upgrade {packages}",
1006
+ file=sys.stderr,
1007
+ )
1008
+ else:
1009
+ print(
1010
+ "Update the older Chrome or ChromeDriver installation so "
1011
+ "their major versions match.",
1012
+ file=sys.stderr,
1013
+ )
1014
+ print(
1015
+ "Then retry. You can verify the versions with:\n"
1016
+ " google-chrome --version\n"
1017
+ " chromedriver --version",
1018
+ file=sys.stderr,
1019
+ )
1020
+ raise SystemExit(1) from None
1021
+ # }}}
951
1022
 
952
1023
 
953
1024
  if __name__ == "__main__":
@@ -52,72 +52,88 @@ def forward_search_listener(stop_event, search_queue):
52
52
  server.close()
53
53
 
54
54
 
55
- def _file_contains_text(path, text):
55
+ def _comment_filter_mode(path, packaged_filters=None):
56
56
  if not os.path.exists(path):
57
- return False
57
+ return "missing"
58
+ if packaged_filters is None:
59
+ package_dir = os.path.dirname(os.path.abspath(__file__))
60
+ packaged_filters = {
61
+ "default": os.path.join(package_dir, "comment_tags.lua"),
62
+ "margin": os.path.join(package_dir, "comment_tags_margin.lua"),
63
+ "none": os.path.join(package_dir, "comment_tags_no_comments.lua"),
64
+ }
58
65
  with open(path, encoding="utf-8") as fp:
59
- return text in fp.read()
60
-
61
-
62
- def _comment_filter_mode(path):
63
- if not os.path.exists(path):
64
- return None
65
- if _file_contains_text(path, MARGIN_COMMENTS_FILTER_MARKER):
66
- return "margin"
67
- if _file_contains_text(path, NO_COMMENTS_FILTER_MARKER):
68
- return "none"
69
- return "default"
70
-
66
+ filter_text = fp.read()
67
+ for mode in ["default", "margin", "none"]:
68
+ with open(packaged_filters[mode], encoding="utf-8") as fp:
69
+ if filter_text == fp.read():
70
+ return mode
71
+ return "custom"
71
72
 
72
- def _set_comment_filter_mode(source_dir, mode):
73
- if mode not in {"default", "margin", "none"}:
74
- raise ValueError(f"Unknown comment filter mode: {mode}")
75
- package_dir = os.path.dirname(os.path.abspath(__file__))
76
- active_filter = os.path.join(source_dir, "comment_tags.lua")
77
- inactive_filter = os.path.join(source_dir, "comment_tags.lua.inactive")
78
- packaged_filters = {
79
- "default": os.path.join(package_dir, "comment_tags.lua"),
80
- "margin": os.path.join(package_dir, "comment_tags_margin.lua"),
81
- "none": os.path.join(package_dir, "comment_tags_no_comments.lua"),
82
- }
83
73
 
84
- active_mode = _comment_filter_mode(active_filter)
85
- inactive_mode = _comment_filter_mode(inactive_filter)
86
-
87
- if mode == "default":
88
- if active_mode == "default":
89
- return
90
- if active_mode in {"margin", "none"}:
91
- if inactive_mode == "default":
92
- temp_filter = active_filter + ".swap_tmp"
93
- os.replace(active_filter, temp_filter)
94
- os.replace(inactive_filter, active_filter)
95
- os.replace(temp_filter, inactive_filter)
96
- else:
97
- os.replace(active_filter, inactive_filter)
98
- shutil.copy2(packaged_filters["default"], active_filter)
99
- return
100
- if active_mode is None:
101
- if inactive_mode == "default":
102
- os.replace(inactive_filter, active_filter)
103
- else:
104
- shutil.copy2(packaged_filters["default"], active_filter)
105
- return
106
- shutil.copy2(packaged_filters["default"], active_filter)
107
- return
74
+ def _confirm_restore_comment_filter(active_mode):
75
+ if active_mode == "none":
76
+ message = (
77
+ "The current lua filter is the one that does not show comments, "
78
+ "but you ran without the --no-comments flag.\n\n"
79
+ "Note that you have not locally edited the filter relative to "
80
+ "the library default.\n\n"
81
+ "Do you want to keep the current filter, or restore the library "
82
+ "default that shows comments?"
83
+ )
84
+ default_choice = "restore"
85
+ elif active_mode == "custom":
86
+ message = (
87
+ "The current lua filter does not match the pyDiffTools library "
88
+ "default or the no-comments filter, but you ran without the "
89
+ "--no-comments flag.\n\n"
90
+ "Note that this looks like a locally edited filter.\n\n"
91
+ "Do you want to keep the current filter, or restore the library "
92
+ "default that shows comments?"
93
+ )
94
+ default_choice = "keep"
95
+ else:
96
+ raise ValueError(
97
+ "Comment filter restore prompt is only valid for custom or "
98
+ f"no-comments filters, not {active_mode!r}"
99
+ )
100
+ prompt_script = """
101
+ import sys
102
+ from PySide6.QtWidgets import QApplication, QMessageBox
108
103
 
109
- if active_mode == mode:
110
- return
111
- if active_mode == "default":
112
- os.replace(active_filter, inactive_filter)
113
- shutil.copy2(packaged_filters[mode], active_filter)
114
- return
115
- if active_mode in {"margin", "none"}:
116
- shutil.copy2(packaged_filters[mode], active_filter)
117
- return
118
- if active_mode is None and inactive_mode != "default":
119
- shutil.copy2(packaged_filters["default"], inactive_filter)
120
- shutil.copy2(packaged_filters[mode], active_filter)
104
+ app = QApplication(sys.argv[:1])
105
+ box = QMessageBox()
106
+ box.setWindowTitle("pydifft cpb")
107
+ box.setIcon(QMessageBox.Icon.Question)
108
+ box.setText(sys.argv[1])
109
+ keep_button = box.addButton(
110
+ "Keep current filter", QMessageBox.ButtonRole.RejectRole
111
+ )
112
+ restore_button = box.addButton(
113
+ "Restore library default", QMessageBox.ButtonRole.AcceptRole
114
+ )
115
+ if sys.argv[2] == "restore":
116
+ box.setDefaultButton(restore_button)
117
+ else:
118
+ box.setDefaultButton(keep_button)
119
+ box.exec()
120
+ if box.clickedButton() is restore_button:
121
+ sys.exit(0)
122
+ sys.exit(1)
123
+ """
124
+ result = subprocess.run(
125
+ [sys.executable, "-c", prompt_script, message, default_choice],
126
+ capture_output=True,
127
+ text=True,
128
+ )
129
+ if result.returncode == 0:
130
+ return True
131
+ if result.returncode == 1:
132
+ return False
133
+ stderr = result.stderr.strip()
134
+ if stderr:
135
+ raise RuntimeError(f"pydifft cpb filter dialog failed: {stderr}")
136
+ raise RuntimeError("pydifft cpb filter dialog failed.")
121
137
 
122
138
 
123
139
  def run_pandoc(
@@ -168,10 +184,87 @@ def run_pandoc(
168
184
  ):
169
185
  # Keep the active comment filter in the markdown directory so pandoc
170
186
  # picks it up alongside other user-supplied project filters.
171
- _set_comment_filter_mode(source_dir, comment_filter_mode)
187
+ # {{{ select the active comment_tags.lua filter
188
+ package_dir = os.path.dirname(os.path.abspath(__file__))
189
+ active_filter = os.path.join(source_dir, "comment_tags.lua")
190
+ inactive_filter = os.path.join(
191
+ source_dir, "comment_tags.lua.inactive"
192
+ )
193
+ packaged_filters = {
194
+ "default": os.path.join(package_dir, "comment_tags.lua"),
195
+ "margin": os.path.join(package_dir, "comment_tags_margin.lua"),
196
+ "none": os.path.join(package_dir, "comment_tags_no_comments.lua"),
197
+ }
198
+ active_mode = _comment_filter_mode(active_filter, packaged_filters)
199
+ inactive_mode = _comment_filter_mode(inactive_filter, packaged_filters)
200
+
201
+ if comment_filter_mode == "default":
202
+ if active_mode == "default":
203
+ effective_filter_mode = "default"
204
+ elif active_mode == "none":
205
+ if _confirm_restore_comment_filter("none"):
206
+ shutil.copy2(
207
+ packaged_filters["default"], active_filter
208
+ )
209
+ effective_filter_mode = "default"
210
+ else:
211
+ effective_filter_mode = "none"
212
+ elif active_mode == "custom":
213
+ if _confirm_restore_comment_filter("custom"):
214
+ shutil.copy2(
215
+ packaged_filters["default"], active_filter
216
+ )
217
+ effective_filter_mode = "default"
218
+ else:
219
+ effective_filter_mode = "custom"
220
+ elif active_mode == "margin":
221
+ if inactive_mode in {"default", "custom"}:
222
+ temp_filter = active_filter + ".swap_tmp"
223
+ os.replace(active_filter, temp_filter)
224
+ os.replace(inactive_filter, active_filter)
225
+ os.replace(temp_filter, inactive_filter)
226
+ effective_filter_mode = inactive_mode
227
+ else:
228
+ os.replace(active_filter, inactive_filter)
229
+ shutil.copy2(
230
+ packaged_filters["default"], active_filter
231
+ )
232
+ effective_filter_mode = "default"
233
+ elif active_mode == "missing":
234
+ if inactive_mode in {"default", "custom"}:
235
+ os.replace(inactive_filter, active_filter)
236
+ effective_filter_mode = inactive_mode
237
+ else:
238
+ shutil.copy2(
239
+ packaged_filters["default"], active_filter
240
+ )
241
+ effective_filter_mode = "default"
242
+ else:
243
+ shutil.copy2(packaged_filters["default"], active_filter)
244
+ effective_filter_mode = "default"
245
+ else:
246
+ effective_filter_mode = comment_filter_mode
247
+ if active_mode == comment_filter_mode:
248
+ pass
249
+ elif active_mode in {"default", "custom"}:
250
+ os.replace(active_filter, inactive_filter)
251
+ shutil.copy2(
252
+ packaged_filters[comment_filter_mode], active_filter
253
+ )
254
+ else:
255
+ if active_mode == "missing" and inactive_mode not in {
256
+ "default",
257
+ "custom",
258
+ }:
259
+ shutil.copy2(
260
+ packaged_filters["default"], inactive_filter
261
+ )
262
+ shutil.copy2(
263
+ packaged_filters[comment_filter_mode], active_filter
264
+ )
265
+ # }}}
172
266
  # Only copy the comment UI assets when comments should be rendered.
173
- if comment_filter_mode != "none":
174
- package_dir = os.path.dirname(os.path.abspath(__file__))
267
+ if effective_filter_mode != "none":
175
268
  for asset_name in ["comments.css", "comment_toggle.js"]:
176
269
  target_path = os.path.join(source_dir, asset_name)
177
270
  if not os.path.exists(target_path):
@@ -483,6 +576,4 @@ def cpb(filename, comments_to_margin=False, no_comments=False):
483
576
 
484
577
 
485
578
  if __name__ == "__main__":
486
- filename = sys.argv[1]
487
- cpb(filename)
488
- # Open the HTML file in the default web browser
579
+ raise SystemExit("Use `pydifft cpb <filename.md>` instead.")
@@ -321,8 +321,7 @@ def _node_text_with_due(node, depends_on_undated_parent=False):
321
321
 
322
322
  # Completed tasks should always show their calendar date so the original
323
323
  # deadline remains visible even if it was today or overdue when finished.
324
- style_name = str(node.get("style", ""))
325
- is_completed = "complete" in style_name
324
+ is_completed = node_is_completed(node)
326
325
  # Replace the actual date with high-visibility notices when the deadline
327
326
  # is today or overdue. These are rendered in a bold 12 pt font so they are
328
327
  # immediately noticeable in the diagram. Completed tasks skip these
@@ -367,6 +366,68 @@ def _node_text_with_due(node, depends_on_undated_parent=False):
367
366
  return formatted
368
367
 
369
368
 
369
+ def node_is_completed(node):
370
+ return "complete" in str(node.get("style", ""))
371
+
372
+
373
+ def node_is_endpoint(node):
374
+ return "endpoint" in str(node.get("style", ""))
375
+
376
+
377
+ def trace_ancestors(
378
+ data,
379
+ start_name,
380
+ stop_at=None,
381
+ include_stopped=False,
382
+ skip_over=None,
383
+ ):
384
+ ancestors = []
385
+ nodes = data.get("nodes", {})
386
+ if start_name not in nodes:
387
+ return ancestors
388
+ ancestors_to_visit = list(nodes[start_name].get("parents", []))
389
+ already_seen = set([start_name])
390
+ while ancestors_to_visit:
391
+ ancestor = ancestors_to_visit.pop()
392
+ if ancestor in already_seen:
393
+ continue
394
+ already_seen.add(ancestor)
395
+ if ancestor not in nodes:
396
+ continue
397
+ ancestor_node = nodes[ancestor]
398
+ if stop_at is not None and stop_at(ancestor, ancestor_node):
399
+ if include_stopped:
400
+ ancestors.append(ancestor)
401
+ continue
402
+ if skip_over is not None and skip_over(ancestor, ancestor_node):
403
+ for parent in ancestor_node.get("parents", []):
404
+ ancestors_to_visit.append(parent)
405
+ continue
406
+ ancestors.append(ancestor)
407
+ for parent in ancestor_node.get("parents", []):
408
+ ancestors_to_visit.append(parent)
409
+ return ancestors
410
+
411
+
412
+ def endpoint_projects(data):
413
+ endpoints = set()
414
+ for name, node_data in data.get("nodes", {}).items():
415
+ if node_is_endpoint(node_data):
416
+ endpoints.add(name)
417
+
418
+ projects = {}
419
+ for endpoint in sorted(endpoints):
420
+ projects[endpoint] = [endpoint]
421
+ projects[endpoint].extend(
422
+ trace_ancestors(
423
+ data,
424
+ endpoint,
425
+ stop_at=lambda name, _node: name in endpoints,
426
+ )
427
+ )
428
+ return projects
429
+
430
+
370
431
  def _node_label(text, wrap_width=55):
371
432
  if text is None:
372
433
  return ""
@@ -415,6 +476,8 @@ def _append_node(
415
476
  if node_has_due:
416
477
  for parent in node.get("parents", []):
417
478
  parent_node = data["nodes"].get(parent, {})
479
+ if node_is_completed(parent_node):
480
+ continue
418
481
  parent_due = parent_node.get("due")
419
482
  if parent_due is None or not str(parent_due).strip():
420
483
  depends_on_undated_parent = True
@@ -586,6 +649,65 @@ def yaml_to_dot(data, wrap_width=55, order_by_date=False):
586
649
  return "\n".join(lines)
587
650
 
588
651
 
652
+ def _filter_nodes_for_dot(
653
+ data, node_names, include_completed_endpoint_ancestors=False
654
+ ):
655
+ nodes = data.get("nodes", {})
656
+ included = []
657
+ included_set = set()
658
+
659
+ def include_node(name):
660
+ if name not in included_set:
661
+ included.append(name)
662
+ included_set.add(name)
663
+
664
+ for name in node_names:
665
+ if name not in nodes:
666
+ continue
667
+ if node_is_completed(nodes[name]):
668
+ continue
669
+ include_node(name)
670
+
671
+ if include_completed_endpoint_ancestors:
672
+ for name in list(included):
673
+ completed_endpoint_ancestors = trace_ancestors(
674
+ data,
675
+ name,
676
+ stop_at=lambda _name, node: node_is_endpoint(node),
677
+ include_stopped=True,
678
+ skip_over=lambda _name, node: (
679
+ node_is_completed(node) and not node_is_endpoint(node)
680
+ ),
681
+ )
682
+ for ancestor in completed_endpoint_ancestors:
683
+ if ancestor not in nodes:
684
+ continue
685
+ if node_is_completed(nodes[ancestor]) and node_is_endpoint(
686
+ nodes[ancestor]
687
+ ):
688
+ include_node(ancestor)
689
+
690
+ data_for_dot = {"nodes": {}, "styles": {}}
691
+ if "styles" in data:
692
+ data_for_dot["styles"] = data["styles"]
693
+ for name in included:
694
+ data_for_dot["nodes"][name] = dict(nodes[name])
695
+ for name in data_for_dot["nodes"]:
696
+ if "children" in data_for_dot["nodes"][name]:
697
+ data_for_dot["nodes"][name]["children"] = [
698
+ child
699
+ for child in data_for_dot["nodes"][name]["children"]
700
+ if child in included_set
701
+ ]
702
+ if "parents" in data_for_dot["nodes"][name]:
703
+ data_for_dot["nodes"][name]["parents"] = [
704
+ parent
705
+ for parent in data_for_dot["nodes"][name]["parents"]
706
+ if parent in included_set
707
+ ]
708
+ return data_for_dot
709
+
710
+
589
711
  def save_graph_yaml(path, data):
590
712
  # Ensure stored dates are normalized before writing.
591
713
  _normalize_graph_dates(data)
@@ -610,6 +732,7 @@ def write_dot_from_yaml(
610
732
  old_data=None,
611
733
  validate_due_dates=False,
612
734
  filter_task=None,
735
+ filter_completed=False,
613
736
  resolve_due_date_conflict=None,
614
737
  ):
615
738
  data = load_graph_yaml(str(yaml_path), old_data=old_data)
@@ -755,40 +878,14 @@ def write_dot_from_yaml(
755
878
  )
756
879
  # Include the target task alongside its ancestors in the filtered view.
757
880
  ancestors = set([filter_task])
758
- parents_to_check = list(data["nodes"][filter_task]["parents"])
759
- while parents_to_check:
760
- parent = parents_to_check.pop()
761
- if parent in ancestors:
762
- continue
763
- ancestors.add(parent)
764
- if parent in data["nodes"] and "parents" in data["nodes"][parent]:
765
- for grandparent in data["nodes"][parent]["parents"]:
766
- parents_to_check.append(grandparent)
767
- incomplete_ancestors = set()
768
- for name in ancestors:
769
- if name not in data["nodes"]:
770
- continue
771
- if "complete" in str(data["nodes"][name].get("style", "")):
772
- continue
773
- incomplete_ancestors.add(name)
774
- data_for_dot = {"nodes": {}, "styles": {}}
775
- if "styles" in data:
776
- data_for_dot["styles"] = data["styles"]
777
- for name in incomplete_ancestors:
778
- data_for_dot["nodes"][name] = dict(data["nodes"][name])
779
- for name in data_for_dot["nodes"]:
780
- if "children" in data_for_dot["nodes"][name]:
781
- data_for_dot["nodes"][name]["children"] = [
782
- child
783
- for child in data_for_dot["nodes"][name]["children"]
784
- if child in incomplete_ancestors
785
- ]
786
- if "parents" in data_for_dot["nodes"][name]:
787
- data_for_dot["nodes"][name]["parents"] = [
788
- parent
789
- for parent in data_for_dot["nodes"][name]["parents"]
790
- if parent in incomplete_ancestors
791
- ]
881
+ ancestors.update(trace_ancestors(data, filter_task))
882
+ data_for_dot = _filter_nodes_for_dot(data, ancestors)
883
+ elif filter_completed:
884
+ data_for_dot = _filter_nodes_for_dot(
885
+ data,
886
+ data.get("nodes", {}).keys(),
887
+ include_completed_endpoint_ancestors=True,
888
+ )
792
889
  dot_str = yaml_to_dot(
793
890
  data_for_dot, wrap_width=wrap_width, order_by_date=order_by_date
794
891
  )