Support xml.sax pyexpat setup APIs (#8238)

* 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

* Keep XML follow-up tests aligned with passing behavior

The pyexpat setup API compatibility fix also makes pulldom's basic parse smoke test pass, so keep RustPython expected-failure markers aligned with current behavior and apply the ruff import-spacing fix.

Constraint: Test body and assertions remain unchanged.

Confidence: high

Scope-risk: narrow

Tested: cargo run --release -- -m test test_pulldom -v

Tested: cargo run --release -- -m test test_sax

Tested: cargo run --release -- -m test test_pyexpat

Tested: cargo run --quiet -- extra_tests/snippets/stdlib_xml.py

Tested: PATH=/tmp/pyshim:/Users/kbm/.codex/tmp/arg0/codex-arg0ejsdGi:/Users/kbm/.omx-runs/run-20260703105454-4fab/.omx/runtime/bin:/Users/kbm/.nvm/versions/node/v24.15.0/bin:/Users/kbm/Library/Java/JavaVirtualMachines/temurin-21.0.9/Contents/Home/bin:/Users/kbm/.antigravity/antigravity/bin:/opt/homebrew/opt/mysql-client/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Library/Apple/usr/bin:/var/folders/vq/1mhmtsbs2kj_03tsx74kd1mm0000gn/T//cmux-cli-shims/93BA47BD-630C-4ED3-8573-0FE6ABFAE824:/Applications/cmux.app/Contents/Resources/bin:/Users/kbm/.nvm/versions/node/v24.15.0/bin:/Users/kbm/Library/Java/JavaVirtualMachines/temurin-21.0.9/Contents/Home/bin:/Users/kbm/.antigravity/antigravity/bin:/opt/homebrew/opt/mysql-client/bin:/var/folders/vq/1mhmtsbs2kj_03tsx74kd1mm0000gn/T/cmux-cli-shims/93BA47BD-630C-4ED3-8573-0FE6ABFAE824:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin prek run --all-files

Tested: cargo build --release --features sqlite && cd extra_tests && /tmp/rustpython-extra-tests-venv/bin/python -m pytest -v

Tested: PYO3_CONFIG_FILE=/Users/kbm/IdeaProjects/RustPython/crates/capi/pyo3-rustpython.config cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher

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
This commit is contained in:
Kwak Byoung Min authored and GitHub committed 2026-07-09 20:24:16 +09:00
1 parent d3f0729fdf
commit 5f9139a3f8
4 files changed
+81 -10

No files matched your search

-1
View File
@@ -24,7 +24,6 @@ SMALL_SAMPLE = """<?xml version="1.0"?>
class PullDOMTestCase(unittest.TestCase):
@unittest.expectedFailure # TODO: RUSTPYTHON; FileNotFoundError: [Errno 2] No such file or directory (os error 2): 'xmltestdata/test.xml' -> 'None'
def test_parse(self):
"""Minimal test of DOMEventStream.parse()"""
-8
View File
@@ -190,7 +190,6 @@ class ParseTest(unittest.TestCase):
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 @@ class ExpatReaderTest(XmlTestBase):
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 @@ class ExpatReaderTest(XmlTestBase):
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 @@ class ExpatReaderTest(XmlTestBase):
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 @@ class ExpatReaderTest(XmlTestBase):
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 @@ class ExpatReaderTest(XmlTestBase):
# ===== 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 @@ class ExpatReaderTest(XmlTestBase):
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 @@ class ExpatReaderTest(XmlTestBase):
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
+34 -1
View File
@@ -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<String>,
#[pytraverse(skip)]
base: PyRwLock<Option<String>>,
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>) -> 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::<str>::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) {
+47
View File
@@ -0,0 +1,47 @@
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("<main><child /></main>", handler)
assert handler.events == [
("start", "main"),
("start", "child"),
("end", "child"),
("end", "main"),
]