From 48269d94b325f71d1a543a29f1e2f172ecd70bb3 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Tue, 10 Feb 2026 00:52:11 -0500 Subject: [PATCH 1/5] Add DocxodusEngine as a second comparison engine Introduce Docxodus (a modernized .NET 8.0 fork of Open-XML-PowerTools with better move detection) as an alternative engine alongside XmlPowerToolsEngine. - Extract BaseEngine class with shared binary extraction and subprocess logic - XmlPowerToolsEngine and DocxodusEngine are thin subclasses setting 3 constants - Add Docxodus as a git submodule at docxodus/ - Refactor build_differ.py into reusable build_engine() function (also fixes missing win-arm64 compression) - Update CI workflow for submodules and .NET SDK - Add integration tests and parametrized contract tests for both engines --- .github/workflows/python-publish.yml | 8 +- .gitignore | 2 + .gitmodules | 3 + CLAUDE.md | 73 ++++++++++++++++ build_differ.py | 90 ++++++++++---------- docxodus | 1 + pyproject.toml | 2 + src/python_redlines/__init__.py | 4 + src/python_redlines/bin_docxodus/.gitignore | 2 + src/python_redlines/dist_docxodus/.gitignore | 2 + src/python_redlines/engines.py | 48 ++++++++--- tests/test_docxodus_engine.py | 32 +++++++ tests/test_engine_contract.py | 45 ++++++++++ 13 files changed, 253 insertions(+), 59 deletions(-) create mode 100644 .gitmodules create mode 100644 CLAUDE.md create mode 160000 docxodus create mode 100644 src/python_redlines/bin_docxodus/.gitignore create mode 100644 src/python_redlines/dist_docxodus/.gitignore create mode 100644 tests/test_docxodus_engine.py create mode 100644 tests/test_engine_contract.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4d213f9..ecf8fd7 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -14,10 +14,16 @@ jobs: steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Python uses: actions/setup-python@v3 with: python-version: '3.x' + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.x' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -26,4 +32,4 @@ jobs: run: hatch build - name: Publish package run: | - hatch publish -u "__token__" -a ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + hatch publish -u "__token__" -a ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index b5c8113..c7a2b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ __pycache__/ # C# Build Dirs csproj/bin/* csproj/obj/* +docxodus/**/bin/* +docxodus/**/obj/* # C extensions *.so diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e3fb994 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docxodus"] + path = docxodus + url = https://github.com/JSv4/Docxodus.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4928436 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Python-Redlines is a Python wrapper around compiled C# binaries that generate `.docx` redline/tracked-changes documents by comparing two Word files. The Python layer handles platform detection, binary extraction, temp file management, and subprocess execution. + +Two comparison engines are available: +- **XmlPowerToolsEngine** — wraps Open-XML-PowerTools WmlComparer (original engine) +- **DocxodusEngine** — wraps Docxodus, a modernized .NET 8.0 fork with better move detection + +## Commands + +```bash +# Run tests +hatch run test + +# Run a single test +hatch run test tests/test_openxml_differ.py::test_run_redlines_with_real_files + +# Run tests with coverage +hatch run cov + +# Type checking +hatch run types:check + +# Build C# binaries for all platforms (requires .NET 8.0 SDK) +hatch run build + +# Build Python package (triggers C# build via custom hook) +hatch build + +# Initialize Docxodus submodule (required before building) +git submodule update --init --recursive +``` + +## Architecture + +The system uses a two-layer wrapper pattern with a shared base class: + +1. **Python layer** (`src/python_redlines/engines.py`): + - `BaseEngine` — shared logic for binary extraction, subprocess invocation, and temp file management + - `XmlPowerToolsEngine(BaseEngine)` — sets constants for the Open-XML-PowerTools binary (`dist/`, `bin/`, `redlines`) + - `DocxodusEngine(BaseEngine)` — sets constants for the Docxodus binary (`dist_docxodus/`, `bin_docxodus/`, `redline`) + + Both engines share the same CLI argument format: ` ` + +2. **C# binaries**: + - `csproj/Program.cs` — Open-XML-PowerTools CLI tool + - `docxodus/tools/redline/Program.cs` — Docxodus CLI tool (git submodule) + +Pre-compiled binaries for 6 platform targets (linux/win/osx x x64/arm64) are stored as archives in `src/python_redlines/dist/` and `src/python_redlines/dist_docxodus/`, included in the wheel. The build script `build_differ.py` compiles both engines using `dotnet publish`. + +## Key Files + +- `src/python_redlines/engines.py` — BaseEngine, XmlPowerToolsEngine, and DocxodusEngine classes +- `src/python_redlines/__init__.py` — Exports all engine classes +- `src/python_redlines/__about__.py` — Single source of truth for package version +- `csproj/Program.cs` — Open-XML-PowerTools C# comparison utility +- `docxodus/` — Docxodus git submodule (tools/redline/ contains the CLI) +- `build_differ.py` — Cross-platform C# build orchestration for both engines +- `hatch_run_build_hook.py` — Hatch build hook that triggers C# compilation +- `tests/fixtures/` — Test `.docx` files (original, modified, expected_redline) + +## Testing Notes + +Tests must be run from the project root (fixtures use relative paths like `tests/fixtures/original.docx`). The XmlPowerToolsEngine integration test validates that comparing the fixture documents produces exactly 9 revisions. Docxodus uses a different stdout format (`"revision(s) found"` vs `"Revisions found: 9"`). + +## Stdout Format Differences + +- **XmlPowerToolsEngine**: `"Revisions found: 9"` +- **DocxodusEngine**: `"Redline complete: 9 revision(s) found"` diff --git a/build_differ.py b/build_differ.py index 0c6ab53..2654fd0 100644 --- a/build_differ.py +++ b/build_differ.py @@ -4,6 +4,16 @@ import zipfile +RIDS = [ + ("linux-x64", ".tar.gz"), + ("linux-arm64", ".tar.gz"), + ("win-x64", ".zip"), + ("win-arm64", ".zip"), + ("osx-x64", ".tar.gz"), + ("osx-arm64", ".tar.gz"), +] + + def get_version(): """ Extracts the version from the specified __about__.py file. @@ -50,57 +60,47 @@ def cleanup_old_builds(dist_dir, current_version): print(f"Deleted old build file: {file}") -def main(): - version = get_version() - print(f"Version: {version}") - - dist_dir = "./src/python_redlines/dist/" - - # Build for Linux x64 - print("Building for Linux x64...") - run_command('dotnet publish ./csproj -c Release -r linux-x64 --self-contained') - - # Build for Linux ARM64 - print("Building for Linux ARM64...") - run_command('dotnet publish ./csproj -c Release -r linux-arm64 --self-contained') - - # Build for Windows x64 - print("Building for Windows x64...") - run_command('dotnet publish ./csproj -c Release -r win-x64 --self-contained') - - # Build for Windows ARM64 - print("Building for Windows ARM64...") - run_command('dotnet publish ./csproj -c Release -r win-arm64 --self-contained') - - # Build for macOS x64 - print("Building for macOS x64...") - run_command('dotnet publish ./csproj -c Release -r osx-x64 --self-contained') - - # Build for macOS ARM64 - print("Building for macOS ARM64...") - run_command('dotnet publish ./csproj -c Release -r osx-arm64 --self-contained') +def build_engine(csproj_path, dist_dir, version): + """ + Builds a C# engine for all platform targets, compresses the output, and cleans up old builds. - # Compress the Linux x64 build - linux_x64_build_dir = './csproj/bin/Release/net8.0/linux-x64' - compress_files(linux_x64_build_dir, f"{dist_dir}/linux-x64-{version}.tar.gz") + :param csproj_path: Path to the .csproj directory (e.g. './csproj' or './docxodus/tools/redline') + :param dist_dir: Path to the distribution directory for compressed binaries + :param version: Version string for archive naming + """ + # Build for each RID + for rid, _ in RIDS: + print(f"Building {csproj_path} for {rid}...") + run_command(f'dotnet publish {csproj_path} -c Release -r {rid} --self-contained') + + # Determine the build output base directory + # dotnet publish outputs to /bin/Release/net8.0/ + build_base = os.path.join(csproj_path, 'bin', 'Release', 'net8.0') + + # Compress each build + for rid, ext in RIDS: + build_dir = os.path.join(build_base, rid) + archive_path = os.path.join(dist_dir, f"{rid}-{version}{ext}") + print(f"Compressing {rid} to {archive_path}...") + compress_files(build_dir, archive_path) - # Compress the Linux ARM64 build - linux_arm64_build_dir = './csproj/bin/Release/net8.0/linux-arm64' - compress_files(linux_arm64_build_dir, f"{dist_dir}/linux-arm64-{version}.tar.gz") + cleanup_old_builds(dist_dir, version) - # Compress the Windows x64 build - windows_build_dir = './csproj/bin/Release/net8.0/win-x64' - compress_files(windows_build_dir, f"{dist_dir}/win-x64-{version}.zip") - # Compress the macOS x64 build - macos_x64_build_dir = './csproj/bin/Release/net8.0/osx-x64' - compress_files(macos_x64_build_dir, f"{dist_dir}/osx-x64-{version}.tar.gz") +def main(): + version = get_version() + print(f"Version: {version}") - # Compress the macOS ARM64 build - macos_arm64_build_dir = './csproj/bin/Release/net8.0/osx-arm64' - compress_files(macos_arm64_build_dir, f"{dist_dir}/osx-arm64-{version}.tar.gz") + # Build the XmlPowerTools engine (original) + build_engine('./csproj', './src/python_redlines/dist/', version) - cleanup_old_builds(dist_dir, version) + # Build the Docxodus engine (if submodule is available) + docxodus_csproj = './docxodus/tools/redline' + if os.path.exists(os.path.join(docxodus_csproj, 'redline.csproj')): + build_engine(docxodus_csproj, './src/python_redlines/dist_docxodus/', version) + else: + print("WARNING: Docxodus submodule not found at docxodus/tools/redline/redline.csproj — skipping Docxodus build.") + print("Run 'git submodule update --init --recursive' to initialize the submodule.") print("Build and compression complete.") diff --git a/docxodus b/docxodus new file mode 160000 index 0000000..0a0b8c6 --- /dev/null +++ b/docxodus @@ -0,0 +1 @@ +Subproject commit 0a0b8c6586566088d80c12de79aded04b349c29b diff --git a/pyproject.toml b/pyproject.toml index 4494c19..0ccfd85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,9 @@ artifacts = [ [tool.hatch.build.targets.sdist] include = [ "python_redlines/dist", + "python_redlines/dist_docxodus", "python_redlines/bin", + "python_redlines/bin_docxodus", ] # Build hook to build the binaries for distribution... diff --git a/src/python_redlines/__init__.py b/src/python_redlines/__init__.py index ec15624..824b82e 100644 --- a/src/python_redlines/__init__.py +++ b/src/python_redlines/__init__.py @@ -1,3 +1,7 @@ # SPDX-FileCopyrightText: 2024-present U.N. Owen # # SPDX-License-Identifier: MIT + +from .engines import XmlPowerToolsEngine, DocxodusEngine, BaseEngine + +__all__ = ["XmlPowerToolsEngine", "DocxodusEngine", "BaseEngine"] diff --git a/src/python_redlines/bin_docxodus/.gitignore b/src/python_redlines/bin_docxodus/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/python_redlines/bin_docxodus/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/python_redlines/dist_docxodus/.gitignore b/src/python_redlines/dist_docxodus/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/python_redlines/dist_docxodus/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/python_redlines/engines.py b/src/python_redlines/engines.py index be80512..7f36980 100644 --- a/src/python_redlines/engines.py +++ b/src/python_redlines/engines.py @@ -13,35 +13,45 @@ logger = logging.getLogger(__name__) -class XmlPowerToolsEngine(object): +class BaseEngine(object): + """ + Base class for redline comparison engines. Subclasses must define: + - DIST_DIR_NAME: directory name under src/python_redlines/ holding compressed binaries + - BIN_DIR_NAME: directory name under src/python_redlines/ for extracted binaries + - BINARY_BASE_NAME: the name of the executable (without .exe extension) + """ + DIST_DIR_NAME: str = NotImplemented + BIN_DIR_NAME: str = NotImplemented + BINARY_BASE_NAME: str = NotImplemented + def __init__(self, target_path: Optional[str] = None): self.target_path = target_path - self.extracted_binaries_path = self.__unzip_binary() + self.extracted_binaries_path = self._unzip_binary() - def __unzip_binary(self): + def _unzip_binary(self): """ Unzips the appropriate C# binary for the current platform. """ base_path = os.path.dirname(__file__) - binaries_path = os.path.join(base_path, 'dist') - target_path = self.target_path if self.target_path else os.path.join(base_path, 'bin') + binaries_path = os.path.join(base_path, self.DIST_DIR_NAME) + target_path = self.target_path if self.target_path else os.path.join(base_path, self.BIN_DIR_NAME) if not os.path.exists(target_path): os.makedirs(target_path) # Get the binary name and zip name based on the OS and architecture - binary_name, zip_name = self.__get_binaries_info() + binary_name, zip_name = self._get_binaries_info() # Check if the binary already exists. If not, extract it. full_binary_path = os.path.join(target_path, binary_name) if not os.path.exists(full_binary_path): zip_path = os.path.join(binaries_path, zip_name) - self.__extract_binary(zip_path, target_path) + self._extract_binary(zip_path, target_path) return os.path.join(target_path, binary_name) - def __extract_binary(self, zip_path: str, target_path: str): + def _extract_binary(self, zip_path: str, target_path: str): """ Extracts the binary from the zip file based on the extension. Supports .zip and .tar.gz files :parameter @@ -56,7 +66,7 @@ def __extract_binary(self, zip_path: str, target_path: str): with tarfile.open(zip_path, 'r:gz') as tar_ref: tar_ref.extractall(target_path) - def __get_binaries_info(self): + def _get_binaries_info(self): """ Returns the binary name and zip name based on the OS and architecture :return @@ -75,15 +85,15 @@ def __get_binaries_info(self): if os_name == 'linux': zip_name = f"linux-{arch}-{__version__}.tar.gz" - binary_name = f'linux-{arch}/redlines' + binary_name = f'linux-{arch}/{self.BINARY_BASE_NAME}' elif os_name == 'windows': zip_name = f"win-{arch}-{__version__}.zip" - binary_name = f'win-{arch}/redlines.exe' + binary_name = f'win-{arch}/{self.BINARY_BASE_NAME}.exe' elif os_name == 'darwin': zip_name = f"osx-{arch}-{__version__}.tar.gz" - binary_name = f'osx-{arch}/redlines' + binary_name = f'osx-{arch}/{self.BINARY_BASE_NAME}' else: raise EnvironmentError("Unsupported OS") @@ -93,7 +103,7 @@ def __get_binaries_info(self): def run_redline(self, author_tag: str, original: Union[bytes, Path], modified: Union[bytes, Path]) \ -> Tuple[bytes, Optional[str], Optional[str]]: """ - Runs the redlines binary. The 'original' and 'modified' arguments can be either bytes or file paths. + Runs the redline binary. The 'original' and 'modified' arguments can be either bytes or file paths. Returns the redline output as bytes. """ temp_files = [] @@ -134,3 +144,15 @@ def _write_to_temp_file(self, data): temp_file.write(data) temp_file.close() return temp_file.name + + +class XmlPowerToolsEngine(BaseEngine): + DIST_DIR_NAME = 'dist' + BIN_DIR_NAME = 'bin' + BINARY_BASE_NAME = 'redlines' + + +class DocxodusEngine(BaseEngine): + DIST_DIR_NAME = 'dist_docxodus' + BIN_DIR_NAME = 'bin_docxodus' + BINARY_BASE_NAME = 'redline' diff --git a/tests/test_docxodus_engine.py b/tests/test_docxodus_engine.py new file mode 100644 index 0000000..04496e5 --- /dev/null +++ b/tests/test_docxodus_engine.py @@ -0,0 +1,32 @@ +import pytest + +from python_redlines.engines import DocxodusEngine + + +def load_docx_bytes(file_path): + with open(file_path, 'rb') as file: + return file.read() + + +@pytest.fixture +def original_docx(): + return load_docx_bytes('tests/fixtures/original.docx') + + +@pytest.fixture +def modified_docx(): + return load_docx_bytes('tests/fixtures/modified.docx') + + +def test_run_docxodus_with_real_files(original_docx, modified_docx): + wrapper = DocxodusEngine() + + author_tag = "TestAuthor" + + redline_output, stdout, stderr = wrapper.run_redline(author_tag, original_docx, modified_docx) + + assert redline_output is not None + assert isinstance(redline_output, bytes) + assert len(redline_output) > 0 + assert stderr is None + assert "revision(s) found" in stdout diff --git a/tests/test_engine_contract.py b/tests/test_engine_contract.py new file mode 100644 index 0000000..fa60769 --- /dev/null +++ b/tests/test_engine_contract.py @@ -0,0 +1,45 @@ +import pytest + +from python_redlines.engines import XmlPowerToolsEngine, DocxodusEngine + + +def load_docx_bytes(file_path): + with open(file_path, 'rb') as file: + return file.read() + + +@pytest.fixture +def original_docx(): + return load_docx_bytes('tests/fixtures/original.docx') + + +@pytest.fixture +def modified_docx(): + return load_docx_bytes('tests/fixtures/modified.docx') + + +@pytest.mark.parametrize("engine_class", [XmlPowerToolsEngine, DocxodusEngine]) +def test_engine_returns_bytes(engine_class, original_docx, modified_docx): + engine = engine_class() + redline_output, stdout, stderr = engine.run_redline("TestAuthor", original_docx, modified_docx) + + assert redline_output is not None + assert isinstance(redline_output, bytes) + assert len(redline_output) > 0 + + +@pytest.mark.parametrize("engine_class", [XmlPowerToolsEngine, DocxodusEngine]) +def test_engine_no_stderr(engine_class, original_docx, modified_docx): + engine = engine_class() + _, _, stderr = engine.run_redline("TestAuthor", original_docx, modified_docx) + + assert stderr is None + + +@pytest.mark.parametrize("engine_class", [XmlPowerToolsEngine, DocxodusEngine]) +def test_engine_has_stdout(engine_class, original_docx, modified_docx): + engine = engine_class() + _, stdout, _ = engine.run_redline("TestAuthor", original_docx, modified_docx) + + assert stdout is not None + assert len(stdout) > 0 From 0cbc2fafe1b20e3acbf7cf83886572ead11417c1 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Tue, 10 Feb 2026 00:55:59 -0500 Subject: [PATCH 2/5] Update README with DocxodusEngine as recommended engine Rewrite README to prominently feature Docxodus as the recommended comparison engine, with a link back to the Docxodus repo. Reorganize sections around the dual-engine architecture and add a quick example. --- README.md | 214 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 119 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 712b974..8cee44d 100644 --- a/README.md +++ b/README.md @@ -3,156 +3,180 @@ ## Project Goal - Democratizing DOCX Comparisons The main goal of this project is to address the significant gap in the open-source ecosystem around `.docx` document -comparison tools. Currently, the process of comparing and generating redline documents (documents that highlight -changes between versions) is complex and largely dominated by commercial software. These -tools, while effective, often come with cost barriers and limitations in terms of accessibility and integration +comparison tools. Currently, the process of comparing and generating redline documents (documents that highlight +changes between versions) is complex and largely dominated by commercial software. These +tools, while effective, often come with cost barriers and limitations in terms of accessibility and integration flexibility. -`Python-redlines` aims to democratize the ability to run tracked change redlines for .docx, providing the +`Python-redlines` aims to democratize the ability to run tracked change redlines for .docx, providing the open-source community with a tool to create `.docx` redlines without the need for commercial software. This will let more legal hackers and hobbyist innovators experiment and create tooling for enterprise and legal. -## Project Roadmap +## Comparison Engines -### Step 1. Open-XML-PowerTools `WmlComparer` Wrapper +Python-Redlines ships with **two comparison engines** — choose the one that best fits your needs: -The [Open-XML-PowerTools](https://github.com/OpenXmlDev/Open-Xml-PowerTools) project historically offered a solid -foundation for working with `.docx` files and has an excellent (if imperfect) comparison engine in its `WmlComparer` -class. However, Microsoft archived the repository almost five years ago, and a forked repo is not being actively -maintained, as its most recent commits dates from 2 years ago and the repo issues list is disabled. +### `DocxodusEngine` — Recommended -As a first step, our project aims to bring the existing capabilities of WmlCompare into the Python world. Thankfully, -XML Power Tools is full cross-platform as it is written in .NET and compiles with the still-maintained .NET 8. The -resulting binaries can be compiled for the latest versions of Windows, OSX and Linux (Ubuntu specifically, though other -distributions should work fine too). We have included an OSX build but do not have an OSX machine to test on. Please -report an issues by opening a new Issue. +**[Docxodus](https://github.com/JSv4/Docxodus)** is a modernized .NET 8.0 fork of Open-XML-PowerTools with +significant improvements: -The initial release has a single engine `XmlPowerToolsEngine`, which is just a Python wrapper for a simple C# utility -written to leverage WmlComparer for 1-to-1 redlines. We hope this provides a stop-gap capability to Python developers -seeking .docx redline capabilities. +- **Move detection** — identifies content that was moved rather than deleted and re-inserted +- **Format change detection** — detects changes to bold, italic, font size, and other run properties +- **Better table handling** — LCS-based row matching for large tables +- **Actively maintained** — regular bug fixes and new features +- **Open XML SDK 3.x compatible** — uses the latest SDK version -**Note**, we don't plan to fork or maintain Open-XML-PowerTools. [Version 4.4.0](https://www.nuget.org/packages/Open-Xml-PowerTools/), -which appears to only be compatible with [Open XML SDK < 3.0.0](https://www.nuget.org/packages/DocumentFormat.OpenXml) works -for now, it needs to be made compatible with the latest versions of the Open XML SDK to extend its life. **There are -also some [issues](https://github.com/dotnet/Open-XML-SDK/issues/1634)**, and it seems the only maintainer of -Open-XML-PowerTools probably won't fix, and understanding the existing code base is no small task. Please be aware that -**Open XML PowerTools is not a perfect comparison engine, but it will work for many purposes. Use at your own risk.** +```python +from python_redlines import DocxodusEngine -### Step 2. Pure Python Comparison Engine - -Looking towards the future, rather than reverse engineer `WmlComparer` and maintain a C# codebase, we envision a -comparison engine written in python. We've done some experimentation with [`xmldiff`](https://github.com/Shoobx/xmldiff) -as the engine to compare the underlying xml of docx files. Specifically, we've built a prototype to unzip `.docx` files, -execute an xml comparison using `xmldiff`, and then reconstructed a tracked changes docx with the proper Open XML -(ooxml) tracked change tags. Preliminary experimentation with this approach has shown promise, indicating its -feasibility for handling modifications such as simple span inserts and deletes. - -However, this ambitious endeavor is not without its challenges. The intricacies of `.docx` files and the potential for -complex, corner-case scenarios necessitate a thoughtful and thorough development process. In the interim, `WmlComparer` -is a great solution as it has clearly been built to account for many such corner cases, through a development process -that clearly was influenced by issues discovered by a large user base. The XMLDiff engine will take some time to reach -a level of maturity similar to WmlComparer. At the moment it is NOT included. +engine = DocxodusEngine() +redline_bytes, stdout, stderr = engine.run_redline("AuthorName", original_bytes, modified_bytes) +``` -## Getting started +### `XmlPowerToolsEngine` — Legacy -### Install .NET Core 8 +Wraps the original [Open-XML-PowerTools](https://github.com/OpenXmlDev/Open-Xml-PowerTools) `WmlComparer`. This +engine remains available for backward compatibility and for users who prefer the original comparison behavior. -The Open-XML-PowerTools engine we're using in the initial releases requires .NET to run (don't worry, this is very -well-supported cross-platform at the moment). Our builds are targeting x86-64 Linux and Windows, however, so you'll -need to modify the build script and build new binaries if you want to target another runtime / architecture. +```python +from python_redlines import XmlPowerToolsEngine -#### On Linux +engine = XmlPowerToolsEngine() +redline_bytes, stdout, stderr = engine.run_redline("AuthorName", original_bytes, modified_bytes) +``` -You can follow [Microsoft's instructions for your Linux distribution](https://learn.microsoft.com/en-us/dotnet/core/install/linux) +> **Note:** Open-XML-PowerTools was archived by Microsoft and is no longer maintained. It uses an older +> version of the Open XML SDK. While it works for many purposes, Docxodus is the recommended engine going forward. -#### On Windows +Both engines share the same API — the only difference is the class you instantiate and the stdout format +(see [Stdout Differences](#stdout-differences) below). -You can follow [Microsoft's instructions for your Windows vesrion](https://learn.microsoft.com/en-us/dotnet/core/install/windows?tabs=net80) +## Getting Started ### Install the Library -At the moment, we are not distributing via pypi. You can easily install directly from this repo, however. - ```commandline pip install git+https://github.com/JSv4/Python-Redlines ``` -You can add this as a dependency like so +You can add this as a dependency like so: ```requirements -python_redlines @ git+https://github.com/JSv4/Python-Redlines@v0.0.1 +python_redlines @ git+https://github.com/JSv4/Python-Redlines@v0.0.4 ``` ### Use the Library If you just want to use the tool, jump into our [quickstart guide](docs/quickstart.md). -## Architecture Overview +### Quick Example + +```python +from python_redlines import DocxodusEngine -`XmlPowerToolsEngine` is a Python wrapper class for the `redlines` C# command-line tool, source of which is available in -[./csproj/Program.cs](./csproj/Program.cs). The redlines utility and wrapper let you compare two docx files and -show the differences in tracked changes (a "redline" document). +# Load your documents as bytes +with open("original.docx", "rb") as f: + original = f.read() +with open("modified.docx", "rb") as f: + modified = f.read() -### C# Functionality +# Generate a redline document +engine = DocxodusEngine() +redline_bytes, stdout, stderr = engine.run_redline("Reviewer", original, modified) -The `redlines` C# utility is a command line tool that requires four arguments: -1. `author_tag` - A tag to identify the author of the changes. -2. `original_path.docx` - Path to the original document. -3. `modified_path.docx` - Path to the modified document. -4. `redline_path.docx` - Path where the redlined document will be saved. +# Save the result +with open("redline.docx", "wb") as f: + f.write(redline_bytes) -The Python wrapper, `XmlPowerToolsEngine` and its main method `run_redline()`, simplifies the use of `redlines` by -orchestrating its execution with Python and letting you pass in bytes or file paths for the original and modified -documents. +print(stdout) # e.g. "Redline complete: 9 revision(s) found" +``` + +## Architecture Overview -### Packaging +Both engines follow the same pattern: a Python wrapper class invokes a self-contained C# binary via subprocess. +The binary takes four arguments: ` `. -The project is structured as follows: ``` python-redlines/ │ -├── csproj/ -│ ├── bin/ -│ ├── obj/ +├── csproj/ # XmlPowerTools C# source │ ├── Program.cs -│ ├── redlines.csproj -│ └── redlines.sln +│ └── redlines.csproj │ -├── docs/ -│ ├── developer-guide.md -│ └── quickstart.md +├── docxodus/ # Docxodus git submodule +│ └── tools/redline/ +│ ├── Program.cs +│ └── redline.csproj │ ├── src/ │ └── python_redlines/ -│ ├── bin/ -│ │ └── .gitignore -│ ├── dist/ -│ │ ├── .gitignore -│ │ ├── linux-x64-0.0.1.tar.gz -│ │ └── win-x64-0.0.1.zip +│ ├── engines.py # BaseEngine, XmlPowerToolsEngine, DocxodusEngine +│ ├── dist/ # XmlPowerTools compressed binaries +│ ├── dist_docxodus/ # Docxodus compressed binaries +│ ├── bin/ # XmlPowerTools extracted binaries (runtime) +│ ├── bin_docxodus/ # Docxodus extracted binaries (runtime) │ ├── __about__.py -│ ├── __init__.py -│ └── engines.py +│ └── __init__.py │ ├── tests/ -| ├── fixtures/ -| ├── test_openxml_differ.py -| └── __init__.py -| -├── .gitignore -├── build_differ.py -├── extract_version.py -├── License.md +│ ├── fixtures/ +│ ├── test_openxml_differ.py # XmlPowerTools integration test +│ ├── test_docxodus_engine.py # Docxodus integration test +│ └── test_engine_contract.py # Shared contract tests for both engines +│ +├── build_differ.py # Builds both engines for all platforms ├── pyproject.toml └── README.md ``` -- `src/your_package/`: Contains the Python wrapper code. -- `dist/`: Contains the zipped C# binaries for different platforms. -- `bin/`: Target directory for extracted binaries. -- `tests/`: Contains test cases and fixtures for the wrapper. +Pre-compiled binaries for 6 platform targets (linux/win/osx x x64/arm64) are bundled in the wheel for each engine. +On first use, the appropriate binary is extracted and cached. + +### Stdout Differences + +The two engines produce slightly different stdout messages: + +| Engine | Example stdout | +|---|---| +| `XmlPowerToolsEngine` | `Revisions found: 9` | +| `DocxodusEngine` | `Redline complete: 9 revision(s) found` | + +## Development + +### Prerequisites + +- Python 3.8+ +- .NET 8.0 SDK (for building C# binaries) + +### Setup + +```bash +# Clone with submodules +git clone --recurse-submodules https://github.com/JSv4/Python-Redlines +cd Python-Redlines + +# If you already cloned without submodules +git submodule update --init --recursive +``` + +### Commands + +```bash +# Run tests +hatch run test + +# Run a single test +hatch run test tests/test_openxml_differ.py::test_run_redlines_with_real_files + +# Build C# binaries for all platforms +hatch run build + +# Build Python package +hatch build +``` -### Detailed Explanation and Dev Setup +### Detailed Dev Setup If you want to contribute to the library or want to dive into some of the C# packaging architecture, go to our [developer guide](docs/developer-guide.md). From 06d3ec63ebb499991f98531e13139f430a4e77a3 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Tue, 10 Feb 2026 01:44:41 -0500 Subject: [PATCH 3/5] Expose Docxodus comparison settings via Python kwargs Thread WmlComparerSettings options from Python kwargs through CLI flags to the Docxodus C# binary. Supports detail_threshold, case_insensitive, detect_moves, simplify_move_markup, move_similarity_threshold, move_minimum_word_count, detect_format_changes, conflate_spaces, and date_time. - Extract _build_command() in BaseEngine, override in DocxodusEngine - Add input validation for thresholds and word count - Update Docxodus CLI to parse --flags (backward compat with legacy format) - Rebuild all platform binaries with new flag support - Add 13 new tests (integration, validation, unit) - Update README with Comparison Settings section --- CLAUDE.md | 2 +- README.md | 36 ++++++++ docxodus | 2 +- src/python_redlines/engines.py | 74 ++++++++++++++- tests/test_docxodus_engine.py | 158 +++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4928436..a4e1792 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ The system uses a two-layer wrapper pattern with a shared base class: - `XmlPowerToolsEngine(BaseEngine)` — sets constants for the Open-XML-PowerTools binary (`dist/`, `bin/`, `redlines`) - `DocxodusEngine(BaseEngine)` — sets constants for the Docxodus binary (`dist_docxodus/`, `bin_docxodus/`, `redline`) - Both engines share the same CLI argument format: ` ` + Both engines expose `run_redline(author_tag, original, modified, **kwargs)`. `DocxodusEngine` overrides `_build_command()` to translate kwargs (e.g. `detect_moves`, `detail_threshold`) into CLI flags for the Docxodus binary. `XmlPowerToolsEngine` uses the legacy 4-positional-arg format and ignores kwargs. 2. **C# binaries**: - `csproj/Program.cs` — Open-XML-PowerTools CLI tool diff --git a/README.md b/README.md index 8cee44d..bd5648c 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,42 @@ with open("redline.docx", "wb") as f: print(stdout) # e.g. "Redline complete: 9 revision(s) found" ``` +## Comparison Settings (DocxodusEngine only) + +`DocxodusEngine` supports fine-grained control over the comparison via keyword arguments to `run_redline()`: + +```python +from python_redlines import DocxodusEngine + +engine = DocxodusEngine() +redline_bytes, stdout, stderr = engine.run_redline( + "Reviewer", original, modified, + detect_moves=True, + simplify_move_markup=True, + detail_threshold=0.3, + case_insensitive=True, +) +``` + +| Setting | Type | Default | Description | +|---|---|---|---| +| `detail_threshold` | float | 0.0 | Comparison granularity (0.0–1.0, lower = more detailed) | +| `case_insensitive` | bool | False | Ignore case differences | +| `detect_moves` | bool | False | Enable move detection | +| `simplify_move_markup` | bool | False | Convert moves to del/ins for Word compatibility | +| `move_similarity_threshold` | float | 0.8 | Jaccard threshold for move matching (0.0–1.0) | +| `move_minimum_word_count` | int | 3 | Minimum words for move detection | +| `detect_format_changes` | bool | True | Detect formatting-only changes | +| `conflate_spaces` | bool | True | Treat breaking/non-breaking spaces the same | +| `date_time` | str | now | Custom ISO 8601 timestamp for revisions | + +> **Warning:** Move detection can cause Word to display "unreadable content" warnings due to a known +> ID collision bug. When using `detect_moves=True`, always set `simplify_move_markup=True` as well. +> This converts move markup to regular del/ins (loses green move styling but ensures Word compatibility). + +> **Note:** These settings are only available on `DocxodusEngine`. `XmlPowerToolsEngine` ignores +> extra keyword arguments. + ## Architecture Overview Both engines follow the same pattern: a Python wrapper class invokes a self-contained C# binary via subprocess. diff --git a/docxodus b/docxodus index 0a0b8c6..d0601cd 160000 --- a/docxodus +++ b/docxodus @@ -1 +1 @@ -Subproject commit 0a0b8c6586566088d80c12de79aded04b349c29b +Subproject commit d0601cd1b96eb13fc807d3b9bd907b690e112268 diff --git a/src/python_redlines/engines.py b/src/python_redlines/engines.py index 7f36980..c2ca8dd 100644 --- a/src/python_redlines/engines.py +++ b/src/python_redlines/engines.py @@ -100,11 +100,23 @@ def _get_binaries_info(self): return binary_name, zip_name - def run_redline(self, author_tag: str, original: Union[bytes, Path], modified: Union[bytes, Path]) \ + def _build_command(self, author_tag: str, original_path, modified_path, target_path, **kwargs): + """ + Build the command list for subprocess execution. + Subclasses can override to customize argument format. + """ + return [self.extracted_binaries_path, author_tag, original_path, modified_path, target_path] + + def run_redline(self, author_tag: str, original: Union[bytes, Path], modified: Union[bytes, Path], **kwargs) \ -> Tuple[bytes, Optional[str], Optional[str]]: """ Runs the redline binary. The 'original' and 'modified' arguments can be either bytes or file paths. Returns the redline output as bytes. + + Additional keyword arguments are passed to _build_command() for engine-specific options. + DocxodusEngine supports: detail_threshold, case_insensitive, detect_moves, + simplify_move_markup, move_similarity_threshold, move_minimum_word_count, + detect_format_changes, conflate_spaces, date_time. """ temp_files = [] try: @@ -114,7 +126,7 @@ def run_redline(self, author_tag: str, original: Union[bytes, Path], modified: U modified_path = self._write_to_temp_file(modified) if isinstance(modified, bytes) else modified temp_files.extend([target_path, original_path, modified_path]) - command = [self.extracted_binaries_path, author_tag, original_path, modified_path, target_path] + command = self._build_command(author_tag, original_path, modified_path, target_path, **kwargs) # Capture stdout and stderr result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) @@ -156,3 +168,61 @@ class DocxodusEngine(BaseEngine): DIST_DIR_NAME = 'dist_docxodus' BIN_DIR_NAME = 'bin_docxodus' BINARY_BASE_NAME = 'redline' + + # Boolean flags (default False — presence enables) + _BOOL_FLAGS = [ + ('case_insensitive', '--case-insensitive'), + ('detect_moves', '--detect-moves'), + ('simplify_move_markup', '--simplify-move-markup'), + ] + + # Negatable flags (default True — --no- prefix disables) + _NEG_FLAGS = [ + ('detect_format_changes', '--no-detect-format-changes'), + ('conflate_spaces', '--no-conflate-spaces'), + ] + + # Value flags + _VALUE_FLAGS = [ + ('detail_threshold', '--detail-threshold'), + ('move_similarity_threshold', '--move-similarity-threshold'), + ('move_minimum_word_count', '--move-minimum-word-count'), + ('date_time', '--date-time'), + ] + + @staticmethod + def _validate_kwargs(kwargs): + if 'detail_threshold' in kwargs: + val = kwargs['detail_threshold'] + if not isinstance(val, (int, float)) or val < 0.0 or val > 1.0: + raise ValueError(f"detail_threshold must be a float between 0.0 and 1.0, got {val!r}") + + if 'move_similarity_threshold' in kwargs: + val = kwargs['move_similarity_threshold'] + if not isinstance(val, (int, float)) or val < 0.0 or val > 1.0: + raise ValueError(f"move_similarity_threshold must be a float between 0.0 and 1.0, got {val!r}") + + if 'move_minimum_word_count' in kwargs: + val = kwargs['move_minimum_word_count'] + if not isinstance(val, int) or val < 1: + raise ValueError(f"move_minimum_word_count must be a positive integer, got {val!r}") + + def _build_command(self, author_tag, original_path, modified_path, target_path, **kwargs): + self._validate_kwargs(kwargs) + + cmd = [self.extracted_binaries_path, original_path, modified_path, target_path, + f'--author={author_tag}'] + + for kwarg, flag in self._BOOL_FLAGS: + if kwargs.get(kwarg): + cmd.append(flag) + + for kwarg, neg_flag in self._NEG_FLAGS: + if kwarg in kwargs and not kwargs[kwarg]: + cmd.append(neg_flag) + + for kwarg, flag in self._VALUE_FLAGS: + if kwarg in kwargs: + cmd.append(f'{flag}={kwargs[kwarg]}') + + return cmd diff --git a/tests/test_docxodus_engine.py b/tests/test_docxodus_engine.py index 04496e5..5036772 100644 --- a/tests/test_docxodus_engine.py +++ b/tests/test_docxodus_engine.py @@ -30,3 +30,161 @@ def test_run_docxodus_with_real_files(original_docx, modified_docx): assert len(redline_output) > 0 assert stderr is None assert "revision(s) found" in stdout + + +# --- Integration tests for comparison settings --- + +def test_docxodus_with_detect_moves(original_docx, modified_docx): + engine = DocxodusEngine() + redline_output, stdout, stderr = engine.run_redline( + "TestAuthor", original_docx, modified_docx, + detect_moves=True, simplify_move_markup=True, + ) + assert redline_output is not None + assert len(redline_output) > 0 + assert stderr is None + assert "revision(s) found" in stdout + + +def test_docxodus_with_detail_threshold(original_docx, modified_docx): + engine = DocxodusEngine() + redline_output, stdout, stderr = engine.run_redline( + "TestAuthor", original_docx, modified_docx, + detail_threshold=0.5, + ) + assert redline_output is not None + assert len(redline_output) > 0 + assert stderr is None + assert "revision(s) found" in stdout + + +def test_docxodus_with_case_insensitive(original_docx, modified_docx): + engine = DocxodusEngine() + redline_output, stdout, stderr = engine.run_redline( + "TestAuthor", original_docx, modified_docx, + case_insensitive=True, + ) + assert redline_output is not None + assert len(redline_output) > 0 + assert stderr is None + assert "revision(s) found" in stdout + + +def test_docxodus_with_no_format_changes(original_docx, modified_docx): + engine = DocxodusEngine() + redline_output, stdout, stderr = engine.run_redline( + "TestAuthor", original_docx, modified_docx, + detect_format_changes=False, + ) + assert redline_output is not None + assert len(redline_output) > 0 + assert stderr is None + assert "revision(s) found" in stdout + + +def test_docxodus_with_all_options(original_docx, modified_docx): + engine = DocxodusEngine() + redline_output, stdout, stderr = engine.run_redline( + "TestAuthor", original_docx, modified_docx, + detail_threshold=0.3, + case_insensitive=True, + detect_moves=True, + simplify_move_markup=True, + move_similarity_threshold=0.7, + move_minimum_word_count=2, + detect_format_changes=False, + conflate_spaces=False, + date_time="2025-01-01T00:00:00Z", + ) + assert redline_output is not None + assert len(redline_output) > 0 + assert stderr is None + assert "revision(s) found" in stdout + + +# --- Validation tests --- + +def test_docxodus_invalid_detail_threshold(): + engine = DocxodusEngine() + with pytest.raises(ValueError, match="detail_threshold must be a float between 0.0 and 1.0"): + engine._build_command("Author", "orig", "mod", "out", detail_threshold=1.5) + + +def test_docxodus_invalid_move_similarity_threshold(): + engine = DocxodusEngine() + with pytest.raises(ValueError, match="move_similarity_threshold must be a float between 0.0 and 1.0"): + engine._build_command("Author", "orig", "mod", "out", move_similarity_threshold=-0.1) + + +def test_docxodus_invalid_move_minimum_word_count(): + engine = DocxodusEngine() + with pytest.raises(ValueError, match="move_minimum_word_count must be a positive integer"): + engine._build_command("Author", "orig", "mod", "out", move_minimum_word_count=0) + + +def test_docxodus_invalid_move_minimum_word_count_type(): + engine = DocxodusEngine() + with pytest.raises(ValueError, match="move_minimum_word_count must be a positive integer"): + engine._build_command("Author", "orig", "mod", "out", move_minimum_word_count=2.5) + + +# --- Unit test for _build_command flag construction --- + +def test_build_command_default(): + engine = DocxodusEngine() + cmd = engine._build_command("Author", "/tmp/orig.docx", "/tmp/mod.docx", "/tmp/out.docx") + assert cmd[1] == "/tmp/orig.docx" + assert cmd[2] == "/tmp/mod.docx" + assert cmd[3] == "/tmp/out.docx" + assert "--author=Author" in cmd + assert len(cmd) == 5 # binary + 3 positional + --author + + +def test_build_command_with_all_flags(): + engine = DocxodusEngine() + cmd = engine._build_command( + "Author", "/tmp/orig.docx", "/tmp/mod.docx", "/tmp/out.docx", + detail_threshold=0.5, + case_insensitive=True, + detect_moves=True, + simplify_move_markup=True, + move_similarity_threshold=0.7, + move_minimum_word_count=2, + detect_format_changes=False, + conflate_spaces=False, + date_time="2025-01-01T00:00:00Z", + ) + assert "--author=Author" in cmd + assert "--case-insensitive" in cmd + assert "--detect-moves" in cmd + assert "--simplify-move-markup" in cmd + assert "--no-detect-format-changes" in cmd + assert "--no-conflate-spaces" in cmd + assert "--detail-threshold=0.5" in cmd + assert "--move-similarity-threshold=0.7" in cmd + assert "--move-minimum-word-count=2" in cmd + assert "--date-time=2025-01-01T00:00:00Z" in cmd + + +def test_build_command_false_bools_not_added(): + """Boolean flags that are False should not be added to the command.""" + engine = DocxodusEngine() + cmd = engine._build_command( + "Author", "/tmp/orig.docx", "/tmp/mod.docx", "/tmp/out.docx", + detect_moves=False, + case_insensitive=False, + ) + assert "--detect-moves" not in cmd + assert "--case-insensitive" not in cmd + + +def test_build_command_negatable_true_not_added(): + """Negatable flags that are True (default) should not add --no- flags.""" + engine = DocxodusEngine() + cmd = engine._build_command( + "Author", "/tmp/orig.docx", "/tmp/mod.docx", "/tmp/out.docx", + detect_format_changes=True, + conflate_spaces=True, + ) + assert "--no-detect-format-changes" not in cmd + assert "--no-conflate-spaces" not in cmd From 0c3060bb142c776fcc3c53f445407d3471361dee Mon Sep 17 00:00:00 2001 From: JSv4 Date: Tue, 10 Feb 2026 02:04:14 -0500 Subject: [PATCH 4/5] Add CI workflow for tests and package build Run tests across 3 OSes x 3 Python versions on push to main and on pull requests. Includes package build verification. --- .github/workflows/ci.yml | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d3e9e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.9', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install hatch + + - name: Build engine binaries + run: python build_differ.py + + - name: Run tests + run: hatch run test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install hatch hatchling + + - name: Build package + run: hatch build + + - name: Check package + run: | + pip install twine + twine check dist/* From 3e96ebd956662fad198e84c060d7f12da8a74615 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Tue, 10 Feb 2026 09:05:44 -0500 Subject: [PATCH 5/5] Fix CI: pin .NET SDK to 8.0 and fail on build errors Add global.json to pin the .NET SDK to 8.0.x, preventing CI runners with .NET 10 pre-installed from using the wrong compiler (which breaks Docxodus due to List.Reverse() vs LINQ Reverse() resolution). Also fix build_differ.py run_command() to raise on non-zero exit codes instead of silently continuing past build failures. --- build_differ.py | 5 ++++- global.json | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 global.json diff --git a/build_differ.py b/build_differ.py index 2654fd0..8c98dfc 100644 --- a/build_differ.py +++ b/build_differ.py @@ -26,11 +26,14 @@ def get_version(): def run_command(command): """ - Runs a shell command and prints its output. + Runs a shell command and prints its output. Raises on non-zero exit code. """ process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) for line in process.stdout: print(line.decode().strip()) + process.wait() + if process.returncode != 0: + raise RuntimeError(f"Command failed with exit code {process.returncode}: {command}") def compress_files(source_dir, target_file): diff --git a/global.json b/global.json new file mode 100644 index 0000000..3fea262 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestFeature" + } +}