python-redlines 0.2.0__py3-none-any.whl
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.
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024-present U.N. Owen <void@some.where>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from .__about__ import __version__
|
|
6
|
+
from .engines import (
|
|
7
|
+
BaseEngine,
|
|
8
|
+
DocxodusEngine,
|
|
9
|
+
EngineNotInstalledError,
|
|
10
|
+
XmlPowerToolsEngine,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BaseEngine",
|
|
15
|
+
"XmlPowerToolsEngine",
|
|
16
|
+
"DocxodusEngine",
|
|
17
|
+
"EngineNotInstalledError",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import importlib.resources
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, Tuple, Union
|
|
12
|
+
|
|
13
|
+
import platformdirs
|
|
14
|
+
|
|
15
|
+
from .__about__ import __version__
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EngineNotInstalledError(ImportError):
|
|
21
|
+
"""Raised when an engine is used but its binary package is not installed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _detect_rid() -> str:
|
|
25
|
+
"""Return the .NET-style runtime identifier for the current platform."""
|
|
26
|
+
os_name = platform.system().lower()
|
|
27
|
+
machine = platform.machine().lower()
|
|
28
|
+
|
|
29
|
+
if machine in ('x86_64', 'amd64'):
|
|
30
|
+
arch = 'x64'
|
|
31
|
+
elif machine in ('arm64', 'aarch64'):
|
|
32
|
+
arch = 'arm64'
|
|
33
|
+
else:
|
|
34
|
+
raise EnvironmentError(f"Unsupported architecture: {machine}")
|
|
35
|
+
|
|
36
|
+
if os_name == 'linux':
|
|
37
|
+
return f'linux-{arch}'
|
|
38
|
+
if os_name == 'windows':
|
|
39
|
+
return f'win-{arch}'
|
|
40
|
+
if os_name == 'darwin':
|
|
41
|
+
return f'osx-{arch}'
|
|
42
|
+
raise EnvironmentError(f"Unsupported OS: {os_name}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BaseEngine(object):
|
|
46
|
+
"""
|
|
47
|
+
Base class for redline comparison engines. Each engine ships its compiled
|
|
48
|
+
binary in a separate, optional companion package; subclasses declare:
|
|
49
|
+
- BINARY_PACKAGE: importable package name that ships the binary archives
|
|
50
|
+
- BINARY_BASE_NAME: the executable name (without .exe extension)
|
|
51
|
+
- EXTRA_NAME: the python-redlines extra that installs the companion package
|
|
52
|
+
"""
|
|
53
|
+
BINARY_PACKAGE: str = NotImplemented
|
|
54
|
+
BINARY_BASE_NAME: str = NotImplemented
|
|
55
|
+
EXTRA_NAME: str = NotImplemented
|
|
56
|
+
|
|
57
|
+
def __init__(self, target_path: Optional[str] = None):
|
|
58
|
+
self.target_path = target_path
|
|
59
|
+
self.extracted_binaries_path = self._resolve_binary()
|
|
60
|
+
|
|
61
|
+
def _resolve_binary(self) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Locate the platform binary inside the companion package, extracting it
|
|
64
|
+
once into a writable cache directory. Returns the path to the executable.
|
|
65
|
+
"""
|
|
66
|
+
rid = _detect_rid()
|
|
67
|
+
is_windows = rid.startswith('win-')
|
|
68
|
+
archive_name = f'{rid}.zip' if is_windows else f'{rid}.tar.gz'
|
|
69
|
+
binary_name = f'{self.BINARY_BASE_NAME}.exe' if is_windows else self.BINARY_BASE_NAME
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
package_root = importlib.resources.files(self.BINARY_PACKAGE)
|
|
73
|
+
except ModuleNotFoundError as exc:
|
|
74
|
+
raise EngineNotInstalledError(
|
|
75
|
+
f"{type(self).__name__} requires the '{self.BINARY_PACKAGE}' package. "
|
|
76
|
+
f"Install it with: pip install python-redlines[{self.EXTRA_NAME}]"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
archive = package_root / '_binaries' / archive_name
|
|
80
|
+
if not archive.is_file():
|
|
81
|
+
raise EngineNotInstalledError(
|
|
82
|
+
f"{type(self).__name__}: '{self.BINARY_PACKAGE}' is installed but contains "
|
|
83
|
+
f"no binary for platform '{rid}'. The wheel may target a different platform."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
extract_root = self._extraction_root() / rid
|
|
87
|
+
binary_path = extract_root / binary_name
|
|
88
|
+
|
|
89
|
+
if not binary_path.exists():
|
|
90
|
+
self._extract_archive(archive, extract_root)
|
|
91
|
+
|
|
92
|
+
if not is_windows:
|
|
93
|
+
os.chmod(binary_path, 0o755)
|
|
94
|
+
|
|
95
|
+
return str(binary_path)
|
|
96
|
+
|
|
97
|
+
def _extraction_root(self) -> Path:
|
|
98
|
+
"""Directory the binary is extracted into (writable, outside site-packages)."""
|
|
99
|
+
if self.target_path:
|
|
100
|
+
return Path(self.target_path)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
pkg_version = importlib.metadata.version(self.BINARY_PACKAGE.replace('_', '-'))
|
|
104
|
+
except importlib.metadata.PackageNotFoundError:
|
|
105
|
+
pkg_version = __version__
|
|
106
|
+
|
|
107
|
+
return Path(platformdirs.user_cache_dir('python-redlines')) / self.EXTRA_NAME / pkg_version
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _extract_archive(archive, target_path: Path):
|
|
111
|
+
"""Extract a .zip or .tar.gz archive (a Traversable) into target_path."""
|
|
112
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
name = archive.name
|
|
114
|
+
|
|
115
|
+
with importlib.resources.as_file(archive) as archive_path:
|
|
116
|
+
if name.endswith('.zip'):
|
|
117
|
+
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
|
118
|
+
zip_ref.extractall(target_path)
|
|
119
|
+
elif name.endswith('.tar.gz'):
|
|
120
|
+
with tarfile.open(archive_path, 'r:gz') as tar_ref:
|
|
121
|
+
try:
|
|
122
|
+
tar_ref.extractall(target_path, filter='data')
|
|
123
|
+
except TypeError:
|
|
124
|
+
tar_ref.extractall(target_path)
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError(f"Unsupported archive format: {name}")
|
|
127
|
+
|
|
128
|
+
def _build_command(self, author_tag: str, original_path, modified_path, target_path, **kwargs):
|
|
129
|
+
"""
|
|
130
|
+
Build the command list for subprocess execution.
|
|
131
|
+
Subclasses can override to customize argument format.
|
|
132
|
+
"""
|
|
133
|
+
return [self.extracted_binaries_path, author_tag, original_path, modified_path, target_path]
|
|
134
|
+
|
|
135
|
+
def run_redline(self, author_tag: str, original: Union[bytes, Path], modified: Union[bytes, Path], **kwargs) \
|
|
136
|
+
-> Tuple[bytes, Optional[str], Optional[str]]:
|
|
137
|
+
"""
|
|
138
|
+
Runs the redline binary. The 'original' and 'modified' arguments can be either bytes or file paths.
|
|
139
|
+
Returns the redline output as bytes.
|
|
140
|
+
|
|
141
|
+
Additional keyword arguments are passed to _build_command() for engine-specific options.
|
|
142
|
+
DocxodusEngine supports: 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.
|
|
145
|
+
"""
|
|
146
|
+
temp_files = []
|
|
147
|
+
try:
|
|
148
|
+
|
|
149
|
+
target_path = tempfile.NamedTemporaryFile(delete=False).name
|
|
150
|
+
original_path = self._write_to_temp_file(original) if isinstance(original, bytes) else original
|
|
151
|
+
modified_path = self._write_to_temp_file(modified) if isinstance(modified, bytes) else modified
|
|
152
|
+
temp_files.extend([target_path, original_path, modified_path])
|
|
153
|
+
|
|
154
|
+
command = self._build_command(author_tag, original_path, modified_path, target_path, **kwargs)
|
|
155
|
+
|
|
156
|
+
# Capture stdout and stderr
|
|
157
|
+
result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
158
|
+
|
|
159
|
+
stdout_output = result.stdout if isinstance(result.stdout, str) and len(result.stdout) > 0 else None
|
|
160
|
+
stderr_output = result.stderr if isinstance(result.stderr, str) and len(result.stderr) > 0 else None
|
|
161
|
+
|
|
162
|
+
redline_output = Path(target_path).read_bytes()
|
|
163
|
+
|
|
164
|
+
return redline_output, stdout_output, stderr_output
|
|
165
|
+
|
|
166
|
+
finally:
|
|
167
|
+
self._cleanup_temp_files(temp_files)
|
|
168
|
+
|
|
169
|
+
def _cleanup_temp_files(self, temp_files):
|
|
170
|
+
for file_path in temp_files:
|
|
171
|
+
try:
|
|
172
|
+
os.remove(file_path)
|
|
173
|
+
except OSError as e:
|
|
174
|
+
print(f"Error deleting temp file {file_path}: {e}")
|
|
175
|
+
|
|
176
|
+
def _write_to_temp_file(self, data):
|
|
177
|
+
"""
|
|
178
|
+
Writes bytes data to a temporary file and returns the file path.
|
|
179
|
+
"""
|
|
180
|
+
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
|
181
|
+
temp_file.write(data)
|
|
182
|
+
temp_file.close()
|
|
183
|
+
return temp_file.name
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class XmlPowerToolsEngine(BaseEngine):
|
|
187
|
+
BINARY_PACKAGE = 'python_redlines_ooxmlpowertools'
|
|
188
|
+
BINARY_BASE_NAME = 'redlines'
|
|
189
|
+
EXTRA_NAME = 'ooxmlpowertools'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class DocxodusEngine(BaseEngine):
|
|
193
|
+
BINARY_PACKAGE = 'python_redlines_docxodus'
|
|
194
|
+
BINARY_BASE_NAME = 'redline'
|
|
195
|
+
EXTRA_NAME = 'docxodus'
|
|
196
|
+
|
|
197
|
+
# Boolean flags (default False — presence enables)
|
|
198
|
+
_BOOL_FLAGS = [
|
|
199
|
+
('case_insensitive', '--case-insensitive'),
|
|
200
|
+
('detect_moves', '--detect-moves'),
|
|
201
|
+
('simplify_move_markup', '--simplify-move-markup'),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
# Negatable flags (default True — --no- prefix disables)
|
|
205
|
+
_NEG_FLAGS = [
|
|
206
|
+
('detect_format_changes', '--no-detect-format-changes'),
|
|
207
|
+
('conflate_spaces', '--no-conflate-spaces'),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
# Value flags
|
|
211
|
+
_VALUE_FLAGS = [
|
|
212
|
+
('detail_threshold', '--detail-threshold'),
|
|
213
|
+
('move_similarity_threshold', '--move-similarity-threshold'),
|
|
214
|
+
('move_minimum_word_count', '--move-minimum-word-count'),
|
|
215
|
+
('date_time', '--date-time'),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _validate_kwargs(kwargs):
|
|
220
|
+
if 'detail_threshold' in kwargs:
|
|
221
|
+
val = kwargs['detail_threshold']
|
|
222
|
+
if not isinstance(val, (int, float)) or val < 0.0 or val > 1.0:
|
|
223
|
+
raise ValueError(f"detail_threshold must be a float between 0.0 and 1.0, got {val!r}")
|
|
224
|
+
|
|
225
|
+
if 'move_similarity_threshold' in kwargs:
|
|
226
|
+
val = kwargs['move_similarity_threshold']
|
|
227
|
+
if not isinstance(val, (int, float)) or val < 0.0 or val > 1.0:
|
|
228
|
+
raise ValueError(f"move_similarity_threshold must be a float between 0.0 and 1.0, got {val!r}")
|
|
229
|
+
|
|
230
|
+
if 'move_minimum_word_count' in kwargs:
|
|
231
|
+
val = kwargs['move_minimum_word_count']
|
|
232
|
+
if not isinstance(val, int) or val < 1:
|
|
233
|
+
raise ValueError(f"move_minimum_word_count must be a positive integer, got {val!r}")
|
|
234
|
+
|
|
235
|
+
def _build_command(self, author_tag, original_path, modified_path, target_path, **kwargs):
|
|
236
|
+
self._validate_kwargs(kwargs)
|
|
237
|
+
|
|
238
|
+
cmd = [self.extracted_binaries_path, original_path, modified_path, target_path,
|
|
239
|
+
f'--author={author_tag}']
|
|
240
|
+
|
|
241
|
+
for kwarg, flag in self._BOOL_FLAGS:
|
|
242
|
+
if kwargs.get(kwarg):
|
|
243
|
+
cmd.append(flag)
|
|
244
|
+
|
|
245
|
+
for kwarg, neg_flag in self._NEG_FLAGS:
|
|
246
|
+
if kwarg in kwargs and not kwargs[kwarg]:
|
|
247
|
+
cmd.append(neg_flag)
|
|
248
|
+
|
|
249
|
+
for kwarg, flag in self._VALUE_FLAGS:
|
|
250
|
+
if kwarg in kwargs:
|
|
251
|
+
cmd.append(f'{flag}={kwargs[kwarg]}')
|
|
252
|
+
|
|
253
|
+
return cmd
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-redlines
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate tracked-change redline .docx documents by comparing Word files.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JSv4/Python-Redlines
|
|
6
|
+
Project-URL: Issues, https://github.com/JSv4/Python-Redlines/issues
|
|
7
|
+
Project-URL: Source, https://github.com/JSv4/Python-Redlines
|
|
8
|
+
Author: John Scrudato IV
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: diff,docx,openxml,redline,tracked-changes,word
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: platformdirs>=3.0
|
|
21
|
+
Provides-Extra: all
|
|
22
|
+
Requires-Dist: python-redlines-docxodus; extra == 'all'
|
|
23
|
+
Requires-Dist: python-redlines-ooxmlpowertools; extra == 'all'
|
|
24
|
+
Provides-Extra: docxodus
|
|
25
|
+
Requires-Dist: python-redlines-docxodus; extra == 'docxodus'
|
|
26
|
+
Provides-Extra: ooxmlpowertools
|
|
27
|
+
Requires-Dist: python-redlines-ooxmlpowertools; extra == 'ooxmlpowertools'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# python-redlines
|
|
31
|
+
|
|
32
|
+
Generate tracked-change "redline" `.docx` documents by comparing two Word files.
|
|
33
|
+
|
|
34
|
+
`python-redlines` is the pure-Python core. The comparison engines themselves are
|
|
35
|
+
compiled .NET binaries shipped in separate, optional companion packages — install
|
|
36
|
+
the one(s) you need as extras:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install python-redlines[docxodus] # Docxodus engine
|
|
40
|
+
pip install python-redlines[ooxmlpowertools] # Open-XML-PowerTools engine
|
|
41
|
+
pip install python-redlines[all] # both
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Binaries are prebuilt for each platform and embedded in the companion package's
|
|
45
|
+
wheel — no .NET SDK and no local compilation are needed to install or use it.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from python_redlines import DocxodusEngine
|
|
51
|
+
|
|
52
|
+
engine = DocxodusEngine()
|
|
53
|
+
redline_bytes, stdout, stderr = engine.run_redline(
|
|
54
|
+
"Author Name",
|
|
55
|
+
original=open("original.docx", "rb").read(),
|
|
56
|
+
modified=open("modified.docx", "rb").read(),
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If an engine's companion package is not installed, instantiating the engine
|
|
61
|
+
raises `EngineNotInstalledError` with the `pip install` command to fix it.
|
|
62
|
+
|
|
63
|
+
See the [project repository](https://github.com/JSv4/Python-Redlines) for details.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
python_redlines/__about__.py,sha256=J_QGK1JW-mg3DuXRwZlvozmOmjTb62CqGVTUK9NcDyU,122
|
|
2
|
+
python_redlines/__init__.py,sha256=E3FScwuaU1ybyZHfrdultE52DXFwHxm9I6zpMqeiCdc,383
|
|
3
|
+
python_redlines/engines.py,sha256=LlxHzxwrH79i3XnThRbsi4PMAqPBqSmkM9S4RYY_9-c,9803
|
|
4
|
+
python_redlines-0.2.0.dist-info/METADATA,sha256=cZxj0P9G6C7zsh3I9ShxSmUCpeq7Hpcr7Fpb-JhGwDU,2450
|
|
5
|
+
python_redlines-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
python_redlines-0.2.0.dist-info/RECORD,,
|