purexml 1.0.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 (43) hide show
  1. purexml-1.0.0/LICENSE +21 -0
  2. purexml-1.0.0/PKG-INFO +158 -0
  3. purexml-1.0.0/README.md +124 -0
  4. purexml-1.0.0/pyproject.toml +87 -0
  5. purexml-1.0.0/setup.cfg +4 -0
  6. purexml-1.0.0/src/purexml/ElementTree.py +52 -0
  7. purexml-1.0.0/src/purexml/__init__.py +98 -0
  8. purexml-1.0.0/src/purexml/__main__.py +78 -0
  9. purexml-1.0.0/src/purexml/_expat_security.py +393 -0
  10. purexml-1.0.0/src/purexml/_parser.py +382 -0
  11. purexml-1.0.0/src/purexml/common.py +41 -0
  12. purexml-1.0.0/src/purexml/errors.py +123 -0
  13. purexml-1.0.0/src/purexml/expatreader.py +110 -0
  14. purexml-1.0.0/src/purexml/limits.py +81 -0
  15. purexml-1.0.0/src/purexml/minidom.py +176 -0
  16. purexml-1.0.0/src/purexml/py.typed +0 -0
  17. purexml-1.0.0/src/purexml/sax.py +69 -0
  18. purexml-1.0.0/src/purexml/xmlrpc.py +183 -0
  19. purexml-1.0.0/src/purexml.egg-info/PKG-INFO +158 -0
  20. purexml-1.0.0/src/purexml.egg-info/SOURCES.txt +41 -0
  21. purexml-1.0.0/src/purexml.egg-info/dependency_links.txt +1 -0
  22. purexml-1.0.0/src/purexml.egg-info/requires.txt +11 -0
  23. purexml-1.0.0/src/purexml.egg-info/top_level.txt +1 -0
  24. purexml-1.0.0/tests/test_attacks.py +71 -0
  25. purexml-1.0.0/tests/test_durability.py +49 -0
  26. purexml-1.0.0/tests/test_equivalence.py +73 -0
  27. purexml-1.0.0/tests/test_examples.py +45 -0
  28. purexml-1.0.0/tests/test_expat_security.py +63 -0
  29. purexml-1.0.0/tests/test_fo_contract.py +293 -0
  30. purexml-1.0.0/tests/test_fuzz_equivalence.py +233 -0
  31. purexml-1.0.0/tests/test_hardening_soak.py +142 -0
  32. purexml-1.0.0/tests/test_misc.py +105 -0
  33. purexml-1.0.0/tests/test_no_io.py +90 -0
  34. purexml-1.0.0/tests/test_public_contract.py +150 -0
  35. purexml-1.0.0/tests/test_v02_surface.py +187 -0
  36. purexml-1.0.0/tests/test_v03_iterparse.py +136 -0
  37. purexml-1.0.0/tests/test_v04_limits.py +182 -0
  38. purexml-1.0.0/tests/test_v05_security_report.py +221 -0
  39. purexml-1.0.0/tests/test_v07_cli.py +97 -0
  40. purexml-1.0.0/tests/test_v10_minidom.py +189 -0
  41. purexml-1.0.0/tests/test_v12_sax.py +196 -0
  42. purexml-1.0.0/tests/test_v13_xmlrpc.py +216 -0
  43. purexml-1.0.0/tests/test_v14_limits_breadth.py +130 -0
purexml-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Russell Pfister
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
purexml-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: purexml
3
+ Version: 1.0.0
4
+ Summary: Safely parse untrusted XML using only the Python standard library — a zero-dependency defusedxml replacement.
5
+ Author-email: Russell Pfister <russalo@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/russalo/purexml
8
+ Project-URL: Repository, https://github.com/russalo/purexml
9
+ Project-URL: Issues, https://github.com/russalo/purexml/issues
10
+ Keywords: xml,security,defusedxml,xxe,billion-laughs,stdlib,hardening
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Text Processing :: Markup :: XML
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Requires-Dist: defusedxml; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+ Requires-Dist: mypy; extra == "dev"
30
+ Provides-Extra: fuzz
31
+ Requires-Dist: atheris; extra == "fuzz"
32
+ Requires-Dist: defusedxml; extra == "fuzz"
33
+ Dynamic: license-file
34
+
35
+ <!-- Plain markdown image with an ABSOLUTE raw URL (not <picture>/<img>): the GitHub iOS
36
+ app doesn't render <picture>, and PyPI doesn't resolve relative paths — markdown ![]
37
+ with a raw.githubusercontent URL renders on all three (GitHub web + mobile, PyPI). -->
38
+ ![purexml logo](https://raw.githubusercontent.com/russalo/purexml/main/assets/logo-light.svg)
39
+
40
+ # purexml
41
+
42
+ > Safely parse untrusted XML using only the Python standard library.
43
+
44
+ [![tests](https://img.shields.io/github/actions/workflow/status/russalo/purexml/tests.yml?branch=main&label=tests)](https://github.com/russalo/purexml/actions/workflows/tests.yml)
45
+ ![Python 3.10–3.13](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)
46
+ ![runtime deps: zero](https://img.shields.io/badge/runtime%20deps-zero-brightgreen)
47
+ ![pure stdlib](https://img.shields.io/badge/pure-stdlib-blue)
48
+ ![coverage ≥90%](https://img.shields.io/badge/coverage-%E2%89%A590%25-brightgreen)
49
+ ![lint: ruff](https://img.shields.io/badge/lint-ruff-30173D)
50
+ ![types: mypy](https://img.shields.io/badge/types-mypy-blue)
51
+ ![differentially fuzzed](https://img.shields.io/badge/differentially-fuzzed-blueviolet)
52
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
53
+
54
+ **purexml is a zero-dependency, drop-in replacement for [`defusedxml`](https://pypi.org/project/defusedxml/).**
55
+ It hardens Python's standard-library XML parsers against the known attack classes —
56
+ entity-expansion bombs, external-entity resolution (XXE), and external-DTD retrieval —
57
+ and hands back the same standard `xml.etree` / `minidom` / SAX objects your code already
58
+ expects. Migrating is a literal find-and-replace: `s/defusedxml/purexml/`.
59
+
60
+ ![How purexml defends untrusted XML in two layers: purexml's own Python-layer handlers block entity bombs, XXE and external-DTD resolution version-independently, while the libexpat parser layer mitigates parser-level DoS depending on your runtime; the result is a standard, trusted xml.etree tree.](https://raw.githubusercontent.com/russalo/purexml/main/assets/defense-layers.svg)
61
+
62
+ ## Why purexml
63
+
64
+ - **Zero runtime dependencies.** Pure standard library — drop a third-party dependency
65
+ with no loss of protection (same parses succeed, same attacks blocked).
66
+ - **A real drop-in.** Mirrors `defusedxml`'s API and defaults across the surface the
67
+ ecosystem actually imports, validated **oracle-gated against `defusedxml` itself**.
68
+ - **Maintained.** `defusedxml` has been frozen since 2021 — the XML threat landscape
69
+ hasn't. purexml tracks the moving libexpat / CPython mitigations and can report what
70
+ your runtime is actually protected against.
71
+ - **Opt-in hardening.** Structural-DoS caps and a posture report that `defusedxml`
72
+ lacks — **off by default**, so the drop-in promise is never broken.
73
+
74
+ ## Install & migrate
75
+
76
+ Until purexml is published to PyPI, depend on it via a git or path reference. Then the
77
+ only change to your code is the import path:
78
+
79
+ ```python
80
+ # before: from defusedxml.ElementTree import fromstring
81
+ from purexml.ElementTree import fromstring
82
+
83
+ root = fromstring(untrusted_xml) # raises on bomb / XXE / external DTD; returns a standard Element
84
+ ```
85
+
86
+ Defaults match `defusedxml` exactly (`forbid_entities=True`, `forbid_external=True`,
87
+ `forbid_dtd=False`), so nothing else changes.
88
+
89
+ > Runnable examples for every surface: **[`examples/`](examples/)** ·
90
+ > Module-by-module migration guide: **[`docs/MIGRATING.md`](docs/MIGRATING.md)**
91
+
92
+ ## What it covers
93
+
94
+ Drop-in replacements for the `defusedxml` modules the ecosystem imports:
95
+
96
+ | purexml module | replaces | surface |
97
+ |---|---|---|
98
+ | `purexml.ElementTree` | `defusedxml.ElementTree` | `fromstring`, `parse`, `iterparse`, `XML`, `XMLParser`, `tostring`, `forbid_*` |
99
+ | `purexml.minidom` | `defusedxml.minidom` | `parse`, `parseString` |
100
+ | `purexml.sax` / `.expatreader` | `defusedxml.sax` | `make_parser`, `parse`, `parseString` |
101
+ | `purexml.xmlrpc` | `defusedxml.xmlrpc` | `monkey_patch`, `unmonkey_patch` |
102
+ | `purexml.common` | `defusedxml.common` | exception catch-site aliases |
103
+
104
+ `defusedxml.pulldom` is deferred (low measured demand); `defusedxml.lxml` is excluded by
105
+ the zero-dependency contract (it wraps the third-party `lxml`).
106
+
107
+ ## Opt-in defense-in-depth
108
+
109
+ Bounded protections `defusedxml` never had — **default-off**, so the strict mirror is
110
+ unchanged until you ask:
111
+
112
+ ```python
113
+ import purexml
114
+ from purexml import fromstring, RECOMMENDED_LIMITS
115
+
116
+ # Bound structural DoS (deep nesting / attribute floods / giant documents):
117
+ root = fromstring(untrusted, limits=RECOMMENDED_LIMITS) # raises LimitExceeded past the caps
118
+
119
+ # See what THIS runtime is actually protected against (read-only; no parse):
120
+ print(purexml.security_report())
121
+ ```
122
+
123
+ There's also a posture CLI — `python -m purexml` (`--json` for machine output, or
124
+ `--check --min-expat X.Y.Z` as an opt-in CI gate).
125
+
126
+ ## Status
127
+
128
+ **v1.0.0 — the public contract is frozen and binding.** The `defusedxml`-mirror surface
129
+ won't move under you without a 2.0 (see [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md)).
130
+
131
+ - Runs on **CPython 3.10–3.13**, zero runtime dependencies.
132
+ - Correctness is **oracle-gated against `defusedxml` every release** — C14N + event-stream
133
+ equivalence over a real corpus, an adversarial attack battery, and differential fuzzing
134
+ (see [`docs/EQUIVALENCE.md`](docs/EQUIVALENCE.md)).
135
+ - The contract freeze was ratified with the anchor consumer (file-observer) — see
136
+ [`docs/v1.0.0_RFC_Specification.md`](docs/v1.0.0_RFC_Specification.md). The opt-in
137
+ defense-in-depth (`Limits`, `security_report()`) stays **provisional** (it tracks the
138
+ moving libexpat threat landscape).
139
+ - **Not yet published to PyPI**, and the `purexml` name is not yet claimed — distribution
140
+ is a separate, deliberate step. Depend on it via git/path until then.
141
+ - **License: MIT.**
142
+
143
+ ## Documentation
144
+
145
+ - **[`examples/`](examples/)** — runnable, copy-paste examples for every surface
146
+ - **[`docs/MIGRATING.md`](docs/MIGRATING.md)** — the `s/defusedxml/purexml/` migration guide
147
+ - [`COMPATIBILITY.md`](COMPATIBILITY.md) — drop-in compatibility contract + exception edge cases
148
+ - [`LIMITATIONS.md`](LIMITATIONS.md) — what purexml deliberately does **not** do
149
+ - [`SECURITY.md`](SECURITY.md) — security policy and how to report issues
150
+ - [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md) — consumer stability commitments (binds at 1.0)
151
+ - [`CHANGELOG.md`](CHANGELOG.md) — release notes · [`HISTORY.md`](HISTORY.md) — full per-release record
152
+ - [`STACK.md`](STACK.md) — language, runtime, dependencies · [`CONVENTIONS.md`](CONVENTIONS.md) — project conventions
153
+
154
+ ## License
155
+
156
+ [MIT](LICENSE) — purexml is give-it-away, zero-dependency infrastructure: pure open
157
+ source, maximum reuse. Publishing to PyPI and claiming the `purexml` name remain a
158
+ separate, deliberate call.
@@ -0,0 +1,124 @@
1
+ <!-- Plain markdown image with an ABSOLUTE raw URL (not <picture>/<img>): the GitHub iOS
2
+ app doesn't render <picture>, and PyPI doesn't resolve relative paths — markdown ![]
3
+ with a raw.githubusercontent URL renders on all three (GitHub web + mobile, PyPI). -->
4
+ ![purexml logo](https://raw.githubusercontent.com/russalo/purexml/main/assets/logo-light.svg)
5
+
6
+ # purexml
7
+
8
+ > Safely parse untrusted XML using only the Python standard library.
9
+
10
+ [![tests](https://img.shields.io/github/actions/workflow/status/russalo/purexml/tests.yml?branch=main&label=tests)](https://github.com/russalo/purexml/actions/workflows/tests.yml)
11
+ ![Python 3.10–3.13](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)
12
+ ![runtime deps: zero](https://img.shields.io/badge/runtime%20deps-zero-brightgreen)
13
+ ![pure stdlib](https://img.shields.io/badge/pure-stdlib-blue)
14
+ ![coverage ≥90%](https://img.shields.io/badge/coverage-%E2%89%A590%25-brightgreen)
15
+ ![lint: ruff](https://img.shields.io/badge/lint-ruff-30173D)
16
+ ![types: mypy](https://img.shields.io/badge/types-mypy-blue)
17
+ ![differentially fuzzed](https://img.shields.io/badge/differentially-fuzzed-blueviolet)
18
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
19
+
20
+ **purexml is a zero-dependency, drop-in replacement for [`defusedxml`](https://pypi.org/project/defusedxml/).**
21
+ It hardens Python's standard-library XML parsers against the known attack classes —
22
+ entity-expansion bombs, external-entity resolution (XXE), and external-DTD retrieval —
23
+ and hands back the same standard `xml.etree` / `minidom` / SAX objects your code already
24
+ expects. Migrating is a literal find-and-replace: `s/defusedxml/purexml/`.
25
+
26
+ ![How purexml defends untrusted XML in two layers: purexml's own Python-layer handlers block entity bombs, XXE and external-DTD resolution version-independently, while the libexpat parser layer mitigates parser-level DoS depending on your runtime; the result is a standard, trusted xml.etree tree.](https://raw.githubusercontent.com/russalo/purexml/main/assets/defense-layers.svg)
27
+
28
+ ## Why purexml
29
+
30
+ - **Zero runtime dependencies.** Pure standard library — drop a third-party dependency
31
+ with no loss of protection (same parses succeed, same attacks blocked).
32
+ - **A real drop-in.** Mirrors `defusedxml`'s API and defaults across the surface the
33
+ ecosystem actually imports, validated **oracle-gated against `defusedxml` itself**.
34
+ - **Maintained.** `defusedxml` has been frozen since 2021 — the XML threat landscape
35
+ hasn't. purexml tracks the moving libexpat / CPython mitigations and can report what
36
+ your runtime is actually protected against.
37
+ - **Opt-in hardening.** Structural-DoS caps and a posture report that `defusedxml`
38
+ lacks — **off by default**, so the drop-in promise is never broken.
39
+
40
+ ## Install & migrate
41
+
42
+ Until purexml is published to PyPI, depend on it via a git or path reference. Then the
43
+ only change to your code is the import path:
44
+
45
+ ```python
46
+ # before: from defusedxml.ElementTree import fromstring
47
+ from purexml.ElementTree import fromstring
48
+
49
+ root = fromstring(untrusted_xml) # raises on bomb / XXE / external DTD; returns a standard Element
50
+ ```
51
+
52
+ Defaults match `defusedxml` exactly (`forbid_entities=True`, `forbid_external=True`,
53
+ `forbid_dtd=False`), so nothing else changes.
54
+
55
+ > Runnable examples for every surface: **[`examples/`](examples/)** ·
56
+ > Module-by-module migration guide: **[`docs/MIGRATING.md`](docs/MIGRATING.md)**
57
+
58
+ ## What it covers
59
+
60
+ Drop-in replacements for the `defusedxml` modules the ecosystem imports:
61
+
62
+ | purexml module | replaces | surface |
63
+ |---|---|---|
64
+ | `purexml.ElementTree` | `defusedxml.ElementTree` | `fromstring`, `parse`, `iterparse`, `XML`, `XMLParser`, `tostring`, `forbid_*` |
65
+ | `purexml.minidom` | `defusedxml.minidom` | `parse`, `parseString` |
66
+ | `purexml.sax` / `.expatreader` | `defusedxml.sax` | `make_parser`, `parse`, `parseString` |
67
+ | `purexml.xmlrpc` | `defusedxml.xmlrpc` | `monkey_patch`, `unmonkey_patch` |
68
+ | `purexml.common` | `defusedxml.common` | exception catch-site aliases |
69
+
70
+ `defusedxml.pulldom` is deferred (low measured demand); `defusedxml.lxml` is excluded by
71
+ the zero-dependency contract (it wraps the third-party `lxml`).
72
+
73
+ ## Opt-in defense-in-depth
74
+
75
+ Bounded protections `defusedxml` never had — **default-off**, so the strict mirror is
76
+ unchanged until you ask:
77
+
78
+ ```python
79
+ import purexml
80
+ from purexml import fromstring, RECOMMENDED_LIMITS
81
+
82
+ # Bound structural DoS (deep nesting / attribute floods / giant documents):
83
+ root = fromstring(untrusted, limits=RECOMMENDED_LIMITS) # raises LimitExceeded past the caps
84
+
85
+ # See what THIS runtime is actually protected against (read-only; no parse):
86
+ print(purexml.security_report())
87
+ ```
88
+
89
+ There's also a posture CLI — `python -m purexml` (`--json` for machine output, or
90
+ `--check --min-expat X.Y.Z` as an opt-in CI gate).
91
+
92
+ ## Status
93
+
94
+ **v1.0.0 — the public contract is frozen and binding.** The `defusedxml`-mirror surface
95
+ won't move under you without a 2.0 (see [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md)).
96
+
97
+ - Runs on **CPython 3.10–3.13**, zero runtime dependencies.
98
+ - Correctness is **oracle-gated against `defusedxml` every release** — C14N + event-stream
99
+ equivalence over a real corpus, an adversarial attack battery, and differential fuzzing
100
+ (see [`docs/EQUIVALENCE.md`](docs/EQUIVALENCE.md)).
101
+ - The contract freeze was ratified with the anchor consumer (file-observer) — see
102
+ [`docs/v1.0.0_RFC_Specification.md`](docs/v1.0.0_RFC_Specification.md). The opt-in
103
+ defense-in-depth (`Limits`, `security_report()`) stays **provisional** (it tracks the
104
+ moving libexpat threat landscape).
105
+ - **Not yet published to PyPI**, and the `purexml` name is not yet claimed — distribution
106
+ is a separate, deliberate step. Depend on it via git/path until then.
107
+ - **License: MIT.**
108
+
109
+ ## Documentation
110
+
111
+ - **[`examples/`](examples/)** — runnable, copy-paste examples for every surface
112
+ - **[`docs/MIGRATING.md`](docs/MIGRATING.md)** — the `s/defusedxml/purexml/` migration guide
113
+ - [`COMPATIBILITY.md`](COMPATIBILITY.md) — drop-in compatibility contract + exception edge cases
114
+ - [`LIMITATIONS.md`](LIMITATIONS.md) — what purexml deliberately does **not** do
115
+ - [`SECURITY.md`](SECURITY.md) — security policy and how to report issues
116
+ - [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md) — consumer stability commitments (binds at 1.0)
117
+ - [`CHANGELOG.md`](CHANGELOG.md) — release notes · [`HISTORY.md`](HISTORY.md) — full per-release record
118
+ - [`STACK.md`](STACK.md) — language, runtime, dependencies · [`CONVENTIONS.md`](CONVENTIONS.md) — project conventions
119
+
120
+ ## License
121
+
122
+ [MIT](LICENSE) — purexml is give-it-away, zero-dependency infrastructure: pure open
123
+ source, maximum reuse. Publishing to PyPI and claiming the `purexml` name remain a
124
+ separate, deliberate call.
@@ -0,0 +1,87 @@
1
+ [build-system]
2
+ requires = ["setuptools>=65", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "purexml"
7
+ version = "1.0.0"
8
+ description = "Safely parse untrusted XML using only the Python standard library — a zero-dependency defusedxml replacement."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ # MIT (decided 2026-06-28) — pure open source, maximum reuse; one-way compatible with
12
+ # AGPL so it's clean as a future file-observer dependency. See LICENSE. Table form
13
+ # (`{text="MIT"}`) + classifier is used rather than the bare SPDX `license = "MIT"`
14
+ # string: the string form needs setuptools>=77 and (per PEP 639) deprecates the license
15
+ # classifier — this form keeps the classifier (which PyPI's UI surfaces) with no warning
16
+ # on the current setuptools>=65 floor.
17
+ license = {text = "MIT"}
18
+ authors = [
19
+ {name = "Russell Pfister", email = "russalo@gmail.com"},
20
+ ]
21
+ keywords = ["xml", "security", "defusedxml", "xxe", "billion-laughs", "stdlib", "hardening"]
22
+ classifiers = [
23
+ "Development Status :: 5 - Production/Stable",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Intended Audience :: Developers",
26
+ "Topic :: Security",
27
+ "Topic :: Text Processing :: Markup :: XML",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ ]
34
+ # Start empty. Add real runtime deps as the project grows; prefer stdlib first.
35
+ dependencies = []
36
+
37
+ [project.optional-dependencies]
38
+ # defusedxml is the dev/test ORACLE only (RFC §3/§5) — never a runtime dependency,
39
+ # never imported under src/. It is the incumbent purexml is validated against.
40
+ # ruff (lint) + pytest-cov (coverage gate) are dev-only quality tooling, run in CI;
41
+ # never runtime deps. (mypy is intentionally NOT here yet — see the typed-badge note
42
+ # in scratch/publish_prep_checklist.md: the surface needs annotating first.)
43
+ dev = ["pytest", "defusedxml", "ruff", "pytest-cov", "mypy"]
44
+
45
+ # Coverage-guided differential fuzzing (v0.5 RFC §3/§4). DEV-ONLY, opt-in — Atheris
46
+ # is heavier tooling (libFuzzer/clang) for on-demand deep sweeps; the always-run
47
+ # seeded differential gate (tests/test_fuzz_equivalence.py) needs NO extra. Atheris
48
+ # is NEVER a runtime dependency and is never imported under src/.
49
+ fuzz = ["atheris", "defusedxml"]
50
+
51
+ # URLs PROVISIONAL — repo home not confirmed (adoption model open; may be vendored
52
+ # rather than published). russalo convention path shown; revisit when publishing is decided.
53
+ [project.urls]
54
+ Homepage = "https://github.com/russalo/purexml"
55
+ Repository = "https://github.com/russalo/purexml"
56
+ Issues = "https://github.com/russalo/purexml/issues"
57
+
58
+ # If/when this project ships a CLI, declare it here:
59
+ # [project.scripts]
60
+ # purexml = "purexml.cli:main"
61
+
62
+ [tool.setuptools.packages.find]
63
+ where = ["src"]
64
+
65
+ # Ship the PEP 561 marker so downstream type-checkers consume purexml's types (v0.8).
66
+ [tool.setuptools.package-data]
67
+ purexml = ["py.typed"]
68
+
69
+ [tool.pytest.ini_options]
70
+ # Only collect the project's own tests. Prevents `pytest` from walking the repo
71
+ # root and importing test files inside scratch/ or any harvested corpora — that
72
+ # both errors on collection AND writes __pycache__/*.pyc into shared dirs.
73
+ testpaths = ["tests"]
74
+ pythonpath = ["src"]
75
+
76
+ [tool.ruff]
77
+ target-version = "py310"
78
+ line-length = 100
79
+
80
+ [tool.coverage.run]
81
+ source = ["purexml"]
82
+ # CI runs: pytest --cov=purexml --cov-fail-under=90 (enforced floor; currently ~94%).
83
+
84
+ [tool.mypy]
85
+ files = ["src/purexml"]
86
+ python_version = "3.10"
87
+ strict = true # v0.8.1: full strict — internals annotated, Any leaks resolved.
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
1
+ """purexml.ElementTree — canonical namespace mirroring ``defusedxml.ElementTree``.
2
+
3
+ The migration off defusedxml is a literal ``s/defusedxml/purexml/`` for the
4
+ implemented surface::
5
+
6
+ # was: from defusedxml.ElementTree import fromstring, parse, XML, XMLParser
7
+ from purexml.ElementTree import fromstring, parse, XML, XMLParser
8
+
9
+ As of v0.3 this covers defusedxml.ElementTree's full surface: ``fromstring``,
10
+ ``parse``, ``iterparse``, ``XML``, ``XMLParser`` (+ ``XMLParse``/``XMLTreeBuilder``
11
+ aliases), ``tostring``, ``ParseError``.
12
+
13
+ ``ParseError`` and ``tostring`` are re-exported from the stdlib exactly as
14
+ defusedxml does. ``Element`` is intentionally NOT re-exported (defusedxml doesn't
15
+ either); import it from ``xml.etree.ElementTree`` if needed. ``fromstringlist`` is
16
+ a stdlib-parity extra beyond defusedxml's surface.
17
+ """
18
+ from xml.etree.ElementTree import ParseError, tostring
19
+
20
+ from ._parser import XMLParser, fromstring, fromstringlist, iterparse, parse
21
+ from .errors import (
22
+ DTDForbidden,
23
+ EntitiesForbidden,
24
+ ExternalReferenceForbidden,
25
+ PureXMLError,
26
+ )
27
+
28
+ #: ``XML`` is defusedxml's/stdlib's alias for ``fromstring``.
29
+ XML = fromstring
30
+
31
+ #: defusedxml back-compat aliases for the parser class.
32
+ XMLParse = XMLTreeBuilder = XMLParser
33
+
34
+ __all__ = [
35
+ # mirrors defusedxml.ElementTree's __all__ (for the implemented surface)
36
+ "ParseError",
37
+ "XML",
38
+ "XMLParse",
39
+ "XMLParser",
40
+ "XMLTreeBuilder",
41
+ "fromstring",
42
+ "parse",
43
+ "iterparse",
44
+ "tostring",
45
+ # stdlib-parity extra (not in defusedxml)
46
+ "fromstringlist",
47
+ # purexml exception hierarchy
48
+ "PureXMLError",
49
+ "DTDForbidden",
50
+ "EntitiesForbidden",
51
+ "ExternalReferenceForbidden",
52
+ ]
@@ -0,0 +1,98 @@
1
+ """purexml — safely parse untrusted XML using only the Python standard library.
2
+
3
+ A maintained, zero-dependency, stdlib-only replacement for ``defusedxml``: returns
4
+ standard ``xml.etree`` objects while blocking the known XML attack classes
5
+ (entity-expansion bombs, XXE, external reference resolution), behaviorally
6
+ equivalent to defusedxml's defaults.
7
+
8
+ The canonical namespace mirrors ``defusedxml.ElementTree`` (migration is a literal
9
+ ``s/defusedxml/purexml/``)::
10
+
11
+ from purexml.ElementTree import fromstring, parse, XML, XMLParser
12
+
13
+ The common entry points are also re-exported at the top level for convenience::
14
+
15
+ from purexml import fromstring
16
+ root = fromstring("<r><a>x</a></r>") # raises on bomb / XXE / malformed
17
+ """
18
+ from xml.etree.ElementTree import ParseError, tostring
19
+
20
+ from . import ElementTree, common, expatreader, minidom, sax, xmlrpc
21
+ from ._expat_security import (
22
+ BLOCKED,
23
+ EXPAT_MITIGATED,
24
+ EXPAT_PARTIAL,
25
+ EXPAT_VERSION,
26
+ LIVE,
27
+ OPT_IN,
28
+ RECOMMENDED_EXPAT_VERSION,
29
+ SAFE_EXPAT_VERSION,
30
+ SecurityReport,
31
+ assert_expat_secure,
32
+ expat_is_secure,
33
+ security_report,
34
+ )
35
+ from ._parser import XMLParser, fromstring, fromstringlist, iterparse, parse
36
+ from .errors import (
37
+ AttributesExceeded,
38
+ DepthExceeded,
39
+ DTDForbidden,
40
+ EntitiesForbidden,
41
+ ExternalReferenceForbidden,
42
+ LimitExceeded,
43
+ NotSupportedError,
44
+ PureXMLError,
45
+ SizeExceeded,
46
+ )
47
+ from .limits import RECOMMENDED_LIMITS, Limits
48
+
49
+ XML = fromstring
50
+
51
+ __version__ = "1.0.0"
52
+
53
+ __all__ = [
54
+ # the ElementTree family (also at purexml.ElementTree)
55
+ "ElementTree",
56
+ "fromstring",
57
+ "parse",
58
+ "iterparse",
59
+ "fromstringlist",
60
+ "XML",
61
+ "XMLParser",
62
+ "tostring",
63
+ "ParseError",
64
+ # other defusedxml-surface modules — import-compatible submodules
65
+ "minidom", # v0.10
66
+ "common", # v0.10
67
+ "sax", # v0.12
68
+ "expatreader", # v0.12 (sax engine)
69
+ "xmlrpc", # v0.13 (monkeypatch shim — lazy)
70
+ # exception hierarchy
71
+ "PureXMLError",
72
+ "DTDForbidden",
73
+ "EntitiesForbidden",
74
+ "ExternalReferenceForbidden",
75
+ "NotSupportedError",
76
+ # opt-in structural-DoS limits (v0.4 mirror-plus)
77
+ "Limits",
78
+ "RECOMMENDED_LIMITS",
79
+ "LimitExceeded",
80
+ "DepthExceeded",
81
+ "AttributesExceeded",
82
+ "SizeExceeded",
83
+ # libexpat version awareness (v0.1.2) — opt-in
84
+ "EXPAT_VERSION",
85
+ "SAFE_EXPAT_VERSION",
86
+ "RECOMMENDED_EXPAT_VERSION",
87
+ "expat_is_secure",
88
+ "assert_expat_secure",
89
+ # security-posture report (v0.5 trust surface) — read-only introspection
90
+ "security_report",
91
+ "SecurityReport",
92
+ "BLOCKED",
93
+ "EXPAT_MITIGATED",
94
+ "EXPAT_PARTIAL",
95
+ "OPT_IN",
96
+ "LIVE",
97
+ "__version__",
98
+ ]
@@ -0,0 +1,78 @@
1
+ """CLI: ``python -m purexml`` — report this runtime's XML-security posture.
2
+
3
+ Read-only introspection over `purexml.security_report()`. **Informs by default
4
+ (exit 0)**; ``--check`` is the *caller* opting into a CI gate (the inform-by-default
5
+ version-assertion stance). This module is the package's one **I/O boundary** — it
6
+ prints, and imports ``argparse``/``json``/``sys`` on top of purexml — but nothing
7
+ that reaches the network / filesystem / subprocess. `tests/test_no_io` enforces both
8
+ halves: the parse surface stays strict stdlib-`xml`, and this file may add only those
9
+ CLI-output modules, never a forbidden one.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+ from . import __version__, security_report
18
+ from . import _expat_security as _es
19
+
20
+
21
+ def _ver(t: tuple[int, ...]) -> str:
22
+ return ".".join(map(str, t))
23
+
24
+
25
+ def _build_parser() -> argparse.ArgumentParser:
26
+ p = argparse.ArgumentParser(
27
+ prog="python -m purexml",
28
+ description="Report this runtime's XML-security posture (purexml).")
29
+ p.add_argument("--json", action="store_true",
30
+ help="emit the posture as JSON (machine-readable; PROVISIONAL shape)")
31
+ p.add_argument("--check", action="store_true",
32
+ help="exit non-zero if libexpat is below the floor (opt-in CI gate)")
33
+ p.add_argument("--min-expat", metavar="X.Y.Z", default=None,
34
+ help="floor for --check (default: the recommended-latest floor)")
35
+ p.add_argument("--version", action="store_true",
36
+ help="print purexml and libexpat versions, then exit")
37
+ return p
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ parser = _build_parser()
42
+ args = parser.parse_args(argv)
43
+
44
+ # --min-expat is meaningless without --check; silently ignoring it would let a
45
+ # user who meant to gate (`--min-expat X`) get a silent exit-0 pass — a security
46
+ # footgun in CI. Fail loudly instead (PR#15 Gemini).
47
+ if args.min_expat is not None and not args.check:
48
+ parser.error("--min-expat requires --check")
49
+
50
+ if args.version:
51
+ print("purexml %s (libexpat %s)" % (__version__, _ver(_es.EXPAT_VERSION)))
52
+ return 0
53
+
54
+ report = security_report()
55
+ if args.json:
56
+ print(json.dumps({"purexml_version": __version__, **report.as_dict()}))
57
+ else:
58
+ print(report)
59
+
60
+ if args.check:
61
+ if args.min_expat is not None:
62
+ try:
63
+ floor = _es._as_version_tuple(args.min_expat)
64
+ except (ValueError, TypeError):
65
+ parser.error("--min-expat must be a version like 2.8.1, got %r"
66
+ % args.min_expat)
67
+ else:
68
+ floor = _es.RECOMMENDED_EXPAT_VERSION
69
+ if _es.EXPAT_VERSION < floor:
70
+ print("FAIL: libexpat %s is below the required floor %s"
71
+ % (_ver(_es.EXPAT_VERSION), _ver(floor)), file=sys.stderr)
72
+ return 1
73
+
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())