pyDiffTools 0.1.24__tar.gz → 0.1.26__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.24/pyDiffTools.egg-info → pydifftools-0.1.26}/PKG-INFO +5 -1
  2. {pydifftools-0.1.24 → pydifftools-0.1.26}/README.rst +4 -0
  3. {pydifftools-0.1.24 → pydifftools-0.1.26/pyDiffTools.egg-info}/PKG-INFO +5 -1
  4. {pydifftools-0.1.24 → pydifftools-0.1.26}/pyDiffTools.egg-info/SOURCES.txt +4 -0
  5. pydifftools-0.1.26/pydifftools/browser_lifecycle.py +68 -0
  6. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/command_line.py +71 -37
  7. pydifftools-0.1.26/pydifftools/command_registry.py +119 -0
  8. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/continuous.py +49 -57
  9. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/flowchart/graph.py +51 -27
  10. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/flowchart/watch_graph.py +307 -44
  11. pydifftools-0.1.26/pydifftools/git_gd.py +356 -0
  12. pydifftools-0.1.26/pydifftools/git_gd_qt.py +334 -0
  13. pydifftools-0.1.26/pydifftools/log_example.py +28 -0
  14. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/notebook/fast_build.py +228 -68
  15. pydifftools-0.1.26/pydifftools/update_check.py +73 -0
  16. {pydifftools-0.1.24 → pydifftools-0.1.26}/pyproject.toml +1 -1
  17. {pydifftools-0.1.24 → pydifftools-0.1.26}/tests/test_browser_lifecycle.py +1 -12
  18. pydifftools-0.1.26/tests/test_command_line_help.py +56 -0
  19. {pydifftools-0.1.24 → pydifftools-0.1.26}/tests/test_continuous_shutdown.py +1 -1
  20. {pydifftools-0.1.24 → pydifftools-0.1.26}/tests/test_update_check.py +48 -0
  21. pydifftools-0.1.24/pydifftools/browser_lifecycle.py +0 -24
  22. pydifftools-0.1.24/pydifftools/command_registry.py +0 -65
  23. pydifftools-0.1.24/pydifftools/update_check.py +0 -31
  24. {pydifftools-0.1.24 → pydifftools-0.1.26}/LICENSE.md +0 -0
  25. {pydifftools-0.1.24 → pydifftools-0.1.26}/MANIFEST.in +0 -0
  26. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/_quarto.yml +0 -0
  27. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  28. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  29. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  30. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  31. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  32. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  33. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  34. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  35. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  36. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  37. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  38. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  39. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  40. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  41. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  42. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  43. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  44. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  45. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  46. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  47. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  48. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  49. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/example.qmd +0 -0
  50. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/example.tex +0 -0
  51. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/index.qmd +0 -0
  52. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  53. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  54. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  55. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  56. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/independent.qmd +0 -0
  57. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/index.qmd +0 -0
  58. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  59. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  60. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  61. {pydifftools-0.1.24 → pydifftools-0.1.26}/example_notebook/project1/test_include.qmd +0 -0
  62. {pydifftools-0.1.24 → pydifftools-0.1.26}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  63. {pydifftools-0.1.24 → pydifftools-0.1.26}/pyDiffTools.egg-info/entry_points.txt +0 -0
  64. {pydifftools-0.1.24 → pydifftools-0.1.26}/pyDiffTools.egg-info/requires.txt +0 -0
  65. {pydifftools-0.1.24 → pydifftools-0.1.26}/pyDiffTools.egg-info/top_level.txt +0 -0
  66. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/__init__.py +0 -0
  67. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/check_numbers.py +0 -0
  68. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/comment_functions.py +0 -0
  69. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/comment_tags.lua +0 -0
  70. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/comment_toggle.js +0 -0
  71. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/comments.css +0 -0
  72. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/copy_files.py +0 -0
  73. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/diff-doc.js +0 -0
  74. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/doc_contents.py +0 -0
  75. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/flowchart/__init__.py +0 -0
  76. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  77. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/html_comments.py +0 -0
  78. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/html_uncomments.py +0 -0
  79. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/match_spaces.py +0 -0
  80. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/notebook/__init__.py +0 -0
  81. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/notebook/tex_to_qmd.py +0 -0
  82. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/onewordify.py +0 -0
  83. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/onewordify_undo.py +0 -0
  84. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/outline.py +0 -0
  85. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/rearrange_tex.py +0 -0
  86. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/searchacro.py +0 -0
  87. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/separate_comments.py +0 -0
  88. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/split_conflict.py +0 -0
  89. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/unseparate_comments.py +0 -0
  90. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/wrap_sentences.py +0 -0
  91. {pydifftools-0.1.24 → pydifftools-0.1.26}/pydifftools/xml2xlsx.vbs +0 -0
  92. {pydifftools-0.1.24 → pydifftools-0.1.26}/setup.cfg +0 -0
  93. {pydifftools-0.1.24 → pydifftools-0.1.26}/tests/test_rrng.py +0 -0
  94. {pydifftools-0.1.24 → pydifftools-0.1.26}/tests/test_tex_to_qmd.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.26
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -106,6 +106,10 @@ included are (listed in order of fun/utility):
106
106
  ``_build``/``_display`` directories; with ``--watch`` it starts the HTTP
107
107
  server and automatically rebuilds the staged fragments whenever you edit
108
108
  a ``.qmd`` file.
109
+ - `pydifft gd [git diff args...]` shows the same Qt review table as the old
110
+ ``git_gd_qt.py`` helper before launching ``git difftool`` for a selected
111
+ file. Run ``pydifft gd --install`` to add the matching ``git gd`` alias
112
+ to your global git config.
109
113
  - `pydifft qmdinit [directory]` scaffolds a new Quarto-style project using
110
114
  the bundled templates and example ``project1`` hierarchy, then downloads
111
115
  MathJax into ``_template/mathjax`` so the builder can run immediately.
@@ -56,6 +56,10 @@ included are (listed in order of fun/utility):
56
56
  ``_build``/``_display`` directories; with ``--watch`` it starts the HTTP
57
57
  server and automatically rebuilds the staged fragments whenever you edit
58
58
  a ``.qmd`` file.
59
+ - `pydifft gd [git diff args...]` shows the same Qt review table as the old
60
+ ``git_gd_qt.py`` helper before launching ``git difftool`` for a selected
61
+ file. Run ``pydifft gd --install`` to add the matching ``git gd`` alias
62
+ to your global git config.
59
63
  - `pydifft qmdinit [directory]` scaffolds a new Quarto-style project using
60
64
  the bundled templates and example ``project1`` hierarchy, then downloads
61
65
  MathJax into ``_template/mathjax`` so the builder can run immediately.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.24
3
+ Version: 0.1.26
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -106,6 +106,10 @@ included are (listed in order of fun/utility):
106
106
  ``_build``/``_display`` directories; with ``--watch`` it starts the HTTP
107
107
  server and automatically rebuilds the staged fragments whenever you edit
108
108
  a ``.qmd`` file.
109
+ - `pydifft gd [git diff args...]` shows the same Qt review table as the old
110
+ ``git_gd_qt.py`` helper before launching ``git difftool`` for a selected
111
+ file. Run ``pydifft gd --install`` to add the matching ``git gd`` alias
112
+ to your global git config.
109
113
  - `pydifft qmdinit [directory]` scaffolds a new Quarto-style project using
110
114
  the bundled templates and example ``project1`` hierarchy, then downloads
111
115
  MathJax into ``_template/mathjax`` so the builder can run immediately.
@@ -57,8 +57,11 @@ pydifftools/continuous.py
57
57
  pydifftools/copy_files.py
58
58
  pydifftools/diff-doc.js
59
59
  pydifftools/doc_contents.py
60
+ pydifftools/git_gd.py
61
+ pydifftools/git_gd_qt.py
60
62
  pydifftools/html_comments.py
61
63
  pydifftools/html_uncomments.py
64
+ pydifftools/log_example.py
62
65
  pydifftools/match_spaces.py
63
66
  pydifftools/onewordify.py
64
67
  pydifftools/onewordify_undo.py
@@ -79,6 +82,7 @@ pydifftools/notebook/__init__.py
79
82
  pydifftools/notebook/fast_build.py
80
83
  pydifftools/notebook/tex_to_qmd.py
81
84
  tests/test_browser_lifecycle.py
85
+ tests/test_command_line_help.py
82
86
  tests/test_continuous_shutdown.py
83
87
  tests/test_rrng.py
84
88
  tests/test_tex_to_qmd.py
@@ -0,0 +1,68 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+
5
+ def browser_window_is_alive(browser):
6
+ # Keep all browser liveness checks in one place so watch commands share
7
+ # the same shutdown behavior when a user closes the browser window.
8
+ # Do not probe with execute_script here: page navigations can briefly
9
+ # interrupt script execution even while the window is still open.
10
+ if browser is None:
11
+ return False
12
+ try:
13
+ handles = browser.window_handles
14
+ if not handles:
15
+ return False
16
+ return True
17
+ except Exception:
18
+ return False
19
+
20
+
21
+ def close_browser_window(browser):
22
+ # Close a Selenium browser session and ignore errors from already-closed
23
+ # windows so cleanup paths stay simple.
24
+ if browser is None:
25
+ return
26
+ try:
27
+ browser.quit()
28
+ except Exception:
29
+ pass
30
+
31
+
32
+ def forward_search_in_browser(browser, search_text):
33
+ # Reuse the same browser-side find logic across cpb and qmdb.
34
+ if browser is None or not search_text:
35
+ return False
36
+ found = browser.execute_script(
37
+ """
38
+ var searchText = arguments[0];
39
+ if (!window.find) {
40
+ return false;
41
+ }
42
+ var didFind = window.find(searchText);
43
+ if (didFind && window.getSelection) {
44
+ var selection = window.getSelection();
45
+ if (selection.rangeCount > 0) {
46
+ var rect = selection.getRangeAt(0).getBoundingClientRect();
47
+ window.scrollBy(0, rect.top - window.innerHeight / 3);
48
+ }
49
+ }
50
+ return didFind;
51
+ """,
52
+ search_text,
53
+ )
54
+ if not found:
55
+ print("forward search did not find text:", search_text)
56
+ # Bring the browser window to the foreground in Linux window managers.
57
+ if os.name == "posix" and shutil.which("wmctrl"):
58
+ window_title = browser.execute_script("return document.title;")
59
+ if window_title:
60
+ # Try common Chromium title forms used by desktop environments.
61
+ for title_candidate in [
62
+ window_title,
63
+ window_title + " - Google Chrome",
64
+ window_title + " - Chromium",
65
+ window_title + " - Chrome",
66
+ ]:
67
+ subprocess.run(["wmctrl", "-a", title_candidate], check=False)
68
+ return found
@@ -27,10 +27,16 @@ from .comment_functions import matchingbrackets
27
27
  from .copy_files import copy_image_files
28
28
  from .searchacro import replace_acros
29
29
  from .rearrange_tex import run as rearrange_tex_run
30
+ from .git_gd import gd # registers git difftool review command
30
31
  from .flowchart.watch_graph import wgrph
31
32
  from .flowchart.graph import load_graph_yaml
32
33
  from .notebook.tex_to_qmd import tex2qmd
33
- from .notebook.fast_build import qmdb, qmdinit
34
+ from .notebook.fast_build import (
35
+ qmdb,
36
+ qmdinit,
37
+ QMDB_FORWARD_SEARCH_HOST,
38
+ QMDB_FORWARD_SEARCH_PORT,
39
+ )
34
40
 
35
41
  from .command_registry import _COMMAND_SPECS, register_command
36
42
 
@@ -518,27 +524,48 @@ def fs(arguments):
518
524
  "markdown forward search, use with cpb to jump to text in the browser"
519
525
  )
520
526
  def mfs(text):
521
- # Send the requested search text to the cpb socket listener.
522
- client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
523
- try:
524
- client.connect((FORWARD_SEARCH_HOST, FORWARD_SEARCH_PORT))
525
- except OSError as exc:
526
- client.close()
527
- # If cpb isn't running yet, choose a markdown file in this directory
528
- # that contains the requested search text and start cpb for it.
527
+ # Normalize markdown-specific markup so search phrases match rendered text.
528
+ search_text = text
529
+ marker_match = re.search(r"(?i)@[a-z]{2,4}:", search_text)
530
+ if marker_match:
531
+ search_text = search_text[: marker_match.start()]
532
+ search_text = re.sub(r"\[@[^\]]+\]", " ", search_text)
533
+ search_text = search_text.replace("**", " ").replace("*", " ")
534
+ search_text = re.sub(r"\s+", " ", search_text).strip()
535
+ if not search_text:
536
+ search_text = text.strip()
537
+
538
+ # Try existing cpb and qmdb listeners before launching a new cpb process.
539
+ socket_addresses = [
540
+ (FORWARD_SEARCH_HOST, FORWARD_SEARCH_PORT),
541
+ (QMDB_FORWARD_SEARCH_HOST, QMDB_FORWARD_SEARCH_PORT),
542
+ ]
543
+ client = None
544
+ for address in socket_addresses:
545
+ candidate = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
546
+ try:
547
+ candidate.connect(address)
548
+ client = candidate
549
+ break
550
+ except OSError:
551
+ candidate.close()
552
+ if client is None:
553
+ # If no listener is running yet, choose a markdown file in this
554
+ # directory that contains the requested search text and start cpb for
555
+ # it.
529
556
  matching_files = []
530
557
  for filename in sorted(os.listdir(".")):
531
558
  if not filename.endswith(".md"):
532
559
  continue
533
560
  with open(filename, encoding="utf-8") as fp:
534
- if text in fp.read():
561
+ if search_text in fp.read():
535
562
  matching_files.append(filename)
536
563
  if len(matching_files) == 0:
537
564
  raise RuntimeError(
538
- "Could not connect to cpb forward search socket and "
565
+ "Could not connect to cpb or qmdb forward search sockets and "
539
566
  "could not find the requested text in any .md file in the "
540
567
  "current directory."
541
- ) from exc
568
+ )
542
569
  if len(matching_files) > 1:
543
570
  print(
544
571
  "Found search text in multiple markdown files. "
@@ -554,19 +581,23 @@ def mfs(text):
554
581
  # Wait up to 20 seconds so the child can bring up the listener,
555
582
  # then resend the forward-search text.
556
583
  for _ in range(80):
557
- client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
558
- try:
559
- client.connect((FORWARD_SEARCH_HOST, FORWARD_SEARCH_PORT))
584
+ for address in socket_addresses:
585
+ candidate = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
586
+ try:
587
+ candidate.connect(address)
588
+ client = candidate
589
+ break
590
+ except OSError:
591
+ candidate.close()
592
+ if client is not None:
560
593
  break
561
- except OSError:
562
- client.close()
563
- time.sleep(0.25)
594
+ time.sleep(0.25)
564
595
  else:
565
596
  raise RuntimeError(
566
597
  "Started cpb automatically, but the forward search socket "
567
598
  "did not come up within 20 seconds."
568
599
  )
569
- client.sendall(text.encode("utf-8"))
600
+ client.sendall(search_text.encode("utf-8"))
570
601
  client.close()
571
602
 
572
603
 
@@ -841,22 +872,11 @@ def build_parser():
841
872
  flags = argument["flags"]
842
873
  kwargs = dict(argument["kwargs"])
843
874
  action = subparser.add_argument(*flags, **kwargs)
844
- if (
845
- FilesCompleter is not None
846
- and name == "wgrph"
847
- and action.dest == "yaml"
848
- ):
849
- # Provide YAML-only completions for the flowchart watcher.
850
- action.completer = FilesCompleter(
851
- allowednames=["*.yaml", "*.yml"]
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"])
875
+ # {{{ attach filename completer
876
+ allowednames = argument.get("completion_allowednames")
877
+ if FilesCompleter is not None and allowednames is not None:
878
+ action.completer = FilesCompleter(allowednames=allowednames)
879
+ # }}}
860
880
  if name == "wgrph" and action.dest == "t":
861
881
  # Offer case-insensitive completions for incomplete task names.
862
882
  action.completer = wgrph_task_completer
@@ -888,9 +908,23 @@ def main(argv=None):
888
908
  file=sys.stderr,
889
909
  )
890
910
  parser = build_parser()
911
+ # {{{ run argcomplete
891
912
  if argcomplete is not None:
892
- # Enable argcomplete integration when the dependency is available.
893
- argcomplete.autocomplete(parser)
913
+ # Only suggest options after the user types '-' so bare
914
+ # `pydifft <tab>` offers subcommands only.
915
+ try:
916
+ argcomplete.autocomplete(
917
+ parser,
918
+ always_complete_options=False,
919
+ exit_method=os._exit,
920
+ output_stream=None,
921
+ )
922
+ except TypeError as exc:
923
+ if "unexpected keyword argument" not in str(exc):
924
+ raise
925
+ # Test stubs may provide a minimal autocomplete(parser) shim only.
926
+ argcomplete.autocomplete(parser)
927
+ # }}}
894
928
  if not argv:
895
929
  parser.print_help()
896
930
  return
@@ -0,0 +1,119 @@
1
+ import argparse
2
+ import inspect
3
+
4
+
5
+ class CommandRegistrationError(Exception):
6
+ """Exception raised when attempting to register a duplicate subcommand."""
7
+
8
+
9
+ # Registry that stores all subcommands made available to the CLI dispatcher.
10
+ _COMMAND_SPECS = {}
11
+
12
+
13
+ def register_command(
14
+ help_text,
15
+ description=None,
16
+ help=None,
17
+ filename_extensions=None,
18
+ ):
19
+ """Register a command handler for the CLI dispatcher."""
20
+
21
+ def decorator(func):
22
+ name = func.__name__.replace("_", "-")
23
+ if name in _COMMAND_SPECS:
24
+ raise CommandRegistrationError(
25
+ f"Command '{name}' already registered"
26
+ )
27
+ signature = inspect.signature(func)
28
+ parameters = [
29
+ parameter
30
+ for parameter in signature.parameters.values()
31
+ if parameter.kind
32
+ not in [
33
+ inspect.Parameter.VAR_POSITIONAL,
34
+ inspect.Parameter.VAR_KEYWORD,
35
+ ]
36
+ ]
37
+ # {{{ normalize filename extensions
38
+ if filename_extensions is None:
39
+ completion_allowednames = {}
40
+ else:
41
+ completion_allowednames = {}
42
+ for argument_name, extensions in filename_extensions.items():
43
+ if isinstance(extensions, str):
44
+ extensions = [extensions]
45
+ completion_allowednames[argument_name] = []
46
+ for extension in extensions:
47
+ if (
48
+ not isinstance(extension, str)
49
+ or len(extension.strip()) == 0
50
+ ):
51
+ raise CommandRegistrationError(
52
+ "filename_extensions must contain non-empty"
53
+ " strings"
54
+ )
55
+ extension = extension.strip()
56
+ if extension.startswith("*."):
57
+ completion_allowednames[argument_name].append(
58
+ extension
59
+ )
60
+ elif extension.startswith("."):
61
+ completion_allowednames[argument_name].append(
62
+ "*" + extension
63
+ )
64
+ else:
65
+ completion_allowednames[argument_name].append(
66
+ "*." + extension
67
+ )
68
+ # }}}
69
+ unknown_arguments = sorted(
70
+ set(completion_allowednames)
71
+ - {parameter.name for parameter in parameters}
72
+ )
73
+ if unknown_arguments:
74
+ raise CommandRegistrationError(
75
+ "filename_extensions references unknown arguments: "
76
+ + ", ".join(unknown_arguments)
77
+ )
78
+ _COMMAND_SPECS[name] = {
79
+ "handler": func,
80
+ "help": help_text.strip(),
81
+ "description": (
82
+ description if description is not None else help_text
83
+ ).strip(),
84
+ "arguments": [],
85
+ }
86
+ argument_help = help if help is not None else {}
87
+ for parameter in parameters:
88
+ flags = []
89
+ kwargs = {}
90
+ if parameter.default is inspect._empty:
91
+ flags.append(parameter.name)
92
+ if parameter.name == "arguments":
93
+ # Most commands accept a raw list of trailing arguments.
94
+ kwargs["nargs"] = argparse.REMAINDER
95
+ kwargs["help"] = argparse.SUPPRESS
96
+ else:
97
+ # Single-letter keywords use a short flag; everything else uses
98
+ # the long two-dash style expected by the CLI.
99
+ dash_prefix = "-" if len(parameter.name) == 1 else "--"
100
+ flags.append(dash_prefix + parameter.name.replace("_", "-"))
101
+ kwargs["default"] = parameter.default
102
+ if isinstance(parameter.default, bool):
103
+ # Boolean flags toggle on or off without needing a value.
104
+ kwargs["action"] = (
105
+ "store_false" if parameter.default else "store_true"
106
+ )
107
+ elif parameter.default is not None:
108
+ kwargs["type"] = type(parameter.default)
109
+ if parameter.name in argument_help:
110
+ kwargs["help"] = argument_help[parameter.name].strip()
111
+ argument_spec = {"flags": flags, "kwargs": kwargs}
112
+ if parameter.name in completion_allowednames:
113
+ argument_spec["completion_allowednames"] = (
114
+ completion_allowednames[parameter.name]
115
+ )
116
+ _COMMAND_SPECS[name]["arguments"].append(argument_spec)
117
+ return func
118
+
119
+ return decorator
@@ -12,7 +12,11 @@ import queue
12
12
  from watchdog.events import FileSystemEventHandler
13
13
  from watchdog.observers import Observer
14
14
  from .command_registry import register_command
15
- from .browser_lifecycle import browser_window_is_alive, close_browser_window
15
+ from .browser_lifecycle import (
16
+ browser_window_is_alive,
17
+ close_browser_window,
18
+ forward_search_in_browser,
19
+ )
16
20
 
17
21
  FORWARD_SEARCH_HOST = "127.0.0.1"
18
22
  FORWARD_SEARCH_PORT = 51235
@@ -122,7 +126,11 @@ def run_pandoc(filename, html_file, comments_to_margin=False):
122
126
  # into the markdown directory before collecting css/lua/js companion files.
123
127
  with open(filename, encoding="utf-8") as fp:
124
128
  markdown_text = fp.read()
125
- if "<comment>" in markdown_text:
129
+ if (
130
+ "<comment>" in markdown_text
131
+ or "comment-right" in markdown_text
132
+ or "comment-left" in markdown_text
133
+ ):
126
134
  package_dir = os.path.dirname(os.path.abspath(__file__))
127
135
  for asset_name in ["comments.css", "comment_toggle.js"]:
128
136
  target_path = os.path.join(source_dir, asset_name)
@@ -139,9 +147,11 @@ def run_pandoc(filename, html_file, comments_to_margin=False):
139
147
  ]
140
148
  if len(localfiles[k]) == 1:
141
149
  localfiles[k] = os.path.join(source_dir, localfiles[k][0])
150
+ elif len(localfiles[k]) == 0:
151
+ localfiles[k] = None
142
152
  else:
143
153
  raise ValueError(
144
- f"You have more than one (or no) {k} file in this directory!"
154
+ f"You have more than one {k} file in this directory!"
145
155
  " Get rid of all but one! of " + "and".join(localfiles[k])
146
156
  )
147
157
  # Include any css files next to the markdown source in the pandoc output.
@@ -150,8 +160,13 @@ def run_pandoc(filename, html_file, comments_to_margin=False):
150
160
  )
151
161
  # Include any lua filters next to the markdown source in the pandoc
152
162
  # output by passing repeated --lua-filter arguments.
163
+ lua_priority = {
164
+ "scholarly-metadata.lua": 0,
165
+ "author-info-blocks.lua": 1,
166
+ }
153
167
  localfiles["lua"] = sorted(
154
- [f for f in os.listdir(source_dir) if f.endswith(".lua")]
168
+ [f for f in os.listdir(source_dir) if f.endswith(".lua")],
169
+ key=lambda name: (lua_priority.get(name, 2), name),
155
170
  )
156
171
  # Include any javascript files next to the markdown source by injecting
157
172
  # script tags after pandoc runs. This adds extra javascript and does not
@@ -161,9 +176,6 @@ def run_pandoc(filename, html_file, comments_to_margin=False):
161
176
  )
162
177
  command = [
163
178
  "pandoc",
164
- "--bibliography",
165
- localfiles["bib"],
166
- f"--csl={localfiles['csl']}",
167
179
  "--filter",
168
180
  "pandoc-crossref",
169
181
  "--citeproc",
@@ -175,16 +187,31 @@ def run_pandoc(filename, html_file, comments_to_margin=False):
175
187
  html_file,
176
188
  filename,
177
189
  ]
190
+ if localfiles["bib"]:
191
+ command[1:1] = ["--bibliography", localfiles["bib"]]
192
+ if localfiles["csl"]:
193
+ command.insert(1, f"--csl={localfiles['csl']}")
178
194
  for css_file in localfiles["css"]:
179
195
  command.extend(["--css", os.path.join(source_dir, css_file)])
180
196
  for lua_file in localfiles["lua"]:
181
197
  command.extend(["--lua-filter", os.path.join(source_dir, lua_file)])
182
198
  # command = ['pandoc', '-s', '--mathjax', '-o', html_file, filename]
183
199
  print("running:", " ".join(command))
184
- subprocess.run(
200
+ completed = subprocess.run(
185
201
  command,
186
202
  )
203
+ if getattr(completed, "returncode", 0) != 0:
204
+ raise RuntimeError(
205
+ f"Pandoc failed with exit code {completed.returncode} while "
206
+ f"building {html_file}.\n"
207
+ f"Command: {' '.join(command)}"
208
+ )
187
209
  print("running:\n", command)
210
+ if not os.path.exists(html_file):
211
+ raise RuntimeError(
212
+ "Pandoc completed but did not create the expected HTML file: "
213
+ f"{html_file}"
214
+ )
188
215
  if has_local_jax:
189
216
  # {{{ for slow internet connection, remove remote files
190
217
  with open(html_file, encoding="utf-8") as fp:
@@ -239,14 +266,14 @@ class Handler(FileSystemEventHandler):
239
266
  self.filename = filename
240
267
  self.comments_to_margin = comments_to_margin
241
268
  self.html_file = filename.rsplit(".", 1)[0] + ".html"
242
- self.init_firefox()
269
+ self.init_chrome()
243
270
 
244
- def init_firefox(self):
271
+ def init_chrome(self):
245
272
  # apparently, selenium breaks stdin/out for tests, so it must be
246
273
  # imported here
247
274
  from selenium import webdriver
248
275
 
249
- self.firefox = webdriver.Chrome()
276
+ self.chrome = webdriver.Chrome()
250
277
  run_pandoc(
251
278
  self.filename,
252
279
  self.html_file,
@@ -255,7 +282,7 @@ class Handler(FileSystemEventHandler):
255
282
  if not os.path.exists(self.html_file):
256
283
  print("html doesn't exist")
257
284
  self.append_autorefresh()
258
- self.firefox.get("file://" + os.path.abspath(self.html_file))
285
+ self.chrome.get("file://" + os.path.abspath(self.html_file))
259
286
 
260
287
  def on_modified(self, event):
261
288
  from selenium.common.exceptions import WebDriverException
@@ -270,14 +297,14 @@ class Handler(FileSystemEventHandler):
270
297
  )
271
298
  self.append_autorefresh()
272
299
  try:
273
- self.firefox.refresh()
300
+ self.chrome.refresh()
274
301
  except WebDriverException:
275
302
  print(
276
303
  "I'm quitting!! You probably suspended the computer, which"
277
304
  " seems to freak selenium out. Just restart"
278
305
  )
279
- self.firefox.quit()
280
- self.init_firefox()
306
+ self.chrome.quit()
307
+ self.init_chrome()
281
308
 
282
309
  def append_autorefresh(self):
283
310
  with open(self.html_file, "r", encoding="utf-8") as fp:
@@ -312,7 +339,8 @@ position
312
339
  );
313
340
  });
314
341
 
315
- // When the page has loaded, restore hidden comments and scroll position
342
+ // When the page has loaded,
343
+ // restore hidden comments and scroll position
316
344
  window.addEventListener('load', function() {
317
345
  var hiddenCommentIndexes = sessionStorage.getItem(
318
346
  'commentHiddenBubbleIndexes'
@@ -347,47 +375,10 @@ position
347
375
  fp.write(all_data)
348
376
 
349
377
  def forward_search(self, search_text):
350
- # Use the browser's built-in window.find to locate the text.
378
+ # Reuse shared browser search behavior so cpb and qmdb stay in sync.
351
379
  if not search_text:
352
380
  return
353
- found = self.firefox.execute_script(
354
- """
355
- var searchText = arguments[0];
356
- if (!window.find) {
357
- return false;
358
- }
359
- var didFind = window.find(searchText);
360
- if (didFind && window.getSelection) {
361
- var selection = window.getSelection();
362
- if (selection.rangeCount > 0) {
363
- var rect = selection.getRangeAt(0).getBoundingClientRect();
364
- window.scrollBy(0, rect.top - window.innerHeight / 3);
365
- }
366
- }
367
- return didFind;
368
- """,
369
- search_text,
370
- )
371
- if not found:
372
- print("forward search did not find text:", search_text)
373
- # Bring the browser window to the foreground in Linux window managers.
374
- if os.name == "posix" and shutil.which("wmctrl"):
375
- window_title = self.firefox.execute_script(
376
- "return document.title;"
377
- )
378
- if window_title:
379
- # Try common Chromium title forms used by desktop environments.
380
- for title_candidate in [
381
- window_title,
382
- window_title + " - Google Chrome",
383
- window_title + " - Chromium",
384
- window_title + " - Chrome",
385
- ]:
386
- subprocess.run(
387
- ["wmctrl", "-a", title_candidate],
388
- check=False,
389
- )
390
-
381
+ forward_search_in_browser(self.chrome, search_text)
391
382
 
392
383
  @register_command(
393
384
  "continuous pandoc build. Like latexmk, but for markdown!",
@@ -398,6 +389,7 @@ position
398
389
  "comments filter for printing."
399
390
  ),
400
391
  },
392
+ filename_extensions={"filename": ".md"},
401
393
  )
402
394
  def cpb(filename, comments_to_margin=False):
403
395
  observer = Observer()
@@ -419,7 +411,7 @@ def cpb(filename, comments_to_margin=False):
419
411
  while True:
420
412
  # Exit when the browser window is closed so cpb does not leave a
421
413
  # background process running after the user closes Chrome.
422
- if not browser_window_is_alive(event_handler.firefox):
414
+ if not browser_window_is_alive(event_handler.chrome):
423
415
  break
424
416
  time.sleep(1)
425
417
  while not search_queue.empty():
@@ -433,7 +425,7 @@ def cpb(filename, comments_to_margin=False):
433
425
  observer.stop()
434
426
  observer.join()
435
427
  socket_thread.join()
436
- close_browser_window(event_handler.firefox)
428
+ close_browser_window(event_handler.chrome)
437
429
 
438
430
 
439
431
  if __name__ == "__main__":