Fix documentation processing for lxml 6.1.3 and newer - #4902
Conversation
lxml 6.1.3 fixed LP#2165901, which had allowed external *parameter*
entities to be resolved under resolve_entities="internal" (the default
since lxml 6.0). The fix disables the SAX getParameterEntity callback in
that mode.
The SCons doc sources are built entirely on external parameter entities:
<!DOCTYPE sconsdoc [
<!ENTITY % scons SYSTEM "../scons.mod">
%scons;
so on lxml 6.1.3+ every doc file failed with "Entity 'scons' not
defined": bin/docs-validate.py failed on all 215 files and "scons doc"
died building scons_xi.xml.
The breakage was in the bare etree.parse() calls that pass no parser and
therefore inherit lxml's restrictive default. Two components were hit:
* bin/SConsDoc.py - TreeFactory.validateXml() and
SConsDocTree.parseXmlFile() now share a make_xml_parser() helper.
* SCons/Tool/docbook/__init__.py - the shipped docbook tool, which is a
user-facing regression: any user whose DocBook sources pull in an
entity module failed to build. The five document parses now use a
_make_xml_parser() helper; the stylesheet parses are left alone, as
XSL files carry no entity modules.
The parser is XMLParser(load_dtd=False, resolve_entities=True,
no_network=True). resolve_entities is spelled as a bool because the
string forms only exist from lxml 6.0 on. load_dtd stays False
deliberately: it controls the *external* DTD subset - the internal subset
holding the entity declarations is read either way - and enabling it
makes libxml2 try to fetch the remote DTD of any document declaring one,
which breaks test/Docbook/basic/xinclude.
Adds test/Docbook/basic/entities, which builds a DocBook source using an
external entity module; it fails without this change.
Verified on lxml 5.0.0, 5.4.0, 6.0.0, 6.1.2 and 6.1.3: docs-validate
passes 215/215, "scons doc" produces all XInclude artifacts, and
test/Docbook has the same pass/fail set as before the change. No lxml
pin is needed, since one parser config covers the whole supported range.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189csDsnNi2NSKrNavwCA4f
| read either way - and turning it on makes libxml2 try to fetch the remote | ||
| DTD of any document declaring one, which no_network then refuses. | ||
| """ | ||
| return etree.XMLParser(load_dtd=False, resolve_entities=True, no_network=True) |
There was a problem hiding this comment.
guess the question here is if we want/need to create multiple parsers, or just stick with one everybody uses?
There was a problem hiding this comment.
? that's being used where SConsDoc is was creating them in more than one place, right?
There was a problem hiding this comment.
In existing code we create one parser, in a method in SConsDocTree. All the others just used the default, as noted in the analysis. Now we create one "everywhere" we're going to call parse (maybe it's just three, but still more than one). Maybe that's a total non-issue.
There was a problem hiding this comment.
There's another in TreeFactory.validateXml()
So that func is used 2 x in SConsDoc.py
And then 5 times in SCons/Tool/docbook/__init__.py
Oh.. So you're saying only ever create 1 parser, and then use it everywhere, instead of creating one each time make_xml_parser() is called?
There was a problem hiding this comment.
asking, not saying, but yes...
There was a problem hiding this comment.
I asked claude if it's expensive to call and if it's safe to reuse
Good question — I measured it rather than guessing.
Is constructing the parser expensive? No, negligibly.
XMLParser(...) construction |
0.47 µs |
One parse of a small doc XML (SCons/Tool/386asm.xml) |
1853 µs |
| Construction as a share of one parse | 0.03% |
Benchmark (lxml 6.1.3, Python 3.14)
import timeit
from lxml import etree
mk = lambda: etree.XMLParser(load_dtd=False, resolve_entities=True, no_network=True)
t = timeit.timeit(mk, number=100_000)
print(f"XMLParser construction: {t/100_000*1e6:.2f} us") # 0.47 us
p = mk()
t2 = timeit.timeit(lambda: etree.parse('SCons/Tool/386asm.xml', p), number=2000)
print(f"parse: {t2/2000*1e6:.2f} us") # 1853.20 usIt's a thin wrapper allocation; the real work is all in etree.parse(). Across the whole doc build (~215 validate parses plus the tool's handful) that's well under a millisecond in total. There's no performance reason to share one.
Can we share a single parser? Technically yes, but I'd keep the factory.
Reuse is functionally safe — I confirmed error_log resets on each parse, and a parser recovers cleanly after a failed parse. But lxml's own XMLParser docstring says:
Note that you should avoid sharing parsers between threads. While this is not harmful, it is more efficient to use separate parsers. This does not apply to the default parser.
That matters here because both call sites can end up multi-threaded. SCons builds with -j, and the docbook tool's parses run inside builder actions and a scanner — so a module-level singleton in SCons/Tool/docbook/__init__.py would be shared across job threads. lxml calls that "not harmful," but it's explicitly the pattern they steer you away from, and it trades a documented recommendation for a 0.47 µs saving.
There's a second reason specific to the tool: a module-level parser would need from lxml import etree at import time. That file deliberately keeps lxml imports lazy inside functions (has_lxml is just a probe) so the tool still loads on systems without lxml — the xsltproc path doesn't need it. A module-level parser instance would break that.
One genuine simplification is available: bin/SConsDoc.py is single-threaded, so its two call sites could share one module-level parser. I'd still leave the tool as a factory — which makes two files do the same thing two different ways, and my read is that the asymmetry costs more in confusion than 1 µs buys.
There was a problem hiding this comment.
I don't thnk the "we could still work is lxml won't import" matters any more since afaik the other branch is gone in the mists of time, but that does answer the question - it's not a biggie to instantiate several parsers.
…SION
The three slides HTML tests pick their input file from the DocBook XSLT
stylesheet version:
v = xsltver.detectXsltVersion('/usr/share/xml/docbook/stylesheet/docbook-xsl')
if v >= (1, 78, 0):
ns_ext = 'ns' # use the namespace-aware input
detectXsltVersion() parsed <fm:Version> out of the stylesheets' VERSION
file with int(), and returned (0, 0, 0) when that raised. Debian and
Ubuntu's docbook-xsl 1.79.2 ships
<fm:Branch>XSL-NS Stylesheets</fm:Branch>
<fm:Version>snapshot</fm:Version>
so int(b'snapshot') raised, detection reported (0, 0, 0), and the tests
handed the plain, non-namespaced virt.xml to those namespace-aware
stylesheets. Nothing matched, xsltproc exited 0, and the result was an
empty <div class="presentation"/> - so the failure surfaced only as a
missing string, with no hint of the real cause.
Callers only use the return value to decide whether the stylesheets are
namespace aware, and <fm:Branch> says so outright, so fall back to it
when the version is not a number and return (1, 78, 0): the exact
threshold the SConstructs compare against, claiming nothing more than
"namespace aware". A distribution shipping a real version number takes
the unchanged numeric path.
Also narrow "except Exception" to the ValueError int() actually raises,
and guard the open() so a missing VERSION returns (0, 0, 0) as the
docstring already promised instead of raising, which lets the two
unreachable returns after the with block go away.
Only test fixtures change; nothing here is visible to a user of SCons.
On Ubuntu 24.04 with docbook-xsl 1.79.2+dfsg-7 this takes test/Docbook
from 6 failures to 1. The remaining one, slidespdf_live, is unrelated:
its virt.xml has no namespaced counterpart and its DOCTYPE points at
docbook.sourceforge.net, which no longer resolves, so xsltproc warns on
stderr and the test's strict stderr comparison rejects it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ybJF6J17J46UYCYAaJKgZ
lxml 6.1.3 fixed LP#2165901, which had allowed external parameter entities to be resolved under resolve_entities="internal" (the default since lxml 6.0). The fix disables the SAX getParameterEntity callback in that mode.
The SCons doc sources are built entirely on external parameter entities:
so on lxml 6.1.3+ every doc file failed with "Entity 'scons' not defined": bin/docs-validate.py failed on all 215 files and "scons doc" died building scons_xi.xml.
The breakage was in the bare etree.parse() calls that pass no parser and therefore inherit lxml's restrictive default. Two components were hit:
bin/SConsDoc.py - TreeFactory.validateXml() and SConsDocTree.parseXmlFile() now share a make_xml_parser() helper.
SCons/Tool/docbook/init.py - the shipped docbook tool, which is a user-facing regression: any user whose DocBook sources pull in an entity module failed to build. The five document parses now use a _make_xml_parser() helper; the stylesheet parses are left alone, as XSL files carry no entity modules.
The parser is XMLParser(load_dtd=False, resolve_entities=True, no_network=True). resolve_entities is spelled as a bool because the string forms only exist from lxml 6.0 on. load_dtd stays False deliberately: it controls the external DTD subset - the internal subset holding the entity declarations is read either way - and enabling it makes libxml2 try to fetch the remote DTD of any document declaring one, which breaks test/Docbook/basic/xinclude.
Adds test/Docbook/basic/entities, which builds a DocBook source using an external entity module; it fails without this change.
Verified on lxml 5.0.0, 5.4.0, 6.0.0, 6.1.2 and 6.1.3: docs-validate passes 215/215, "scons doc" produces all XInclude artifacts, and test/Docbook has the same pass/fail set as before the change. No lxml pin is needed, since one parser config covers the whole supported range.
Claude-Session: https://claude.ai/code/session_0189csDsnNi2NSKrNavwCA4f
Remove this paragraph
Please have a look at our developer documentation before submitting your Pull Request.
https://scons.org/guidelines.html
Contributor Checklist:
CHANGES.txtandRELEASE.txt(and read theREADME.rst).