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,4 @@
1
+ # SPDX-FileCopyrightText: 2024-present U.N. Owen <void@some.where>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.2.0"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any