Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion myst_nb/sphinx_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from myst_parser.sphinx_ext.main import setup_sphinx as setup_myst_parser
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.util import logging as sphinx_logging
from sphinx.util.fileutil import copy_asset_file

Expand Down Expand Up @@ -39,6 +40,11 @@
# so we can tell if they have been set by a user, and warn them
_UNSET = "--unset--"

# sphinx-build -D passes these strings. Sphinx itself only converts "0" and "1"
# when the option's default is a bool.
_CLI_BOOL_TRUE = frozenset({"1", "true", "yes", "on"})
_CLI_BOOL_FALSE = frozenset({"0", "false", "no", "off"})


def sphinx_setup(app: Sphinx):
"""Initialize Sphinx extension."""
Expand Down Expand Up @@ -71,6 +77,8 @@ def sphinx_setup(app: Sphinx):
app.add_source_parser(Parser)
app.add_source_suffix(".md", "myst-nb", override=True)
app.add_source_suffix(".ipynb", "myst-nb")
# -D boolean strings must be real bools before Sphinx's 0/1 check (priority 800)
app.connect("config-inited", coerce_cli_bool_overrides, priority=100)
# add additional file suffixes for parsing
app.connect("config-inited", add_nb_custom_formats)
# ensure notebook checkpoints are excluded from parsing
Expand Down Expand Up @@ -123,6 +131,48 @@ def sphinx_setup(app: Sphinx):
}


def _coerce_cli_bool(value: Any) -> Any:
"""Return a real bool for the usual ``sphinx-build -D`` spellings."""
if not isinstance(value, str):
return value
token = value.strip().lower()
if token in _CLI_BOOL_TRUE:
return True
if token in _CLI_BOOL_FALSE:
return False
return value


def coerce_cli_bool_overrides(_app: Sphinx, config: Config) -> None:
"""Accept ``-D nb_execution_allow_errors=True`` as a boolean.

``sphinx-build -D`` supplies strings. These options are registered as
``Any``, and a bool default is only converted from ``0`` and ``1``.
The string ``True`` would otherwise be rejected before notebook config
is built. ``False`` stays false.
"""
names: list[str] = []
for name, _default, field in NbParserConfig().as_triple():
if field.type is not bool or field.metadata.get("sphinx_exclude"):
continue
names.append(f"nb_{name}")
legacy_name = field.metadata.get("legacy_name")
if isinstance(legacy_name, str):
names.append(legacy_name)
overrides = config.overrides
for name in names:
if name not in overrides:
continue
original = overrides[name]
coerced = _coerce_cli_bool(original)
if coerced is original:
continue
overrides[name] = coerced
# Sphinx logs the config object before this handler, and that repr
# caches the original string. Writing the attribute replaces it.
config[name] = coerced


def add_nb_custom_formats(app: Sphinx, config):
"""Add custom conversion formats."""
for suffix in config.nb_custom_formats:
Expand Down Expand Up @@ -226,4 +276,6 @@ def add_per_page_html_resources(
return
js_files = NbMetadataCollector.get_js_files(cast(SphinxEnvType, app.env), pagename)
for path, kwargs in js_files.values():
app.add_js_file(path, **kwargs) # type: ignore[arg-type]
# Stored options are dict[str, str]. Sphinx types priority as int, so
# unpacking that dict fails when this module is checked with the collector.
app.add_js_file(path, **cast(dict[str, Any], kwargs))
53 changes: 53 additions & 0 deletions tests/test_cli_bool_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Regression: sphinx-build -D boolean overrides are real bools."""

from pathlib import Path

from sphinx.application import Sphinx


def _app_with_allow_errors(root: Path, allow_errors: str) -> Sphinx:
"""Build a Sphinx app the way ``sphinx-build -D`` supplies a string."""
src = root / "src"
src.mkdir(parents=True)
(src / "conf.py").write_text(
"extensions = ['myst_nb']\nnb_execution_mode = 'off'\n",
encoding="utf-8",
)
(src / "index.md").write_text("# hi\n", encoding="utf-8")
return Sphinx(
srcdir=src,
confdir=src,
outdir=root / "out",
doctreedir=root / "doctree",
buildername="html",
confoverrides={"nb_execution_allow_errors": allow_errors},
)


def test_dash_d_allow_errors_string_is_bool(tmp_path: Path) -> None:
"""``-D nb_execution_allow_errors=True`` is a bool, and ``False`` stays false."""
true_app = _app_with_allow_errors(tmp_path / "true", "True")
assert true_app.env.mystnb_config.execution_allow_errors is True

false_app = _app_with_allow_errors(tmp_path / "false", "False")
assert false_app.env.mystnb_config.execution_allow_errors is False


def test_dash_d_legacy_allow_errors_string_is_bool(tmp_path: Path) -> None:
"""The deprecated ``execution_allow_errors`` override is a bool too."""
src = tmp_path / "src"
src.mkdir()
(src / "conf.py").write_text(
"extensions = ['myst_nb']\nnb_execution_mode = 'off'\n",
encoding="utf-8",
)
(src / "index.md").write_text("# hi\n", encoding="utf-8")
app = Sphinx(
srcdir=src,
confdir=src,
outdir=tmp_path / "out",
doctreedir=tmp_path / "doctree",
buildername="html",
confoverrides={"execution_allow_errors": "False"},
)
assert app.env.mystnb_config.execution_allow_errors is False