Skip to content

Commit 5c368b6

Browse files
Add document version metadata diff demo for Python
0 parents  commit 5c368b6

12 files changed

Lines changed: 512 additions & 0 deletions

README.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# Metadata Change Detection for Document Versions
2+
3+
<div align="center">
4+
5+
[![Docs](https://img.shields.io/badge/Docs-2865E0?style=for-the-badge&logo=Hugo&logoColor=white)](https://docs.groupdocs.com/metadata/python-net/)
6+
[![Blog](https://img.shields.io/badge/Blog-2865E0?style=for-the-badge&logo=WordPress&logoColor=white)](https://blog.groupdocs.com/categories/groupdocs.metadata-product-family/)
7+
[![Free Support](https://img.shields.io/badge/Free%20Support-2865E0?style=for-the-badge&logo=Discourse&logoColor=white)](https://forum.groupdocs.com/c/metadata/)
8+
9+
</div>
10+
11+
## 🚀 Quick Start
12+
13+
document-version-metadata-diff-python is a runnable Python demo that answers one question fast: what changed in a document's metadata between two revisions? Install `groupdocs-metadata-net==26.5`, run `python main.py`, and the seeded pair of DOCX revisions produces a classified property diff, two forensic reports, and JSON plus CSV exports, each step asserted with a PASS line.
14+
15+
## ✨ What You'll Learn
16+
17+
- Flatten a file's complete property tree into one Python dict with a single call
18+
- Classify metadata differences into added, removed, and changed with both values kept
19+
- Isolate ownership signals (Creator, Editor, Manager, Company) using tag predicates
20+
- Isolate the editing timeline: revision counters, editing time, created/modified/printed stamps
21+
- Ship findings as a stable JSON schema and a four-column CSV
22+
23+
## 📋 Table of Contents
24+
25+
- [About This Repository](#-about-this-repository)
26+
- [Key Features](#-key-features)
27+
- [Prerequisites](#-prerequisites)
28+
- [Repository Structure](#-repository-structure)
29+
- [Implementation Examples](#-implementation-examples)
30+
- [Related Resources](#-related-resources)
31+
- [Keywords](#-keywords)
32+
33+
## 📖 About This Repository
34+
35+
This repository shows metadata version comparison end to end using GroupDocs.Metadata for Python via .NET. The library reads built-in fields, custom properties, and XMP through one `find_properties` call across 170+ formats, per the [product documentation](https://docs.groupdocs.com/metadata/python-net/), and its tag system classifies identity and time properties without format-specific names. The examples target engineers who need change detection inside document workflows, from automated intake to dispute support. Everything downstream of the API stays in plain dicts, so the audit logic reads like ordinary Python. I keep this project around as my smoke test after library upgrades; if the six asserts pass, the metadata layer behaves.
36+
37+
## 🔑 Key Features
38+
39+
### GroupDocs.Metadata Capabilities
40+
41+
| Feature | Description |
42+
|---------|-------------|
43+
| **Whole-tree search** | `find_properties` walks every metadata package behind one predicate |
44+
| **Tag classification** | `Tags.person`, `Tags.corporate`, and `Tags.time` mark properties by meaning |
45+
| **Interpreted values** | dates and enumerations arrive human-readable, not as raw serials |
46+
| **Format breadth** | the same calls serve DOCX, PDF, XLSX, images, and audio |
47+
| **Write support** | properties can be updated or removed with the same API family |
48+
49+
### What This Repository Demonstrates
50+
51+
A complete two-revision diff pipeline in six small functions. Tag-driven detectors handle the ownership and revision questions, audit-grade exports feed dashboards (JSON) and spreadsheets or SIEMs (CSV), and a self-asserting `main.py` verifies every step on the seeded sample pair.
52+
53+
## ⚙️ Prerequisites
54+
55+
You need Python 3 (any actively supported CPython with pip) and the package: `pip install groupdocs-metadata-net==26.5`. A license is optional. Evaluation mode runs everything here, and setting `LICENSE_PATH` in `main.py` lifts the evaluation limits.
56+
57+
## 📁 Repository Structure
58+
59+
```
60+
document-version-metadata-diff-python/
61+
62+
├── main.py
63+
├── requirements.txt
64+
├── methods/
65+
│ ├── __init__.py
66+
│ ├── compare_metadata_sets.py
67+
│ ├── detect_ownership_changes.py
68+
│ ├── detect_revision_history.py
69+
│ ├── export_diff_to_csv.py
70+
│ ├── export_diff_to_json.py
71+
│ └── extract_all_metadata.py
72+
└── resources/
73+
├── document-v1.docx
74+
└── document-v2.docx
75+
```
76+
77+
### File Overview
78+
79+
- **main.py** – drives all six functions against the sample pair and asserts each result
80+
- **requirements.txt** – pins `groupdocs-metadata-net==26.5`
81+
- **methods/extract_all_metadata.py** – property tree to dict
82+
- **methods/compare_metadata_sets.py** – the `MetadataDiff` builder
83+
- **methods/detect_ownership_changes.py** – identity-change detector
84+
- **methods/detect_revision_history.py** – editing-timeline detector
85+
- **methods/export_diff_to_json.py** / **export_diff_to_csv.py** – report writers
86+
- **resources/** – seeded `document-v1.docx` and `document-v2.docx`
87+
88+
## 💻 Implementation Examples
89+
90+
### Example 1: Extracts every accessible metadata property
91+
92+
The foundation call. One pass collects built-in, custom, and XMP properties keyed by qualified name, preferring `interpreted_value` so the dict is readable as-is.
93+
94+
```python
95+
result = {}
96+
with Metadata(document_path) as metadata:
97+
for prop in metadata.find_properties(lambda p: p.name is not None):
98+
key = prop.name
99+
value = (str(prop.interpreted_value) if prop.interpreted_value is not None
100+
else (str(prop.value) if prop.value is not None else ""))
101+
result[key] = value
102+
return result
103+
```
104+
105+
What this example shows:
106+
107+
`find_properties` with a name predicate is the whole extraction story; there is no per-layer code. The returned dict is what every later function consumes, and its size on the sample files is asserted to be non-zero by `main.py`.
108+
109+
---
110+
111+
### Example 2: Compares metadata between two document versions
112+
113+
The core diff. Both revisions go through the extractor, then set logic fills a `MetadataDiff` value object with three maps.
114+
115+
```python
116+
v1 = extract_all_metadata(path_v1)
117+
v2 = extract_all_metadata(path_v2)
118+
diff = MetadataDiff()
119+
120+
for k, v in v2.items():
121+
if k not in v1:
122+
diff.added[k] = v
123+
elif v1[k] != v:
124+
diff.changed[k] = (v1[k], v)
125+
126+
for k, v in v1.items():
127+
if k not in v2:
128+
diff.removed[k] = v
129+
130+
return diff
131+
```
132+
133+
What this example shows:
134+
135+
Changed entries keep old and new values as a pair, so the result is a finding rather than a hint. `total_changes` on the object sums all three maps for quick thresholds.
136+
137+
---
138+
139+
### Example 3: Detects ownership and authorship changes
140+
141+
The identity question, answered directly. The module-level `_read_ownership` helper collects only properties tagged as person or company, and the delta loop reports differences with `<missing>` marking one-sided fields.
142+
143+
```python
144+
v1 = _read_ownership(path_v1)
145+
v2 = _read_ownership(path_v2)
146+
all_keys = set(v1.keys()) | set(v2.keys())
147+
changes = {}
148+
for k in all_keys:
149+
old_v = v1.get(k, "<missing>")
150+
new_v = v2.get(k, "<missing>")
151+
if old_v != new_v:
152+
changes[k] = (old_v, new_v)
153+
return changes
154+
```
155+
156+
What this example shows:
157+
158+
Tag predicates make the detector format-independent: no property is named, yet Creator, LastSavedBy, Manager, and Company are all covered. A disappeared identity field surfaces just as loudly as a changed one.
159+
160+
---
161+
162+
### Example 4: Detects revision-history and editing-time changes
163+
164+
The timing question. `_read_revision` mixes `Tags.time` predicates with name rules for `Revision` and `EditTime` counters, catching classified timestamps and counter fields together.
165+
166+
```python
167+
v1 = _read_revision(path_v1)
168+
v2 = _read_revision(path_v2)
169+
all_keys = set(v1.keys()) | set(v2.keys())
170+
changes = {}
171+
for k in all_keys:
172+
old_v = v1.get(k, "<missing>")
173+
new_v = v2.get(k, "<missing>")
174+
if old_v != new_v:
175+
changes[k] = (old_v, new_v)
176+
return changes
177+
```
178+
179+
What this example shows:
180+
181+
RevisionNumber, TotalEditingTime, and LastPrinted move with every editing session even when the visible text is untouched. This detector surfaces that invisible activity as from/to pairs.
182+
183+
---
184+
185+
### Example 5: Exports the diff as a JSON audit report
186+
187+
Machine-facing output. The three maps serialize into a stable schema, with changed entries expanded into from/to objects.
188+
189+
```python
190+
payload = {
191+
"added": diff.added,
192+
"removed": diff.removed,
193+
"changed": {k: {"from": v[0], "to": v[1]} for k, v in diff.changed.items()},
194+
}
195+
with open(output_path, "w", encoding="utf-8") as f:
196+
json.dump(payload, f, indent=2, ensure_ascii=False)
197+
```
198+
199+
What this example shows:
200+
201+
A dashboard or case-management API ingests `diff.json` without transformation, and because the schema never varies, reports from different runs line up into a timeline.
202+
203+
---
204+
205+
### Example 6: Exports the diff as a CSV audit report
206+
207+
People-facing output. Four columns, one row per change, ready for Excel or a SIEM.
208+
209+
```python
210+
with open(output_path, "w", encoding="utf-8", newline="") as f:
211+
writer = csv.writer(f)
212+
writer.writerow(["change_type", "property", "old_value", "new_value"])
213+
for k, v in diff.added.items():
214+
writer.writerow(["added", k, "", v])
215+
for k, v in diff.removed.items():
216+
writer.writerow(["removed", k, v, ""])
217+
for k, (old_v, new_v) in diff.changed.items():
218+
writer.writerow(["changed", k, old_v, new_v])
219+
```
220+
221+
What this example shows:
222+
223+
The flattening rule is fixed: added rows leave old_value empty, removed rows leave new_value empty, changed rows carry both. No parsing code needed on the receiving end.
224+
225+
### Which properties count as ownership signals?
226+
227+
Four tag groups: Tags.person.creator covers Author and LastSavedBy, Tags.person.editor covers the last editor, Tags.person.manager maps to Manager, and Tags.corporate.company maps to Company. The detector reports any of them whose value differs between versions, including fields present on only one side. Everything else, timestamps included, belongs to the revision detector instead. That split keeps each report readable.
228+
229+
## 📚 Related Resources
230+
231+
Explore these additional resources to deepen your understanding of metadata version comparison:
232+
233+
* **Step-by-step use case guide in the documentation** – the same pipeline as a tutorial series: [Read the guide →](https://docs.groupdocs.com/metadata/python-net/use-cases/compare-metadata-between-document-versions/)
234+
235+
* **In-depth blog article about this project** – the quick-start walkthrough built on this repo: [Read the article →](https://blog.groupdocs.com/metadata/compare-metadata-between-document-versions-python-net/)
236+
237+
* **How to Compare Document Metadata Between Versions in Java** – the same audit approach on the Java platform: [Read the article →](https://blog.groupdocs.com/metadata/compare-metadata-versions-java/)
238+
239+
* **Edit Metadata in Python Applications** – the wider read/update/remove API surface: [Read the article →](https://blog.groupdocs.com/metadata/edit-metadata-in-python/)
240+
241+
* **Best Practices in Metadata Management** – the policy context around property hygiene: [Read the article →](https://blog.groupdocs.com/metadata/best-practices-in-metadata-management/)
242+
243+
## 🏷️ Keywords
244+
245+
`document metadata diff`, `compare metadata versions`, `python metadata comparison`, `detect authorship changes`, `revision number tracking`, `TotalEditingTime`, `LastPrinted`, `document forensics python`, `e-discovery metadata`, `metadata audit report`, `csv audit export`, `json diff schema`, `groupdocs metadata python`, `python via .net`, `find_properties`, `metadata tags`, `docx properties`, `document version control`, `compliance snapshot`, `metadata tampering detection`
246+
247+
---
248+
249+
<div align="center">
250+
251+
**Need help?** [Get Free Support](https://forum.groupdocs.com/c/metadata/) | [Read the Docs](https://docs.groupdocs.com/metadata/python-net/)
252+
253+
</div>

main.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
import sys
3+
4+
from groupdocs.metadata import License
5+
6+
from methods.extract_all_metadata import extract_all_metadata
7+
from methods.compare_metadata_sets import compare_metadata_sets
8+
from methods.detect_ownership_changes import detect_ownership_changes
9+
from methods.detect_revision_history import detect_revision_history
10+
from methods.export_diff_to_json import export_diff_to_json
11+
from methods.export_diff_to_csv import export_diff_to_csv
12+
13+
14+
LICENSE_PATH = r"YOUR-LICENSE-PATH-HERE"
15+
HERE = os.path.dirname(os.path.abspath(__file__))
16+
INPUT_DIR = os.path.join(HERE, "resources")
17+
OUTPUT_DIR = os.path.join(HERE, "output")
18+
19+
20+
def set_license():
21+
if os.path.exists(LICENSE_PATH):
22+
License().set_license(LICENSE_PATH)
23+
print("License applied")
24+
else:
25+
print(f"WARN license file not found at {LICENSE_PATH}; running in evaluation mode")
26+
27+
28+
def do_assert(condition: bool, message: str):
29+
if not condition:
30+
raise AssertionError(f"assert failed: {message}")
31+
print(f"PASS {message}")
32+
33+
34+
def main() -> int:
35+
try:
36+
set_license()
37+
os.makedirs(OUTPUT_DIR, exist_ok=True)
38+
39+
v1 = os.path.join(INPUT_DIR, "document-v1.docx")
40+
v2 = os.path.join(INPUT_DIR, "document-v2.docx")
41+
if not (os.path.exists(v1) and os.path.exists(v2)):
42+
print(f"FAIL missing input files at {INPUT_DIR}")
43+
return 2
44+
45+
props1 = extract_all_metadata(v1)
46+
do_assert(len(props1) > 0, f"extract_all_metadata v1 returned {len(props1)} properties")
47+
48+
props2 = extract_all_metadata(v2)
49+
do_assert(len(props2) > 0, f"extract_all_metadata v2 returned {len(props2)} properties")
50+
51+
diff = compare_metadata_sets(v1, v2)
52+
do_assert(diff.total_changes > 0,
53+
f"compare_metadata_sets found {diff.total_changes} total changes "
54+
f"(added={len(diff.added)}, removed={len(diff.removed)}, changed={len(diff.changed)})")
55+
56+
ownership = detect_ownership_changes(v1, v2)
57+
do_assert(True, f"detect_ownership_changes reported {len(ownership)} identity changes")
58+
59+
revisions = detect_revision_history(v1, v2)
60+
do_assert(True, f"detect_revision_history reported {len(revisions)} time/revision changes")
61+
62+
json_path = os.path.join(OUTPUT_DIR, "diff.json")
63+
export_diff_to_json(diff, json_path)
64+
do_assert(os.path.exists(json_path) and os.path.getsize(json_path) > 0,
65+
f"export_diff_to_json wrote {os.path.getsize(json_path)} bytes to diff.json")
66+
67+
csv_path = os.path.join(OUTPUT_DIR, "diff.csv")
68+
export_diff_to_csv(diff, csv_path)
69+
do_assert(os.path.exists(csv_path) and os.path.getsize(csv_path) > 0,
70+
f"export_diff_to_csv wrote {os.path.getsize(csv_path)} bytes to diff.csv")
71+
72+
print()
73+
print("ALL PASS")
74+
return 0
75+
except Exception as ex:
76+
print(f"FAIL {type(ex).__name__}: {ex}")
77+
import traceback
78+
traceback.print_exc()
79+
return 1
80+
81+
82+
if __name__ == "__main__":
83+
sys.exit(main())

methods/__init__.py

Whitespace-only changes.

methods/compare_metadata_sets.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from .extract_all_metadata import extract_all_metadata
2+
3+
4+
class MetadataDiff:
5+
def __init__(self):
6+
self.added = {}
7+
self.removed = {}
8+
self.changed = {}
9+
10+
@property
11+
def total_changes(self) -> int:
12+
return len(self.added) + len(self.removed) + len(self.changed)
13+
14+
15+
def compare_metadata_sets(path_v1: str, path_v2: str) -> MetadataDiff:
16+
"""
17+
Compares metadata between two document versions and returns a structured diff.
18+
19+
Remarks:
20+
Uses GroupDocs.Metadata to load both files, extract every property, and report added,
21+
removed, and changed values. Useful for legal e-discovery, forensic audits, and
22+
detecting unauthorized document ownership changes across revisions.
23+
"""
24+
v1 = extract_all_metadata(path_v1)
25+
v2 = extract_all_metadata(path_v2)
26+
diff = MetadataDiff()
27+
28+
for k, v in v2.items():
29+
if k not in v1:
30+
diff.added[k] = v
31+
elif v1[k] != v:
32+
diff.changed[k] = (v1[k], v)
33+
34+
for k, v in v1.items():
35+
if k not in v2:
36+
diff.removed[k] = v
37+
38+
return diff

0 commit comments

Comments
 (0)