Skip to content

Fix documentation processing for lxml 6.1.3 and newer - #4902

Merged
bdbaddog merged 2 commits into
SCons:masterfrom
bdbaddog:fix_lxml_613_plus
Sep 8, 2026
Merged

bdbaddog merged 2 commits into
SCons:masterfrom
bdbaddog:fix_lxml_613_plus

Conversation

@bdbaddog

@bdbaddog bdbaddog commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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.

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:

  • I have created a new test or updated the unit tests to cover the new/changed functionality.
  • I have updated CHANGES.txt and RELEASE.txt (and read the README.rst).
  • I have updated the appropriate documentation

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
Comment thread bin/SConsDoc.py
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guess the question here is if we want/need to create multiple parsers, or just stick with one everybody uses?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? that's being used where SConsDoc is was creating them in more than one place, right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asking, not saying, but yes...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 us

It'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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@bdbaddog
bdbaddog marked this pull request as ready for review September 8, 2026 22:10
@bdbaddog
bdbaddog merged commit 7afde3b into SCons:master Sep 8, 2026
10 of 12 checks passed
@mwichmann mwichmann added Release Any an all issues with releasing and packaging SCons itself documentation labels Sep 9, 2026
@mwichmann mwichmann added this to the NextRelease milestone Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Release Any an all issues with releasing and packaging SCons itself

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants