pseudonymize 0.2.1__tar.gz → 0.3.0__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.
Files changed (140) hide show
  1. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.agents/PRIVATE_RELEASE_PLAN.md +20 -1
  2. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/CHANGELOG.md +8 -0
  3. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/PKG-INFO +7 -1
  4. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/ROADMAP.md +2 -1
  5. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/pyproject.toml +15 -3
  6. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/document.py +47 -2
  7. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/engine.py +25 -3
  8. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/formats.py +8 -0
  9. pseudonymize-0.3.0/src/pseudonymize/inspection/__init__.py +4 -0
  10. pseudonymize-0.3.0/src/pseudonymize/inspection/office.py +112 -0
  11. pseudonymize-0.3.0/src/pseudonymize/inspection/pdf.py +61 -0
  12. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/compatibility/test_b1_contract.py +4 -0
  13. pseudonymize-0.3.0/tests/integration/test_inspection_adapters.py +141 -0
  14. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_document.py +18 -1
  15. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_formats.py +1 -1
  16. pseudonymize-0.3.0/tests/unit/test_inspection.py +60 -0
  17. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/uv.lock +439 -2
  18. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.editorconfig +0 -0
  19. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.gitattributes +0 -0
  20. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/CODEOWNERS +0 -0
  21. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  22. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  23. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/detector-request.yml +0 -0
  24. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/feature.yml +0 -0
  25. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/question.yml +0 -0
  26. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/PUBLICATION_CHECKLIST.md +0 -0
  27. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/dependabot.yml +0 -0
  28. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/pull_request_template.md +0 -0
  29. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/ci.yml +0 -0
  30. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/docs.yml +0 -0
  31. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/package.yml +0 -0
  32. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/release.yml +0 -0
  33. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.gitignore +0 -0
  34. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.pre-commit-config.yaml +0 -0
  35. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  36. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/CONTRIBUTING.md +0 -0
  37. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/GEMINI.md +0 -0
  38. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/LICENSE +0 -0
  39. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/README.md +0 -0
  40. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/SECURITY.md +0 -0
  41. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/SUPPORT.md +0 -0
  42. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/VISION.md +0 -0
  43. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/README.md +0 -0
  44. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/benchmark_detectors.py +0 -0
  45. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/benchmark_engine.py +0 -0
  46. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/datasets/synthetic_messages.jsonl +0 -0
  47. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0001-dependency-free-core.md +0 -0
  48. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0002-token-format.md +0 -0
  49. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0003-no-reversible-storage.md +0 -0
  50. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0004-optional-ml-backend.md +0 -0
  51. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/api.md +0 -0
  52. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/architecture.md +0 -0
  53. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/benchmarks.md +0 -0
  54. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/compatibility.md +0 -0
  55. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/contributing.md +0 -0
  56. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/deployment.md +0 -0
  57. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/detectors.md +0 -0
  58. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/index.md +0 -0
  59. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/limitations.md +0 -0
  60. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/llm-gateways.md +0 -0
  61. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/llm-payloads.md +0 -0
  62. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/migration-a2.md +0 -0
  63. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/migration-a3.md +0 -0
  64. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/policies.md +0 -0
  65. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/quickstart.md +0 -0
  66. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0.md +0 -0
  67. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0a2.md +0 -0
  68. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0a3.md +0 -0
  69. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0b1.md +0 -0
  70. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0rc1.md +0 -0
  71. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releasing.md +0 -0
  72. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/security.md +0 -0
  73. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/threat-model.md +0 -0
  74. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/examples/llm_gateway.py +0 -0
  75. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/mkdocs.yml +0 -0
  76. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/models/README.md +0 -0
  77. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/__init__.py +0 -0
  78. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/audit_install.py +0 -0
  79. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/smoke_wheel.py +0 -0
  80. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/verify_release.py +0 -0
  81. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/__init__.py +0 -0
  82. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/adapters.py +0 -0
  83. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/api.py +0 -0
  84. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/__init__.py +0 -0
  85. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/base.py +0 -0
  86. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/composite.py +0 -0
  87. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/ml/__init__.py +0 -0
  88. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/ml/onnx.py +0 -0
  89. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/rules.py +0 -0
  90. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/cli.py +0 -0
  91. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/__init__.py +0 -0
  92. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/base.py +0 -0
  93. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/email.py +0 -0
  94. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/iban.py +0 -0
  95. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/ip_address.py +0 -0
  96. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/payment_card.py +0 -0
  97. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/phone.py +0 -0
  98. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/registry.py +0 -0
  99. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/secret.py +0 -0
  100. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/url.py +0 -0
  101. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/exceptions.py +0 -0
  102. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/normalization.py +0 -0
  103. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/policy.py +0 -0
  104. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/processing.py +0 -0
  105. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/py.typed +0 -0
  106. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/resolution.py +0 -0
  107. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/result.py +0 -0
  108. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/spans.py +0 -0
  109. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/__init__.py +0 -0
  110. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/alias.py +0 -0
  111. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/base.py +0 -0
  112. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/hmac.py +0 -0
  113. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/mode.py +0 -0
  114. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/placeholder.py +0 -0
  115. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/redact.py +0 -0
  116. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/de.jsonl +0 -0
  117. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/en.jsonl +0 -0
  118. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/files.json +0 -0
  119. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/it.jsonl +0 -0
  120. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_builtin_files.py +0 -0
  121. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_cli.py +0 -0
  122. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_cross_platform_corpus.py +0 -0
  123. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_file_processing.py +0 -0
  124. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_llm_gateway_example.py +0 -0
  125. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_public_api.py +0 -0
  126. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_release_stress.py +0 -0
  127. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_release_verifier.py +0 -0
  128. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/property/test_invariants.py +0 -0
  129. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/backends/test_onnx.py +0 -0
  130. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/detectors/test_detectors.py +0 -0
  131. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_backends.py +0 -0
  132. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_cli_coverage.py +0 -0
  133. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_engine.py +0 -0
  134. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_modes.py +0 -0
  135. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_normalization.py +0 -0
  136. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_policy.py +0 -0
  137. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_processing.py +0 -0
  138. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_spans.py +0 -0
  139. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_transforms.py +0 -0
  140. {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_types.py +0 -0
@@ -109,7 +109,7 @@ Tag: v0.2.1
109
109
  PyPI upload verified: Pending workflow
110
110
  7-day metrics:
111
111
  28-day metrics:
112
- Status: Ready
112
+ Status: Released
113
113
 
114
114
  Implementation started: 2026-08-21
115
115
  Starting commit: 3205c22ef6143aa8ae38fa2db9eea64fc237777d
@@ -128,3 +128,22 @@ PyPI upload verified: Pending workflow
128
128
  28-day metrics:
129
129
  Status: Released
130
130
 
131
+ ### 0.3.0: Document inspection
132
+
133
+ Implementation started: 2026-08-22
134
+ Starting commit: 54a8b7d97ef1f61ebae722d902fa2d31797efb0e
135
+ Starting PyPI version: 0.2.1
136
+ Baseline downloads day/week/month: 0/0/0
137
+ Baseline stars/forks/watchers: 0/0/0
138
+ Files changed: 10
139
+ Compatibility tests added: Yes
140
+ Artifact smoke environments: Win local isolated wheel
141
+ Known limitations: Extract-only; no format-preserving rewriting yet.
142
+ Deferred items: Format-preserving rewrite deferred to 0.4.0.
143
+ Release commit: TBD
144
+ Tag: v0.3.0
145
+ PyPI upload verified: Pending workflow
146
+ 7-day metrics:
147
+ 28-day metrics:
148
+ Status: Ready
149
+
@@ -4,6 +4,14 @@ All notable changes follow Keep a Changelog and Semantic Versioning.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.0] - 2026-08-22
8
+
9
+ ### Added
10
+
11
+ - Added `pdf` extra (using `pdfminer.six`) for PDF text and coordinate extraction.
12
+ - Added `office` extra (using `python-docx`, `openpyxl`, `python-pptx`) for DOCX, XLSX, and PPTX inspection.
13
+ - Introduced `CoordinateLocation` and `StructuralLocation` for granular source tracking in complex file formats.
14
+
7
15
  ## [0.2.1] - 2026-08-22
8
16
 
9
17
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pseudonymize
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Local-first PII pseudonymization for text, structured data, and LLM payloads.
5
5
  Project-URL: Homepage, https://github.com/ma2za/pseudonymize
6
6
  Project-URL: Documentation, https://ma2za.github.io/pseudonymize/
@@ -28,6 +28,12 @@ Provides-Extra: ml
28
28
  Requires-Dist: huggingface-hub>=1.28.0; extra == 'ml'
29
29
  Requires-Dist: onnxruntime>=1.29.0; extra == 'ml'
30
30
  Requires-Dist: tokenizers>=0.23.1; extra == 'ml'
31
+ Provides-Extra: office
32
+ Requires-Dist: openpyxl>=3.1.0; extra == 'office'
33
+ Requires-Dist: python-docx>=1.1.0; extra == 'office'
34
+ Requires-Dist: python-pptx>=1.0.0; extra == 'office'
35
+ Provides-Extra: pdf
36
+ Requires-Dist: pdfminer-six>=20240706; extra == 'pdf'
31
37
  Description-Content-Type: text/markdown
32
38
 
33
39
  # Pseudonymize
@@ -13,7 +13,8 @@ publishable without requiring unfinished later layers.
13
13
  | `0.1.0b1` | Published | Core API freeze and production-oriented examples |
14
14
  | `0.1.0rc1` | Published | External installation and release validation |
15
15
  | `0.1.0` | Published | First stable text and machine-readable release |
16
- | `0.2.0` | Next | Optional local machine learning identification for PII |
16
+ | `0.2.0` | Published | Optional local machine learning identification for PII |
17
+ | `0.3.0` | Next | Document inspection (PDF, DOCX, XLSX, PPTX) |
17
18
 
18
19
  Alpha releases optimize for the cleanest safe architecture, not backward compatibility. They may
19
20
  remove, rename, or replace public APIs without aliases or shims. Material changes are documented,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pseudonymize"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Local-first PII pseudonymization for text, structured data, and LLM payloads."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -43,12 +43,20 @@ ml = [
43
43
  "onnxruntime>=1.29.0",
44
44
  "tokenizers>=0.23.1",
45
45
  ]
46
+ office = [
47
+ "openpyxl>=3.1.0",
48
+ "python-docx>=1.1.0",
49
+ "python-pptx>=1.0.0",
50
+ ]
51
+ pdf = [
52
+ "pdfminer.six>=20240706",
53
+ ]
46
54
 
47
55
  [tool.hatch.build.targets.wheel]
48
56
  packages = ["src/pseudonymize"]
49
57
 
50
58
  [dependency-groups]
51
- test = ["hypothesis>=6", "pytest>=8", "pytest-benchmark>=5", "pytest-cov>=5"]
59
+ test = ["hypothesis>=6", "pytest>=8", "pytest-benchmark>=5", "pytest-cov>=5", "fpdf2>=2.7.9"]
52
60
  quality = ["mypy>=1.11", "pre-commit>=4", "ruff>=0.9"]
53
61
  docs = ["mkdocs-material>=9", "mkdocstrings[python]>=1.0.6"]
54
62
  release = ["build>=1", "twine>=6"]
@@ -81,7 +89,11 @@ files = ["src", "tests", "scripts"]
81
89
  module = [
82
90
  "numpy.*",
83
91
  "onnxruntime.*",
84
- "tokenizers.*"
92
+ "tokenizers.*",
93
+ "pdfminer.*",
94
+ "docx.*",
95
+ "pptx.*",
96
+ "openpyxl.*"
85
97
  ]
86
98
  ignore_missing_imports = true
87
99
  follow_imports = "skip"
@@ -68,7 +68,43 @@ class CSVCellLocation:
68
68
  raise ValueError("CSV row and column indexes must be non-negative")
69
69
 
70
70
 
71
- SourceLocation: TypeAlias = TextOffsetLocation | JSONPathLocation | CSVCellLocation
71
+ @dataclass(frozen=True, slots=True)
72
+ class CoordinateLocation:
73
+ page: int
74
+ x0: float
75
+ y0: float
76
+ x1: float
77
+ y1: float
78
+
79
+ def __post_init__(self) -> None:
80
+ if not isinstance(self.page, int) or isinstance(self.page, bool) or self.page < 0:
81
+ raise ValueError("page must be a non-negative integer")
82
+ for val in (self.x0, self.y0, self.x1, self.y1):
83
+ if not isinstance(val, (int, float)) or isinstance(val, bool) or not isfinite(val):
84
+ raise TypeError("coordinates must be finite numbers")
85
+ if self.x0 > self.x1 or self.y0 > self.y1:
86
+ raise ValueError("coordinates must describe a valid bounding box")
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class StructuralLocation:
91
+ path: tuple[str | int, ...]
92
+
93
+ def __post_init__(self) -> None:
94
+ object.__setattr__(self, "path", tuple(self.path))
95
+ if any(not isinstance(part, (str, int)) or isinstance(part, bool) for part in self.path):
96
+ raise TypeError("structural path parts must be strings or integers")
97
+ if any(isinstance(part, int) and part < 0 for part in self.path):
98
+ raise ValueError("structural path indexes must be non-negative")
99
+
100
+
101
+ SourceLocation: TypeAlias = (
102
+ TextOffsetLocation
103
+ | JSONPathLocation
104
+ | CSVCellLocation
105
+ | CoordinateLocation
106
+ | StructuralLocation
107
+ )
72
108
 
73
109
 
74
110
  @dataclass(frozen=True, slots=True)
@@ -83,7 +119,16 @@ class ContentBlock:
83
119
  raise ValueError("block id must not be empty")
84
120
  if not isinstance(self.text, str):
85
121
  raise TypeError("block text must be a string")
86
- if not isinstance(self.location, (TextOffsetLocation, JSONPathLocation, CSVCellLocation)):
122
+ if not isinstance(
123
+ self.location,
124
+ (
125
+ TextOffsetLocation,
126
+ JSONPathLocation,
127
+ CSVCellLocation,
128
+ CoordinateLocation,
129
+ StructuralLocation,
130
+ ),
131
+ ):
87
132
  raise TypeError("block location must be a supported source location")
88
133
  object.__setattr__(self, "metadata", _metadata(self.metadata))
89
134
 
@@ -467,8 +467,20 @@ def _processing_adapters(
467
467
  output_adapter: OutputAdapter | None,
468
468
  ) -> tuple[InputAdapter[Path], OutputAdapter]:
469
469
  if input_adapter is None and output_adapter is None:
470
- adapter = BuiltinFileAdapter(select_file_format(source, format), encoding)
471
- return adapter, adapter
470
+ selected_format = select_file_format(source, format)
471
+ if selected_format == FileFormat.PDF:
472
+ from pseudonymize.inspection.pdf import PDFInspectionAdapter
473
+
474
+ pdf_adapter = PDFInspectionAdapter()
475
+ return pdf_adapter, pdf_adapter
476
+ elif selected_format in (FileFormat.DOCX, FileFormat.XLSX, FileFormat.PPTX):
477
+ from pseudonymize.inspection.office import OfficeInspectionAdapter
478
+
479
+ office_adapter = OfficeInspectionAdapter(selected_format)
480
+ return office_adapter, office_adapter
481
+ else:
482
+ builtin_adapter = BuiltinFileAdapter(selected_format, encoding)
483
+ return builtin_adapter, builtin_adapter
472
484
  if input_adapter is None or output_adapter is None:
473
485
  raise ValueError("custom file processing requires input and output adapters")
474
486
  if format is not None or encoding is not None:
@@ -483,7 +495,17 @@ def _inspection_adapter(
483
495
  input_adapter: InputAdapter[Path] | None,
484
496
  ) -> InputAdapter[Path]:
485
497
  if input_adapter is None:
486
- return BuiltinFileAdapter(select_file_format(source, format), encoding)
498
+ selected_format = select_file_format(source, format)
499
+ if selected_format == FileFormat.PDF:
500
+ from pseudonymize.inspection.pdf import PDFInspectionAdapter
501
+
502
+ return PDFInspectionAdapter()
503
+ elif selected_format in (FileFormat.DOCX, FileFormat.XLSX, FileFormat.PPTX):
504
+ from pseudonymize.inspection.office import OfficeInspectionAdapter
505
+
506
+ return OfficeInspectionAdapter(selected_format)
507
+ else:
508
+ return BuiltinFileAdapter(selected_format, encoding)
487
509
  if format is not None or encoding is not None:
488
510
  raise ValueError("custom adapters cannot be combined with format or encoding")
489
511
  return input_adapter
@@ -32,6 +32,10 @@ class FileFormat(StrEnum):
32
32
  JSON = "json"
33
33
  JSONL = "jsonl"
34
34
  CSV = "csv"
35
+ PDF = "pdf"
36
+ DOCX = "docx"
37
+ XLSX = "xlsx"
38
+ PPTX = "pptx"
35
39
 
36
40
 
37
41
  _SUFFIX_FORMATS = {
@@ -43,6 +47,10 @@ _SUFFIX_FORMATS = {
43
47
  ".jsonl": FileFormat.JSONL,
44
48
  ".ndjson": FileFormat.JSONL,
45
49
  ".csv": FileFormat.CSV,
50
+ ".pdf": FileFormat.PDF,
51
+ ".docx": FileFormat.DOCX,
52
+ ".xlsx": FileFormat.XLSX,
53
+ ".pptx": FileFormat.PPTX,
46
54
  }
47
55
  _CSV_LIMIT_LOCK = threading.Lock()
48
56
 
@@ -0,0 +1,4 @@
1
+ from pseudonymize.inspection.office import OfficeInspectionAdapter
2
+ from pseudonymize.inspection.pdf import PDFInspectionAdapter
3
+
4
+ __all__ = ["OfficeInspectionAdapter", "PDFInspectionAdapter"]
@@ -0,0 +1,112 @@
1
+ from pathlib import Path
2
+
3
+ from pseudonymize.document import ContentBlock, Document, StructuralLocation
4
+ from pseudonymize.exceptions import AdapterContractError, AdapterExecutionError
5
+
6
+ try:
7
+ import docx
8
+ import openpyxl
9
+ import pptx
10
+
11
+ HAS_OFFICE = True
12
+ except ImportError:
13
+ HAS_OFFICE = False
14
+
15
+
16
+ class OfficeInspectionAdapter:
17
+ """Extracts text from Office documents (DOCX, XLSX, PPTX) for inspection."""
18
+
19
+ def __init__(self, format_hint: str) -> None:
20
+ if not HAS_OFFICE:
21
+ raise RuntimeError(
22
+ "The Office adapter requires the 'office' extra. "
23
+ "Install it with `pip install pseudonymize[office]`."
24
+ )
25
+ self.format_hint = format_hint.lower()
26
+ if self.format_hint not in ("docx", "xlsx", "pptx"):
27
+ raise ValueError(f"Unsupported Office format hint: {format_hint}")
28
+
29
+ def extract(self, source: Path) -> Document:
30
+ blocks: list[ContentBlock] = []
31
+ try:
32
+ if self.format_hint == "docx":
33
+ self._extract_docx(source, blocks)
34
+ elif self.format_hint == "xlsx":
35
+ self._extract_xlsx(source, blocks)
36
+ elif self.format_hint == "pptx":
37
+ self._extract_pptx(source, blocks)
38
+ except Exception as e:
39
+ raise AdapterExecutionError(f"Failed to read Office document: {e}") from e
40
+
41
+ metadata = {"format": self.format_hint}
42
+ return Document("file", tuple(blocks), metadata)
43
+
44
+ def _extract_docx(self, source: Path, blocks: list[ContentBlock]) -> None:
45
+ doc = docx.Document(str(source))
46
+ for para_index, para in enumerate(doc.paragraphs):
47
+ text = para.text.strip()
48
+ if text:
49
+ blocks.append(
50
+ ContentBlock(
51
+ id=f"para-{para_index:06d}",
52
+ text=text,
53
+ location=StructuralLocation(("paragraph", para_index)),
54
+ )
55
+ )
56
+ # We can also extract tables if needed, for simplicity we stick to paragraphs and tables
57
+ for table_index, table in enumerate(doc.tables):
58
+ for row_index, row in enumerate(table.rows):
59
+ for col_index, cell in enumerate(row.cells):
60
+ text = cell.text.strip()
61
+ if text:
62
+ blocks.append(
63
+ ContentBlock(
64
+ id=f"table-{table_index:06d}-row-{row_index:06d}-col-{col_index:06d}",
65
+ text=text,
66
+ location=StructuralLocation(
67
+ ("table", table_index, "row", row_index, "column", col_index)
68
+ ),
69
+ )
70
+ )
71
+
72
+ def _extract_xlsx(self, source: Path, blocks: list[ContentBlock]) -> None:
73
+ wb = openpyxl.load_workbook(filename=source, data_only=True)
74
+ for sheet_index, sheet_name in enumerate(wb.sheetnames):
75
+ sheet = wb[sheet_name]
76
+ for row_index, row in enumerate(sheet.iter_rows(values_only=True)):
77
+ for col_index, cell_value in enumerate(row):
78
+ if cell_value is not None:
79
+ text = str(cell_value).strip()
80
+ if text:
81
+ blocks.append(
82
+ ContentBlock(
83
+ id=f"sheet-{sheet_index:06d}-row-{row_index:06d}-col-{col_index:06d}",
84
+ text=text,
85
+ location=StructuralLocation(
86
+ ("sheet", sheet_name, "row", row_index, "column", col_index)
87
+ ),
88
+ )
89
+ )
90
+
91
+ def _extract_pptx(self, source: Path, blocks: list[ContentBlock]) -> None:
92
+ prs = pptx.Presentation(str(source))
93
+ for slide_index, slide in enumerate(prs.slides):
94
+ for shape_index, shape in enumerate(slide.shapes):
95
+ if hasattr(shape, "text") and shape.text:
96
+ text = shape.text.strip()
97
+ if text:
98
+ blocks.append(
99
+ ContentBlock(
100
+ id=f"slide-{slide_index:06d}-shape-{shape_index:06d}",
101
+ text=text,
102
+ location=StructuralLocation(
103
+ ("slide", slide_index, "shape", shape_index)
104
+ ),
105
+ )
106
+ )
107
+
108
+ def render(self, document: Document) -> bytes:
109
+ raise AdapterContractError(
110
+ f"{self.format_hint.upper()} format-preserving rendering is not yet supported. "
111
+ "Office extraction is for inspection only."
112
+ )
@@ -0,0 +1,61 @@
1
+ from pathlib import Path
2
+
3
+ from pseudonymize.document import ContentBlock, CoordinateLocation, Document
4
+ from pseudonymize.exceptions import AdapterContractError, AdapterExecutionError
5
+
6
+ try:
7
+ from pdfminer.high_level import extract_pages
8
+ from pdfminer.layout import LTTextContainer
9
+
10
+ HAS_PDF = True
11
+ except ImportError:
12
+ HAS_PDF = False
13
+
14
+
15
+ class PDFInspectionAdapter:
16
+ """Extracts text from PDF documents for inspection-only pipelines."""
17
+
18
+ def __init__(self) -> None:
19
+ if not HAS_PDF:
20
+ raise RuntimeError(
21
+ "The PDF adapter requires the 'pdf' extra. "
22
+ "Install it with `pip install pseudonymize[pdf]`."
23
+ )
24
+
25
+ def extract(self, source: Path) -> Document:
26
+ blocks: list[ContentBlock] = []
27
+ try:
28
+ with source.open("rb") as f:
29
+ for page_index, page_layout in enumerate(extract_pages(f)):
30
+ for element in page_layout:
31
+ if isinstance(element, LTTextContainer):
32
+ text = element.get_text().strip()
33
+ if text:
34
+ # PDFMiner bounding box is (x0, y0, x1, y1)
35
+ # where y0 is distance from bottom
36
+ x0, y0, x1, y1 = element.bbox
37
+ location = CoordinateLocation(
38
+ page=page_index,
39
+ x0=float(x0),
40
+ y0=float(y0),
41
+ x1=float(x1),
42
+ y1=float(y1),
43
+ )
44
+ blocks.append(
45
+ ContentBlock(
46
+ id=f"page-{page_index}-box-{len(blocks):06d}",
47
+ text=text,
48
+ location=location,
49
+ )
50
+ )
51
+ except Exception as e:
52
+ raise AdapterExecutionError(f"Failed to read PDF: {e}") from e
53
+
54
+ metadata = {"format": "pdf"}
55
+ return Document("file", tuple(blocks), metadata)
56
+
57
+ def render(self, document: Document) -> bytes:
58
+ raise AdapterContractError(
59
+ "PDF format-preserving rendering is not yet supported. "
60
+ "PDF extraction is for inspection only."
61
+ )
@@ -117,6 +117,10 @@ def test_frozen_enums_and_data_models() -> None:
117
117
  "json",
118
118
  "jsonl",
119
119
  "csv",
120
+ "pdf",
121
+ "docx",
122
+ "xlsx",
123
+ "pptx",
120
124
  ]
121
125
  expected_fields = {
122
126
  Document: ("id", "blocks", "metadata"),
@@ -0,0 +1,141 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from pseudonymize.engine import Pseudonymizer
6
+ from pseudonymize.formats import FileFormat
7
+ from pseudonymize.policy import Policy
8
+
9
+ # Optional imports for generators
10
+ try:
11
+ import fpdf
12
+
13
+ HAS_FPDF = True
14
+ except ImportError:
15
+ HAS_FPDF = False
16
+
17
+ try:
18
+ import docx
19
+ import openpyxl
20
+ import pptx
21
+
22
+ HAS_OFFICE = True
23
+ except ImportError:
24
+ HAS_OFFICE = False
25
+
26
+ pytestmark = pytest.mark.integration
27
+
28
+
29
+ @pytest.fixture
30
+ def test_pdf_path(tmp_path: Path) -> Path:
31
+ if not HAS_FPDF:
32
+ pytest.skip("fpdf2 not installed")
33
+ path = tmp_path / "test.pdf"
34
+ pdf = fpdf.FPDF()
35
+ pdf.add_page()
36
+ pdf.set_font("helvetica", size=12)
37
+ pdf.cell(text="Contact me at alice@example.com for more info.")
38
+ pdf.ln()
39
+ pdf.cell(text=" ")
40
+ pdf.output(str(path))
41
+ return path
42
+
43
+
44
+ @pytest.fixture
45
+ def test_docx_path(tmp_path: Path) -> Path:
46
+ if not HAS_OFFICE:
47
+ pytest.skip("python-docx not installed")
48
+ path = tmp_path / "test.docx"
49
+ doc = docx.Document()
50
+ doc.add_paragraph("Hello, my email is bob@example.com.")
51
+ doc.add_paragraph(" ") # Empty text
52
+ table = doc.add_table(rows=2, cols=2)
53
+ table.rows[0].cells[0].text = "Header"
54
+ table.rows[0].cells[1].text = "Value"
55
+ table.rows[1].cells[0].text = "Data"
56
+ table.rows[1].cells[1].text = " " # Empty text
57
+ doc.save(str(path))
58
+ return path
59
+
60
+
61
+ @pytest.fixture
62
+ def test_xlsx_path(tmp_path: Path) -> Path:
63
+ if not HAS_OFFICE:
64
+ pytest.skip("openpyxl not installed")
65
+ path = tmp_path / "test.xlsx"
66
+ wb = openpyxl.Workbook()
67
+ ws = wb.active
68
+ ws["A1"] = "User Email"
69
+ ws["B1"] = "charlie@example.com"
70
+ ws["C1"] = None
71
+ ws["D1"] = " "
72
+ wb.save(path)
73
+ return path
74
+
75
+
76
+ @pytest.fixture
77
+ def test_pptx_path(tmp_path: Path) -> Path:
78
+ if not HAS_OFFICE:
79
+ pytest.skip("python-pptx not installed")
80
+ path = tmp_path / "test.pptx"
81
+ prs = pptx.Presentation()
82
+ slide = prs.slides.add_slide(prs.slide_layouts[0])
83
+ slide.shapes.title.text = "Presentation"
84
+ slide.placeholders[1].text = "Reach us at diana@example.com"
85
+ slide.shapes.add_shape(pptx.enum.shapes.MSO_SHAPE.RECTANGLE, 0, 0, 100, 100) # No text
86
+ tb = slide.shapes.add_textbox(0, 0, 100, 100)
87
+ tb.text = " " # Empty text
88
+ prs.save(str(path))
89
+ return path
90
+
91
+
92
+ def test_inspect_pdf(test_pdf_path: Path) -> None:
93
+ engine = Pseudonymizer(
94
+ policy=Policy.default(),
95
+ )
96
+ result = engine.inspect_file(test_pdf_path, format=FileFormat.PDF)
97
+ assert result.statistics.detections_found >= 1
98
+ assert any(d.entity_type == "EMAIL" for d in result.detections)
99
+
100
+
101
+ def test_inspect_docx(test_docx_path: Path) -> None:
102
+ engine = Pseudonymizer(
103
+ policy=Policy.default(),
104
+ )
105
+ result = engine.inspect_file(test_docx_path, format=FileFormat.DOCX)
106
+ assert result.statistics.detections_found >= 1
107
+ assert any(d.entity_type == "EMAIL" for d in result.detections)
108
+
109
+
110
+ def test_inspect_xlsx(test_xlsx_path: Path) -> None:
111
+ engine = Pseudonymizer(
112
+ policy=Policy.default(),
113
+ )
114
+ result = engine.inspect_file(test_xlsx_path, format=FileFormat.XLSX)
115
+ assert result.statistics.detections_found >= 1
116
+ assert any(d.entity_type == "EMAIL" for d in result.detections)
117
+
118
+
119
+ def test_inspect_pptx(test_pptx_path: Path) -> None:
120
+ engine = Pseudonymizer(
121
+ policy=Policy.default(),
122
+ )
123
+ result = engine.inspect_file(test_pptx_path, format=FileFormat.PPTX)
124
+ assert result.statistics.detections_found >= 1
125
+ assert any(d.entity_type == "EMAIL" for d in result.detections)
126
+
127
+
128
+ def test_process_pdf_fails(test_pdf_path: Path, tmp_path: Path) -> None:
129
+ engine = Pseudonymizer(policy=Policy.default())
130
+ from pseudonymize.exceptions import AdapterExecutionError
131
+
132
+ with pytest.raises(AdapterExecutionError, match="output adapter failed during rendering"):
133
+ engine.process_file(test_pdf_path, tmp_path / "out.pdf", format=FileFormat.PDF)
134
+
135
+
136
+ def test_process_docx_fails(test_docx_path: Path, tmp_path: Path) -> None:
137
+ engine = Pseudonymizer(policy=Policy.default())
138
+ from pseudonymize.exceptions import AdapterExecutionError
139
+
140
+ with pytest.raises(AdapterExecutionError, match="output adapter failed during rendering"):
141
+ engine.process_file(test_docx_path, tmp_path / "out.docx", format=FileFormat.DOCX)
@@ -4,11 +4,13 @@ from typing import Any, cast
4
4
 
5
5
  import pytest
6
6
 
7
- from pseudonymize import (
7
+ from pseudonymize.document import (
8
8
  ContentBlock,
9
+ CoordinateLocation,
9
10
  CSVCellLocation,
10
11
  Document,
11
12
  JSONPathLocation,
13
+ StructuralLocation,
12
14
  TextOffsetLocation,
13
15
  )
14
16
 
@@ -79,6 +81,9 @@ def test_location_values_are_immutable_and_validated() -> None:
79
81
  "content",
80
82
  )
81
83
  assert CSVCellLocation(0, 0).row == 0
84
+ assert CoordinateLocation(1, 0.0, 0.0, 10.0, 10.0).page == 1
85
+ assert StructuralLocation(("section", 1)).path == ("section", 1)
86
+
82
87
  with pytest.raises((TypeError, ValueError)):
83
88
  TextOffsetLocation(cast(int, True), 1)
84
89
  with pytest.raises(ValueError):
@@ -94,6 +99,18 @@ def test_location_values_are_immutable_and_validated() -> None:
94
99
  with pytest.raises(ValueError):
95
100
  CSVCellLocation(0, -1)
96
101
 
102
+ with pytest.raises(ValueError):
103
+ CoordinateLocation(-1, 0.0, 0.0, 1.0, 1.0)
104
+ with pytest.raises(TypeError):
105
+ CoordinateLocation(1, float("inf"), 0.0, 1.0, 1.0)
106
+ with pytest.raises(ValueError):
107
+ CoordinateLocation(1, 10.0, 10.0, 0.0, 0.0)
108
+
109
+ with pytest.raises(TypeError):
110
+ StructuralLocation(("doc", cast(Any, True)))
111
+ with pytest.raises(ValueError):
112
+ StructuralLocation(("doc", -1))
113
+
97
114
 
98
115
  def test_content_block_rejects_invalid_text_location_and_document_blocks() -> None:
99
116
  with pytest.raises(TypeError, match="text"):
@@ -44,7 +44,7 @@ def test_format_selection_rejects_unknown_values_without_guessing() -> None:
44
44
  with pytest.raises(UnsupportedFormatError, match="pass format explicitly"):
45
45
  select_file_format(Path("input.unknown"), None)
46
46
  with pytest.raises(UnsupportedFormatError, match="unsupported file format"):
47
- select_file_format(Path("input.txt"), "pdf")
47
+ select_file_format(Path("input.txt"), "unknown")
48
48
 
49
49
 
50
50
  @pytest.mark.parametrize("format", [FileFormat.TEXT, FileFormat.MARKDOWN, FileFormat.LOG])