From 03e2d0f56e36df666e48ee3ecf4327dcb96ac0e2 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min Date: Wed, 8 Jul 2026 17:46:38 +0900 Subject: [PATCH] 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 --- Lib/test/test_sax.py | 8 ----- crates/stdlib/src/pyexpat.rs | 35 +++++++++++++++++++++- extra_tests/snippets/stdlib_xml.py | 48 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 extra_tests/snippets/stdlib_xml.py diff --git a/Lib/test/test_sax.py b/Lib/test/test_sax.py index faaf4dd95b6..e9e6b604d0d 100644 --- a/Lib/test/test_sax.py +++ b/Lib/test/test_sax.py @@ -190,7 +190,6 @@ def test_parse_bytes(self): with self.assertRaises(SAXException): self.check_parse(f) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_parse_path_object(self): make_xml_file(self.data, 'utf-8', None) self.check_parse(FakePath(TESTFN)) @@ -1018,7 +1017,6 @@ def test_expat_external_dtd_enabled(self): resolver.entities, [(None, 'unsupported://non-existing')] ) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_external_dtd_default(self): parser = create_parser() resolver = self.TestEntityRecorder() @@ -1084,7 +1082,6 @@ def startElement(self, name, attrs): def startElementNS(self, name, qname, attrs): self._attrs = attrs - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_attrs_empty(self): parser = create_parser() gather = self.AttrGatherer() @@ -1095,7 +1092,6 @@ def test_expat_attrs_empty(self): self.verify_empty_attrs(gather._attrs) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_attrs_wattr(self): parser = create_parser() gather = self.AttrGatherer() @@ -1106,7 +1102,6 @@ def test_expat_attrs_wattr(self): self.verify_attrs_wattr(gather._attrs) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_nsattrs_empty(self): parser = create_parser(1) gather = self.AttrGatherer() @@ -1300,7 +1295,6 @@ def test_flush_reparse_deferral_disabled(self): # ===== Locator support - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_locator_noinfo(self): result = BytesIO() xmlgen = XMLGenerator(result) @@ -1315,7 +1309,6 @@ def test_expat_locator_noinfo(self): self.assertEqual(parser.getPublicId(), None) self.assertEqual(parser.getLineNumber(), 1) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_locator_withinfo(self): result = BytesIO() xmlgen = XMLGenerator(result) @@ -1326,7 +1319,6 @@ def test_expat_locator_withinfo(self): self.assertEqual(parser.getSystemId(), TEST_XMLFILE) self.assertEqual(parser.getPublicId(), None) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' @requires_nonascii_filenames def test_expat_locator_withinfo_nonascii(self): fname = os_helper.TESTFN_UNICODE diff --git a/crates/stdlib/src/pyexpat.rs b/crates/stdlib/src/pyexpat.rs index 8323a4ea106..fab4e7b5e93 100644 --- a/crates/stdlib/src/pyexpat.rs +++ b/crates/stdlib/src/pyexpat.rs @@ -43,7 +43,7 @@ mod _pyexpat { Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyBytesRef, PyException, PyModule, PyStr, PyStrRef, PyType, PyUtf8StrRef}, extend_module, - function::{ArgBytesLike, Either, IntoFuncArgs, OptionalArg}, + function::{ArgBytesLike, ArgPrimitiveIndex, Either, IntoFuncArgs, OptionalArg}, types::Constructor, }; use rustpython_common::lock::PyRwLock; @@ -70,6 +70,13 @@ mod _pyexpat { #[pyattr(name = "version_info")] pub(super) const VERSION_INFO: (u32, u32, u32) = (2, 7, 1); + #[pyattr] + const XML_PARAM_ENTITY_PARSING_NEVER: i32 = 0; + #[pyattr] + const XML_PARAM_ENTITY_PARSING_UNLESS_STANDALONE: i32 = 1; + #[pyattr] + const XML_PARAM_ENTITY_PARSING_ALWAYS: i32 = 2; + #[pyattr] #[pyattr(name = "XMLParserType")] #[pyclass(name = "xmlparser", module = false, traverse)] @@ -77,6 +84,8 @@ mod _pyexpat { pub(super) struct PyExpatLikeXmlParser { #[pytraverse(skip)] namespace_separator: Option, + #[pytraverse(skip)] + base: PyRwLock>, start_element: MutableObject, end_element: MutableObject, character_data: MutableObject, @@ -128,6 +137,7 @@ mod _pyexpat { let intern_dict = intern.unwrap_or_else(|| vm.ctx.new_dict().into()); Self { namespace_separator, + base: PyRwLock::new(None), start_element: MutableObject::new(vm.ctx.none()), end_element: MutableObject::new(vm.ctx.none()), character_data: MutableObject::new(vm.ctx.none()), @@ -297,6 +307,29 @@ mod _pyexpat { .whitespace_to_characters(true) } + #[pymethod(name = "SetParamEntityParsing")] + fn set_param_entity_parsing(&self, _flag: ArgPrimitiveIndex) -> i32 { + // Compatibility shim: xml.sax requires this setup API, but xml-rs + // does not expose Expat parameter entity parsing configuration. + 1 + } + + #[pymethod(name = "SetBase")] + fn set_base(&self, base: PyStrRef) { + // Store-only compatibility state for xml.sax locator APIs. The + // xml-rs backend still does not perform Expat-style base URI + // resolution for external entities. + *self.base.write() = Some(AsRef::::as_ref(&base).to_owned()); + } + + #[pymethod(name = "GetBase")] + fn get_base(&self, vm: &VirtualMachine) -> PyObjectRef { + self.base.read().as_ref().map_or_else( + || vm.ctx.none(), + |base| vm.ctx.new_str(base.as_str()).into(), + ) + } + /// Construct element name with namespace if separator is set fn make_name(&self, name: &xml::name::OwnedName) -> String { match (&self.namespace_separator, &name.namespace) { diff --git a/extra_tests/snippets/stdlib_xml.py b/extra_tests/snippets/stdlib_xml.py new file mode 100644 index 00000000000..013a1e59536 --- /dev/null +++ b/extra_tests/snippets/stdlib_xml.py @@ -0,0 +1,48 @@ +import xml.sax +from xml.parsers import expat + +from testutils import assert_raises + + +assert expat.XML_PARAM_ENTITY_PARSING_NEVER == 0 +assert expat.XML_PARAM_ENTITY_PARSING_UNLESS_STANDALONE == 1 +assert expat.XML_PARAM_ENTITY_PARSING_ALWAYS == 2 + +parser = expat.ParserCreate() +for value in (0, 1, 2, 3, -1, True): + assert parser.SetParamEntityParsing(value) == 1 + +for value in ("x", None): + with assert_raises(TypeError): + parser.SetParamEntityParsing(value) + +with assert_raises(OverflowError): + parser.SetParamEntityParsing(2**100) + +assert parser.GetBase() is None +assert parser.SetBase("example.xml") is None +assert parser.GetBase() == "example.xml" +for value in (b"example.xml", None, 123): + with assert_raises(TypeError): + parser.SetBase(value) + + +class Handler(xml.sax.handler.ContentHandler): + def __init__(self): + self.events = [] + + def startElement(self, name, attrs): + self.events.append(("start", name)) + + def endElement(self, name): + self.events.append(("end", name)) + + +handler = Handler() +xml.sax.parseString("
", handler) +assert handler.events == [ + ("start", "main"), + ("start", "child"), + ("end", "child"), + ("end", "main"), +]