pyDiffTools 0.1.43__tar.gz → 0.1.45__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 (114) hide show
  1. {pydifftools-0.1.43/pyDiffTools.egg-info → pydifftools-0.1.45}/PKG-INFO +1 -1
  2. {pydifftools-0.1.43 → pydifftools-0.1.45/pyDiffTools.egg-info}/PKG-INFO +1 -1
  3. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/continuous.py +47 -9
  4. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/wrap_sentences.py +363 -3
  5. {pydifftools-0.1.43 → pydifftools-0.1.45}/pyproject.toml +1 -1
  6. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_wrap_check.py +277 -0
  7. {pydifftools-0.1.43 → pydifftools-0.1.45}/LICENSE.md +0 -0
  8. {pydifftools-0.1.43 → pydifftools-0.1.45}/MANIFEST.in +0 -0
  9. {pydifftools-0.1.43 → pydifftools-0.1.45}/README.rst +0 -0
  10. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/_quarto.yml +0 -0
  11. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  12. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  13. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  14. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  15. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  16. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  17. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  18. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  19. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  20. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  21. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  22. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  23. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  24. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  25. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  26. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  27. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  28. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  29. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  30. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  31. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  32. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  33. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/example.qmd +0 -0
  34. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/example.tex +0 -0
  35. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/index.qmd +0 -0
  36. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  37. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  38. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  39. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  40. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/independent.qmd +0 -0
  41. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/index.qmd +0 -0
  42. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  43. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  44. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  45. {pydifftools-0.1.43 → pydifftools-0.1.45}/example_notebook/project1/test_include.qmd +0 -0
  46. {pydifftools-0.1.43 → pydifftools-0.1.45}/pyDiffTools.egg-info/SOURCES.txt +0 -0
  47. {pydifftools-0.1.43 → pydifftools-0.1.45}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  48. {pydifftools-0.1.43 → pydifftools-0.1.45}/pyDiffTools.egg-info/entry_points.txt +0 -0
  49. {pydifftools-0.1.43 → pydifftools-0.1.45}/pyDiffTools.egg-info/requires.txt +0 -0
  50. {pydifftools-0.1.43 → pydifftools-0.1.45}/pyDiffTools.egg-info/top_level.txt +0 -0
  51. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/__init__.py +0 -0
  52. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/bibliography.py +0 -0
  53. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/browser_lifecycle.py +0 -0
  54. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/check_numbers.py +0 -0
  55. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/citation_dialog.py +0 -0
  56. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/command_line.py +0 -0
  57. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/command_registry.py +0 -0
  58. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comment_functions.py +0 -0
  59. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comment_migration.py +0 -0
  60. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comment_tags.lua +0 -0
  61. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comment_tags_margin.lua +0 -0
  62. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comment_tags_no_comments.lua +0 -0
  63. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comment_toggle.js +0 -0
  64. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/comments.css +0 -0
  65. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/copy_files.py +0 -0
  66. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/diff-doc.js +0 -0
  67. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/doc_contents.py +0 -0
  68. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/__init__.py +0 -0
  69. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/comparison.py +0 -0
  70. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  71. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/graph.py +0 -0
  72. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  73. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/watch_graph.py +0 -0
  74. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/flowchart/wgrph_view.js +0 -0
  75. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/forward_search.py +0 -0
  76. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/git_gd.py +0 -0
  77. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/git_gd_history.py +0 -0
  78. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/git_gd_image.py +0 -0
  79. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/git_gd_qt.py +0 -0
  80. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/html_comments.py +0 -0
  81. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/html_uncomments.py +0 -0
  82. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/log_example.py +0 -0
  83. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/match_spaces.py +0 -0
  84. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/notebook/__init__.py +0 -0
  85. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/notebook/display.py +0 -0
  86. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/notebook/fast_build.py +0 -0
  87. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/notebook/tex_to_qmd.py +0 -0
  88. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/onewordify.py +0 -0
  89. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/onewordify_undo.py +0 -0
  90. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/outline.py +0 -0
  91. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/rearrange_tex.py +0 -0
  92. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/searchacro.py +0 -0
  93. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/separate_comments.py +0 -0
  94. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/split_conflict.py +0 -0
  95. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/unseparate_comments.py +0 -0
  96. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/update_check.py +0 -0
  97. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/xml2xlsx.vbs +0 -0
  98. {pydifftools-0.1.43 → pydifftools-0.1.45}/pydifftools/zotero.py +0 -0
  99. {pydifftools-0.1.43 → pydifftools-0.1.45}/setup.cfg +0 -0
  100. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_bibliography.py +0 -0
  101. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_browser_lifecycle.py +0 -0
  102. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_check_single_use.py +0 -0
  103. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_command_line_help.py +0 -0
  104. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_comment_migration.py +0 -0
  105. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_continuous_shutdown.py +0 -0
  106. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_forward_search.py +0 -0
  107. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_git_gd_history.py +0 -0
  108. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_git_gd_image.py +0 -0
  109. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_git_gd_qt.py +0 -0
  110. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_rrng.py +0 -0
  111. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_tex_to_qmd.py +0 -0
  112. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_update_check.py +0 -0
  113. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_wrapping.py +0 -0
  114. {pydifftools-0.1.43 → pydifftools-0.1.45}/tests/test_zotero.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.43
3
+ Version: 0.1.45
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.43
3
+ Version: 0.1.45
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -145,13 +145,14 @@ def show_markdown_fix_dialog(report):
145
145
 
146
146
  fixes = report["fixes"]
147
147
  warnings = report["warnings"]
148
- if not fixes and not warnings:
148
+ layout = report.get("layout", [])
149
+ if not fixes and not warnings and not layout:
149
150
  return
150
151
 
152
+ # a QApplication must exist before any widget is constructed
153
+ application = QApplication.instance() or QApplication([])
151
154
  dialog = QDialog()
152
- dialog._pydifftools_application = (
153
- QApplication.instance() or QApplication([])
154
- )
155
+ dialog._pydifftools_application = application
155
156
  dialog.setWindowTitle("Markdown source fixes")
156
157
  fixed_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
157
158
  # {{{ group fixes of one kind onto pages that fill the screen
@@ -196,6 +197,7 @@ def show_markdown_fix_dialog(report):
196
197
  records.append(
197
198
  {"record_type": "fix", "reason": reason, "fixes": page}
198
199
  )
200
+ records += [{"record_type": "layout", **notice} for notice in layout]
199
201
  records += [
200
202
  {"record_type": "warning", **warning} for warning in warnings
201
203
  ]
@@ -279,6 +281,40 @@ def show_markdown_fix_dialog(report):
279
281
  )
280
282
  # }}}
281
283
  button.setText("Next" if index + 1 < len(records) else "Done")
284
+ elif record["record_type"] == "layout":
285
+ heading.setText(
286
+ "These line breaks differ from git HEAD in "
287
+ f"{record['moves']} places." + page
288
+ )
289
+ details.setText(
290
+ "Keeping these lines as they are in git HEAD would need "
291
+ f"{record['moves']} line breaks moved, so I left them as you "
292
+ "wrote them. If you did not mean to rearrange these lines, "
293
+ "lay them out as suggested."
294
+ )
295
+ line_numbers.setVisible(False)
296
+ # {{{ show HEAD, the current source and the suggested layout
297
+ sections = []
298
+ for title, text, numbered in (
299
+ ("git HEAD:", record["head"], False),
300
+ ("Now:", record["current"], True),
301
+ ("Suggested:", record["suggested"], True),
302
+ ):
303
+ rows = [escape(title)]
304
+ for offset, value in enumerate(text.split("\n")):
305
+ number = record["line"] + offset if numbered else ""
306
+ rows.append(
307
+ f"<span style='color:#666'>{number:>6}</span> "
308
+ + escape(value)
309
+ )
310
+ sections.append("<br>".join(rows))
311
+ preview.setHtml(
312
+ "<pre style='font-family:monospace; margin:0'>"
313
+ + "<br><br>".join(sections)
314
+ + "</pre>"
315
+ )
316
+ # }}}
317
+ button.setText("Next" if index + 1 < len(records) else "Done")
282
318
  else:
283
319
  line = record["line"]
284
320
  heading.setText("This source span is not closed." + page)
@@ -336,10 +372,10 @@ def show_markdown_reload_dialog():
336
372
  QVBoxLayout,
337
373
  )
338
374
 
375
+ # a QApplication must exist before any widget is constructed
376
+ application = QApplication.instance() or QApplication([])
339
377
  dialog = QDialog()
340
- dialog._pydifftools_application = (
341
- QApplication.instance() or QApplication([])
342
- )
378
+ dialog._pydifftools_application = application
343
379
  dialog.setWindowTitle("Reload the Markdown source")
344
380
  dialog.resize(440, 150)
345
381
  layout = QVBoxLayout(dialog)
@@ -369,9 +405,11 @@ def run_pandoc(
369
405
 
370
406
  automatic_fixes_were_made = False
371
407
  while True:
372
- report = autofix_markdown_file(filename, wrapnumber=wrapnumber)
408
+ report = autofix_markdown_file(
409
+ filename, wrapnumber=wrapnumber, git_head=True
410
+ )
373
411
  automatic_fixes_were_made |= bool(report["fixes"])
374
- if report["fixes"] or report["warnings"]:
412
+ if report["fixes"] or report["warnings"] or report["layout"]:
375
413
  user_says_fixed = show_markdown_fix_dialog(report)
376
414
  if report["warnings"]:
377
415
  if not user_says_fixed:
@@ -1,7 +1,10 @@
1
+ import os
1
2
  import re
3
+ import subprocess
2
4
  import sys
3
5
  import itertools
4
6
  from dataclasses import dataclass, field
7
+ from difflib import SequenceMatcher
5
8
 
6
9
  from .command_registry import register_command
7
10
 
@@ -1289,11 +1292,364 @@ def apply_markdown_issue_fix(content, line_number, message):
1289
1292
  return "".join(lines), old_line, after, reason
1290
1293
 
1291
1294
 
1292
- def autofix_markdown_file(filename, wrapnumber=55, punctuation_slop=20):
1293
- """Apply safe source line breaks and report unfinished source spans."""
1295
+ def autofix_markdown_file(
1296
+ filename, wrapnumber=55, punctuation_slop=20, git_head=False
1297
+ ):
1298
+ """Apply safe source line breaks and report unfinished source spans.
1299
+
1300
+ With `git_head`, line breaks in the hunks that differ from the file's
1301
+ git HEAD version are first placed to keep that diff small (see
1302
+ "layout" in the returned report for hunks that would need more than
1303
+ one line break moved).
1304
+ """
1294
1305
  with open(filename, encoding="utf-8", newline="") as fp:
1295
1306
  content = fp.read()
1296
1307
  fixes = []
1308
+ layout = []
1309
+ baseline = None
1310
+ if git_head:
1311
+ directory, basename = os.path.split(os.path.abspath(filename))
1312
+ try:
1313
+ with subprocess.Popen(
1314
+ ["git", "-C", directory, "show", "HEAD:./" + basename],
1315
+ stdout=subprocess.PIPE,
1316
+ stderr=subprocess.DEVNULL,
1317
+ ) as shown:
1318
+ output = shown.communicate()[0]
1319
+ if shown.returncode == 0:
1320
+ baseline = output.decode("utf-8")
1321
+ except FileNotFoundError:
1322
+ # git is not installed
1323
+ pass
1324
+ if baseline is not None:
1325
+ # {{{ place line breaks in changed hunks to keep the HEAD diff small
1326
+
1327
+ def mask_comment(match):
1328
+ return "".join("\n" if char == "\n" else " " for char in match[0])
1329
+
1330
+ prefix_pattern = re.compile(
1331
+ r"^(?P<quote>(?:[ \t]*>[ \t]?)*)(?P<indent>[ \t]*)"
1332
+ r"(?:(?P<marker>[-+*]|[0-9]+[.)])(?P<space>[ \t]+))?"
1333
+ )
1334
+
1335
+ def line_tokens(text):
1336
+ """Words of each line, after its list or quote prefix."""
1337
+ masked = re.sub(
1338
+ r"<!--.*?(?:-->|\Z)", mask_comment, text, flags=re.DOTALL
1339
+ )
1340
+ result = []
1341
+ start = 0
1342
+ for number, line in enumerate(masked.splitlines(keepends=True)):
1343
+ prefix = prefix_pattern.match(line)
1344
+ result.extend(
1345
+ (number, start + found.start(), start + found.end())
1346
+ for found in WORD.finditer(line, prefix.end())
1347
+ )
1348
+ start += len(line)
1349
+ return masked, result
1350
+
1351
+ head_lines = baseline.splitlines(keepends=True)
1352
+ lines = content.splitlines(keepends=True)
1353
+ line_starts = [0]
1354
+ for line in lines:
1355
+ line_starts.append(line_starts[-1] + len(line))
1356
+ masked, tokens = line_tokens(content)
1357
+ prose = [
1358
+ allowed and line.strip() != ""
1359
+ for allowed, line in classify_lines(masked, strict=False)
1360
+ ]
1361
+ prefixes = [prefix_pattern.match(line) for line in lines]
1362
+ continuations = [
1363
+ found["quote"]
1364
+ + found["indent"]
1365
+ + (
1366
+ " " * (len(found["marker"]) + len(found["space"]))
1367
+ if found["marker"]
1368
+ else ""
1369
+ )
1370
+ for found in prefixes
1371
+ ]
1372
+ results = []
1373
+ for operation, i1, i2, j1, j2 in SequenceMatcher(
1374
+ None, head_lines, lines, autojunk=False
1375
+ ).get_opcodes():
1376
+ if operation == "equal" or j1 == j2:
1377
+ continue
1378
+ hunk_start, hunk_end = line_starts[j1], line_starts[j2]
1379
+ hunk_tokens = [token for token in tokens if j1 <= token[0] < j2]
1380
+ head_text = "".join(head_lines[i1:i2])
1381
+ head_masked, head_tokens = line_tokens(head_text)
1382
+ matched = set()
1383
+ for block in SequenceMatcher(
1384
+ None,
1385
+ [head_masked[a:b] for _, a, b in head_tokens],
1386
+ [masked[a:b] for _, a, b in hunk_tokens],
1387
+ autojunk=False,
1388
+ ).get_matching_blocks():
1389
+ matched.update(range(block.b, block.b + block.size))
1390
+ # {{{ find the gaps between prose words where a break may move
1391
+ gaps = []
1392
+ for k in range(len(hunk_tokens) - 1):
1393
+ left_line, _, left_end = hunk_tokens[k]
1394
+ right_line, right_start, _ = hunk_tokens[k + 1]
1395
+ raw = content[left_end:right_start]
1396
+ if not (prose[left_line] and prose[right_line]):
1397
+ continue
1398
+ if left_line == right_line:
1399
+ if raw.strip(" \t"):
1400
+ continue
1401
+ elif (
1402
+ right_line != left_line + 1
1403
+ or prefixes[right_line]["marker"]
1404
+ or prefixes[right_line].group(0)
1405
+ != continuations[left_line]
1406
+ or raw.strip(" \t\r\n") != prefixes[right_line][
1407
+ "quote"
1408
+ ].strip(" \t")
1409
+ ):
1410
+ continue
1411
+ gaps.append(
1412
+ {
1413
+ "start": left_end,
1414
+ "stop": right_start,
1415
+ "newline": "\n" in raw,
1416
+ "line": left_line,
1417
+ # breaks between two newly written words are the
1418
+ # writer's choice; only lint rules move those
1419
+ "free": k not in matched and k + 1 not in matched,
1420
+ "sentence": bool(
1421
+ re.search(
1422
+ "(" + SENTENCE_END + r")$",
1423
+ content[max(0, left_end - 4) : left_end],
1424
+ )
1425
+ ),
1426
+ }
1427
+ )
1428
+ # }}}
1429
+ # a hunk this large is a wholesale reflow, not a stray line
1430
+ # break, so leave it to the lint rules alone
1431
+ if not gaps or len(gaps) > 400:
1432
+ continue
1433
+ ending = "\r\n" if "\r\n" in content[hunk_start:hunk_end] else "\n"
1434
+ rendered = {}
1435
+ scores = {}
1436
+
1437
+ def render(flags):
1438
+ """The hunk with each gap a space or a line break, and the
1439
+ (row, column) where each gap starts."""
1440
+ key = tuple(flags)
1441
+ if key not in rendered:
1442
+ output = []
1443
+ columns = []
1444
+ where = [0, 0] # row and column of the output's end
1445
+
1446
+ def append(piece):
1447
+ output.append(piece)
1448
+ if "\n" in piece:
1449
+ where[0] += piece.count("\n")
1450
+ where[1] = len(piece) - piece.rfind("\n") - 1
1451
+ else:
1452
+ where[1] += len(piece)
1453
+
1454
+ position = hunk_start
1455
+ for gap, flag in zip(gaps, flags):
1456
+ append(content[position : gap["start"]])
1457
+ columns.append(tuple(where))
1458
+ if flag == gap["newline"]:
1459
+ append(content[gap["start"] : gap["stop"]])
1460
+ elif flag:
1461
+ append(ending + continuations[gap["line"]])
1462
+ else:
1463
+ append(" ")
1464
+ position = gap["stop"]
1465
+ append(content[position:hunk_end])
1466
+ rendered[key] = ("".join(output), columns)
1467
+ return rendered[key]
1468
+
1469
+ def kept(text):
1470
+ """How many HEAD lines the hunk keeps unchanged.
1471
+
1472
+ Fewer lines alone does not count as closer to HEAD, so
1473
+ rewritten text keeps the writer's line breaks.
1474
+ """
1475
+ if text not in scores:
1476
+ scores[text] = sum(
1477
+ block.size
1478
+ for block in SequenceMatcher(
1479
+ None,
1480
+ [line.rstrip() for line in head_text.splitlines()],
1481
+ [line.rstrip() for line in text.splitlines()],
1482
+ autojunk=False,
1483
+ ).get_matching_blocks()
1484
+ )
1485
+ return scores[text]
1486
+
1487
+ def too_long(flags):
1488
+ """Rendered lines past the width that a gap could break."""
1489
+ text, columns = render(flags)
1490
+ rendered = text.splitlines()
1491
+ return [
1492
+ number
1493
+ for number in sorted(
1494
+ {row for (row, _), flag in zip(columns, flags)
1495
+ if not flag}
1496
+ )
1497
+ if len(rendered[number].rstrip()) > wrapnumber
1498
+ ]
1499
+
1500
+ original = [gap["newline"] for gap in gaps]
1501
+ # {{{ lint first: a break after each sentence, and a break in
1502
+ # each overlong line wherever it keeps the most HEAD lines
1503
+ base = [
1504
+ flag or gap["sentence"] for flag, gap in zip(original, gaps)
1505
+ ]
1506
+ for _ in range(len(gaps)):
1507
+ overlong = too_long(base)
1508
+ if not overlong:
1509
+ break
1510
+ text, columns = render(base)
1511
+ row = overlong[0]
1512
+ line_text = text.splitlines()[row]
1513
+ prefix = prefix_pattern.match(line_text).group(0)
1514
+ words = WORD.findall(line_text[len(prefix) :])
1515
+ upto = _next_break(
1516
+ words,
1517
+ wrapnumber - len(prefix),
1518
+ punctuation_slop,
1519
+ _math_boundaries(words),
1520
+ 0,
1521
+ )
1522
+ wr_column = len(prefix) + len(" ".join(words[: upto + 1]))
1523
+ candidates = [
1524
+ number
1525
+ for number, ((gap_row, column), flag) in enumerate(
1526
+ zip(columns, base)
1527
+ )
1528
+ if gap_row == row and not flag
1529
+ ]
1530
+ fitting = [
1531
+ number
1532
+ for number in candidates
1533
+ if columns[number][1] <= wrapnumber
1534
+ ] or candidates[:1]
1535
+
1536
+ def with_break(number):
1537
+ return [
1538
+ flag or other == number
1539
+ for other, flag in enumerate(base)
1540
+ ]
1541
+
1542
+ base = with_break(
1543
+ min(
1544
+ fitting,
1545
+ key=lambda number: (
1546
+ -kept(render(with_break(number))[0]),
1547
+ abs(columns[number][1] - wr_column),
1548
+ ),
1549
+ )
1550
+ )
1551
+ # }}}
1552
+ # {{{ then join or move breaks next to HEAD's words while that
1553
+ # restores HEAD lines; only the lint rules above may add a break
1554
+ best = list(base)
1555
+ best_kept = kept(render(best)[0])
1556
+ moves = 0
1557
+ for _ in range(len(gaps)):
1558
+ trials = []
1559
+ for number, gap in enumerate(gaps):
1560
+ if (
1561
+ not best[number]
1562
+ or gap["free"]
1563
+ or gap["sentence"]
1564
+ ):
1565
+ continue
1566
+ joined = list(best)
1567
+ joined[number] = False
1568
+ trials.append(joined)
1569
+ # or slide the break to another gap of the joined line
1570
+ columns = render(joined)[1]
1571
+ for other, flag in enumerate(joined):
1572
+ if (
1573
+ not flag
1574
+ and other != number
1575
+ and columns[other][0] == columns[number][0]
1576
+ ):
1577
+ moved = list(joined)
1578
+ moved[other] = True
1579
+ trials.append(moved)
1580
+ limit = len(too_long(best))
1581
+ scored = [
1582
+ (kept(render(trial)[0]), trial)
1583
+ for trial in trials
1584
+ if len(too_long(trial)) <= limit
1585
+ ]
1586
+ if not scored:
1587
+ break
1588
+ trial_kept, trial = max(scored, key=lambda item: item[0])
1589
+ if trial_kept <= best_kept:
1590
+ break
1591
+ best_kept, best = trial_kept, trial
1592
+ moves += 1
1593
+ # }}}
1594
+ chosen = best if moves == 1 else base
1595
+ results.append(
1596
+ {
1597
+ "j1": j1,
1598
+ "j2": j2,
1599
+ "start": hunk_start,
1600
+ "stop": hunk_end,
1601
+ "old": content[hunk_start:hunk_end],
1602
+ "new": render(chosen)[0],
1603
+ "linted": base != original,
1604
+ "moved": moves == 1,
1605
+ "head": head_text,
1606
+ "suggested": render(best)[0] if moves > 1 else None,
1607
+ "moves": moves,
1608
+ }
1609
+ )
1610
+ shift = 0
1611
+ for result in results:
1612
+ line = result["j1"] + 1 + shift
1613
+ if result["new"] != result["old"]:
1614
+ reasons = []
1615
+ if result["linted"]:
1616
+ reasons.append(
1617
+ "This edit made a line too long or ended a sentence "
1618
+ "mid-line. I broke it where it keeps the most lines "
1619
+ "unchanged from git HEAD."
1620
+ )
1621
+ if result["moved"]:
1622
+ reasons.append(
1623
+ "This edit moved a line break away from where git "
1624
+ "HEAD has it. I put it back so the unchanged lines "
1625
+ "stay unchanged."
1626
+ )
1627
+ fixes.append(
1628
+ {
1629
+ "line": line,
1630
+ "reason": " ".join(reasons),
1631
+ "before": result["old"].rstrip("\r\n"),
1632
+ "after": result["new"].rstrip("\r\n"),
1633
+ }
1634
+ )
1635
+ if result["suggested"] is not None:
1636
+ layout.append(
1637
+ {
1638
+ "line": line,
1639
+ "moves": result["moves"],
1640
+ "head": result["head"].rstrip("\r\n"),
1641
+ "current": result["new"].rstrip("\r\n"),
1642
+ "suggested": result["suggested"].rstrip("\r\n"),
1643
+ }
1644
+ )
1645
+ shift += result["new"].count("\n") - result["old"].count("\n")
1646
+ for result in reversed(results):
1647
+ content = (
1648
+ content[: result["start"]]
1649
+ + result["new"]
1650
+ + content[result["stop"] :]
1651
+ )
1652
+ # }}}
1297
1653
  # Lint once, fix every flagged line, then lint again, since fixing one
1298
1654
  # issue at a time re-lints the whole file for every single fix.
1299
1655
  for _ in range(100):
@@ -1338,7 +1694,11 @@ def autofix_markdown_file(filename, wrapnumber=55, punctuation_slop=20):
1338
1694
  if fixes:
1339
1695
  with open(filename, "w", encoding="utf-8", newline="") as fp:
1340
1696
  fp.write(content)
1341
- return {"fixes": fixes, "warnings": unclosed_markdown_spans(content)}
1697
+ return {
1698
+ "fixes": fixes,
1699
+ "warnings": unclosed_markdown_spans(content),
1700
+ "layout": layout,
1701
+ }
1342
1702
 
1343
1703
 
1344
1704
  @register_command(
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyDiffTools"
7
- version = "0.1.43"
7
+ version = "0.1.45"
8
8
  authors = [{ name = "J M Franck" }]
9
9
  description = "Diff tools"
10
10
  readme = "README.rst"
@@ -1,5 +1,7 @@
1
+ import os
1
2
  import re
2
3
  import subprocess
4
+ import sys
3
5
 
4
6
  import pytest
5
7
 
@@ -660,3 +662,278 @@ def test_wrchk_latex_document(tmp_path):
660
662
  wrchk(str(path), wrapnumber=30, punctuation_slop=20)
661
663
  wr(str(path), wrapnumber=30)
662
664
  wrchk(str(path), wrapnumber=30)
665
+
666
+
667
+ # {{{ keep line breaks close to git HEAD
668
+ # from RM_ESR, wrapped the way the manuscript already is
669
+ HEAD_PARAGRAPH = (
670
+ "Existing Python libraries enable rapid development of a script\n"
671
+ "that estimates the correlation time of these spectra.\n"
672
+ "This script determines the field positions and amplitudes of the\n"
673
+ "spectral lines for an entire variable-temperature experiment.\n"
674
+ "It implements spline-based smoothing of the spectra in order to\n"
675
+ "minimize the impact of any noise as part of this determination.\n"
676
+ )
677
+
678
+
679
+ def committed_source(tmp_path, text):
680
+ """A content.md committed to a fresh git repository."""
681
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
682
+ path = tmp_path / "content.md"
683
+ path.write_text(text)
684
+ subprocess.run(
685
+ ["git", "-C", str(tmp_path), "add", "content.md"], check=True
686
+ )
687
+ subprocess.run(
688
+ [
689
+ "git", "-C", str(tmp_path), "-c", "user.name=t",
690
+ "-c", "user.email=t@example.org", "commit", "-q", "-m", "head",
691
+ ],
692
+ check=True,
693
+ )
694
+ return path
695
+
696
+
697
+ def numstat(path):
698
+ """(added, removed) lines against git HEAD."""
699
+ output = subprocess.run(
700
+ ["git", "-C", str(path.parent), "diff", "--numstat", "HEAD"],
701
+ capture_output=True, text=True, check=True,
702
+ ).stdout.split()
703
+ return (int(output[0]), int(output[1])) if output else (0, 0)
704
+
705
+
706
+ def test_words_appended_past_the_width_become_one_new_line(tmp_path):
707
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
708
+ path.write_text(
709
+ HEAD_PARAGRAPH.replace(
710
+ "of these spectra.\n", "of these spectra across all samples.\n"
711
+ ).replace(
712
+ "rapid development of a script\n",
713
+ "rapid development of a script in a few lines\n",
714
+ )
715
+ )
716
+ # the first edit fits; the second pushes a line past 79 characters
717
+ path.write_text(
718
+ path.read_text().replace(
719
+ "of the\nspectral",
720
+ "of the three hyperfine lines, and of the\nspectral",
721
+ )
722
+ )
723
+
724
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
725
+
726
+ fixed = path.read_text()
727
+ assert report["layout"] == []
728
+ assert markdown_lint_issues_from_text(fixed, 79) == []
729
+ # the HEAD line stays intact and the added words get one new line
730
+ assert (
731
+ "This script determines the field positions and amplitudes of the\n"
732
+ "three hyperfine lines, and of the\n"
733
+ ) in fixed
734
+ assert numstat(path) == (3, 2)
735
+
736
+
737
+ def test_stray_line_break_in_an_unchanged_line_is_rejoined(tmp_path):
738
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
739
+ path.write_text(
740
+ HEAD_PARAGRAPH.replace(
741
+ "development of a script", "development\nof a script"
742
+ )
743
+ )
744
+
745
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
746
+
747
+ assert path.read_text() == HEAD_PARAGRAPH
748
+ assert numstat(path) == (0, 0)
749
+ assert "put it back" in report["fixes"][0]["reason"]
750
+
751
+
752
+ def test_one_moved_line_break_is_moved_back(tmp_path):
753
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
754
+ path.write_text(
755
+ HEAD_PARAGRAPH.replace(
756
+ "amplitudes of the\nspectral lines",
757
+ "amplitudes of\nthe spectral lines",
758
+ )
759
+ )
760
+
761
+ autofix_markdown_file(path, wrapnumber=79, git_head=True)
762
+
763
+ assert path.read_text() == HEAD_PARAGRAPH
764
+
765
+
766
+ def test_joined_head_lines_past_the_width_get_heads_break_back(tmp_path):
767
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
768
+ path.write_text(
769
+ HEAD_PARAGRAPH.replace("in order to\nminimize", "in order to minimize")
770
+ )
771
+
772
+ autofix_markdown_file(path, wrapnumber=79, git_head=True)
773
+
774
+ assert path.read_text() == HEAD_PARAGRAPH
775
+
776
+
777
+ def test_two_moved_line_breaks_in_one_hunk_are_only_reported(tmp_path):
778
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
779
+ edited = HEAD_PARAGRAPH.replace(
780
+ "amplitudes of the\nspectral lines",
781
+ "amplitudes\nof the spectral lines",
782
+ ).replace(
783
+ "variable-temperature experiment.\nIt implements",
784
+ "variable-temperature experiment.\nIt\nimplements",
785
+ )
786
+ path.write_text(edited)
787
+
788
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
789
+
790
+ assert path.read_text() == edited
791
+ ((notice),) = report["layout"]
792
+ assert notice["moves"] == 2
793
+ assert notice["line"] == 3
794
+ assert notice["suggested"] + "\n" in HEAD_PARAGRAPH
795
+
796
+
797
+ def test_removed_words_keep_their_line_break(tmp_path):
798
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
799
+ edited = HEAD_PARAGRAPH.replace(
800
+ "development of a script\nthat", "development\nthat"
801
+ )
802
+ path.write_text(edited)
803
+
804
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
805
+
806
+ assert path.read_text() == edited
807
+ assert report["fixes"] == [] and report["layout"] == []
808
+
809
+
810
+ def test_new_paragraph_keeps_the_writers_line_breaks(tmp_path):
811
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
812
+ new_paragraph = (
813
+ "\nThese estimates\n"
814
+ "depend only weakly on the choice of smoothing.\n"
815
+ )
816
+ path.write_text(HEAD_PARAGRAPH + new_paragraph)
817
+
818
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
819
+
820
+ assert path.read_text() == HEAD_PARAGRAPH + new_paragraph
821
+ assert report["fixes"] == [] and report["layout"] == []
822
+
823
+
824
+ def test_whole_file_is_still_linted_against_head(tmp_path):
825
+ # a problem that git HEAD already has still has to be fixed
826
+ path = committed_source(
827
+ tmp_path, HEAD_PARAGRAPH.replace("spectra.\n", "spectra. \n")
828
+ )
829
+
830
+ autofix_markdown_file(path, wrapnumber=79, git_head=True)
831
+
832
+ assert path.read_text() == HEAD_PARAGRAPH
833
+
834
+
835
+ def test_file_outside_git_is_linted_without_head(tmp_path):
836
+ path = tmp_path / "content.md"
837
+ path.write_text(
838
+ HEAD_PARAGRAPH.replace(
839
+ "development of a script", "development\nof a script"
840
+ )
841
+ )
842
+
843
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
844
+
845
+ assert report["fixes"] == [] and report["layout"] == []
846
+
847
+
848
+ # }}}
849
+
850
+
851
+ def test_rewritten_sentence_keeps_its_semantic_line_breaks(tmp_path):
852
+ # from RM_ESR: a rewrite that breaks at each phrase shares words with
853
+ # HEAD, but joining its lines would not restore any HEAD line
854
+ head = (
855
+ "Peric *et al.* also observed that\n"
856
+ "even similarly sized nitroxide probes can report\n"
857
+ "different rotational dynamics in bulk water.\n"
858
+ )
859
+ rewrite = (
860
+ "Peric *et al.* also observed that\n"
861
+ "the rotational dynamics\n"
862
+ "of different small nitroxide probes\n"
863
+ "in bulk water\n"
864
+ "differ significantly.\n"
865
+ )
866
+ path = committed_source(tmp_path, head)
867
+ path.write_text(rewrite)
868
+
869
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
870
+
871
+ assert path.read_text() == rewrite
872
+ assert report["fixes"] == [] and report["layout"] == []
873
+
874
+
875
+ def test_qt_layout_notice_shows_head_now_and_suggested(tmp_path):
876
+ pytest.importorskip("PySide6")
877
+ from PySide6.QtCore import QTimer
878
+ from PySide6.QtWidgets import QApplication, QDialog, QLabel
879
+ from PySide6.QtWidgets import QPushButton, QTextEdit
880
+
881
+ from pydifftools.continuous import show_markdown_fix_dialog
882
+
883
+ app = QApplication.instance() or QApplication([])
884
+ path = committed_source(tmp_path, HEAD_PARAGRAPH)
885
+ path.write_text(
886
+ HEAD_PARAGRAPH.replace(
887
+ "amplitudes of the\nspectral lines",
888
+ "amplitudes\nof the spectral lines",
889
+ ).replace(
890
+ "experiment.\nIt implements", "experiment.\nIt\nimplements"
891
+ )
892
+ )
893
+ report = autofix_markdown_file(path, wrapnumber=79, git_head=True)
894
+ seen = {}
895
+
896
+ def inspect():
897
+ dialog = next(
898
+ widget
899
+ for widget in app.topLevelWidgets()
900
+ if isinstance(widget, QDialog) and widget.isVisible()
901
+ )
902
+ seen["heading"] = dialog.findChildren(QLabel)[0].text()
903
+ seen["preview"] = dialog.findChild(QTextEdit).toPlainText()
904
+ dialog.findChild(QPushButton).click()
905
+
906
+ QTimer.singleShot(0, inspect)
907
+ show_markdown_fix_dialog(report)
908
+
909
+ assert "differ from git HEAD in 2 places" in seen["heading"]
910
+ head, now, suggested = seen["preview"].split("\n\n")
911
+ assert head.startswith("git HEAD:")
912
+ assert re.search(r"^ +3 This script determines .* amplitudes$", now, re.M)
913
+ assert re.search(r"^ +4 spectral lines for", suggested, re.M)
914
+
915
+
916
+ def test_qt_fix_windows_create_their_own_application():
917
+ # cpb shows these windows without a QApplication; constructing the
918
+ # dialog first aborted the whole process
919
+ pytest.importorskip("PySide6")
920
+ script = (
921
+ "from PySide6.QtWidgets import QDialog\n"
922
+ "QDialog.exec = lambda self: 0\n"
923
+ "from pydifftools.continuous import (\n"
924
+ " show_markdown_fix_dialog, show_markdown_reload_dialog)\n"
925
+ "show_markdown_fix_dialog({'fixes': [{'line': 1, 'reason': 'r',\n"
926
+ " 'before': 'a ', 'after': 'a'}], 'warnings': []})\n"
927
+ "show_markdown_reload_dialog()\n"
928
+ "print('shown')\n"
929
+ )
930
+
931
+ completed = subprocess.run(
932
+ [sys.executable, "-c", script],
933
+ capture_output=True,
934
+ text=True,
935
+ env={**os.environ, "QT_QPA_PLATFORM": "offscreen"},
936
+ )
937
+
938
+ assert completed.returncode == 0, completed.stderr
939
+ assert completed.stdout.strip() == "shown"
File without changes
File without changes
File without changes
File without changes