pyDiffTools 0.1.25__tar.gz → 0.1.27__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 (96) hide show
  1. {pydifftools-0.1.25 → pydifftools-0.1.27}/MANIFEST.in +2 -0
  2. {pydifftools-0.1.25/pyDiffTools.egg-info → pydifftools-0.1.27}/PKG-INFO +5 -1
  3. {pydifftools-0.1.25 → pydifftools-0.1.27}/README.rst +4 -0
  4. {pydifftools-0.1.25 → pydifftools-0.1.27/pyDiffTools.egg-info}/PKG-INFO +5 -1
  5. {pydifftools-0.1.25 → pydifftools-0.1.27}/pyDiffTools.egg-info/SOURCES.txt +6 -0
  6. pydifftools-0.1.27/pydifftools/browser_lifecycle.py +68 -0
  7. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/command_line.py +80 -37
  8. pydifftools-0.1.27/pydifftools/command_registry.py +119 -0
  9. pydifftools-0.1.27/pydifftools/comment_tags_margin.lua +411 -0
  10. pydifftools-0.1.27/pydifftools/comment_tags_no_comments.lua +328 -0
  11. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/continuous.py +143 -98
  12. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/flowchart/graph.py +151 -36
  13. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/flowchart/watch_graph.py +165 -45
  14. pydifftools-0.1.27/pydifftools/git_gd.py +356 -0
  15. pydifftools-0.1.27/pydifftools/git_gd_qt.py +334 -0
  16. pydifftools-0.1.27/pydifftools/log_example.py +28 -0
  17. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/notebook/fast_build.py +228 -68
  18. pydifftools-0.1.27/pydifftools/update_check.py +73 -0
  19. {pydifftools-0.1.25 → pydifftools-0.1.27}/pyproject.toml +3 -1
  20. pydifftools-0.1.27/tests/test_command_line_help.py +70 -0
  21. {pydifftools-0.1.25 → pydifftools-0.1.27}/tests/test_continuous_shutdown.py +1 -1
  22. {pydifftools-0.1.25 → pydifftools-0.1.27}/tests/test_update_check.py +48 -0
  23. pydifftools-0.1.25/pydifftools/browser_lifecycle.py +0 -25
  24. pydifftools-0.1.25/pydifftools/command_registry.py +0 -65
  25. pydifftools-0.1.25/pydifftools/update_check.py +0 -31
  26. {pydifftools-0.1.25 → pydifftools-0.1.27}/LICENSE.md +0 -0
  27. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/_quarto.yml +0 -0
  28. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  29. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  30. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  31. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  32. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  33. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  34. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  35. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  36. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  37. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  38. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  39. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  40. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  41. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  42. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  43. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  44. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  45. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  46. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  47. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  48. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  49. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  50. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/example.qmd +0 -0
  51. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/example.tex +0 -0
  52. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/index.qmd +0 -0
  53. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  54. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  55. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  56. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  57. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/independent.qmd +0 -0
  58. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/index.qmd +0 -0
  59. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  60. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  61. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  62. {pydifftools-0.1.25 → pydifftools-0.1.27}/example_notebook/project1/test_include.qmd +0 -0
  63. {pydifftools-0.1.25 → pydifftools-0.1.27}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  64. {pydifftools-0.1.25 → pydifftools-0.1.27}/pyDiffTools.egg-info/entry_points.txt +0 -0
  65. {pydifftools-0.1.25 → pydifftools-0.1.27}/pyDiffTools.egg-info/requires.txt +0 -0
  66. {pydifftools-0.1.25 → pydifftools-0.1.27}/pyDiffTools.egg-info/top_level.txt +0 -0
  67. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/__init__.py +0 -0
  68. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/check_numbers.py +0 -0
  69. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/comment_functions.py +0 -0
  70. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/comment_tags.lua +0 -0
  71. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/comment_toggle.js +0 -0
  72. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/comments.css +0 -0
  73. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/copy_files.py +0 -0
  74. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/diff-doc.js +0 -0
  75. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/doc_contents.py +0 -0
  76. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/flowchart/__init__.py +0 -0
  77. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  78. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/html_comments.py +0 -0
  79. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/html_uncomments.py +0 -0
  80. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/match_spaces.py +0 -0
  81. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/notebook/__init__.py +0 -0
  82. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/notebook/tex_to_qmd.py +0 -0
  83. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/onewordify.py +0 -0
  84. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/onewordify_undo.py +0 -0
  85. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/outline.py +0 -0
  86. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/rearrange_tex.py +0 -0
  87. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/searchacro.py +0 -0
  88. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/separate_comments.py +0 -0
  89. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/split_conflict.py +0 -0
  90. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/unseparate_comments.py +0 -0
  91. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/wrap_sentences.py +0 -0
  92. {pydifftools-0.1.25 → pydifftools-0.1.27}/pydifftools/xml2xlsx.vbs +0 -0
  93. {pydifftools-0.1.25 → pydifftools-0.1.27}/setup.cfg +0 -0
  94. {pydifftools-0.1.25 → pydifftools-0.1.27}/tests/test_browser_lifecycle.py +0 -0
  95. {pydifftools-0.1.25 → pydifftools-0.1.27}/tests/test_rrng.py +0 -0
  96. {pydifftools-0.1.25 → pydifftools-0.1.27}/tests/test_tex_to_qmd.py +0 -0
@@ -5,5 +5,7 @@ include pydifftools/diff-doc.js
5
5
  include pydifftools/xml2xlsx.vbs
6
6
  include pydifftools/comments.css
7
7
  include pydifftools/comment_tags.lua
8
+ include pydifftools/comment_tags_margin.lua
9
+ include pydifftools/comment_tags_no_comments.lua
8
10
  include pydifftools/comment_toggle.js
9
11
  recursive-include example_notebook *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.25
3
+ Version: 0.1.27
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.25
3
+ Version: 0.1.27
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.
@@ -51,14 +51,19 @@ pydifftools/command_line.py
51
51
  pydifftools/command_registry.py
52
52
  pydifftools/comment_functions.py
53
53
  pydifftools/comment_tags.lua
54
+ pydifftools/comment_tags_margin.lua
55
+ pydifftools/comment_tags_no_comments.lua
54
56
  pydifftools/comment_toggle.js
55
57
  pydifftools/comments.css
56
58
  pydifftools/continuous.py
57
59
  pydifftools/copy_files.py
58
60
  pydifftools/diff-doc.js
59
61
  pydifftools/doc_contents.py
62
+ pydifftools/git_gd.py
63
+ pydifftools/git_gd_qt.py
60
64
  pydifftools/html_comments.py
61
65
  pydifftools/html_uncomments.py
66
+ pydifftools/log_example.py
62
67
  pydifftools/match_spaces.py
63
68
  pydifftools/onewordify.py
64
69
  pydifftools/onewordify_undo.py
@@ -79,6 +84,7 @@ pydifftools/notebook/__init__.py
79
84
  pydifftools/notebook/fast_build.py
80
85
  pydifftools/notebook/tex_to_qmd.py
81
86
  tests/test_browser_lifecycle.py
87
+ tests/test_command_line_help.py
82
88
  tests/test_continuous_shutdown.py
83
89
  tests/test_rrng.py
84
90
  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
@@ -900,6 +934,15 @@ def main(argv=None):
900
934
  parser._pydifft_subparsers[subcommand].print_help()
901
935
  return
902
936
  namespace = parser.parse_args(argv)
937
+ if (
938
+ namespace.command == "cpb"
939
+ and namespace.comments_to_margin
940
+ and namespace.no_comments
941
+ ):
942
+ parser._pydifft_subparsers["cpb"].error(
943
+ "argument --no-comments: not allowed with argument "
944
+ "--comments-to-margin"
945
+ )
903
946
  handler = namespace._handler
904
947
  handler_kwargs = dict(vars(namespace))
905
948
  handler_kwargs.pop("_handler", None)
@@ -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