pysfi 0.1.11__tar.gz → 0.1.12__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.
- {pysfi-0.1.11 → pysfi-0.1.12}/PKG-INFO +3 -1
- {pysfi-0.1.11 → pysfi-0.1.12}/pyproject.toml +4 -1
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/__init__.py +1 -1
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/bumpversion/__init__.py +1 -1
- pysfi-0.1.12/sfi/docdiff/README.md +108 -0
- pysfi-0.1.12/sfi/docdiff/docdiff.py +238 -0
- pysfi-0.1.12/sfi/docdiff/tests/test_benchmark.py +98 -0
- pysfi-0.1.12/sfi/docdiff/tests/test_docdiff.py +188 -0
- pysfi-0.1.12/sfi/docdiff/tests/test_enhanced.py +150 -0
- pysfi-0.1.12/sfi/docdiff/tests/test_functional.py +186 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/__init__.py +1 -1
- pysfi-0.1.12/sfi/workflowengine/README.md +0 -0
- pysfi-0.1.12/sfi/workflowengine/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/.gitignore +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/examples/pack_demo/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/alarmclock/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/alarmclock/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/alarmclock/alarmclock.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/bumpversion/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/bumpversion/bumpversion.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/bumpversion/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/bumpversion/tests/test_bumpversion.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/cleanbuild/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/cleanbuild/cleanbuild.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/cli.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/condasetup/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/condasetup/condasetup.py +0 -0
- {pysfi-0.1.11/sfi/docscan/lang → pysfi-0.1.12/sfi/docdiff/tests}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/docscan.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/docscan_gui.py +0 -0
- {pysfi-0.1.11/sfi/filedate → pysfi-0.1.12/sfi/docscan/lang}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/lang/eng.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/lang/zhcn.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/tests/test_benchmark.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/tests/test_docscan.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/docscan/tests/test_enhanced.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/filedate/README.md +0 -0
- {pysfi-0.1.11/sfi/llmquantize → pysfi-0.1.12/sfi/filedate}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/filedate/filedate.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/filedate/tests/test_filedate.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/gittool/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/gittool/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/gittool/gittool.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmclient/README.md +0 -0
- {pysfi-0.1.11/sfi/makepython → pysfi-0.1.12/sfi/llmclient}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmclient/llmclient.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmclient/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmclient/tests/test_benchmark.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmclient/tests/test_llmclient.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmquantize/README.md +0 -0
- {pysfi-0.1.11/sfi/pdfsplit → pysfi-0.1.12/sfi/llmquantize}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmquantize/llmquantize.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmquantize/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmquantize/tests/test_llmquantize.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmserver/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/llmserver/llmserver.py +0 -0
- {pysfi-0.1.11/sfi/pdfsplit/tests → pysfi-0.1.12/sfi/makepython}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/makepython/makepython.py +0 -0
- {pysfi-0.1.11/sfi/pyembedinstall → pysfi-0.1.12/sfi/pdfsplit}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pdfsplit/pdfsplit.py +0 -0
- {pysfi-0.1.11/sfi/pylibpack → pysfi-0.1.12/sfi/pdfsplit/tests}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pdfsplit/tests/test_pdfsplit.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyarchive/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyarchive/pyarchive.py +0 -0
- {pysfi-0.1.11/sfi/pylibpack/tests → pysfi-0.1.12/sfi/pyembedinstall}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyembedinstall/pyembedinstall.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyembedinstall/tests/test_pyembedinstall.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pylibpack/README.md +0 -0
- {pysfi-0.1.11/sfi/pyloadergen → pysfi-0.1.12/sfi/pylibpack}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pylibpack/pylibpack.py +0 -0
- {pysfi-0.1.11/sfi/pyloadergen → pysfi-0.1.12/sfi/pylibpack}/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pylibpack/tests/test_benchmark.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pylibpack/tests/test_pylibpack.py +0 -0
- {pysfi-0.1.11/sfi/pypack → pysfi-0.1.12/sfi/pyloadergen}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyloadergen/pyloadergen.py +0 -0
- {pysfi-0.1.11/sfi/pypack → pysfi-0.1.12/sfi/pyloadergen}/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyloadergen/tests/test_benchmark.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyloadergen/tests/test_pyloadergen.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pypack/README.md +0 -0
- {pysfi-0.1.11/sfi/pyprojectparse → pysfi-0.1.12/sfi/pypack}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pypack/pypack.py +0 -0
- {pysfi-0.1.11/sfi/quizbase → pysfi-0.1.12/sfi/pypack/tests}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pypack/tests/test_pack.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyprojectparse/README.md +0 -0
- {pysfi-0.1.11/sfi/regexvalidate → pysfi-0.1.12/sfi/pyprojectparse}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyprojectparse/pyprojectparse.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyprojectparse/tests/test_benchmark.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pyprojectparse/tests/test_projectparse.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pysourcepack/README.md +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/pysourcepack/pysourcepack.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/quizbase/README.md +0 -0
- {pysfi-0.1.11/sfi/taskkill → pysfi-0.1.12/sfi/quizbase}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/quizbase/quizbase.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/quizbase/quizbase_gui.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/quizbase/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/quizbase/tests/test_quizbase.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/quizbase/tests/test_quizbase_gui.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/regexvalidate/README.md +0 -0
- {pysfi-0.1.11/sfi/which → pysfi-0.1.12/sfi/regexvalidate}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/regexvalidate/regexvalidate.py +0 -0
- {pysfi-0.1.11/sfi/workflowengine → pysfi-0.1.12/sfi/taskkill}/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/taskkill/taskkill.py +0 -0
- /pysfi-0.1.11/sfi/workflowengine/README.md → /pysfi-0.1.12/sfi/which/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/which/which.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/workflowengine/tests/__init__.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/workflowengine/tests/test_benchmark.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/workflowengine/tests/test_workflowengine.py +0 -0
- {pysfi-0.1.11 → pysfi-0.1.12}/sfi/workflowengine/workflowengine.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pysfi
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.12
|
|
4
4
|
Summary: Single File commands for Interactive python.
|
|
5
5
|
Requires-Python: >=3.8
|
|
6
6
|
Requires-Dist: tomli>=2.4.0; python_version < '3.11'
|
|
@@ -17,6 +17,7 @@ Requires-Dist: pyside2>=5.15.2.1; extra == 'all'
|
|
|
17
17
|
Requires-Dist: pytesseract>=0.3.10; extra == 'all'
|
|
18
18
|
Requires-Dist: python-docx>=1.1.0; extra == 'all'
|
|
19
19
|
Requires-Dist: python-pptx>=0.6.21; extra == 'all'
|
|
20
|
+
Requires-Dist: pywin32>=311; (sys_platform == 'win32') and extra == 'all'
|
|
20
21
|
Provides-Extra: extra
|
|
21
22
|
Requires-Dist: ebooklib>=0.18; extra == 'extra'
|
|
22
23
|
Requires-Dist: markdown>=3.5; extra == 'extra'
|
|
@@ -33,6 +34,7 @@ Requires-Dist: openpyxl>=3.1.0; extra == 'office'
|
|
|
33
34
|
Requires-Dist: pymupdf>=1.24.11; extra == 'office'
|
|
34
35
|
Requires-Dist: python-docx>=1.1.0; extra == 'office'
|
|
35
36
|
Requires-Dist: python-pptx>=0.6.21; extra == 'office'
|
|
37
|
+
Requires-Dist: pywin32>=311; (sys_platform == 'win32') and extra == 'office'
|
|
36
38
|
Description-Content-Type: text/markdown
|
|
37
39
|
|
|
38
40
|
# pysfi
|
|
@@ -8,13 +8,14 @@ description = "Single File commands for Interactive python."
|
|
|
8
8
|
name = "pysfi"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
11
|
-
version = "0.1.
|
|
11
|
+
version = "0.1.12"
|
|
12
12
|
|
|
13
13
|
[project.scripts]
|
|
14
14
|
alarmclk = "sfi.alarmclock.alarmclock:main"
|
|
15
15
|
bumpversion = "sfi.bumpversion.bumpversion:main"
|
|
16
16
|
cleanbuild = "sfi.cleanbuild.cleanbuild:main"
|
|
17
17
|
condasetup = "sfi.condasetup.condasetup:main"
|
|
18
|
+
docdiff = "sfi.docdiff.docdiff:main"
|
|
18
19
|
docscan = "sfi.docscan.docscan:main"
|
|
19
20
|
docscan-gui = "sfi.docscan.docscan_gui:main"
|
|
20
21
|
filedate = "sfi.filedate.filedate:main"
|
|
@@ -49,6 +50,7 @@ office = [
|
|
|
49
50
|
"pymupdf>=1.24.11",
|
|
50
51
|
"python-docx>=1.1.0",
|
|
51
52
|
"python-pptx>=0.6.21",
|
|
53
|
+
"pywin32>=311; sys.platform=='win32'",
|
|
52
54
|
]
|
|
53
55
|
|
|
54
56
|
[tool.hatch.build.targets.wheel]
|
|
@@ -129,6 +131,7 @@ members = [
|
|
|
129
131
|
"sfi/bumpversion",
|
|
130
132
|
"sfi/cleanbuild",
|
|
131
133
|
"sfi/condasetup",
|
|
134
|
+
"sfi/docdiff",
|
|
132
135
|
"sfi/docscan",
|
|
133
136
|
"sfi/filedate",
|
|
134
137
|
"sfi/gittool",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# DocDiff - Word Document Comparison Tool
|
|
2
|
+
|
|
3
|
+
DocDiff is a Python tool that compares two Microsoft Word documents (.doc/.docx) and generates a comparison report highlighting the differences between them.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Compares two Word documents and highlights differences
|
|
8
|
+
- Supports both .doc and .docx file formats
|
|
9
|
+
- Configurable comparison options
|
|
10
|
+
- Automatic configuration persistence
|
|
11
|
+
- Command-line interface for easy automation
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
This module is part of the pysfi package. Make sure you have the required dependencies:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install pywin32
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Note: This tool only works on Windows systems with Microsoft Word installed.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Command Line Interface
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Basic usage
|
|
29
|
+
python -m sfi.docdiff old_document.docx new_document.docx
|
|
30
|
+
|
|
31
|
+
# Specify output directory
|
|
32
|
+
python -m sfi.docdiff old_document.docx new_document.docx --output-dir ./results
|
|
33
|
+
|
|
34
|
+
# Customize the comparison title
|
|
35
|
+
python -m sfi.docdiff old_document.docx new_document.docx --title "My Comparison Report"
|
|
36
|
+
|
|
37
|
+
# Control change visibility
|
|
38
|
+
python -m sfi.docdiff old_document.docx new_document.docx --show-changes
|
|
39
|
+
python -m sfi.docdiff old_document.docx new_document.docx --hide-changes
|
|
40
|
+
|
|
41
|
+
# Specify comparison mode
|
|
42
|
+
python -m sfi.docdiff old_document.docx new_document.docx --compare-mode original
|
|
43
|
+
python -m sfi.docdiff old_document.docx new_document.docx --compare-mode revised
|
|
44
|
+
|
|
45
|
+
# Specify custom output path
|
|
46
|
+
python -m sfi.docdiff old_document.docx new_document.docx -o ./output/comparison_result.docx
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Configuration
|
|
50
|
+
|
|
51
|
+
The tool automatically saves and loads configuration from `~/.sfi/docdiff.json`. The configuration includes:
|
|
52
|
+
|
|
53
|
+
- `DOC_DIFF_TITLE`: Title for the comparison result
|
|
54
|
+
- `OUTPUT_DIR`: Directory for output files
|
|
55
|
+
- `COMPARE_MODE`: Comparison mode (original/revised)
|
|
56
|
+
- `SHOW_CHANGES`: Whether to show changes in the comparison
|
|
57
|
+
- `TRACK_REVISIONS`: Whether to track revisions
|
|
58
|
+
|
|
59
|
+
## Programmatic Usage
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from sfi.docdiff.docdiff import diff_doc
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
# Compare two documents
|
|
66
|
+
old_file = Path("old_document.docx")
|
|
67
|
+
new_file = Path("new_document.docx")
|
|
68
|
+
diff_doc(old_file, new_file)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration API
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from sfi.docdiff.docdiff import DocDiffConfig
|
|
75
|
+
|
|
76
|
+
# Access current configuration
|
|
77
|
+
config = DocDiffConfig()
|
|
78
|
+
print(config.DOC_DIFF_TITLE)
|
|
79
|
+
|
|
80
|
+
# Modify configuration (will be saved on program exit)
|
|
81
|
+
config.DOC_DIFF_TITLE = "New Title"
|
|
82
|
+
config.SHOW_CHANGES = False
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Requirements
|
|
86
|
+
|
|
87
|
+
- Windows operating system
|
|
88
|
+
- Microsoft Word installed
|
|
89
|
+
- Python 3.8+
|
|
90
|
+
- pywin32 package
|
|
91
|
+
|
|
92
|
+
## Testing
|
|
93
|
+
|
|
94
|
+
Run the unit tests:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python -m pytest sfi/docdiff/tests/test_docdiff.py
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Run the benchmark tests:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python -m pytest sfi/docdiff/tests/test_benchmark.py
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
This project is part of pysfi and is licensed under the terms specified in the main project repository.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import atexit
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from functools import cached_property
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
CONFIG_FILE = Path.home() / ".sfi" / "docdiff.json"
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DocDiffConfig:
|
|
23
|
+
"""Document comparison configuration."""
|
|
24
|
+
|
|
25
|
+
DOC_DIFF_TITLE: str = "Comparison Result"
|
|
26
|
+
OUTPUT_DIR: str = str(Path.home()) # Use current directory if empty
|
|
27
|
+
COMPARE_MODE: str = "original" # Options: original, revised
|
|
28
|
+
SHOW_CHANGES: bool = True
|
|
29
|
+
TRACK_REVISIONS: bool = True
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
if CONFIG_FILE.exists():
|
|
33
|
+
logger.info("Loading configuration from %s", CONFIG_FILE)
|
|
34
|
+
config_data = json.loads(CONFIG_FILE.read_text())
|
|
35
|
+
# Update configuration items, keeping defaults as fallback
|
|
36
|
+
for key, value in config_data.items():
|
|
37
|
+
if hasattr(self, key):
|
|
38
|
+
setattr(self, key, value)
|
|
39
|
+
else:
|
|
40
|
+
logger.info("Using default configuration")
|
|
41
|
+
|
|
42
|
+
def save(self) -> None:
|
|
43
|
+
"""Save configuration."""
|
|
44
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
CONFIG_FILE.write_text(json.dumps(vars(self), indent=4))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
conf = DocDiffConfig()
|
|
49
|
+
atexit.register(conf.save)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class DiffDocCommand:
|
|
54
|
+
"""Document comparison command."""
|
|
55
|
+
|
|
56
|
+
old_doc: Path
|
|
57
|
+
new_doc: Path
|
|
58
|
+
output_path: Path | None = None
|
|
59
|
+
|
|
60
|
+
def run(self) -> None:
|
|
61
|
+
"""Run the document comparison command."""
|
|
62
|
+
if platform.system() != "Windows":
|
|
63
|
+
logger.error("This tool is only available on Windows.")
|
|
64
|
+
return
|
|
65
|
+
if not self.old_doc.exists():
|
|
66
|
+
logger.error(f"Old file does not exist: {self.old_doc}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not self.new_doc.exists():
|
|
70
|
+
logger.error(f"New file does not exist: {self.new_doc}")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if not self.validate_files:
|
|
74
|
+
logger.error("Invalid file paths or extensions")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if self.word_app is None:
|
|
78
|
+
logger.error("Word application is not available")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if self.compare_data is None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
self.output.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
try:
|
|
86
|
+
self.compare_data.SaveAs2(str(self.output))
|
|
87
|
+
self.compare_data.Close()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.exception(f"Comparison failed: {e}")
|
|
90
|
+
else:
|
|
91
|
+
logger.info(f"Comparison completed. Saved to: {self.output}")
|
|
92
|
+
finally:
|
|
93
|
+
try:
|
|
94
|
+
self.word_app.Documents.Close(SaveChanges=False)
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.exception("Close document failed!")
|
|
97
|
+
else:
|
|
98
|
+
self.word_app.Quit()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
subprocess.run(
|
|
102
|
+
["taskkill", "/f", "/t", "/im", "WINWORD.EXE"], check=False
|
|
103
|
+
)
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("Taskkill failed!")
|
|
106
|
+
else:
|
|
107
|
+
logger.info("Taskkill completed successfully")
|
|
108
|
+
|
|
109
|
+
@cached_property
|
|
110
|
+
def word_app(self) -> Any:
|
|
111
|
+
try:
|
|
112
|
+
import win32com.client as win32 # type: ignore
|
|
113
|
+
except ImportError:
|
|
114
|
+
logger.exception("win32com.client is not installed, exiting.")
|
|
115
|
+
raise
|
|
116
|
+
else:
|
|
117
|
+
logger.info("Started Word application")
|
|
118
|
+
app = win32.gencache.EnsureDispatch("Word.Application") # type: ignore
|
|
119
|
+
app.Visible = False
|
|
120
|
+
app.DisplayAlerts = False
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
app.Options.TrackRevisions = conf.TRACK_REVISIONS
|
|
124
|
+
except AttributeError:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"TrackRevisions option not available in this Word version"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return app
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def validate_files(self) -> bool:
|
|
133
|
+
return all([
|
|
134
|
+
self.old_doc.exists(),
|
|
135
|
+
self.new_doc.exists(),
|
|
136
|
+
self.old_doc.suffix.lower() in [".doc", ".docx"],
|
|
137
|
+
self.new_doc.suffix.lower() in [".doc", ".docx"],
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
@cached_property
|
|
141
|
+
def compare_data(self) -> Any:
|
|
142
|
+
try:
|
|
143
|
+
compared = self.word_app.CompareDocuments(
|
|
144
|
+
self.old_doc_data,
|
|
145
|
+
self.new_doc_data,
|
|
146
|
+
0,
|
|
147
|
+
2 if conf.COMPARE_MODE == "revised" else 0,
|
|
148
|
+
True,
|
|
149
|
+
)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.exception(f"Comparison failed: {e}")
|
|
152
|
+
return None
|
|
153
|
+
else:
|
|
154
|
+
if compared:
|
|
155
|
+
logger.info("Comparison completed successfully")
|
|
156
|
+
compared.ShowRevisions = conf.SHOW_CHANGES
|
|
157
|
+
return compared
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
@cached_property
|
|
161
|
+
def old_doc_data(self) -> Any:
|
|
162
|
+
logger.info(f"Opening old file: {self.old_doc}")
|
|
163
|
+
return self.word_app.Documents.Open(str(self.old_doc.resolve()))
|
|
164
|
+
|
|
165
|
+
@cached_property
|
|
166
|
+
def new_doc_data(self) -> Any:
|
|
167
|
+
logger.info(f"Opening new file: {self.new_doc}")
|
|
168
|
+
return self.word_app.Documents.Open(str(self.new_doc.resolve()))
|
|
169
|
+
|
|
170
|
+
@cached_property
|
|
171
|
+
def output(self) -> Path:
|
|
172
|
+
"""Determine the output directory for the comparison result."""
|
|
173
|
+
output_filename = (
|
|
174
|
+
f"{conf.DOC_DIFF_TITLE}@{time.strftime('%Y%m%d_%H_%M_%S')}.docx"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if self.output_path is None:
|
|
178
|
+
output_dir = (
|
|
179
|
+
Path(conf.OUTPUT_DIR) if conf.OUTPUT_DIR else self.new_doc.parent
|
|
180
|
+
)
|
|
181
|
+
return output_dir / output_filename
|
|
182
|
+
|
|
183
|
+
if self.output_path.is_dir():
|
|
184
|
+
return self.output_path / output_filename
|
|
185
|
+
elif self.output_path.is_file():
|
|
186
|
+
return self.output_path
|
|
187
|
+
else:
|
|
188
|
+
raise ValueError(f"Invalid output path: {self.output_path}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_args():
|
|
192
|
+
parser = argparse.ArgumentParser(description="Compare two doc/docx files.")
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"files", nargs=2, help="Two input files to compare (old_file new_file)"
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"-o", "--output", dest="output", default=".", help="Output file path"
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument("--title", help="Title for the comparison result")
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--show-changes", action="store_true", help="Show changes in the comparison"
|
|
202
|
+
)
|
|
203
|
+
parser.add_argument(
|
|
204
|
+
"--hide-changes", action="store_true", help="Hide changes in the comparison"
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--compare-mode",
|
|
208
|
+
choices=["original", "revised"],
|
|
209
|
+
help="Compare mode: original or revised",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument("--output-dir", help="Output directory for the result file")
|
|
212
|
+
|
|
213
|
+
args = parser.parse_args()
|
|
214
|
+
|
|
215
|
+
# Update configuration from command line arguments
|
|
216
|
+
if args.title:
|
|
217
|
+
conf.DOC_DIFF_TITLE = args.title
|
|
218
|
+
if args.show_changes:
|
|
219
|
+
conf.SHOW_CHANGES = True
|
|
220
|
+
if args.hide_changes:
|
|
221
|
+
conf.SHOW_CHANGES = False
|
|
222
|
+
if args.compare_mode:
|
|
223
|
+
conf.COMPARE_MODE = args.compare_mode
|
|
224
|
+
if args.output_dir:
|
|
225
|
+
conf.OUTPUT_DIR = args.output_dir
|
|
226
|
+
|
|
227
|
+
return args
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main() -> None:
|
|
231
|
+
"""Compare two doc/docx files."""
|
|
232
|
+
args = parse_args()
|
|
233
|
+
|
|
234
|
+
DiffDocCommand(
|
|
235
|
+
Path(args.files[0]),
|
|
236
|
+
Path(args.files[1]),
|
|
237
|
+
Path(args.output),
|
|
238
|
+
).run()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Benchmark tests for docdiff module.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from sfi.docdiff.docdiff import DiffDocCommand, DocDiffConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestDocDiffBenchmark:
|
|
13
|
+
"""Benchmark tests for docdiff functionality."""
|
|
14
|
+
|
|
15
|
+
@pytest.mark.benchmark(group="config_operations")
|
|
16
|
+
def test_config_creation_benchmark(self, benchmark):
|
|
17
|
+
"""Benchmark configuration creation."""
|
|
18
|
+
result = benchmark(DocDiffConfig)
|
|
19
|
+
assert result is not None
|
|
20
|
+
|
|
21
|
+
@pytest.mark.benchmark(group="config_operations")
|
|
22
|
+
def test_config_save_benchmark(self, benchmark, tmp_path):
|
|
23
|
+
"""Benchmark configuration save operation."""
|
|
24
|
+
# Create a temporary config file
|
|
25
|
+
temp_config_file = tmp_path / "benchmark_config.json"
|
|
26
|
+
|
|
27
|
+
# Patch the global CONFIG_FILE
|
|
28
|
+
with patch("sfi.docdiff.docdiff.CONFIG_FILE", temp_config_file):
|
|
29
|
+
config = DocDiffConfig()
|
|
30
|
+
|
|
31
|
+
result = benchmark(config.save)
|
|
32
|
+
assert result is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_command_creation_benchmark(benchmark, tmp_path):
|
|
36
|
+
"""Benchmark DiffDocCommand creation."""
|
|
37
|
+
old_file = tmp_path / "old.docx"
|
|
38
|
+
new_file = tmp_path / "new.docx"
|
|
39
|
+
output_path = tmp_path / "output"
|
|
40
|
+
|
|
41
|
+
old_file.touch()
|
|
42
|
+
new_file.touch()
|
|
43
|
+
|
|
44
|
+
def create_command():
|
|
45
|
+
return DiffDocCommand(old_file, new_file, output_path)
|
|
46
|
+
|
|
47
|
+
result = benchmark(create_command)
|
|
48
|
+
assert result is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_full_workflow_benchmark(benchmark, tmp_path):
|
|
52
|
+
"""Benchmark the full workflow including config and command."""
|
|
53
|
+
|
|
54
|
+
def full_workflow():
|
|
55
|
+
# Create config
|
|
56
|
+
config = DocDiffConfig()
|
|
57
|
+
|
|
58
|
+
# Create dummy files
|
|
59
|
+
old_file = tmp_path / "old_bench.docx"
|
|
60
|
+
new_file = tmp_path / "new_bench.docx"
|
|
61
|
+
|
|
62
|
+
old_file.touch()
|
|
63
|
+
new_file.touch()
|
|
64
|
+
|
|
65
|
+
# Create command
|
|
66
|
+
command = DiffDocCommand(old_file, new_file)
|
|
67
|
+
|
|
68
|
+
# Mock the win32com import and Word operations
|
|
69
|
+
with patch.dict(
|
|
70
|
+
"sys.modules", {"win32com": MagicMock(), "win32com.client": MagicMock()}
|
|
71
|
+
):
|
|
72
|
+
import win32com.client as win32
|
|
73
|
+
|
|
74
|
+
with patch.object(win32.gencache, "EnsureDispatch") as mock_dispatch:
|
|
75
|
+
mock_word_app = MagicMock()
|
|
76
|
+
mock_dispatch.return_value = mock_word_app
|
|
77
|
+
|
|
78
|
+
mock_doc_old = MagicMock()
|
|
79
|
+
mock_doc_new = MagicMock()
|
|
80
|
+
mock_doc_compare = MagicMock()
|
|
81
|
+
|
|
82
|
+
# Configure the mocked documents
|
|
83
|
+
mock_word_app.Documents.Open.side_effect = [mock_doc_old, mock_doc_new]
|
|
84
|
+
mock_word_app.CompareDocuments.return_value = mock_doc_compare
|
|
85
|
+
|
|
86
|
+
# We can't call run() directly because of the complex property dependencies
|
|
87
|
+
# So we'll just test property access
|
|
88
|
+
try:
|
|
89
|
+
_ = command.validate_files
|
|
90
|
+
except:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
benchmark(full_workflow)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
# Run benchmark tests
|
|
98
|
+
pytest.main([__file__, "-v", "--benchmark-only", "--benchmark-sort=fullname"])
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from sfi.docdiff.docdiff import DiffDocCommand, DocDiffConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestDocDiffConfig:
|
|
11
|
+
"""Test DocDiffConfig functionality."""
|
|
12
|
+
|
|
13
|
+
def test_config_initialization_with_defaults(self):
|
|
14
|
+
"""Test that config initializes with default values."""
|
|
15
|
+
config = DocDiffConfig()
|
|
16
|
+
|
|
17
|
+
assert config.DOC_DIFF_TITLE == "Comparison Result"
|
|
18
|
+
assert str(Path.home()) == config.OUTPUT_DIR
|
|
19
|
+
assert config.COMPARE_MODE == "original"
|
|
20
|
+
assert config.SHOW_CHANGES is True
|
|
21
|
+
assert config.TRACK_REVISIONS is True
|
|
22
|
+
|
|
23
|
+
def test_config_save_and_load(self, tmp_path):
|
|
24
|
+
"""Test saving and loading config."""
|
|
25
|
+
# Create a temporary config file
|
|
26
|
+
temp_config_file = tmp_path / "docdiff_test.json"
|
|
27
|
+
|
|
28
|
+
# Patch the global CONFIG_FILE
|
|
29
|
+
with patch("sfi.docdiff.docdiff.CONFIG_FILE", temp_config_file):
|
|
30
|
+
# Create config and modify values
|
|
31
|
+
config = DocDiffConfig()
|
|
32
|
+
config.DOC_DIFF_TITLE = "Test Title"
|
|
33
|
+
config.OUTPUT_DIR = str(tmp_path / "output")
|
|
34
|
+
|
|
35
|
+
# Save config
|
|
36
|
+
config.save()
|
|
37
|
+
|
|
38
|
+
# Verify file was created
|
|
39
|
+
assert temp_config_file.exists()
|
|
40
|
+
|
|
41
|
+
# Create new config instance (should load from file)
|
|
42
|
+
new_config = DocDiffConfig()
|
|
43
|
+
assert new_config.DOC_DIFF_TITLE == "Test Title"
|
|
44
|
+
assert str(tmp_path / "output") == new_config.OUTPUT_DIR
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestDiffDocCommand:
|
|
48
|
+
"""Test DiffDocCommand functionality."""
|
|
49
|
+
|
|
50
|
+
def test_command_initialization(self, tmp_path):
|
|
51
|
+
"""Test DiffDocCommand initialization."""
|
|
52
|
+
old_file = tmp_path / "old.docx"
|
|
53
|
+
new_file = tmp_path / "new.docx"
|
|
54
|
+
output_path = tmp_path / "output"
|
|
55
|
+
|
|
56
|
+
command = DiffDocCommand(old_file, new_file, output_path)
|
|
57
|
+
|
|
58
|
+
assert command.old_doc == old_file
|
|
59
|
+
assert command.new_doc == new_file
|
|
60
|
+
assert command.output_path == output_path
|
|
61
|
+
|
|
62
|
+
def test_command_with_nonexistent_files(self, tmp_path, caplog):
|
|
63
|
+
"""Test DiffDocCommand with nonexistent files."""
|
|
64
|
+
old_file = tmp_path / "nonexistent_old.docx"
|
|
65
|
+
new_file = tmp_path / "nonexistent_new.docx"
|
|
66
|
+
|
|
67
|
+
command = DiffDocCommand(old_file, new_file)
|
|
68
|
+
command.run()
|
|
69
|
+
|
|
70
|
+
# Check that error messages were logged
|
|
71
|
+
assert "Old file does not exist" in caplog.text
|
|
72
|
+
|
|
73
|
+
def test_validate_files_property(self, tmp_path):
|
|
74
|
+
"""Test the validate_files property."""
|
|
75
|
+
old_file = tmp_path / "old.docx"
|
|
76
|
+
new_file = tmp_path / "new.docx"
|
|
77
|
+
|
|
78
|
+
# Create the files
|
|
79
|
+
old_file.touch()
|
|
80
|
+
new_file.touch()
|
|
81
|
+
|
|
82
|
+
command = DiffDocCommand(old_file, new_file)
|
|
83
|
+
|
|
84
|
+
# Since validate_files is a cached property, we can't easily patch it.
|
|
85
|
+
# Instead, we'll test the underlying logic by checking file extensions
|
|
86
|
+
assert old_file.suffix.lower() in [".doc", ".docx"]
|
|
87
|
+
assert new_file.suffix.lower() in [".doc", ".docx"]
|
|
88
|
+
assert old_file.exists()
|
|
89
|
+
assert new_file.exists()
|
|
90
|
+
|
|
91
|
+
# Test with invalid extension
|
|
92
|
+
bad_file = tmp_path / "bad.txt"
|
|
93
|
+
bad_file.touch()
|
|
94
|
+
|
|
95
|
+
# Create a new command with the bad file
|
|
96
|
+
bad_command = DiffDocCommand(bad_file, new_file)
|
|
97
|
+
# We can't easily test the property directly due to frozen dataclass,
|
|
98
|
+
# but we can test the logic
|
|
99
|
+
assert bad_file.suffix.lower() not in [".doc", ".docx"]
|
|
100
|
+
|
|
101
|
+
def test_command_missing_win32com(self, tmp_path, caplog):
|
|
102
|
+
"""Test DiffDocCommand when win32com is not available."""
|
|
103
|
+
old_file = tmp_path / "old.docx"
|
|
104
|
+
new_file = tmp_path / "new.docx"
|
|
105
|
+
|
|
106
|
+
# Create the files
|
|
107
|
+
old_file.touch()
|
|
108
|
+
new_file.touch()
|
|
109
|
+
|
|
110
|
+
# Mock platform and file validation to bypass early checks
|
|
111
|
+
with patch(
|
|
112
|
+
"sfi.docdiff.docdiff.platform.system", return_value="Windows"
|
|
113
|
+
), patch.object(Path, "exists", return_value=True):
|
|
114
|
+
command = DiffDocCommand(old_file, new_file)
|
|
115
|
+
|
|
116
|
+
# Temporarily remove win32com from sys.modules if present
|
|
117
|
+
original_win32com = sys.modules.get("win32com")
|
|
118
|
+
if "win32com" in sys.modules:
|
|
119
|
+
del sys.modules["win32com"]
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Try to access the word_app property which will trigger win32com import
|
|
123
|
+
with patch.dict("sys.modules", {"win32com.client": None}):
|
|
124
|
+
try:
|
|
125
|
+
_ = command.word_app # This should trigger the exception
|
|
126
|
+
except ImportError:
|
|
127
|
+
pass # Expected
|
|
128
|
+
except Exception:
|
|
129
|
+
pass # Other exceptions are OK too
|
|
130
|
+
finally:
|
|
131
|
+
# Restore original module if it existed
|
|
132
|
+
if original_win32com is not None:
|
|
133
|
+
sys.modules["win32com"] = original_win32com
|
|
134
|
+
|
|
135
|
+
def test_word_app_property_success(self, tmp_path):
|
|
136
|
+
"""Test word_app property when win32com is available."""
|
|
137
|
+
old_file = tmp_path / "old.docx"
|
|
138
|
+
new_file = tmp_path / "new.docx"
|
|
139
|
+
|
|
140
|
+
# Create the files
|
|
141
|
+
old_file.touch()
|
|
142
|
+
new_file.touch()
|
|
143
|
+
|
|
144
|
+
# Mock platform to bypass early checks
|
|
145
|
+
with patch(
|
|
146
|
+
"sfi.docdiff.docdiff.platform.system", return_value="Windows"
|
|
147
|
+
), patch.object(Path, "exists", return_value=True):
|
|
148
|
+
command = DiffDocCommand(old_file, new_file)
|
|
149
|
+
|
|
150
|
+
# Mock win32com
|
|
151
|
+
mock_win32 = MagicMock()
|
|
152
|
+
mock_app = MagicMock()
|
|
153
|
+
mock_win32.gencache.EnsureDispatch.return_value = mock_app
|
|
154
|
+
|
|
155
|
+
with patch.dict("sys.modules", {"win32com.client": mock_win32}):
|
|
156
|
+
# Access the property
|
|
157
|
+
app = command.word_app
|
|
158
|
+
|
|
159
|
+
# Verify the dispatch was called correctly
|
|
160
|
+
mock_win32.gencache.EnsureDispatch.assert_called_once_with(
|
|
161
|
+
"Word.Application"
|
|
162
|
+
)
|
|
163
|
+
assert app == mock_app
|
|
164
|
+
assert mock_app.Visible is False
|
|
165
|
+
assert mock_app.DisplayAlerts is False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TestIntegration:
|
|
169
|
+
"""Integration tests for docdiff functionality."""
|
|
170
|
+
|
|
171
|
+
def test_command_creation(self, tmp_path):
|
|
172
|
+
"""Test creating and using DiffDocCommand."""
|
|
173
|
+
old_file = tmp_path / "old.docx"
|
|
174
|
+
new_file = tmp_path / "new.docx"
|
|
175
|
+
output_file = tmp_path / "output.docx"
|
|
176
|
+
|
|
177
|
+
old_file.touch()
|
|
178
|
+
new_file.touch()
|
|
179
|
+
|
|
180
|
+
command = DiffDocCommand(old_file, new_file, output_file)
|
|
181
|
+
|
|
182
|
+
assert command.old_doc == old_file
|
|
183
|
+
assert command.new_doc == new_file
|
|
184
|
+
assert command.output_path == output_file
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
pytest.main([__file__, "-v"])
|