Skip to content

Commit 03e2d0f

Browse files
committed
Let xml.sax initialize pyexpat-backed parsers
RustPython already exposes an Expat-like XML parser, but xml.sax expects a few setup APIs before parsing. Add compatible parser setup methods and constants without pretending to implement full Expat external entity behavior. Constraint: Lib/test changes only remove expectedFailure markers for tests that now pass; test bodies and assertions are unchanged. Rejected: Implementing full Expat parameter entity parsing | xml-rs does not expose that configuration and the immediate failure is the missing setup API. Confidence: high Scope-risk: narrow Directive: Keep SetParamEntityParsing as a compatibility shim unless the backend grows real Expat-style entity parsing support. Tested: PATH=/tmp/pyshim:$PATH prek run --all-files Tested: cargo run --quiet -- extra_tests/snippets/stdlib_xml.py Tested: cargo run --release -- -m test test_sax Tested: cargo run --release -- -m test test_pyexpat Tested: cargo test --workspace --exclude rustpython-capi --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --features threading --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env && (cd crates/capi && cargo test) Tested: PYO3_CONFIG_FILE=$PWD/crates/capi/pyo3-rustpython.config cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher Tested: cargo build --release --features sqlite && cd extra_tests && /tmp/rustpython-extra-tests-venv/bin/python -m pytest -v Tested: cargo clippy --workspace --exclude rustpython-capi --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --features threading --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env Assisted-by: Codex:gpt-5.5
1 parent 3f33073 commit 03e2d0f

3 files changed

Lines changed: 82 additions & 9 deletions

File tree

Lib/test/test_sax.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,6 @@ def test_parse_bytes(self):
190190
with self.assertRaises(SAXException):
191191
self.check_parse(f)
192192

193-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
194193
def test_parse_path_object(self):
195194
make_xml_file(self.data, 'utf-8', None)
196195
self.check_parse(FakePath(TESTFN))
@@ -1018,7 +1017,6 @@ def test_expat_external_dtd_enabled(self):
10181017
resolver.entities, [(None, 'unsupported://non-existing')]
10191018
)
10201019

1021-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
10221020
def test_expat_external_dtd_default(self):
10231021
parser = create_parser()
10241022
resolver = self.TestEntityRecorder()
@@ -1084,7 +1082,6 @@ def startElement(self, name, attrs):
10841082
def startElementNS(self, name, qname, attrs):
10851083
self._attrs = attrs
10861084

1087-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
10881085
def test_expat_attrs_empty(self):
10891086
parser = create_parser()
10901087
gather = self.AttrGatherer()
@@ -1095,7 +1092,6 @@ def test_expat_attrs_empty(self):
10951092

10961093
self.verify_empty_attrs(gather._attrs)
10971094

1098-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
10991095
def test_expat_attrs_wattr(self):
11001096
parser = create_parser()
11011097
gather = self.AttrGatherer()
@@ -1106,7 +1102,6 @@ def test_expat_attrs_wattr(self):
11061102

11071103
self.verify_attrs_wattr(gather._attrs)
11081104

1109-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
11101105
def test_expat_nsattrs_empty(self):
11111106
parser = create_parser(1)
11121107
gather = self.AttrGatherer()
@@ -1300,7 +1295,6 @@ def test_flush_reparse_deferral_disabled(self):
13001295

13011296
# ===== Locator support
13021297

1303-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
13041298
def test_expat_locator_noinfo(self):
13051299
result = BytesIO()
13061300
xmlgen = XMLGenerator(result)
@@ -1315,7 +1309,6 @@ def test_expat_locator_noinfo(self):
13151309
self.assertEqual(parser.getPublicId(), None)
13161310
self.assertEqual(parser.getLineNumber(), 1)
13171311

1318-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
13191312
def test_expat_locator_withinfo(self):
13201313
result = BytesIO()
13211314
xmlgen = XMLGenerator(result)
@@ -1326,7 +1319,6 @@ def test_expat_locator_withinfo(self):
13261319
self.assertEqual(parser.getSystemId(), TEST_XMLFILE)
13271320
self.assertEqual(parser.getPublicId(), None)
13281321

1329-
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing'
13301322
@requires_nonascii_filenames
13311323
def test_expat_locator_withinfo_nonascii(self):
13321324
fname = os_helper.TESTFN_UNICODE

crates/stdlib/src/pyexpat.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ mod _pyexpat {
4343
Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine,
4444
builtins::{PyBytesRef, PyException, PyModule, PyStr, PyStrRef, PyType, PyUtf8StrRef},
4545
extend_module,
46-
function::{ArgBytesLike, Either, IntoFuncArgs, OptionalArg},
46+
function::{ArgBytesLike, ArgPrimitiveIndex, Either, IntoFuncArgs, OptionalArg},
4747
types::Constructor,
4848
};
4949
use rustpython_common::lock::PyRwLock;
@@ -70,13 +70,22 @@ mod _pyexpat {
7070
#[pyattr(name = "version_info")]
7171
pub(super) const VERSION_INFO: (u32, u32, u32) = (2, 7, 1);
7272

73+
#[pyattr]
74+
const XML_PARAM_ENTITY_PARSING_NEVER: i32 = 0;
75+
#[pyattr]
76+
const XML_PARAM_ENTITY_PARSING_UNLESS_STANDALONE: i32 = 1;
77+
#[pyattr]
78+
const XML_PARAM_ENTITY_PARSING_ALWAYS: i32 = 2;
79+
7380
#[pyattr]
7481
#[pyattr(name = "XMLParserType")]
7582
#[pyclass(name = "xmlparser", module = false, traverse)]
7683
#[derive(Debug, PyPayload)]
7784
pub(super) struct PyExpatLikeXmlParser {
7885
#[pytraverse(skip)]
7986
namespace_separator: Option<String>,
87+
#[pytraverse(skip)]
88+
base: PyRwLock<Option<String>>,
8089
start_element: MutableObject,
8190
end_element: MutableObject,
8291
character_data: MutableObject,
@@ -128,6 +137,7 @@ mod _pyexpat {
128137
let intern_dict = intern.unwrap_or_else(|| vm.ctx.new_dict().into());
129138
Self {
130139
namespace_separator,
140+
base: PyRwLock::new(None),
131141
start_element: MutableObject::new(vm.ctx.none()),
132142
end_element: MutableObject::new(vm.ctx.none()),
133143
character_data: MutableObject::new(vm.ctx.none()),
@@ -297,6 +307,29 @@ mod _pyexpat {
297307
.whitespace_to_characters(true)
298308
}
299309

310+
#[pymethod(name = "SetParamEntityParsing")]
311+
fn set_param_entity_parsing(&self, _flag: ArgPrimitiveIndex<i32>) -> i32 {
312+
// Compatibility shim: xml.sax requires this setup API, but xml-rs
313+
// does not expose Expat parameter entity parsing configuration.
314+
1
315+
}
316+
317+
#[pymethod(name = "SetBase")]
318+
fn set_base(&self, base: PyStrRef) {
319+
// Store-only compatibility state for xml.sax locator APIs. The
320+
// xml-rs backend still does not perform Expat-style base URI
321+
// resolution for external entities.
322+
*self.base.write() = Some(AsRef::<str>::as_ref(&base).to_owned());
323+
}
324+
325+
#[pymethod(name = "GetBase")]
326+
fn get_base(&self, vm: &VirtualMachine) -> PyObjectRef {
327+
self.base.read().as_ref().map_or_else(
328+
|| vm.ctx.none(),
329+
|base| vm.ctx.new_str(base.as_str()).into(),
330+
)
331+
}
332+
300333
/// Construct element name with namespace if separator is set
301334
fn make_name(&self, name: &xml::name::OwnedName) -> String {
302335
match (&self.namespace_separator, &name.namespace) {

extra_tests/snippets/stdlib_xml.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import xml.sax
2+
from xml.parsers import expat
3+
4+
from testutils import assert_raises
5+
6+
7+
assert expat.XML_PARAM_ENTITY_PARSING_NEVER == 0
8+
assert expat.XML_PARAM_ENTITY_PARSING_UNLESS_STANDALONE == 1
9+
assert expat.XML_PARAM_ENTITY_PARSING_ALWAYS == 2
10+
11+
parser = expat.ParserCreate()
12+
for value in (0, 1, 2, 3, -1, True):
13+
assert parser.SetParamEntityParsing(value) == 1
14+
15+
for value in ("x", None):
16+
with assert_raises(TypeError):
17+
parser.SetParamEntityParsing(value)
18+
19+
with assert_raises(OverflowError):
20+
parser.SetParamEntityParsing(2**100)
21+
22+
assert parser.GetBase() is None
23+
assert parser.SetBase("example.xml") is None
24+
assert parser.GetBase() == "example.xml"
25+
for value in (b"example.xml", None, 123):
26+
with assert_raises(TypeError):
27+
parser.SetBase(value)
28+
29+
30+
class Handler(xml.sax.handler.ContentHandler):
31+
def __init__(self):
32+
self.events = []
33+
34+
def startElement(self, name, attrs):
35+
self.events.append(("start", name))
36+
37+
def endElement(self, name):
38+
self.events.append(("end", name))
39+
40+
41+
handler = Handler()
42+
xml.sax.parseString("<main><child /></main>", handler)
43+
assert handler.events == [
44+
("start", "main"),
45+
("start", "child"),
46+
("end", "child"),
47+
("end", "main"),
48+
]

0 commit comments

Comments
 (0)