# Probe for 99-python3-sphinxcontrib-htmlhelp.test -- ONE interpreter start for
# the whole suite.
#
# Every value is emitted as `key=value' on its own line. The .test compares with
# shell `test x = y', so any value that could carry a newline or a run of spaces
# is escaped reversibly here: a newline becomes the two characters \n and a
# space becomes \s. Nothing else is transformed, so a red prints the whole value
# on one readable line.
#
# ===========================================================================
# THIS PROBE STANDS SPHINX UP IN STUB FORM AND USES THE REAL docutils.
# ===========================================================================
#
# sphinxcontrib/htmlhelp/__init__.py imports Sphinx at MODULE scope -- fourteen
# separate `from sphinx...' lines -- and, worse for a stub, SUBCLASSES
# sphinx.builders.html.StandaloneHTMLBuilder at module scope. This image does
# not install Sphinx (python3-sphinx exists in poky and is in neither
# core-image-brintos-emcraft nor any port ticket), so
#
# python3 -c 'import sphinxcontrib.htmlhelp'
#
# raises ModuleNotFoundError: No module named 'sphinx'. The .test asserts that
# failure directly, from the shell, so the fact is recorded rather than worked
# around in silence; this probe then installs the smallest sphinx.* surface the
# module actually touches and measures the package against it.
#
# WHAT IS AND IS NOT MEASURED, stated plainly, because this stub is larger than
# a one-attribute stub and the line matters:
#
# * MEASURED, entirely this package's own code:
# - chm_htmlescape()'s hex-to-decimal rewrite;
# - ToCTreeVisitor, run over a REAL docutils doctree (python3-docutils IS
# on this image), so the .hhc body is the package's output;
# - build_hhx(), which writes the .hhk with no template at all -- the
# literal // text, the See-Also / single-Local /
# numbered-Name branches, the subitem nesting, and the cp1252 +
# xmlcharrefreplace encoding of the result;
# - build_project_file()'s os.walk sieve and its backslash separator;
# - the context dicts handed to the renderer;
# - init()'s locale lookup, setup()'s registrations, the class attributes.
#
# * STOOD IN FOR, and therefore NOT a statement about Sphinx:
# - SphinxRenderer. Sphinx's renderer is a Jinja2 environment over
# template_dir; python3-jinja2 IS on this image, so the probe renders
# the two templates through a plain jinja2.Environment. What that
# measures is THIS PACKAGE'S TEMPLATES AND CONTEXT, not Sphinx's
# renderer -- a change on Sphinx's side would not be caught here, and
# `jinja2-available' below is the case that says whether the render
# happened at all.
# - make_filename_from_project(). It is Sphinx's slugging, not this
# package's, so the stub returns a MARKER and `default-basename' asserts
# only the `+ "doc"' concatenation, which is the whole of what
# default_htmlhelp_basename() contributes.
# - NodeMatcher, relpath, copy_asset_file, progress_message,
# get_translation, logging: reconstructed to Sphinx's documented
# behaviour so the package's code can run. Each is named in the case
# that depends on it.
#
# The stub is deliberately inert everywhere it can be. A richer stub would risk
# measuring the stub instead of the package.
import base64
import hashlib
import os
import shutil
import sys
import tempfile
import types
OUT = []
def emit(k, v):
OUT.append("%s=%s" % (k, v))
def esc(s):
return str(s).replace("\\", "\\\\").replace("\n", "\\n").replace(" ", "\\s")
def guard(key, fn):
"""Run fn and emit its value, or emit the exception type. A probe that dies
at the first surprise leaves every later case unscorable; one that records
the surprise and continues leaves exactly one red beside the evidence."""
try:
emit(key, fn())
except Exception as exc: # noqa: BLE001 -- the type IS the measurement
emit(key, "EXC:%s" % type(exc).__name__)
emit(key + "_detail", esc(str(exc))[:200])
# ------------------------------------------------ the import, twice over ---
# FIRST, WITHOUT THE STUB, so the probe measures the same failure the .test
# asserts from the shell rather than taking it on trust. This must be done
# before any stub is installed and before any import of the package, because a
# successful import is cached in sys.modules and cannot be undone cleanly.
def _bare_import():
try:
import sphinxcontrib.htmlhelp # noqa: F401
return "imported"
except ModuleNotFoundError as exc:
return "ModuleNotFoundError:%s" % exc.name
except Exception as exc: # noqa: BLE001
return "EXC:%s" % type(exc).__name__
emit("bare_import", _bare_import())
# Whatever the line above left behind. A failed `import sphinxcontrib.htmlhelp'
# still binds the NAMESPACE package `sphinxcontrib' -- the failure is in the
# submodule -- so the submodule, and only the submodule, must be gone for the
# stubbed import below to actually run the module body.
for _n in [n for n in sys.modules if n.startswith("sphinxcontrib.")]:
del sys.modules[_n]
# docutils is REAL here. It is on this image (python3-docutils 0.20.1), the
# module imports it at module scope, and ToCTreeVisitor is a docutils
# NodeVisitor -- so the visitor cases below run against the genuine article.
import docutils # noqa: E402
from docutils import nodes as dnodes # noqa: E402
from docutils.frontend import get_default_settings # noqa: E402
from docutils.utils import new_document # noqa: E402
emit("docutils_version", docutils.__version__)
try:
import jinja2
_JINJA = jinja2
emit("jinja2_available", "yes")
emit("jinja2_version", jinja2.__version__)
except Exception as exc: # noqa: BLE001
_JINJA = None
emit("jinja2_available", "EXC:%s" % type(exc).__name__)
emit("jinja2_version", "")
# ---------------------------------------------------------- the sphinx stub --
def _mod(name, **attrs):
m = types.ModuleType(name)
for k, v in attrs.items():
setattr(m, k, v)
sys.modules[name] = m
return m
# `version_info' is read at module scope to choose where progress_message comes
# from; (7, 2, 6) takes the >= (6, 1) branch, which is the modern one and the
# one a current Sphinx would take.
_sphinx = _mod("sphinx", version_info=(7, 2, 6, "final", 0), __version__="7.2.6")
class compact_paragraph(dnodes.General, dnodes.Element):
"""Sphinx's addnodes.compact_paragraph, as a real docutils Element so the
fixture doctree below is a real doctree and ToCTreeVisitor walks it for
real."""
_mod("sphinx.addnodes", compact_paragraph=compact_paragraph)
_sphinx.addnodes = sys.modules["sphinx.addnodes"]
class Sphinx: # typing only -- setup(app) is annotated with it, never uses it
pass
_mod("sphinx.application", Sphinx=Sphinx)
class Config: # typing only
pass
_mod("sphinx.config", Config=Config)
_BUILDER_CALLS = []
class StandaloneHTMLBuilder:
"""Inert. Every method HTMLHelpBuilder calls through `super()' records the
call and does nothing else, so what the subclass does BEFORE and AFTER the
super() call is what the cases measure."""
name = "html"
out_suffix = ".html"
link_suffix = ".html"
copysource = True
add_permalinks = True
embedded = False
search = True
supported_image_types = []
def init(self):
_BUILDER_CALLS.append("init")
def prepare_writing(self, docnames):
_BUILDER_CALLS.append("prepare_writing:%s" % ",".join(sorted(docnames)))
def write_doc(self, docname, doctree):
_BUILDER_CALLS.append("write_doc:%s" % docname)
_mod("sphinx.builders.html", StandaloneHTMLBuilder=StandaloneHTMLBuilder)
_mod("sphinx.builders")
sys.modules["sphinx.builders"].html = sys.modules["sphinx.builders.html"]
# IndexEntries(env).create_index(builder) -- the probe supplies the index, so
# every byte build_hhx() writes comes from the package's own formatting code
# rather than from Sphinx's index builder.
_INDEX_FIXTURE = []
class IndexEntries:
def __init__(self, env):
self.env = env
def create_index(self, builder):
return _INDEX_FIXTURE
_mod("sphinx.environment.adapters.indexentries", IndexEntries=IndexEntries)
_mod("sphinx.environment.adapters")
_mod("sphinx.environment")
def get_translation(package, catalog="general"):
# Sphinx returns a gettext-like callable. Identity is the honest stand-in:
# what the package does with it is pass literals through, and the .mo files
# it ships are asserted separately, parsed with the stdlib gettext.
def _(message, *args):
return message
return _
_mod("sphinx.locale", get_translation=get_translation)
class _Logger:
def __init__(self):
self.lines = []
def info(self, *a, **kw):
self.lines.append(a[0] if a else "")
warning = error = debug = info
_LOGGER = _Logger()
_logging_mod = _mod("sphinx.util.logging", getLogger=lambda name: _LOGGER)
def progress_message(message):
# Sphinx's decorator wraps the call in a status line. Inert here: the
# decorated methods' OUTPUT is what the cases read.
def deco(fn):
return fn
return deco
_mod("sphinx.util.display", progress_message=progress_message)
_COPIED = []
def copy_asset_file(source, destination, context=None, renderer=None):
_COPIED.append((source, destination))
if os.path.isdir(destination):
destination = os.path.join(destination, os.path.basename(source))
shutil.copyfile(source, destination)
_mod("sphinx.util.fileutil", copy_asset_file=copy_asset_file)
class NodeMatcher:
"""Sphinx's matcher: a node matches when it is an instance of one of the
given classes AND every keyword equals the node's attribute of that name."""
def __init__(self, *classes, **attrs):
self.classes = classes
self.attrs = attrs
def match(self, node):
if self.classes and not isinstance(node, self.classes):
return False
for k, v in self.attrs.items():
if node.get(k) != v:
return False
return True
def __call__(self, node):
return self.match(node)
_mod("sphinx.util.nodes", NodeMatcher=NodeMatcher)
_MFP_CALLS = []
def make_filename_from_project(project):
# A MARKER, not Sphinx's slugging. See the header: `default-basename'
# asserts the `+ "doc"' concatenation, which is this package's whole
# contribution, and nothing about how Sphinx turns a project name into a
# filename.
_MFP_CALLS.append(project)
return "" % project
def relpath(path, start=os.curdir):
return os.path.relpath(path, start)
_mod("sphinx.util.osutil",
make_filename_from_project=make_filename_from_project, relpath=relpath)
_RENDERS = []
class SphinxRenderer:
"""Records (template_dir, name, context) and, where jinja2 is importable,
renders the package's own template through a plain jinja2 environment. See
the header for exactly what that does and does not measure."""
def __init__(self, template_path=None):
self.template_path = template_path
def render(self, name, context):
_RENDERS.append((self.template_path, name, dict(context)))
if _JINJA is None:
return ""
env = _JINJA.Environment(
loader=_JINJA.FileSystemLoader(self.template_path),
keep_trailing_newline=True,
)
return env.get_template(name).render(context)
_mod("sphinx.util.template", SphinxRenderer=SphinxRenderer)
_util = _mod("sphinx.util", logging=_logging_mod, progress_message=progress_message)
_util.fileutil = sys.modules["sphinx.util.fileutil"]
_util.nodes = sys.modules["sphinx.util.nodes"]
_util.osutil = sys.modules["sphinx.util.osutil"]
_util.template = sys.modules["sphinx.util.template"]
_util.display = sys.modules["sphinx.util.display"]
import sphinxcontrib # noqa: E402
import sphinxcontrib.htmlhelp as H # noqa: E402
# --------------------------------------------------------------- identity ---
emit("version", H.__version__)
emit("version_info", ".".join(str(n) for n in H.__version_info__))
emit("module_file", H.__file__)
emit("package_dir", H.package_dir)
emit("template_dir", H.template_dir)
def _dist():
from importlib import metadata
d = metadata.distribution("sphinxcontrib-htmlhelp")
return d
guard("dist_version", lambda: _dist().version)
guard("dist_name", lambda: _dist().metadata["Name"])
# UPSTREAM DECLARES SPHINX ONLY AS AN EXTRA, and that is the difference between
# this port and python3-sphinxcontrib-jquery's. jquery's METADATA carries a bare
# `Requires-Dist: Sphinx>=1.8'; this one carries `Requires-Dist: Sphinx>=5 ;
# extra == "standalone"'. The dependency direction upstream intends is the other
# way round -- Sphinx depends on this package, which is why the requirement is
# optional here -- so the .deb's `Depends: python3-core' is NOT the plain
# disagreement with upstream that jquery's is.
guard("dist_requires", lambda: ";".join(
esc(r) for r in sorted(_dist().requires or [])))
guard("dist_requires_unconditional", lambda: ",".join(
esc(r) for r in sorted(_dist().requires or []) if "extra ==" not in r) or "none")
guard("ep_count", lambda: len(list(_dist().entry_points)))
# ------------------------------------------------- the namespace package ---
emit("ns_file", getattr(sphinxcontrib, "__file__", None))
emit("ns_path_type", type(sphinxcontrib.__path__).__name__)
emit("ns_path", ",".join(sphinxcontrib.__path__))
# ------------------------------------------------------------------ the API ---
emit("api_surface", ",".join(sorted(n for n in dir(H) if not n.startswith("_"))))
emit("api_callables", ",".join(sorted(
n for n in dir(H) if not n.startswith("_") and callable(getattr(H, n)))))
# ------------------------------------------------------- the locale table ---
# chm_locales maps a Sphinx language code to (LCID, encoding). The LCID goes
# into the .hhp's Language= line as `%#x' and the encoding is what every one of
# the three output files is written in, so a wrong row here is a .chm that
# renders as mojibake with no error anywhere.
emit("locales_count", len(H.chm_locales))
emit("locales_keys", ",".join(sorted(H.chm_locales)))
for _lang in ("en", "de", "ja", "ko", "ru", "tr", "zh_CN", "zh_TW", "et", "cs"):
_lcid, _enc = H.chm_locales[_lang]
emit("locale_%s" % _lang, "%#x,%s" % (_lcid, _enc))
# EVERY ENCODING THE TABLE NAMES MUST RESOLVE ON THIS IMAGE. OE splits the
# interpreter's codecs into python3-codecs; without it `cp932' and friends do
# not exist and a Japanese or Chinese .chm build dies at the open(). Asserting
# the whole table is cheap and says WHICH one went if one ever does.
import codecs # noqa: E402
def _missing_codecs():
bad = []
for _l, (_c, _e) in sorted(H.chm_locales.items()):
try:
codecs.lookup(_e)
except LookupError:
bad.append("%s:%s" % (_l, _e))
return ",".join(bad) or "none"
emit("codecs_missing", _missing_codecs())
# ----------------------------------------------------- chm_htmlescape() ---
# THE ONE PIECE OF PURE LOGIC THIS PACKAGE OWNS OUTRIGHT. html.escape() may emit
# the HEX entity ' for an apostrophe; .hhc/.hhk files do not recognise hex
# escaping, so the wrapper rewrites it to the decimal '. Everything a help
# index displays goes through here, so a regression is silently wrong output
# rather than a crash.
_ESCAPES = [
("amp", "a & b"),
("lt", ""),
("gt", "x > y"),
("quot", 'say "hi"'),
("apos", "it's"),
("mixed", " & "),
("nonascii", "café → naïve"),
("empty", ""),
]
for _k, _v in _ESCAPES:
emit("esc_%s" % _k, esc(H.chm_htmlescape(_v)))
# quote=False leaves both quote characters alone -- including the apostrophe,
# so the decimal rewrite has nothing to do.
emit("esc_noquote_apos", esc(H.chm_htmlescape("it's", False)))
emit("esc_noquote_quot", esc(H.chm_htmlescape('say "hi"', False)))
# ...AND NO HEX ENTITY SURVIVES. The rewrite is a single targeted replace, so
# this is the case that would notice a future html.escape() emitting some other
# hex form.
emit("esc_no_hex", "yes" if not any(
"" in H.chm_htmlescape(v, True) for _, v in _ESCAPES) else "no")
# ------------------------------------------------------ the builder class ---
B = H.HTMLHelpBuilder
emit("b_name", B.name)
emit("b_mro_parent", B.__mro__[1].__name__)
emit("b_copysource", B.copysource)
emit("b_add_permalinks", B.add_permalinks)
emit("b_embedded", B.embedded)
emit("b_search", B.search)
emit("b_lcid", "%#x" % B.lcid)
emit("b_encoding", B.encoding)
emit("b_image_types", ",".join(B.supported_image_types))
class _Cfg:
def __init__(self, **kw):
self.language = None
self.htmlhelp_basename = "testdoc"
self.html_title = "Test Project Documentation"
self.html_short_title = "Test"
self.version = "1.2"
self.project = "Test Project"
self.master_doc = "index"
for k, v in kw.items():
setattr(self, k, v)
def _builder(outdir, **cfg):
b = B.__new__(B)
b.config = _Cfg(**cfg)
b.outdir = outdir
b.env = object()
b.domain_indices = []
b.globalcontext = {}
return b
# init() -- the locale lookup. HTMLHelpBuilder.init() sets the two suffixes,
# calls super().init(), then overrides lcid/encoding from chm_locales when the
# configured language has a row. An UNKNOWN language must leave the class
# defaults standing (0x409 / cp1252), which is the branch a typo in conf.py
# takes.
def _init_for(lang):
b = _builder(".", language=lang)
del _BUILDER_CALLS[:]
b.init()
return "%s,%s,%#x,%s,%s" % (
b.out_suffix, b.link_suffix, b.lcid, b.encoding, ",".join(_BUILDER_CALLS))
for _lang, _key in ((None, "none"), ("en", "en"), ("ja", "ja"),
("ru", "ru"), ("xx_YY", "unknown")):
guard("init_%s" % _key, lambda _l=_lang: _init_for(_l))
# prepare_writing() forces the HTML4 doctype. HTML Help Workshop's renderer is
# an old Trident and does not parse an HTML5 doctype, so this one line is what
# keeps a .chm readable.
def _prepare():
b = _builder(".")
del _BUILDER_CALLS[:]
b.prepare_writing({"index", "foo"})
return "%s,%s" % (b.globalcontext["html5_doctype"], ",".join(_BUILDER_CALLS))
guard("prepare_writing", _prepare)
# update_page_context() is how the per-page template learns the code page.
def _page_ctx():
b = _builder(".", language="ja")
b.init()
ctx = {}
b.update_page_context("index", "page.html", ctx, None)
return ctx.get("encoding")
guard("page_context_encoding", _page_ctx)
# write_doc() marks EXTERNAL links target=_blank -- a .chm is a local viewer,
# so an external link left in-frame navigates the help window away from the
# help. Internal links must be left alone.
def _write_doc():
b = _builder(".")
doc = new_document("", get_default_settings())
ext = dnodes.reference("", "ext", refuri="https://example.invalid/")
inte = dnodes.reference("", "int", refuri="foo.html", internal=True)
noref = dnodes.reference("", "noref")
doc += dnodes.paragraph("", "", ext, inte, noref)
del _BUILDER_CALLS[:]
b.write_doc("index", doc)
return "ext=%s,int=%s,noref=%s,%s" % (
ext.get("target"), inte.get("target"), noref.get("target"),
",".join(_BUILDER_CALLS))
guard("write_doc_targets", _write_doc)
# ------------------------------------------------------------- setup(app) ---
class _App:
def __init__(self):
self.required = []
self.extensions = []
self.builders = []
self.catalogs = []
self.config_values = []
def require_sphinx(self, version):
self.required.append(version)
def setup_extension(self, name):
self.extensions.append(name)
def add_builder(self, builder):
self.builders.append(builder.__name__)
def add_message_catalog(self, catalog, locale_dir):
self.catalogs.append((catalog, locale_dir))
def add_config_value(self, name, default, rebuild, types=()):
self.config_values.append((name, default, rebuild, types))
_app = _App()
guard("setup_return", lambda: esc(repr(H.setup(_app))))
emit("setup_require_sphinx", ",".join(_app.required))
emit("setup_extensions", ",".join(_app.extensions))
emit("setup_builders", ",".join(_app.builders))
emit("setup_catalog_name", ",".join(c for c, _ in _app.catalogs))
emit("setup_catalog_dir", ",".join(d for _, d in _app.catalogs))
emit("setup_config_names", ",".join(n for n, _, _, _ in _app.config_values))
emit("setup_config_rebuild", ",".join(
"%s:%s" % (n, r if r != "" else "") for n, _, r, _ in _app.config_values))
emit("setup_config_types", ",".join(
"%s:%s" % (n, ",".join(t.__name__ for t in (ty or ())) or "")
for n, _, _, ty in _app.config_values))
# htmlhelp_basename's default is a CALLABLE, not a value -- Sphinx calls it with
# the Config so the default can be derived from the project name. The other two
# default to None, which the HTML builder reads as "use out_suffix".
emit("setup_basename_default_callable",
"yes" if callable(_app.config_values[0][1]) else "no")
emit("setup_defaults_none", ",".join(
str(d) for n, d, _, _ in _app.config_values if n != "htmlhelp_basename"))
# default_htmlhelp_basename() is `make_filename_from_project(project) + "doc"'.
# The stub's marker is what makes this a statement about the CONCATENATION --
# the package's own contribution -- and not about Sphinx's slugging.
guard("default_basename",
lambda: H.default_htmlhelp_basename(_Cfg(project="Test Project")))
emit("mfp_called_with", ",".join(_MFP_CALLS))
# ------------------------------------------------------ the three writers ---
_W = tempfile.mkdtemp(prefix="bst-schh-")
def _tree(root):
"""A fixture output tree with one of each thing build_project_file()'s
sieve has to decide about."""
os.makedirs(os.path.join(root, "_static"), exist_ok=True)
os.makedirs(os.path.join(root, "sub"), exist_ok=True)
for rel, body in (
("index.html", "index"),
("sub/page.html", "page"),
("notes.txt", "not a page"),
("_static/app.js", "// js"),
("_static/app.css", "/* css */"),
("_static/logo.png", "PNG"),
):
p = os.path.join(root, rel.replace("/", os.sep))
with open(p, "w") as f:
f.write(body)
return root
# --- copy_stopword_list(): the .stp the full-text search reads -------------
def _stopwords():
out = _tree(os.path.join(_W, "stp"))
b = _builder(out)
b.init()
del _COPIED[:]
b.copy_stopword_list()
src, dst = _COPIED[0]
same = open(src, "rb").read() == open(dst, "rb").read()
return "%s,%s,%s" % (os.path.basename(src), os.path.basename(dst),
"identical" if same else "DIFFERS")
guard("stopword_copy", _stopwords)
# --- build_project_file(): the .hhp --------------------------------------
# THE SIEVE IS THE INTERESTING PART, AND IT READS BACKWARDS UNTIL YOU LOOK:
#
# if (in_staticdir and not fn.endswith('.js')) or fn.endswith('.html'):
#
# Under _static, a file is listed when it does NOT end in `.js' -- so the CSS
# and the images go into [FILES] and the JavaScript is left OUT, which is right
# for a .chm, whose viewer will not execute script anyway. Everywhere else only
# `.html' is listed, so a stray `.txt' beside the pages is dropped. Every path
# is then emitted with a BACKSLASH separator, because HTML Help Workshop is a
# Windows tool reading a Windows path list; forward slashes would compile to a
# .chm with no pages in it.
#
# The fixture below carries one file of each kind for exactly this reason, and
# `hhp-files' states the whole resulting list in order -- which is the only way
# a reader can tell the .js exclusion is deliberate rather than a bug here.
def _hhp():
out = _tree(os.path.join(_W, "hhp"))
b = _builder(out, language="en")
b.init()
del _RENDERS[:]
b.build_project_file()
_tpl, _name, ctx = _RENDERS[0]
emit("hhp_template_name", _name)
emit("hhp_template_dir_is_package", "yes" if _tpl == H.template_dir else "no")
emit("hhp_files", "|".join(ctx["files"]))
emit("hhp_ctx_outname", ctx["outname"])
emit("hhp_ctx_title", esc(ctx["title"]))
emit("hhp_ctx_version", ctx["version"])
emit("hhp_ctx_project", esc(ctx["project"]))
emit("hhp_ctx_lcid", "%#x" % ctx["lcid"])
emit("hhp_ctx_master_doc", ctx["master_doc"])
p = os.path.join(out, "testdoc.hhp")
emit("hhp_written", "yes" if os.path.isfile(p) else "no")
raw = open(p, "rb").read()
emit("hhp_bytes", len(raw))
if _JINJA is not None:
text = raw.decode("cp1252")
for _line in ("Compiled file=testdoc.chm", "Contents file=testdoc.hhc",
"Index file=testdoc.hhk", "Default topic=index.html",
"Full text search stop list file=testdoc.stp",
"Language=0x409", "Title=Test Project Documentation"):
emit("hhp_has_%s" % _line.split("=")[0].replace(" ", "_").lower(),
"yes" if _line in text.splitlines() else "no")
emit("hhp_files_section", "|".join(
text.split("[FILES]", 1)[1].strip().splitlines()))
return "ok"
guard("hhp", _hhp)
# --- build_toc_file(): ToCTreeVisitor and the .hhc ------------------------
# ToCTreeVisitor IS this package, walking a REAL docutils doctree. The depth
# bookkeeping is the whole of it: the OUTERMOST bullet_list emits no
# (the template supplies it), every nested one does, and a reference emits the
# two lines and then SkipNode so its children are never visited.
def _toctree_fixture():
doc = new_document("", get_default_settings())
top = compact_paragraph()
top["toctree"] = True
outer = dnodes.bullet_list()
def item(text, uri, child=None):
li = dnodes.list_item()
cp = compact_paragraph()
cp += dnodes.reference("", text, refuri=uri)
li += cp
if child is not None:
li += child
return li
inner = dnodes.bullet_list()
inner += item("Bar & Baz", "bar.html")
outer += item("Welcome ", "index.html")
outer += item("Foo's page", "foo.html", inner)
top += outer
doc += top
return doc
def _hhc():
out = _tree(os.path.join(_W, "hhc"))
b = _builder(out, language="en")
b.init()
doc = _toctree_fixture()
b.env = types.SimpleNamespace(
get_and_resolve_doctree=lambda *a, **kw: doc)
del _RENDERS[:]
b.build_toc_file()
_tpl, _name, ctx = _RENDERS[0]
emit("hhc_template_name", _name)
emit("hhc_ctx_suffix", ctx["suffix"])
emit("hhc_ctx_short_title", esc(ctx["short_title"]))
emit("hhc_ctx_master_doc", ctx["master_doc"])
emit("hhc_ctx_domain_indices", ",".join(str(d) for d in ctx["domain_indices"]) or "none")
emit("hhc_body", esc(ctx["body"]))
p = os.path.join(out, "testdoc.hhc")
emit("hhc_written", "yes" if os.path.isfile(p) else "no")
if _JINJA is not None:
text = open(p, "rb").read().decode("cp1252")
emit("hhc_has_doctype",
"yes" if text.startswith('')
else "no")
emit("hhc_has_generator",
"yes" if 'content="Microsoft® HTML Help Workshop 4.1"' in text else "no")
emit("hhc_root_sitemap",
"yes" if ' ' in text else "no")
emit("hhc_body_in_output", "yes" if ctx["body"] in text else "no")
return "ok"
guard("hhc", _hhc)
# --- build_hhx(): the .hhk index -----------------------------------------
# NO TEMPLATE IS INVOLVED. build_hhx() writes the file itself, byte by byte, so
# what comes out is entirely this package's code over the index the probe
# supplied. It is therefore the strongest single assertion in this suite, and
# it is where the CP-1252 encoding is exercised for real: `é' is IN the
# code page and must come out as the single byte 0xE9, while `→' is NOT
# and must come out as the numeric reference → -- that is what
# errors='xmlcharrefreplace' buys, and it is the behaviour most likely to
# differ under a differently built interpreter.
#
# Four index shapes, one per branch of write_index():
# 0 refs -> `See Also' pointing at the term itself
# 1 ref -> a single `Local'
# 2 refs -> a numbered `Name'/`Local' pair per reference
# subitems-> a nested of one-ref entries
def _hhk():
global _INDEX_FIXTURE
out = _tree(os.path.join(_W, "hhk"))
b = _builder(out, language="en")
b.init()
_INDEX_FIXTURE = [
("A", [
("alpha & beta", ([("", "a.html#alpha")], [], None)),
("gamma ", ([], [], None)),
("delta's", ([("", "d1.html"), ("", "d2.html")], [], None)),
("epsilon → café",
([("", "e.html")], [("sub été", [("", "s.html")])], None)),
]),
]
b.build_hhx(out, b.config.htmlhelp_basename)
p = os.path.join(out, "testdoc.hhk")
emit("hhk_written", "yes" if os.path.isfile(p) else "no")
raw = open(p, "rb").read()
emit("hhk_sha256", hashlib.sha256(raw).hexdigest())
emit("hhk_bytes", len(raw))
# THE BYTES, not a decoded string: this is where the code page is asserted.
emit("hhk_has_cp1252_eacute", "yes" if b"\xe9" in raw else "no")
emit("hhk_no_utf8_eacute", "yes" if b"\xc3\xa9" not in raw else "no")
emit("hhk_arrow_charref", "yes" if b"→" in raw else "no")
text = raw.decode("cp1252")
emit("hhk_first_line", esc(text.splitlines()[0]))
emit("hhk_last_line", esc(text.splitlines()[-1]))
emit("hhk_keywords", "|".join(
l.split('value="', 1)[1].rsplit('">', 1)[0]
for l in text.splitlines() if 'name="Keyword"' in l))
emit("hhk_see_also", "|".join(
l.split('value="', 1)[1].rsplit('">', 1)[0]
for l in text.splitlines() if 'name="See Also"' in l))
emit("hhk_locals", "|".join(
l.split('value="', 1)[1].rsplit('">', 1)[0]
for l in text.splitlines() if 'name="Local"' in l))
emit("hhk_names", "|".join(
l.split('value="', 1)[1].rsplit('">', 1)[0]
for l in text.splitlines() if 'name="Name"' in l))
emit("hhk_object_count", text.count(''))
emit("hhk_ul_open", text.count(""))
emit("hhk_ul_close", text.count(" "))
# AND THE WHOLE FILE, escaped. A red here prints the entire output on one
# line, which is what makes a formatting regression readable rather than a
# digest that only says "different".
emit("hhk_all", esc(text))
return "ok"
guard("hhk", _hhk)
# handle_finish() is the order the three writers run in, and the .stp copy has
# to precede the .hhp that names it.
def _handle_finish():
out = _tree(os.path.join(_W, "fin"))
b = _builder(out, language="en")
b.init()
b.env = types.SimpleNamespace(
get_and_resolve_doctree=lambda *a, **kw: _toctree_fixture())
order = []
for _m in ("copy_stopword_list", "build_project_file", "build_toc_file"):
def _wrap(name, orig):
def _f(*a, **kw):
order.append(name)
return orig(*a, **kw)
return _f
setattr(b, _m, _wrap(_m, getattr(b, _m)))
_orig_hhx = b.build_hhx
def _hhx(*a, **kw):
order.append("build_hhx")
return _orig_hhx(*a, **kw)
b.build_hhx = _hhx
global _INDEX_FIXTURE
_INDEX_FIXTURE = [("A", [("only", ([("", "a.html")], [], None))])]
b.handle_finish()
produced = sorted(f for f in os.listdir(out) if f.startswith("testdoc."))
return "%s;%s" % (",".join(order), ",".join(produced))
guard("handle_finish", _handle_finish)
# ------------------------------------------------------- the message catalogs --
# 54 languages of console messages, shipped as compiled .mo beside their .po
# source. The package hands the directory to Sphinx's add_message_catalog and
# never opens one itself, so this is the only place the .mo files are exercised
# at all -- parsed with the STDLIB gettext, which is what proves they survived
# packaging as something a reader could actually load.
def _catalogs():
import gettext as _gt
d = os.path.join(H.package_dir, "locales")
langs = sorted(n for n in os.listdir(d)
if os.path.isdir(os.path.join(d, n)) and not n.startswith("."))
emit("mo_langs", len(langs))
mos = []
for root, _dirs, files in os.walk(d):
mos.extend(os.path.join(root, f) for f in files if f.endswith(".mo"))
emit("mo_count", len(mos))
bad = []
for m in sorted(mos):
try:
with open(m, "rb") as f:
_gt.GNUTranslations(f)
except Exception: # noqa: BLE001
bad.append(os.path.relpath(m, d))
emit("mo_unparseable", ",".join(bad) or "none")
with open(os.path.join(d, "ja", "LC_MESSAGES", "sphinxcontrib.htmlhelp.mo"), "rb") as f:
t = _gt.GNUTranslations(f)
return t.gettext("writing project file")
guard("mo_ja_sample", _catalogs)
# THE .po SOURCES AND THE TRANSIFEX CONFIG ARE INSTALLED TOO, and that is a
# packaging wart upstream ships in the wheel rather than anything OE did. They
# are inert -- nothing reads a .po at runtime -- but they are ~40% of the
# installed file count, so the number is stated rather than left to surprise a
# reader counting files.
guard("po_count", lambda: sum(
1 for _r, _d, fs in os.walk(os.path.join(H.package_dir, "locales"))
for f in fs if f.endswith(".po")))
guard("tx_config", lambda: "yes" if os.path.isfile(
os.path.join(H.package_dir, "locales", ".tx", "config")) else "no")
# ---------------------------------------------------- the templates, by hash --
# The three template files, digested. They are the package's data rather than
# its code, and the .hhp/.hhc cases above render two of them -- so a digest
# change here with those cases still green would mean the render is not reading
# what this package installed.
def _tpl_digest(name):
with open(os.path.join(H.template_dir, name), "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
for _t in ("project.hhp", "project.hhc", "project.stp"):
guard("tpl_%s" % _t.split(".")[1], lambda _n=_t: _tpl_digest(_n))
# The stopword list is a fixed vocabulary and Microsoft documents a 256-512 byte
# ceiling for it, which the package's own docstring cites. 165 bytes leaves
# room; a translation that grew it past 512 would be silently truncated by
# Workshop.
guard("stp_bytes", lambda: os.path.getsize(
os.path.join(H.template_dir, "project.stp")))
guard("stp_words", lambda: len(open(
os.path.join(H.template_dir, "project.stp")).read().split()))
# ------------------------------------------------- the manifest checksums ---
# EVERY PAYLOAD FILE RE-HASHED ON TARGET AND SCORED AGAINST THE WHEEL'S OWN
# RECORD -- a sha256 and a byte count per row. RECORD's own row carries no
# digest (a file cannot hash itself) and is skipped by construction rather than
# by name. The __pycache__ .pyc is correctly absent from RECORD: OE creates it
# at build time with compileall, the wheel never carried it.
def _record():
site = os.path.dirname(os.path.dirname(H.package_dir))
rec = os.path.join(site, "sphinxcontrib_htmlhelp-2.0.5.dist-info", "RECORD")
rows = missing = bad_digest = bad_size = 0
miss, baddig, badsz = [], [], []
with open(rec, "r") as f:
for line in f:
line = line.rstrip("\n")
if not line:
continue
path, digest, size = line.rsplit(",", 2)
if not digest:
continue
rows += 1
full = os.path.join(site, path)
if not os.path.isfile(full):
missing += 1
miss.append(path)
continue
with open(full, "rb") as g:
data = g.read()
algo, want = digest.split("=", 1)
got = base64.urlsafe_b64encode(
hashlib.new(algo, data).digest()).rstrip(b"=").decode()
if got != want:
bad_digest += 1
baddig.append(path)
if len(data) != int(size):
bad_size += 1
badsz.append(path)
emit("record_rows", rows)
emit("record_checked", rows - missing)
emit("record_missing", ",".join(sorted(miss)))
emit("record_bad_digest", ",".join(sorted(baddig)))
emit("record_bad_size", ",".join(sorted(badsz)))
return "ok"
try:
_record()
except Exception as exc: # noqa: BLE001
for _k in ("record_rows", "record_checked", "record_missing",
"record_bad_digest", "record_bad_size"):
emit(_k, "EXC:%s" % type(exc).__name__)
emit("record_detail", esc(str(exc))[:200])
# NOTHING THIS PACKAGE LOADED IS A COMPILED EXTENSION -- the interpreter's own
# view of the packaging-side `pkg-no-so'. This image's python3 is one static
# binary with an empty lib-dynload and no dynamic loader (BRINTOS-1476) and an
# empty Py* export surface (BRINTOS-2046), so a module carrying an extension
# could not be imported here at all. Read off __file__ for every loaded module
# in the tree. The namespace package's __file__ is None and is not a finding.
def _all_pure():
bad = []
for name, mod in list(sys.modules.items()):
if not (name == "sphinxcontrib" or name.startswith("sphinxcontrib.")):
continue
f = getattr(mod, "__file__", None) or ""
if f and not (f.endswith(".py") or f.endswith(".pyc")):
bad.append(f)
return ",".join(sorted(bad)) or "yes"
emit("all_pure_python", _all_pure())
shutil.rmtree(_W, ignore_errors=True)
print("\n".join(OUT))