python-redlines 0.3.0__tar.gz → 1.0.0__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.
@@ -13,6 +13,9 @@ docxodus/**/obj/*
13
13
  packages/*/src/*/_binaries/*.tar.gz
14
14
  packages/*/src/*/_binaries/*.zip
15
15
 
16
+ # MkDocs build output
17
+ site/
18
+
16
19
  # C extensions
17
20
  *.so
18
21
 
@@ -1,14 +1,16 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-redlines
3
- Version: 0.3.0
4
- Summary: Generate tracked-change redline .docx documents by comparing Word files.
3
+ Version: 1.0.0
4
+ Summary: Open-source DOCX comparison tool to generate native Word tracked changes (redlines) in Python without MS Word dependencies.
5
5
  Project-URL: Homepage, https://github.com/JSv4/Python-Redlines
6
+ Project-URL: Documentation, https://jsv4.github.io/Python-Redlines/
7
+ Project-URL: Demo, https://redlines.opensource.legal
6
8
  Project-URL: Issues, https://github.com/JSv4/Python-Redlines/issues
7
9
  Project-URL: Source, https://github.com/JSv4/Python-Redlines
8
10
  Author: John Scrudato IV
9
11
  License-Expression: MIT
10
- Keywords: diff,docx,openxml,redline,tracked-changes,word
11
- Classifier: Development Status :: 4 - Beta
12
+ Keywords: compare-word-documents,diff,docx,docx-comparison,docxodus,legal-tech,openxml,python-docx-diff,redline,track-changes-api,tracked-changes,word
13
+ Classifier: Development Status :: 5 - Production/Stable
12
14
  Classifier: License :: OSI Approved :: MIT License
13
15
  Classifier: Programming Language :: Python
14
16
  Classifier: Programming Language :: Python :: 3.9
@@ -5,16 +5,29 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "python-redlines"
7
7
  dynamic = ["version"]
8
- description = "Generate tracked-change redline .docx documents by comparing Word files."
8
+ description = "Open-source DOCX comparison tool to generate native Word tracked changes (redlines) in Python without MS Word dependencies."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = "MIT"
12
- keywords = ["docx", "redline", "diff", "tracked-changes", "openxml", "word"]
12
+ keywords = [
13
+ "docx",
14
+ "redline",
15
+ "diff",
16
+ "tracked-changes",
17
+ "openxml",
18
+ "word",
19
+ "docx-comparison",
20
+ "compare-word-documents",
21
+ "track-changes-api",
22
+ "python-docx-diff",
23
+ "legal-tech",
24
+ "docxodus",
25
+ ]
13
26
  authors = [
14
27
  { name = "John Scrudato IV" },
15
28
  ]
16
29
  classifiers = [
17
- "Development Status :: 4 - Beta",
30
+ "Development Status :: 5 - Production/Stable",
18
31
  "License :: OSI Approved :: MIT License",
19
32
  "Programming Language :: Python",
20
33
  "Programming Language :: Python :: 3.9",
@@ -31,12 +44,17 @@ dependencies = [
31
44
  # its extra, e.g. `pip install python-redlines[docxodus]`. All three packages
32
45
  # are released together from the same repository on each tagged release.
33
46
  [project.optional-dependencies]
47
+ # Deprecated: wraps the archived Open-XML-PowerTools WmlComparer. Kept so that
48
+ # anyone who needs that algorithm's output has somewhere to stand; `all` keeps
49
+ # installing it so the extra does not change meaning under existing pins.
34
50
  ooxmlpowertools = ["python-redlines-ooxmlpowertools"]
35
51
  docxodus = ["python-redlines-docxodus"]
36
52
  all = ["python-redlines-ooxmlpowertools", "python-redlines-docxodus"]
37
53
 
38
54
  [project.urls]
39
55
  Homepage = "https://github.com/JSv4/Python-Redlines"
56
+ Documentation = "https://jsv4.github.io/Python-Redlines/"
57
+ Demo = "https://redlines.opensource.legal"
40
58
  Issues = "https://github.com/JSv4/Python-Redlines/issues"
41
59
  Source = "https://github.com/JSv4/Python-Redlines"
42
60
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2024-present U.N. Owen <void@some.where>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.3.0"
4
+ __version__ = "1.0.0"
@@ -6,6 +6,7 @@ import platform
6
6
  import subprocess
7
7
  import tarfile
8
8
  import tempfile
9
+ import warnings
9
10
  import zipfile
10
11
  from pathlib import Path
11
12
  from typing import Optional, Tuple, Union
@@ -138,23 +139,26 @@ class BaseEngine(object):
138
139
  Runs the redline binary. The 'original' and 'modified' arguments can be either bytes or file paths
139
140
  (as ``str`` or ``pathlib.Path``). Returns the redline output as bytes.
140
141
 
141
- Additional keyword arguments are passed to _build_command() for engine-specific options.
142
- DocxodusEngine supports: engine, detail_threshold, case_insensitive, detect_moves,
143
- simplify_move_markup, move_similarity_threshold, move_minimum_word_count,
144
- detect_format_changes, conflate_spaces, date_time.
142
+ A path the caller supplies is never deleted; only scratch files this
143
+ method creates are cleaned up.
145
144
 
146
- DocxodusEngine's engine kwarg selects the comparison algorithm: 'wmlcomparer'
147
- (the default) or 'docxdiff'. The docxdiff engine ignores detail_threshold,
148
- simplify_move_markup, and detect_format_changes, so passing them alongside
149
- engine='docxdiff' raises ValueError rather than silently changing nothing.
145
+ Additional keyword arguments are passed to _build_command() for engine-specific
146
+ options. DocxodusEngine supports: case_insensitive, detect_moves,
147
+ move_similarity_threshold, move_minimum_word_count, detect_format_changes,
148
+ conflate_spaces, date_time. It raises ValueError for unrecognised settings and
149
+ for the WmlComparer-era settings removed in Docxodus v11.0.0 (engine,
150
+ detail_threshold, simplify_move_markup). XmlPowerToolsEngine ignores kwargs.
150
151
  """
151
- temp_files = []
152
+ scratch_files = []
152
153
  try:
154
+ # mkstemp, not NamedTemporaryFile: we want a path, and NamedTemporaryFile
155
+ # hands back an open file object that nothing here would close (issue #30).
156
+ handle, target_path = tempfile.mkstemp(suffix='.docx')
157
+ os.close(handle)
158
+ scratch_files.append(target_path)
153
159
 
154
- target_path = tempfile.NamedTemporaryFile(delete=False).name
155
- original_path = self._write_to_temp_file(original) if isinstance(original, bytes) else original
156
- modified_path = self._write_to_temp_file(modified) if isinstance(modified, bytes) else modified
157
- temp_files.extend([target_path, original_path, modified_path])
160
+ original_path = self._as_path(original, scratch_files)
161
+ modified_path = self._as_path(modified, scratch_files)
158
162
 
159
163
  command = self._build_command(author_tag, original_path, modified_path, target_path, **kwargs)
160
164
 
@@ -169,14 +173,27 @@ class BaseEngine(object):
169
173
  return redline_output, stdout_output, stderr_output
170
174
 
171
175
  finally:
172
- self._cleanup_temp_files(temp_files)
176
+ self._cleanup_temp_files(scratch_files)
177
+
178
+ def _as_path(self, document, scratch_files):
179
+ """Return a filesystem path for *document*, writing bytes out if needed.
180
+
181
+ Only a path this method creates is appended to *scratch_files*. A path
182
+ the caller passed in is the caller's own document, and registering it
183
+ for cleanup would delete the file they asked us to compare.
184
+ """
185
+ if isinstance(document, bytes):
186
+ path = self._write_to_temp_file(document)
187
+ scratch_files.append(path)
188
+ return path
189
+ return os.fspath(document)
173
190
 
174
191
  def _cleanup_temp_files(self, temp_files):
175
192
  for file_path in temp_files:
176
193
  try:
177
194
  os.remove(file_path)
178
195
  except OSError as e:
179
- print(f"Error deleting temp file {file_path}: {e}")
196
+ logger.warning("Error deleting temp file %s: %s", file_path, e)
180
197
 
181
198
  def _write_to_temp_file(self, data):
182
199
  """
@@ -193,24 +210,49 @@ class XmlPowerToolsEngine(BaseEngine):
193
210
  BINARY_BASE_NAME = 'redlines'
194
211
  EXTRA_NAME = 'ooxmlpowertools'
195
212
 
213
+ def __init__(self, target_path: Optional[str] = None):
214
+ warnings.warn(
215
+ "XmlPowerToolsEngine wraps the original, unmaintained Open-XML-PowerTools "
216
+ "WmlComparer and is deprecated; it will be removed in a future major "
217
+ "release. Use DocxodusEngine, which is actively maintained and runs the "
218
+ "DocxDiff algorithm.",
219
+ DeprecationWarning,
220
+ stacklevel=2,
221
+ )
222
+ super().__init__(target_path)
223
+
196
224
 
197
225
  class DocxodusEngine(BaseEngine):
198
226
  BINARY_PACKAGE = 'python_redlines_docxodus'
199
227
  BINARY_BASE_NAME = 'redline'
200
228
  EXTRA_NAME = 'docxodus'
201
229
 
202
- # Comparison engines accepted by the redline CLI's --engine flag.
203
- ENGINES = ('wmlcomparer', 'docxdiff')
204
-
205
- # DocxCompare.ToDocxDiffSettings drops these on the docxdiff branch, and the CLI
206
- # accepts them there without complaint, so reject them before we shell out.
207
- _WMLCOMPARER_ONLY = ('detail_threshold', 'simplify_move_markup', 'detect_format_changes')
230
+ # Settings that died with WmlComparer in Docxodus v11.0.0. They are rejected
231
+ # rather than dropped, because dropping them would not be a breaking change
232
+ # but a wrong-answer one: the v12 CLI rejects --engine as an unknown flag,
233
+ # and merely warns-and-ignores the other two, so a caller who passed
234
+ # engine='wmlcomparer' would silently receive DocxDiff output believing they
235
+ # had selected something else.
236
+ _REMOVED_KWARGS = {
237
+ 'engine': (
238
+ "the comparison-engine selector was removed in Docxodus v11.0.0, which "
239
+ "deleted WmlComparer. DocxDiff is now the only algorithm — drop the argument"
240
+ ),
241
+ 'detail_threshold': (
242
+ "it tuned WmlComparer's LCS granularity and went with it in Docxodus "
243
+ "v11.0.0. DocxDiff's granularity is structural, with no equivalent knob — "
244
+ "drop the argument"
245
+ ),
246
+ 'simplify_move_markup': (
247
+ "it worked around WmlComparer's move markup and went with it in Docxodus "
248
+ "v11.0.0. DocxDiff renders moves natively — drop the argument"
249
+ ),
250
+ }
208
251
 
209
252
  # Boolean flags (default False — presence enables)
210
253
  _BOOL_FLAGS = [
211
254
  ('case_insensitive', '--case-insensitive'),
212
255
  ('detect_moves', '--detect-moves'),
213
- ('simplify_move_markup', '--simplify-move-markup'),
214
256
  ]
215
257
 
216
258
  # Negatable flags (default True — --no- prefix disables)
@@ -221,43 +263,31 @@ class DocxodusEngine(BaseEngine):
221
263
 
222
264
  # Value flags
223
265
  _VALUE_FLAGS = [
224
- ('detail_threshold', '--detail-threshold'),
225
266
  ('move_similarity_threshold', '--move-similarity-threshold'),
226
267
  ('move_minimum_word_count', '--move-minimum-word-count'),
227
268
  ('date_time', '--date-time'),
228
269
  ]
229
270
 
230
271
  @classmethod
231
- def _normalize_engine(cls, kwargs):
232
- """The chosen engine, lowercased and stripped, or None if the caller didn't pick one."""
233
- if 'engine' not in kwargs:
234
- return None
235
-
236
- engine = kwargs['engine']
237
- if not isinstance(engine, str):
238
- raise ValueError(f"engine must be a string, got {engine!r}")
239
-
240
- normalized = engine.strip().lower()
241
- if normalized not in cls.ENGINES:
242
- raise ValueError(
243
- f"engine must be one of {', '.join(cls.ENGINES)}, got {engine!r}"
244
- )
245
- return normalized
272
+ def _supported_kwargs(cls):
273
+ """Every comparison setting this engine still understands."""
274
+ return {name for name, _ in (*cls._BOOL_FLAGS, *cls._NEG_FLAGS, *cls._VALUE_FLAGS)}
246
275
 
247
276
  @classmethod
248
277
  def _validate_kwargs(cls, kwargs):
249
- if cls._normalize_engine(kwargs) == 'docxdiff':
250
- for name in cls._WMLCOMPARER_ONLY:
251
- if name in kwargs:
252
- raise ValueError(
253
- f"{name} is not supported by the 'docxdiff' engine "
254
- f"(WmlComparer-only). Remove it or use engine='wmlcomparer'."
255
- )
256
-
257
- if 'detail_threshold' in kwargs:
258
- val = kwargs['detail_threshold']
259
- if not isinstance(val, (int, float)) or val < 0.0 or val > 1.0:
260
- raise ValueError(f"detail_threshold must be a float between 0.0 and 1.0, got {val!r}")
278
+ # Removed settings first, so they get their specific explanation rather
279
+ # than being lumped in with typos below.
280
+ for name, reason in cls._REMOVED_KWARGS.items():
281
+ if name in kwargs:
282
+ raise ValueError(f"{name} is no longer supported: {reason}.")
283
+
284
+ supported = cls._supported_kwargs()
285
+ unknown = sorted(set(kwargs) - supported)
286
+ if unknown:
287
+ raise ValueError(
288
+ f"Unknown comparison setting(s): {', '.join(unknown)}. "
289
+ f"Supported settings: {', '.join(sorted(supported))}."
290
+ )
261
291
 
262
292
  if 'move_similarity_threshold' in kwargs:
263
293
  val = kwargs['move_similarity_threshold']
@@ -271,14 +301,10 @@ class DocxodusEngine(BaseEngine):
271
301
 
272
302
  def _build_command(self, author_tag, original_path, modified_path, target_path, **kwargs):
273
303
  self._validate_kwargs(kwargs)
274
- engine = self._normalize_engine(kwargs)
275
304
 
276
305
  cmd = [self.extracted_binaries_path, original_path, modified_path, target_path,
277
306
  f'--author={author_tag}']
278
307
 
279
- if engine is not None:
280
- cmd.append(f'--engine={engine}')
281
-
282
308
  for kwarg, flag in self._BOOL_FLAGS:
283
309
  if kwargs.get(kwarg):
284
310
  cmd.append(flag)