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.
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.agents/PRIVATE_RELEASE_PLAN.md +20 -1
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/CHANGELOG.md +8 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/PKG-INFO +7 -1
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/ROADMAP.md +2 -1
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/pyproject.toml +15 -3
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/document.py +47 -2
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/engine.py +25 -3
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/formats.py +8 -0
- pseudonymize-0.3.0/src/pseudonymize/inspection/__init__.py +4 -0
- pseudonymize-0.3.0/src/pseudonymize/inspection/office.py +112 -0
- pseudonymize-0.3.0/src/pseudonymize/inspection/pdf.py +61 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/compatibility/test_b1_contract.py +4 -0
- pseudonymize-0.3.0/tests/integration/test_inspection_adapters.py +141 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_document.py +18 -1
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_formats.py +1 -1
- pseudonymize-0.3.0/tests/unit/test_inspection.py +60 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/uv.lock +439 -2
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.editorconfig +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.gitattributes +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/CODEOWNERS +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/detector-request.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/feature.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/ISSUE_TEMPLATE/question.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/PUBLICATION_CHECKLIST.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/dependabot.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/pull_request_template.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/ci.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/docs.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/package.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.github/workflows/release.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.gitignore +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/.pre-commit-config.yaml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/CONTRIBUTING.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/GEMINI.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/LICENSE +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/README.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/SECURITY.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/SUPPORT.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/VISION.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/README.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/benchmark_detectors.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/benchmark_engine.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/benchmarks/datasets/synthetic_messages.jsonl +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0001-dependency-free-core.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0002-token-format.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0003-no-reversible-storage.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/adr/0004-optional-ml-backend.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/api.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/architecture.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/benchmarks.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/compatibility.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/contributing.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/deployment.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/detectors.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/index.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/limitations.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/llm-gateways.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/llm-payloads.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/migration-a2.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/migration-a3.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/policies.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/quickstart.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0a2.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0a3.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0b1.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releases/0.1.0rc1.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/releasing.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/security.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/docs/threat-model.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/examples/llm_gateway.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/mkdocs.yml +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/models/README.md +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/__init__.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/audit_install.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/smoke_wheel.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/scripts/verify_release.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/__init__.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/adapters.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/api.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/__init__.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/base.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/composite.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/ml/__init__.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/ml/onnx.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/backends/rules.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/cli.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/__init__.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/base.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/email.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/iban.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/ip_address.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/payment_card.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/phone.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/registry.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/secret.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/detectors/url.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/exceptions.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/normalization.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/policy.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/processing.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/py.typed +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/resolution.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/result.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/spans.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/__init__.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/alias.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/base.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/hmac.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/mode.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/placeholder.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/src/pseudonymize/transforms/redact.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/de.jsonl +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/en.jsonl +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/files.json +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/corpus/it.jsonl +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_builtin_files.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_cli.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_cross_platform_corpus.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_file_processing.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_llm_gateway_example.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_public_api.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_release_stress.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/integration/test_release_verifier.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/property/test_invariants.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/backends/test_onnx.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/detectors/test_detectors.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_backends.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_cli_coverage.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_engine.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_modes.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_normalization.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_policy.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_processing.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_spans.py +0 -0
- {pseudonymize-0.2.1 → pseudonymize-0.3.0}/tests/unit/test_transforms.py +0 -0
- {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:
|
|
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.
|
|
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` |
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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,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
|
+
)
|
|
@@ -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"), "
|
|
47
|
+
select_file_format(Path("input.txt"), "unknown")
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
@pytest.mark.parametrize("format", [FileFormat.TEXT, FileFormat.MARKDOWN, FileFormat.LOG])
|