From 965121331d688e35fab4c12f71f608a284f661d3 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Mon, 24 Aug 2026 12:30:40 +1000 Subject: [PATCH 1/6] FEAT: Add solution_collapsed option to fold solutions by default Adds a `solution_collapsed` boolean configuration option that renders every solution directive folded by default, so readers opt in to seeing the answer. This addresses reader feedback on books that use `exercise_style = "solution_follow_exercise"`, where an inline solution sitting directly under its exercise is hard to look away from. The option adds the `dropdown` class to solution nodes, which is the class sphinx-togglebutton already consumes (its default selector is `.toggle, .admonition.dropdown`). It is equivalent to writing `:class: dropdown` on every solution, and is applied in `SolutionDirective.run()`, which `SolutionStartDirective` inherits, so gated `solution-start` / `solution-end` pairs are covered by the same code path. Details: - Registered with the "env" rebuild trigger, matching `hide_solutions` and `exercise_style`, because the class is injected at read time and baked into the pickled doctree. - Directive-level `:class:` values are preserved, and an explicit `:class: dropdown` is not duplicated. - `:class: toggle-shown` keeps an individual solution expanded while the rest of the project is collapsed. - Non-HTML builders are unaffected: the LaTeX branch of `visit_solution_node` never reads `node["classes"]`, so solutions render inline as before. - An HTML build with the option enabled but no extension providing the `dropdown` class emits a warning, rather than silently rendering every solution expanded. - Default is `False`, so existing projects and all committed regression fixtures are unchanged. Adds `sphinx_togglebutton` to the `testing` extra so the no-warning path can be covered; the test skips when it is unavailable. Refs #84 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++ docs/source/syntax.md | 47 ++++++ pyproject.toml | 1 + sphinx_exercise/__init__.py | 33 +++++ sphinx_exercise/directive.py | 7 + tests/books/test-mybook/index.rst | 1 + .../solution/_linked_enum_dropdown.rst | 8 ++ tests/test_solution_collapsed.py | 134 ++++++++++++++++++ 8 files changed, 242 insertions(+) create mode 100644 tests/books/test-mybook/solution/_linked_enum_dropdown.rst create mode 100644 tests/test_solution_collapsed.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5b655..1162020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### New ✨ + +- Added `solution_collapsed` configuration option to render all solutions folded by default ([#84](https://github.com/executablebooks/sphinx-exercise/issues/84)) + - Set to `True` to add the `dropdown` class to every solution, so readers opt in to seeing the answer + - Works with both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs + - Directive-level `:class:` values are preserved, and `:class: toggle-shown` keeps an individual solution expanded + - Requires `sphinx_togglebutton`; a warning is issued during HTML builds if it is not loaded + - Default is `False`, which maintains the original behaviour + ## [v1.2.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.2.1) (2025-11-17) ### Fixes 🐛 diff --git a/docs/source/syntax.md b/docs/source/syntax.md index ec0463b..202c85d 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -411,6 +411,53 @@ sphinx: ... ``` +### Collapse All Solutions + +All solution directives can be rendered folded by default, so readers have to opt in to seeing the answer, by setting `solution_collapsed` to `True`. This is useful when solutions are written directly after their exercises (see the **Solution Title Styling** section below), where an inline solution is otherwise hard to look away from. + +This option requires [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/) to be enabled, as it provides the drop-down behaviour for the `dropdown` class. For Sphinx projects, add the configuration key in the `conf.py` file: + +```python +# conf.py +extensions = [ + ... + "sphinx_togglebutton" + ... +] + +solution_collapsed = True +``` + +For Jupyter Book projects, set the configuration key in `_config.yml`: + +```yaml +... +sphinx: + extra_extensions: + - sphinx_togglebutton + config: + solution_collapsed: True +... +``` + +Setting `solution_collapsed` to `True` is equivalent to adding `:class: dropdown` to every solution directive in your project, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Any classes you have set on an individual directive are preserved. + +```{note} +The `dropdown` class only affects HTML output. Other builders, such as LaTeX/PDF, render the solution inline as usual. + +If `solution_collapsed` is set to `True` but no extension providing the `dropdown` class is loaded, a warning is issued during an HTML build and solutions render expanded. +``` + +To keep an individual solution expanded while the rest of the project is collapsed, add `:class: toggle-shown` to that directive: + +````md +```{solution} my-exercise +:class: toggle-shown + +This solution stays open even when `solution_collapsed = True`. +``` +```` + ### Solution Title Styling By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `exercise_style` configuration option. diff --git a/pyproject.toml b/pyproject.toml index 1600f3f..e52a1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ testing = [ "pytest-regressions", "pytest>=8.0", "sphinx>=6.1,<9", + "sphinx_togglebutton", "texsoup", "defusedxml", # Required by sphinx-testing ] diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index 1e74f78..fceac51 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -272,11 +272,44 @@ def doctree_read(app: Sphinx, document: Node) -> None: ) +# Extensions that provide the collapsible behaviour for the "dropdown" class +TOGGLE_EXTENSIONS = ("sphinx_togglebutton", "sphinx_design") + + +def check_collapsed_solutions(app: Sphinx) -> None: + """ + Warn when solution_collapsed is enabled for an HTML build but no extension + that implements the "dropdown" class is loaded. + + Without one of TOGGLE_EXTENSIONS the class is inert, so solutions would + render fully expanded and the option would silently do nothing. + """ + if not app.config.solution_collapsed: + return + + # The dropdown class is only meaningful to HTML-family builders; LaTeX and + # other builders render the solution inline, which is the intended fallback + if getattr(app.builder, "format", None) != "html": + return + + if any(ext in app.extensions for ext in TOGGLE_EXTENSIONS): + return + + logger.warning( + "[sphinx-exercise] solution_collapsed=True requires 'sphinx_togglebutton' " + "to be added to your extensions, otherwise solutions will render " + "expanded. See https://sphinx-togglebutton.readthedocs.io", + color="yellow", + ) + + def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("hide_solutions", False, "env") app.add_config_value("exercise_style", "", "env") + app.add_config_value("solution_collapsed", False, "env") app.connect("config-inited", init_numfig) # event order - 1 + app.connect("builder-inited", check_collapsed_solutions) # event order - 2 app.connect("env-purge-doc", purge_exercises) # event order - 5 per file app.connect("doctree-read", doctree_read) # event order - 8 app.connect("env-merge-info", merge_exercises) # event order - 9 diff --git a/sphinx_exercise/directive.py b/sphinx_exercise/directive.py index 6dd8fcf..cb3f4a3 100644 --- a/sphinx_exercise/directive.py +++ b/sphinx_exercise/directive.py @@ -267,6 +267,13 @@ def run(self) -> List[Node]: if self.options.get("class"): classes += self.options.get("class") + # Fold the solution by default when solution_collapsed is enabled. + # The "dropdown" class is consumed by sphinx-togglebutton, whose + # default selector is ".toggle, .admonition.dropdown". Authors can + # still opt an individual solution back open with :class: toggle-shown. + if self.env.app.config.solution_collapsed and "dropdown" not in classes: + classes.append("dropdown") + # Construct Node node = self.solution_node() node += title diff --git a/tests/books/test-mybook/index.rst b/tests/books/test-mybook/index.rst index 79e31ed..92c3064 100644 --- a/tests/books/test-mybook/index.rst +++ b/tests/books/test-mybook/index.rst @@ -32,6 +32,7 @@ A Test Program! solution/_linked_enum solution/_linked_enum_class + solution/_linked_enum_dropdown solution/_linked_missing_arg solution/_linked_unenum_mathtitle solution/_linked_unenum_mathtitle2 diff --git a/tests/books/test-mybook/solution/_linked_enum_dropdown.rst b/tests/books/test-mybook/solution/_linked_enum_dropdown.rst new file mode 100644 index 0000000..0bf885c --- /dev/null +++ b/tests/books/test-mybook/solution/_linked_enum_dropdown.rst @@ -0,0 +1,8 @@ +_linked_enum_dropdown +===================== + +.. solution:: ex-number + :label: solution-dropdown-label + :class: dropdown + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. diff --git a/tests/test_solution_collapsed.py b/tests/test_solution_collapsed.py new file mode 100644 index 0000000..eb8ea27 --- /dev/null +++ b/tests/test_solution_collapsed.py @@ -0,0 +1,134 @@ +"""Tests for the ``solution_collapsed`` configuration option. + +``solution_collapsed = True`` adds the ``dropdown`` class to every solution +directive so that solutions render folded by default. The class is consumed by +sphinx-togglebutton, whose default selector is ``.toggle, .admonition.dropdown``. +""" + +import importlib.util + +import pytest +from bs4 import BeautifulSoup + +HAS_TOGGLEBUTTON = importlib.util.find_spec("sphinx_togglebutton") is not None + + +def get_solution_classes(app, docname): + """Return the class list of the first ``div.solution`` in a built page.""" + path = app.outdir / docname + assert path.exists(), f"{docname} was not built" + soup = BeautifulSoup(path.read_text(encoding="utf8"), "html.parser") + solutions = soup.select("div.solution") + assert solutions, f"no solution directive found in {docname}" + return solutions[0].get("class", []) + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_adds_dropdown_class(app): + """solution_collapsed=True adds the 'dropdown' class to a solution.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" in classes, f"expected 'dropdown' in {classes}" + assert "solution" in classes, "the 'solution' class must be preserved" + + +@pytest.mark.sphinx("html", testroot="mybook") +def test_solution_collapsed_default_is_off(app): + """By default no 'dropdown' class is added (backwards compatibility).""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" not in classes, ( + "solution_collapsed defaults to False so no 'dropdown' class should be " + f"added, got {classes}" + ) + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_preserves_custom_class(app): + """A directive-level :class: is kept alongside the injected 'dropdown'.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum_class.html") + assert "dropdown" in classes, f"expected 'dropdown' in {classes}" + assert ( + "test-solution" in classes + ), f"the author's :class: value must be preserved, got {classes}" + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_no_duplicate_dropdown(app): + """An explicit ':class: dropdown' is not duplicated by the config option.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum_dropdown.html") + assert ( + classes.count("dropdown") == 1 + ), f"'dropdown' should appear exactly once, got {classes}" + + +@pytest.mark.sphinx("html", testroot="mybook") +def test_solution_collapsed_off_keeps_explicit_dropdown(app): + """':class: dropdown' keeps working when the config option is off.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum_dropdown.html") + assert ( + "dropdown" in classes + ), f"an explicit ':class: dropdown' must still be honoured, got {classes}" + + +@pytest.mark.sphinx( + "html", testroot="gateddirective", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_gated_directive(app): + """Gated solution-start/solution-end pairs are collapsed too. + + The class list is rebuilt by ``MergeGatedSolutions`` when the pair is merged + into a single solution node, so this guards against the injected class being + dropped in the process. + """ + app.build() + classes = get_solution_classes(app, "solution-exercise-gated.html") + assert ( + "dropdown" in classes + ), f"gated solutions should also be collapsed, got {classes}" + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_warns_without_togglebutton(app, warnings): + """A warning is emitted when no extension implements the dropdown class. + + The 'mybook' test root does not load sphinx-togglebutton, so the injected + class would be inert and the solutions would silently render expanded. + """ + app.build() + assert "solution_collapsed=True requires 'sphinx_togglebutton'" in warnings(app) + + +@pytest.mark.skipif(not HAS_TOGGLEBUTTON, reason="sphinx-togglebutton is not installed") +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "solution_collapsed": True, + "extensions": ["sphinx_exercise", "myst_nb", "sphinx_togglebutton"], + }, +) +def test_solution_collapsed_no_warning_with_togglebutton(app, warnings): + """No warning when sphinx-togglebutton is loaded.""" + app.build() + assert "solution_collapsed=True requires" not in warnings(app) + + +@pytest.mark.sphinx( + "latex", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_no_warning_for_latex(app, warnings): + """Non-HTML builders render solutions inline, so no warning is emitted.""" + app.build() + assert "solution_collapsed=True requires" not in warnings(app) From db9c761e0c8c03c8854aec5a81fce7aed0daeef9 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Mon, 24 Aug 2026 12:39:41 +1000 Subject: [PATCH 2/6] TST: Move gated collapsed test off the sphinx_book_theme testroot The `gateddirective` test root sets `html_theme = "sphinx_book_theme"`, which is currently broken on Sphinx 6 and 7 in CI: a newer pydata-sphinx-theme calls `_get_toctree_ancestors`, which does not exist in those Sphinx versions, so every test using that root fails with a ThemeError. Five pre-existing `test_gateddirective.py` tests fail the same way; that breakage is unrelated to this branch and is left alone here. Adds a self-contained gated fixture to the `mybook` test root, which uses alabaster and is unaffected, and points the gated collapsed test at it. The new fixture is also much lighter than the `gateddirective` one, which executes matplotlib code cells. Verified the fixture genuinely exercises the merge path rather than falling back: the rendered solution carries `class="solution dropdown admonition"`, the title resolves to "Solution to Exercise 7 (A gated example)", the intervening content is merged into the admonition, and no solution-end marker survives. Also adds a default-off counterpart so the gated path is covered in both directions. Refs #85 Co-Authored-By: Claude Fable 5 --- tests/books/test-mybook/index.rst | 1 + tests/books/test-mybook/solution/_linked_gated.rst | 14 ++++++++++++++ tests/test_solution_collapsed.py | 12 ++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/books/test-mybook/solution/_linked_gated.rst diff --git a/tests/books/test-mybook/index.rst b/tests/books/test-mybook/index.rst index 92c3064..5f32b96 100644 --- a/tests/books/test-mybook/index.rst +++ b/tests/books/test-mybook/index.rst @@ -33,6 +33,7 @@ A Test Program! solution/_linked_enum solution/_linked_enum_class solution/_linked_enum_dropdown + solution/_linked_gated solution/_linked_missing_arg solution/_linked_unenum_mathtitle solution/_linked_unenum_mathtitle2 diff --git a/tests/books/test-mybook/solution/_linked_gated.rst b/tests/books/test-mybook/solution/_linked_gated.rst new file mode 100644 index 0000000..7d9fae4 --- /dev/null +++ b/tests/books/test-mybook/solution/_linked_gated.rst @@ -0,0 +1,14 @@ +_linked_gated +============= + +.. exercise:: A gated example + :label: gated-ex-label + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +.. solution-start:: gated-ex-label + :label: gated-solution-label + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +.. solution-end:: diff --git a/tests/test_solution_collapsed.py b/tests/test_solution_collapsed.py index eb8ea27..94d16fa 100644 --- a/tests/test_solution_collapsed.py +++ b/tests/test_solution_collapsed.py @@ -81,7 +81,7 @@ def test_solution_collapsed_off_keeps_explicit_dropdown(app): @pytest.mark.sphinx( - "html", testroot="gateddirective", confoverrides={"solution_collapsed": True} + "html", testroot="mybook", confoverrides={"solution_collapsed": True} ) def test_solution_collapsed_gated_directive(app): """Gated solution-start/solution-end pairs are collapsed too. @@ -91,12 +91,20 @@ def test_solution_collapsed_gated_directive(app): dropped in the process. """ app.build() - classes = get_solution_classes(app, "solution-exercise-gated.html") + classes = get_solution_classes(app, "solution/_linked_gated.html") assert ( "dropdown" in classes ), f"gated solutions should also be collapsed, got {classes}" +@pytest.mark.sphinx("html", testroot="mybook") +def test_solution_collapsed_gated_default_is_off(app): + """Gated solutions get no 'dropdown' class by default.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_gated.html") + assert "dropdown" not in classes, f"expected no 'dropdown' in {classes}" + + @pytest.mark.sphinx( "html", testroot="mybook", confoverrides={"solution_collapsed": True} ) From 56b5a0a3305e685a6c01cc44c27af0270328957b Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Mon, 24 Aug 2026 12:48:13 +1000 Subject: [PATCH 3/6] FIX: Correct the togglebutton check and document collapse caveats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from an adversarial review of the initial implementation. 1. Drop `sphinx_design` from `TOGGLE_EXTENSIONS`. It does not provide the `dropdown` class: its dropdown is a directive emitting `.sd-dropdown`, and its stylesheet ships no rule for a bare `dropdown` class (verified — the compiled CSS contains only `.sd-dropdown`). Listing it suppressed the warning in exactly the case the warning exists for, and because Jupyter Book loads sphinx-design by default, a Jupyter Book project without sphinx-togglebutton would have silently rendered every solution expanded with no diagnostic. 2. Give the warning `type="exercise"` / `subtype="solution_collapsed"`. It was untyped, so it could not be suppressed and made `-W` builds fail outright for projects that supply their own `.admonition.dropdown` CSS instead of loading the extension — a setup the check's own docstring anticipates. `suppress_warnings = ["exercise.solution_collapsed"]` now silences it while still applying the class. 3. Document that collapsing hides content by zeroing its height rather than removing it, so outputs that measure themselves at load time (plotly, bokeh, ipywidgets, altair) render at zero size inside a collapsed solution. This matters for exactly the executable-book audience the option targets. Static images including matplotlib are unaffected; `:class: toggle-shown` is the per-directive escape hatch. Also hardens the test fixtures: the two new toctree entries move to the end of `test-mybook/index.rst` and the gated fixture's exercise becomes `:nonumber:`, so neither can shift the global exercise numbers baked into ~40 committed regression fixtures. A comment records the constraint for future entries. Adds a test that the warning is suppressible while the class is still applied. Verified: 127 tests pass; the new tests pass on Sphinx 6.2.1, 7.4.7 and 8.2.3; docs build adds no new warnings. Refs #85 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- docs/source/syntax.md | 8 +- sphinx_exercise/__init__.py | 15 ++- tests/books/test-mybook/index.rst | 9 +- .../test-mybook/solution/_linked_gated.rst | 1 + .../exercise-gated-0.sphinx9.html | 9 ++ .../exercise-gated.sphinx9.xml | 24 ++++ .../solution-exercise-0.sphinx9.html | 43 +++++++ .../solution-exercise-gated-0.sphinx9.html | 43 +++++++ .../solution-exercise-gated.sphinx9.xml | 111 +++++++++++++++++ .../solution-exercise.sphinx9.xml | 114 ++++++++++++++++++ tests/test_solution_collapsed.py | 22 ++++ 12 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 tests/test_gateddirective/exercise-gated-0.sphinx9.html create mode 100644 tests/test_gateddirective/exercise-gated.sphinx9.xml create mode 100644 tests/test_gateddirective/solution-exercise-0.sphinx9.html create mode 100644 tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html create mode 100644 tests/test_gateddirective/solution-exercise-gated.sphinx9.xml create mode 100644 tests/test_gateddirective/solution-exercise.sphinx9.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1162020..527eed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Set to `True` to add the `dropdown` class to every solution, so readers opt in to seeing the answer - Works with both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs - Directive-level `:class:` values are preserved, and `:class: toggle-shown` keeps an individual solution expanded - - Requires `sphinx_togglebutton`; a warning is issued during HTML builds if it is not loaded + - Requires `sphinx_togglebutton`; a warning is issued during HTML builds if it is not loaded, suppressible with `suppress_warnings = ["exercise.solution_collapsed"]` - Default is `False`, which maintains the original behaviour ## [v1.2.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.2.1) (2025-11-17) diff --git a/docs/source/syntax.md b/docs/source/syntax.md index 202c85d..1f8bfba 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -445,7 +445,13 @@ Setting `solution_collapsed` to `True` is equivalent to adding `:class: dropdown ```{note} The `dropdown` class only affects HTML output. Other builders, such as LaTeX/PDF, render the solution inline as usual. -If `solution_collapsed` is set to `True` but no extension providing the `dropdown` class is loaded, a warning is issued during an HTML build and solutions render expanded. +If `solution_collapsed` is set to `True` but `sphinx_togglebutton` is not loaded, a warning is issued during an HTML build and solutions render expanded. If your theme supplies its own `.admonition.dropdown` styling and you do not need the extension, silence the warning with `suppress_warnings = ["exercise.solution_collapsed"]`. +``` + +```{warning} +A collapsed solution is hidden by setting its height to zero rather than by removing it from the page. Outputs that measure their own size when the page loads — such as plotly, bokeh, ipywidgets and altair figures produced by `{code-cell}` blocks — will therefore render at zero size inside a collapsed solution, and may stay blank until the reader toggles it open. + +Static images, including matplotlib figures, are unaffected. If a solution contains an interactive output, keep that one expanded with `:class: toggle-shown`. ``` To keep an individual solution expanded while the rest of the project is collapsed, add `:class: toggle-shown` to that directive: diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index fceac51..1d0b26d 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -272,8 +272,14 @@ def doctree_read(app: Sphinx, document: Node) -> None: ) -# Extensions that provide the collapsible behaviour for the "dropdown" class -TOGGLE_EXTENSIONS = ("sphinx_togglebutton", "sphinx_design") +# Extensions that make the "dropdown" class collapsible. +# +# Only sphinx-togglebutton qualifies: its default togglebutton_selector is +# ".toggle, .admonition.dropdown". Note that sphinx-design does NOT belong +# here - its dropdown is a directive emitting ".sd-dropdown", and it ships no +# rule for a bare "dropdown" class. Adding it would suppress the warning below +# for Jupyter Book projects, which load sphinx-design by default. +TOGGLE_EXTENSIONS = ("sphinx_togglebutton",) def check_collapsed_solutions(app: Sphinx) -> None: @@ -283,6 +289,9 @@ def check_collapsed_solutions(app: Sphinx) -> None: Without one of TOGGLE_EXTENSIONS the class is inert, so solutions would render fully expanded and the option would silently do nothing. + + Projects that supply their own ".admonition.dropdown" CSS can silence this + with suppress_warnings = ["exercise.solution_collapsed"]. """ if not app.config.solution_collapsed: return @@ -299,6 +308,8 @@ def check_collapsed_solutions(app: Sphinx) -> None: "[sphinx-exercise] solution_collapsed=True requires 'sphinx_togglebutton' " "to be added to your extensions, otherwise solutions will render " "expanded. See https://sphinx-togglebutton.readthedocs.io", + type="exercise", + subtype="solution_collapsed", color="yellow", ) diff --git a/tests/books/test-mybook/index.rst b/tests/books/test-mybook/index.rst index 5f32b96..0dcd122 100644 --- a/tests/books/test-mybook/index.rst +++ b/tests/books/test-mybook/index.rst @@ -32,8 +32,6 @@ A Test Program! solution/_linked_enum solution/_linked_enum_class - solution/_linked_enum_dropdown - solution/_linked_gated solution/_linked_missing_arg solution/_linked_unenum_mathtitle solution/_linked_unenum_mathtitle2 @@ -49,3 +47,10 @@ A Test Program! solution/_linked_ref_wronglabel solution/_linked_duplicate_label + + .. NOTE: append new entries here. Documents containing enumerated + exercises must never be inserted above existing entries, or the + global exercise numbers baked into the regression fixtures shift. + + solution/_linked_enum_dropdown + solution/_linked_gated diff --git a/tests/books/test-mybook/solution/_linked_gated.rst b/tests/books/test-mybook/solution/_linked_gated.rst index 7d9fae4..ea50220 100644 --- a/tests/books/test-mybook/solution/_linked_gated.rst +++ b/tests/books/test-mybook/solution/_linked_gated.rst @@ -3,6 +3,7 @@ _linked_gated .. exercise:: A gated example :label: gated-ex-label + :nonumber: Lorem ipsum dolor sit amet, consectetur adipiscing elit. diff --git a/tests/test_gateddirective/exercise-gated-0.sphinx9.html b/tests/test_gateddirective/exercise-gated-0.sphinx9.html new file mode 100644 index 0000000..efd2440 --- /dev/null +++ b/tests/test_gateddirective/exercise-gated-0.sphinx9.html @@ -0,0 +1,9 @@ +
+

Exercise 3

+
+

Replicate this figure using matplotlib

+
+_images/sphx_glr_cohere_001_2_0x.png +
+
+
\ No newline at end of file diff --git a/tests/test_gateddirective/exercise-gated.sphinx9.xml b/tests/test_gateddirective/exercise-gated.sphinx9.xml new file mode 100644 index 0000000..e4f870e --- /dev/null +++ b/tests/test_gateddirective/exercise-gated.sphinx9.xml @@ -0,0 +1,24 @@ + +
+ + Gated Exercises + <paragraph> + Some Gated reference exercises + <exercise_enumerable_node classes="exercise" docname="exercise-gated" hidden="0" ids="gated-exercise-1" label="gated-exercise-1" names="gated-exercise-1" serial_number="0" title="Exercise" type="exercise"> + <exercise_title> + Exercise + <section ids="exercise-content"> + <paragraph> + Replicate this figure using matplotlib + <figure> + <image candidates="{'*': 'sphx_glr_cohere_001_2_0x.png'}" uri="sphx_glr_cohere_001_2_0x.png"> + <paragraph> + and another version with a title embedded + <exercise_enumerable_node classes="exercise" docname="exercise-gated" hidden="0" ids="gated-exercise-2" label="gated-exercise-2" names="gated-exercise-2" serial_number="1" title="Exercise" type="exercise"> + <exercise_title> + Exercise + <exercise_subtitle> + Replicate Matplotlib Plot + <section ids="exercise-content"> + <figure> + <image candidates="{'*': 'sphx_glr_cohere_001_2_0x.png'}" uri="sphx_glr_cohere_001_2_0x.png"> diff --git a/tests/test_gateddirective/solution-exercise-0.sphinx9.html b/tests/test_gateddirective/solution-exercise-0.sphinx9.html new file mode 100644 index 0000000..609f4a5 --- /dev/null +++ b/tests/test_gateddirective/solution-exercise-0.sphinx9.html @@ -0,0 +1,43 @@ +<div class="solution admonition" id="solution-gated-1"> +<p class="admonition-title">Solution to<a class="reference internal" href="exercise.html#exercise-1"> Exercise 1</a></p> +<section id="solution-content"> +<p>This is a solution to Non-Gated Exercise 1</p> +<div class="cell docutils container"> +<div class="cell_input docutils container"> +<div class="highlight-ipython3 notranslate"><div class="highlight"><pre><span></span><span class="kn">import</span><span class="w"> </span><span class="nn">numpy</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">np</span> +<span class="kn">import</span><span class="w"> </span><span class="nn">matplotlib.pyplot</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">plt</span> + +<span class="c1"># Fixing random state for reproducibility</span> +<span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">seed</span><span class="p">(</span><span class="mi">19680801</span><span class="p">)</span> + +<span class="n">dt</span> <span class="o">=</span> <span class="mf">0.01</span> +<span class="n">t</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">arange</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">30</span><span class="p">,</span> <span class="n">dt</span><span class="p">)</span> +<span class="n">nse1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 1</span> +<span class="n">nse2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 2</span> + +<span class="c1"># Two signals with a coherent part at 10Hz and a random part</span> +<span class="n">s1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse1</span> +<span class="n">s2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse2</span> + +<span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="o">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">plot</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">s1</span><span class="p">,</span> <span class="n">t</span><span class="p">,</span> <span class="n">s2</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlim</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlabel</span><span class="p">(</span><span class="s1">'time'</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'s1 and s2'</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">grid</span><span class="p">(</span><span class="kc">True</span><span class="p">)</span> + +<span class="n">cxy</span><span class="p">,</span> <span class="n">f</span> <span class="o">=</span> <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">cohere</span><span class="p">(</span><span class="n">s1</span><span class="p">,</span> <span class="n">s2</span><span class="p">,</span> <span class="n">NFFT</span><span class="o">=</span><span class="mi">256</span><span class="p">,</span> <span class="n">Fs</span><span class="o">=</span><span class="mf">1.</span> <span class="o">/</span> <span class="n">dt</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'coherence'</span><span class="p">)</span> + +<span class="n">fig</span><span class="o">.</span><span class="n">tight_layout</span><span class="p">()</span> +<span class="n">plt</span><span class="o">.</span><span class="n">show</span><span class="p">()</span> +</pre></div> +</div> +</div> +<div class="cell_output docutils container"> +<img alt="_images/IMAGEHASH.png" src="_images/IMAGEHASH.png"/> +</div> +</div> +<p>With some follow up text to the solution</p> +</section> +</div> \ No newline at end of file diff --git a/tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html b/tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html new file mode 100644 index 0000000..cb426ea --- /dev/null +++ b/tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html @@ -0,0 +1,43 @@ +<div class="solution admonition" id="gated-exercise-solution-1"> +<p class="admonition-title">Solution to<a class="reference internal" href="exercise-gated.html#gated-exercise-1"> Exercise 3</a></p> +<section id="solution-content"> +<p>This is a solution to Gated Exercise 1</p> +<div class="cell docutils container"> +<div class="cell_input docutils container"> +<div class="highlight-ipython3 notranslate"><div class="highlight"><pre><span></span><span class="kn">import</span><span class="w"> </span><span class="nn">numpy</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">np</span> +<span class="kn">import</span><span class="w"> </span><span class="nn">matplotlib.pyplot</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">plt</span> + +<span class="c1"># Fixing random state for reproducibility</span> +<span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">seed</span><span class="p">(</span><span class="mi">19680801</span><span class="p">)</span> + +<span class="n">dt</span> <span class="o">=</span> <span class="mf">0.01</span> +<span class="n">t</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">arange</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">30</span><span class="p">,</span> <span class="n">dt</span><span class="p">)</span> +<span class="n">nse1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 1</span> +<span class="n">nse2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 2</span> + +<span class="c1"># Two signals with a coherent part at 10Hz and a random part</span> +<span class="n">s1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse1</span> +<span class="n">s2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse2</span> + +<span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="o">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">plot</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">s1</span><span class="p">,</span> <span class="n">t</span><span class="p">,</span> <span class="n">s2</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlim</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlabel</span><span class="p">(</span><span class="s1">'time'</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'s1 and s2'</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">grid</span><span class="p">(</span><span class="kc">True</span><span class="p">)</span> + +<span class="n">cxy</span><span class="p">,</span> <span class="n">f</span> <span class="o">=</span> <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">cohere</span><span class="p">(</span><span class="n">s1</span><span class="p">,</span> <span class="n">s2</span><span class="p">,</span> <span class="n">NFFT</span><span class="o">=</span><span class="mi">256</span><span class="p">,</span> <span class="n">Fs</span><span class="o">=</span><span class="mf">1.</span> <span class="o">/</span> <span class="n">dt</span><span class="p">)</span> +<span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'coherence'</span><span class="p">)</span> + +<span class="n">fig</span><span class="o">.</span><span class="n">tight_layout</span><span class="p">()</span> +<span class="n">plt</span><span class="o">.</span><span class="n">show</span><span class="p">()</span> +</pre></div> +</div> +</div> +<div class="cell_output docutils container"> +<img alt="_images/IMAGEHASH.png" src="_images/IMAGEHASH.png"/> +</div> +</div> +<p>With some follow up text to the solution</p> +</section> +</div> \ No newline at end of file diff --git a/tests/test_gateddirective/solution-exercise-gated.sphinx9.xml b/tests/test_gateddirective/solution-exercise-gated.sphinx9.xml new file mode 100644 index 0000000..8fea8bb --- /dev/null +++ b/tests/test_gateddirective/solution-exercise-gated.sphinx9.xml @@ -0,0 +1,111 @@ +<document source="solution-exercise-gated.md"> + <section ids="gated-solutions-to-exercise-gated-md" names="gated\ solutions\ to\ exercise-gated.md"> + <title> + Gated Solutions to exercise-gated.md + <paragraph> + A solution using the gated directive + <solution_node classes="solution" docname="solution-exercise-gated" hidden="0" ids="gated-exercise-solution-1" label="gated-exercise-solution-1" names="gated-exercise-solution-1" serial_number="0" target_label="gated-exercise-1" title="Solution to" type="solution"> + <solution_title> + Solution to + <section ids="solution-content"> + <paragraph> + This is a solution to Gated Exercise 1 + <container cell_index="1" cell_metadata="{}" classes="cell" exec_count="1" nb_element="cell_code"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" xml:space="preserve"> + import numpy as np + import matplotlib.pyplot as plt + + # Fixing random state for reproducibility + np.random.seed(19680801) + + dt = 0.01 + t = np.arange(0, 30, dt) + nse1 = np.random.randn(len(t)) # white noise 1 + nse2 = np.random.randn(len(t)) # white noise 2 + + # Two signals with a coherent part at 10Hz and a random part + s1 = np.sin(2 * np.pi * 10 * t) + nse1 + s2 = np.sin(2 * np.pi * 10 * t) + nse2 + + fig, axs = plt.subplots(2, 1) + axs[0].plot(t, s1, t, s2) + axs[0].set_xlim(0, 2) + axs[0].set_xlabel('time') + axs[0].set_ylabel('s1 and s2') + axs[0].grid(True) + + cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) + axs[1].set_ylabel('coherence') + + fig.tight_layout() + plt.show() + <container classes="cell_output" nb_element="cell_code_output"> + <container nb_element="mime_bundle"> + <container mime_type="text/plain"> + <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> + <Figure size 640x480 with 2 Axes> + <container mime_type="image/png"> + <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> + <paragraph> + With some follow up text to the solution + <paragraph> + and then a solution to + <pending_xref refdoc="solution-exercise-gated" refdomain="std" refexplicit="0" reftarget="gated-exercise-2" reftype="ref" refwarn="1"> + <inline classes="xref std std-ref"> + gated-exercise-2 + <paragraph> + A solution using the gated directive + <solution_node classes="solution" docname="solution-exercise-gated" hidden="0" ids="gated-exercise-solution-2" label="gated-exercise-solution-2" names="gated-exercise-solution-2" serial_number="1" target_label="gated-exercise-2" title="Solution to" type="solution"> + <solution_title> + Solution to + <section ids="solution-content"> + <paragraph> + This is a solution to Gated Exercise 2 + <container cell_index="3" cell_metadata="{}" classes="cell" exec_count="2" nb_element="cell_code"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" xml:space="preserve"> + import numpy as np + import matplotlib.pyplot as plt + + # Fixing random state for reproducibility + np.random.seed(19680801) + + dt = 0.01 + t = np.arange(0, 30, dt) + nse1 = np.random.randn(len(t)) # white noise 1 + nse2 = np.random.randn(len(t)) # white noise 2 + + # Two signals with a coherent part at 10Hz and a random part + s1 = np.sin(2 * np.pi * 10 * t) + nse1 + s2 = np.sin(2 * np.pi * 10 * t) + nse2 + + fig, axs = plt.subplots(2, 1) + axs[0].plot(t, s1, t, s2) + axs[0].set_xlim(0, 2) + axs[0].set_xlabel('time') + axs[0].set_ylabel('s1 and s2') + axs[0].grid(True) + + cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) + axs[1].set_ylabel('coherence') + + fig.tight_layout() + plt.show() + <container classes="cell_output" nb_element="cell_code_output"> + <container nb_element="mime_bundle"> + <container mime_type="text/plain"> + <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> + <Figure size 640x480 with 2 Axes> + <container mime_type="image/png"> + <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> + <paragraph> + With some follow up text to the solution + <section ids="references-to-solutions" names="references\ to\ solutions"> + <title> + References to Solutions + <paragraph> + This is a reference to + <pending_xref refdoc="solution-exercise-gated" refdomain="std" refexplicit="0" reftarget="gated-exercise-solution-1" reftype="ref" refwarn="1"> + <inline classes="xref std std-ref"> + gated-exercise-solution-1 diff --git a/tests/test_gateddirective/solution-exercise.sphinx9.xml b/tests/test_gateddirective/solution-exercise.sphinx9.xml new file mode 100644 index 0000000..e864745 --- /dev/null +++ b/tests/test_gateddirective/solution-exercise.sphinx9.xml @@ -0,0 +1,114 @@ +<document source="solution-exercise.md"> + <section ids="gated-solutions-to-exercise-md" names="gated\ solutions\ to\ exercise.md"> + <title> + Gated Solutions to exercise.md + <paragraph> + A solution using the gated directive + <solution_node classes="solution" docname="solution-exercise" hidden="0" ids="solution-gated-1" label="solution-gated-1" names="solution-gated-1" serial_number="0" target_label="exercise-1" title="Solution to" type="solution"> + <solution_title> + Solution to + <section ids="solution-content"> + <paragraph> + This is a solution to Non-Gated Exercise 1 + <container cell_index="1" cell_metadata="{}" classes="cell" exec_count="1" nb_element="cell_code"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" xml:space="preserve"> + import numpy as np + import matplotlib.pyplot as plt + + # Fixing random state for reproducibility + np.random.seed(19680801) + + dt = 0.01 + t = np.arange(0, 30, dt) + nse1 = np.random.randn(len(t)) # white noise 1 + nse2 = np.random.randn(len(t)) # white noise 2 + + # Two signals with a coherent part at 10Hz and a random part + s1 = np.sin(2 * np.pi * 10 * t) + nse1 + s2 = np.sin(2 * np.pi * 10 * t) + nse2 + + fig, axs = plt.subplots(2, 1) + axs[0].plot(t, s1, t, s2) + axs[0].set_xlim(0, 2) + axs[0].set_xlabel('time') + axs[0].set_ylabel('s1 and s2') + axs[0].grid(True) + + cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) + axs[1].set_ylabel('coherence') + + fig.tight_layout() + plt.show() + <container classes="cell_output" nb_element="cell_code_output"> + <container nb_element="mime_bundle"> + <container mime_type="text/plain"> + <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> + <Figure size 640x480 with 2 Axes> + <container mime_type="image/png"> + <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> + <paragraph> + With some follow up text to the solution + <paragraph> + and a solution to + <pending_xref refdoc="solution-exercise" refdomain="std" refexplicit="0" reftarget="exercise-2" reftype="ref" refwarn="1"> + <inline classes="xref std std-ref"> + exercise-2 + <solution_node classes="solution" docname="solution-exercise" hidden="0" ids="solution-gated-2" label="solution-gated-2" names="solution-gated-2" serial_number="1" target_label="exercise-2" title="Solution to" type="solution"> + <solution_title> + Solution to + <section ids="solution-content"> + <paragraph> + This is a solution to Non-Gated Exercise 1 + <container cell_index="3" cell_metadata="{}" classes="cell" exec_count="2" nb_element="cell_code"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" xml:space="preserve"> + import numpy as np + import matplotlib.pyplot as plt + + # Fixing random state for reproducibility + np.random.seed(19680801) + + dt = 0.01 + t = np.arange(0, 30, dt) + nse1 = np.random.randn(len(t)) # white noise 1 + nse2 = np.random.randn(len(t)) # white noise 2 + + # Two signals with a coherent part at 10Hz and a random part + s1 = np.sin(2 * np.pi * 10 * t) + nse1 + s2 = np.sin(2 * np.pi * 10 * t) + nse2 + + fig, axs = plt.subplots(2, 1) + axs[0].plot(t, s1, t, s2) + axs[0].set_xlim(0, 2) + axs[0].set_xlabel('time') + axs[0].set_ylabel('s1 and s2') + axs[0].grid(True) + + cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) + axs[1].set_ylabel('coherence') + + fig.tight_layout() + plt.show() + <container classes="cell_output" nb_element="cell_code_output"> + <container nb_element="mime_bundle"> + <container mime_type="text/plain"> + <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> + <Figure size 640x480 with 2 Axes> + <container mime_type="image/png"> + <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> + <paragraph> + With some follow up text to the solution + <section ids="references" names="references"> + <title> + References + <paragraph> + This is a reference to + <pending_xref refdoc="solution-exercise" refdomain="std" refexplicit="0" reftarget="solution-gated-1" reftype="ref" refwarn="1"> + <inline classes="xref std std-ref"> + solution-gated-1 + <paragraph> + This is a reference to + <pending_xref refdoc="solution-exercise" refdomain="std" refexplicit="0" reftarget="solution-gated-2" reftype="ref" refwarn="1"> + <inline classes="xref std std-ref"> + solution-gated-2 diff --git a/tests/test_solution_collapsed.py b/tests/test_solution_collapsed.py index 94d16fa..2f7d1c3 100644 --- a/tests/test_solution_collapsed.py +++ b/tests/test_solution_collapsed.py @@ -133,6 +133,28 @@ def test_solution_collapsed_no_warning_with_togglebutton(app, warnings): assert "solution_collapsed=True requires" not in warnings(app) +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "solution_collapsed": True, + "suppress_warnings": ["exercise.solution_collapsed"], + }, +) +def test_solution_collapsed_warning_is_suppressible(app, warnings): + """The warning is typed, so projects supplying their own CSS can silence it. + + Without a type/subtype the warning would be unsuppressible and would break + any ``-W`` build for a project that provides its own ``.admonition.dropdown`` + rules instead of loading sphinx-togglebutton. + """ + app.build() + assert "solution_collapsed=True requires" not in warnings(app) + # the class is still applied - suppression only silences the warning + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" in classes, f"expected 'dropdown' in {classes}" + + @pytest.mark.sphinx( "latex", testroot="mybook", confoverrides={"solution_collapsed": True} ) From ad8e423b25c62a6e403a9a633b8f88879ba821d9 Mon Sep 17 00:00:00 2001 From: Matt McKay <mmcky@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:52:24 +1000 Subject: [PATCH 4/6] CHORE: Remove stray Sphinx 9 regression fixtures These six .sphinx9 baselines were generated accidentally while reproducing an unrelated CI failure in a scratch environment that had been upgraded past the supported Sphinx range. pytest-regressions writes a new baseline when it finds no file for the running version's suffix, so they were created as a side effect rather than deliberately. They do not belong in the tree: the project supports sphinx>=6.1,<9, so there is no CI job that would ever read them, and they record output produced with a theme/Sphinx combination the suite does not target. Refs #85 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../exercise-gated-0.sphinx9.html | 9 -- .../exercise-gated.sphinx9.xml | 24 ---- .../solution-exercise-0.sphinx9.html | 43 ------- .../solution-exercise-gated-0.sphinx9.html | 43 ------- .../solution-exercise-gated.sphinx9.xml | 111 ----------------- .../solution-exercise.sphinx9.xml | 114 ------------------ 6 files changed, 344 deletions(-) delete mode 100644 tests/test_gateddirective/exercise-gated-0.sphinx9.html delete mode 100644 tests/test_gateddirective/exercise-gated.sphinx9.xml delete mode 100644 tests/test_gateddirective/solution-exercise-0.sphinx9.html delete mode 100644 tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html delete mode 100644 tests/test_gateddirective/solution-exercise-gated.sphinx9.xml delete mode 100644 tests/test_gateddirective/solution-exercise.sphinx9.xml diff --git a/tests/test_gateddirective/exercise-gated-0.sphinx9.html b/tests/test_gateddirective/exercise-gated-0.sphinx9.html deleted file mode 100644 index efd2440..0000000 --- a/tests/test_gateddirective/exercise-gated-0.sphinx9.html +++ /dev/null @@ -1,9 +0,0 @@ -<div class="exercise admonition" id="gated-exercise-1"> -<p class="admonition-title"><span class="caption-number">Exercise 3 </span></p> -<section id="exercise-content"> -<p>Replicate this figure using matplotlib</p> -<figure class="align-default"> -<img alt="_images/sphx_glr_cohere_001_2_0x.png" src="_images/sphx_glr_cohere_001_2_0x.png"/> -</figure> -</section> -</div> \ No newline at end of file diff --git a/tests/test_gateddirective/exercise-gated.sphinx9.xml b/tests/test_gateddirective/exercise-gated.sphinx9.xml deleted file mode 100644 index e4f870e..0000000 --- a/tests/test_gateddirective/exercise-gated.sphinx9.xml +++ /dev/null @@ -1,24 +0,0 @@ -<document source="exercise-gated.md"> - <section ids="gated-exercises" names="gated\ exercises"> - <title> - Gated Exercises - <paragraph> - Some Gated reference exercises - <exercise_enumerable_node classes="exercise" docname="exercise-gated" hidden="0" ids="gated-exercise-1" label="gated-exercise-1" names="gated-exercise-1" serial_number="0" title="Exercise" type="exercise"> - <exercise_title> - Exercise - <section ids="exercise-content"> - <paragraph> - Replicate this figure using matplotlib - <figure> - <image candidates="{'*': 'sphx_glr_cohere_001_2_0x.png'}" uri="sphx_glr_cohere_001_2_0x.png"> - <paragraph> - and another version with a title embedded - <exercise_enumerable_node classes="exercise" docname="exercise-gated" hidden="0" ids="gated-exercise-2" label="gated-exercise-2" names="gated-exercise-2" serial_number="1" title="Exercise" type="exercise"> - <exercise_title> - Exercise - <exercise_subtitle> - Replicate Matplotlib Plot - <section ids="exercise-content"> - <figure> - <image candidates="{'*': 'sphx_glr_cohere_001_2_0x.png'}" uri="sphx_glr_cohere_001_2_0x.png"> diff --git a/tests/test_gateddirective/solution-exercise-0.sphinx9.html b/tests/test_gateddirective/solution-exercise-0.sphinx9.html deleted file mode 100644 index 609f4a5..0000000 --- a/tests/test_gateddirective/solution-exercise-0.sphinx9.html +++ /dev/null @@ -1,43 +0,0 @@ -<div class="solution admonition" id="solution-gated-1"> -<p class="admonition-title">Solution to<a class="reference internal" href="exercise.html#exercise-1"> Exercise 1</a></p> -<section id="solution-content"> -<p>This is a solution to Non-Gated Exercise 1</p> -<div class="cell docutils container"> -<div class="cell_input docutils container"> -<div class="highlight-ipython3 notranslate"><div class="highlight"><pre><span></span><span class="kn">import</span><span class="w"> </span><span class="nn">numpy</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">np</span> -<span class="kn">import</span><span class="w"> </span><span class="nn">matplotlib.pyplot</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">plt</span> - -<span class="c1"># Fixing random state for reproducibility</span> -<span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">seed</span><span class="p">(</span><span class="mi">19680801</span><span class="p">)</span> - -<span class="n">dt</span> <span class="o">=</span> <span class="mf">0.01</span> -<span class="n">t</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">arange</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">30</span><span class="p">,</span> <span class="n">dt</span><span class="p">)</span> -<span class="n">nse1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 1</span> -<span class="n">nse2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 2</span> - -<span class="c1"># Two signals with a coherent part at 10Hz and a random part</span> -<span class="n">s1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse1</span> -<span class="n">s2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse2</span> - -<span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="o">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">plot</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">s1</span><span class="p">,</span> <span class="n">t</span><span class="p">,</span> <span class="n">s2</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlim</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlabel</span><span class="p">(</span><span class="s1">'time'</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'s1 and s2'</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">grid</span><span class="p">(</span><span class="kc">True</span><span class="p">)</span> - -<span class="n">cxy</span><span class="p">,</span> <span class="n">f</span> <span class="o">=</span> <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">cohere</span><span class="p">(</span><span class="n">s1</span><span class="p">,</span> <span class="n">s2</span><span class="p">,</span> <span class="n">NFFT</span><span class="o">=</span><span class="mi">256</span><span class="p">,</span> <span class="n">Fs</span><span class="o">=</span><span class="mf">1.</span> <span class="o">/</span> <span class="n">dt</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'coherence'</span><span class="p">)</span> - -<span class="n">fig</span><span class="o">.</span><span class="n">tight_layout</span><span class="p">()</span> -<span class="n">plt</span><span class="o">.</span><span class="n">show</span><span class="p">()</span> -</pre></div> -</div> -</div> -<div class="cell_output docutils container"> -<img alt="_images/IMAGEHASH.png" src="_images/IMAGEHASH.png"/> -</div> -</div> -<p>With some follow up text to the solution</p> -</section> -</div> \ No newline at end of file diff --git a/tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html b/tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html deleted file mode 100644 index cb426ea..0000000 --- a/tests/test_gateddirective/solution-exercise-gated-0.sphinx9.html +++ /dev/null @@ -1,43 +0,0 @@ -<div class="solution admonition" id="gated-exercise-solution-1"> -<p class="admonition-title">Solution to<a class="reference internal" href="exercise-gated.html#gated-exercise-1"> Exercise 3</a></p> -<section id="solution-content"> -<p>This is a solution to Gated Exercise 1</p> -<div class="cell docutils container"> -<div class="cell_input docutils container"> -<div class="highlight-ipython3 notranslate"><div class="highlight"><pre><span></span><span class="kn">import</span><span class="w"> </span><span class="nn">numpy</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">np</span> -<span class="kn">import</span><span class="w"> </span><span class="nn">matplotlib.pyplot</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">plt</span> - -<span class="c1"># Fixing random state for reproducibility</span> -<span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">seed</span><span class="p">(</span><span class="mi">19680801</span><span class="p">)</span> - -<span class="n">dt</span> <span class="o">=</span> <span class="mf">0.01</span> -<span class="n">t</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">arange</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">30</span><span class="p">,</span> <span class="n">dt</span><span class="p">)</span> -<span class="n">nse1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 1</span> -<span class="n">nse2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">t</span><span class="p">))</span> <span class="c1"># white noise 2</span> - -<span class="c1"># Two signals with a coherent part at 10Hz and a random part</span> -<span class="n">s1</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse1</span> -<span class="n">s2</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="mi">10</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="o">+</span> <span class="n">nse2</span> - -<span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="o">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">plot</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">s1</span><span class="p">,</span> <span class="n">t</span><span class="p">,</span> <span class="n">s2</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlim</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_xlabel</span><span class="p">(</span><span class="s1">'time'</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'s1 and s2'</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">grid</span><span class="p">(</span><span class="kc">True</span><span class="p">)</span> - -<span class="n">cxy</span><span class="p">,</span> <span class="n">f</span> <span class="o">=</span> <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">cohere</span><span class="p">(</span><span class="n">s1</span><span class="p">,</span> <span class="n">s2</span><span class="p">,</span> <span class="n">NFFT</span><span class="o">=</span><span class="mi">256</span><span class="p">,</span> <span class="n">Fs</span><span class="o">=</span><span class="mf">1.</span> <span class="o">/</span> <span class="n">dt</span><span class="p">)</span> -<span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s1">'coherence'</span><span class="p">)</span> - -<span class="n">fig</span><span class="o">.</span><span class="n">tight_layout</span><span class="p">()</span> -<span class="n">plt</span><span class="o">.</span><span class="n">show</span><span class="p">()</span> -</pre></div> -</div> -</div> -<div class="cell_output docutils container"> -<img alt="_images/IMAGEHASH.png" src="_images/IMAGEHASH.png"/> -</div> -</div> -<p>With some follow up text to the solution</p> -</section> -</div> \ No newline at end of file diff --git a/tests/test_gateddirective/solution-exercise-gated.sphinx9.xml b/tests/test_gateddirective/solution-exercise-gated.sphinx9.xml deleted file mode 100644 index 8fea8bb..0000000 --- a/tests/test_gateddirective/solution-exercise-gated.sphinx9.xml +++ /dev/null @@ -1,111 +0,0 @@ -<document source="solution-exercise-gated.md"> - <section ids="gated-solutions-to-exercise-gated-md" names="gated\ solutions\ to\ exercise-gated.md"> - <title> - Gated Solutions to exercise-gated.md - <paragraph> - A solution using the gated directive - <solution_node classes="solution" docname="solution-exercise-gated" hidden="0" ids="gated-exercise-solution-1" label="gated-exercise-solution-1" names="gated-exercise-solution-1" serial_number="0" target_label="gated-exercise-1" title="Solution to" type="solution"> - <solution_title> - Solution to - <section ids="solution-content"> - <paragraph> - This is a solution to Gated Exercise 1 - <container cell_index="1" cell_metadata="{}" classes="cell" exec_count="1" nb_element="cell_code"> - <container classes="cell_input" nb_element="cell_code_source"> - <literal_block language="ipython3" xml:space="preserve"> - import numpy as np - import matplotlib.pyplot as plt - - # Fixing random state for reproducibility - np.random.seed(19680801) - - dt = 0.01 - t = np.arange(0, 30, dt) - nse1 = np.random.randn(len(t)) # white noise 1 - nse2 = np.random.randn(len(t)) # white noise 2 - - # Two signals with a coherent part at 10Hz and a random part - s1 = np.sin(2 * np.pi * 10 * t) + nse1 - s2 = np.sin(2 * np.pi * 10 * t) + nse2 - - fig, axs = plt.subplots(2, 1) - axs[0].plot(t, s1, t, s2) - axs[0].set_xlim(0, 2) - axs[0].set_xlabel('time') - axs[0].set_ylabel('s1 and s2') - axs[0].grid(True) - - cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) - axs[1].set_ylabel('coherence') - - fig.tight_layout() - plt.show() - <container classes="cell_output" nb_element="cell_code_output"> - <container nb_element="mime_bundle"> - <container mime_type="text/plain"> - <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> - <Figure size 640x480 with 2 Axes> - <container mime_type="image/png"> - <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> - <paragraph> - With some follow up text to the solution - <paragraph> - and then a solution to - <pending_xref refdoc="solution-exercise-gated" refdomain="std" refexplicit="0" reftarget="gated-exercise-2" reftype="ref" refwarn="1"> - <inline classes="xref std std-ref"> - gated-exercise-2 - <paragraph> - A solution using the gated directive - <solution_node classes="solution" docname="solution-exercise-gated" hidden="0" ids="gated-exercise-solution-2" label="gated-exercise-solution-2" names="gated-exercise-solution-2" serial_number="1" target_label="gated-exercise-2" title="Solution to" type="solution"> - <solution_title> - Solution to - <section ids="solution-content"> - <paragraph> - This is a solution to Gated Exercise 2 - <container cell_index="3" cell_metadata="{}" classes="cell" exec_count="2" nb_element="cell_code"> - <container classes="cell_input" nb_element="cell_code_source"> - <literal_block language="ipython3" xml:space="preserve"> - import numpy as np - import matplotlib.pyplot as plt - - # Fixing random state for reproducibility - np.random.seed(19680801) - - dt = 0.01 - t = np.arange(0, 30, dt) - nse1 = np.random.randn(len(t)) # white noise 1 - nse2 = np.random.randn(len(t)) # white noise 2 - - # Two signals with a coherent part at 10Hz and a random part - s1 = np.sin(2 * np.pi * 10 * t) + nse1 - s2 = np.sin(2 * np.pi * 10 * t) + nse2 - - fig, axs = plt.subplots(2, 1) - axs[0].plot(t, s1, t, s2) - axs[0].set_xlim(0, 2) - axs[0].set_xlabel('time') - axs[0].set_ylabel('s1 and s2') - axs[0].grid(True) - - cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) - axs[1].set_ylabel('coherence') - - fig.tight_layout() - plt.show() - <container classes="cell_output" nb_element="cell_code_output"> - <container nb_element="mime_bundle"> - <container mime_type="text/plain"> - <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> - <Figure size 640x480 with 2 Axes> - <container mime_type="image/png"> - <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> - <paragraph> - With some follow up text to the solution - <section ids="references-to-solutions" names="references\ to\ solutions"> - <title> - References to Solutions - <paragraph> - This is a reference to - <pending_xref refdoc="solution-exercise-gated" refdomain="std" refexplicit="0" reftarget="gated-exercise-solution-1" reftype="ref" refwarn="1"> - <inline classes="xref std std-ref"> - gated-exercise-solution-1 diff --git a/tests/test_gateddirective/solution-exercise.sphinx9.xml b/tests/test_gateddirective/solution-exercise.sphinx9.xml deleted file mode 100644 index e864745..0000000 --- a/tests/test_gateddirective/solution-exercise.sphinx9.xml +++ /dev/null @@ -1,114 +0,0 @@ -<document source="solution-exercise.md"> - <section ids="gated-solutions-to-exercise-md" names="gated\ solutions\ to\ exercise.md"> - <title> - Gated Solutions to exercise.md - <paragraph> - A solution using the gated directive - <solution_node classes="solution" docname="solution-exercise" hidden="0" ids="solution-gated-1" label="solution-gated-1" names="solution-gated-1" serial_number="0" target_label="exercise-1" title="Solution to" type="solution"> - <solution_title> - Solution to - <section ids="solution-content"> - <paragraph> - This is a solution to Non-Gated Exercise 1 - <container cell_index="1" cell_metadata="{}" classes="cell" exec_count="1" nb_element="cell_code"> - <container classes="cell_input" nb_element="cell_code_source"> - <literal_block language="ipython3" xml:space="preserve"> - import numpy as np - import matplotlib.pyplot as plt - - # Fixing random state for reproducibility - np.random.seed(19680801) - - dt = 0.01 - t = np.arange(0, 30, dt) - nse1 = np.random.randn(len(t)) # white noise 1 - nse2 = np.random.randn(len(t)) # white noise 2 - - # Two signals with a coherent part at 10Hz and a random part - s1 = np.sin(2 * np.pi * 10 * t) + nse1 - s2 = np.sin(2 * np.pi * 10 * t) + nse2 - - fig, axs = plt.subplots(2, 1) - axs[0].plot(t, s1, t, s2) - axs[0].set_xlim(0, 2) - axs[0].set_xlabel('time') - axs[0].set_ylabel('s1 and s2') - axs[0].grid(True) - - cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) - axs[1].set_ylabel('coherence') - - fig.tight_layout() - plt.show() - <container classes="cell_output" nb_element="cell_code_output"> - <container nb_element="mime_bundle"> - <container mime_type="text/plain"> - <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> - <Figure size 640x480 with 2 Axes> - <container mime_type="image/png"> - <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> - <paragraph> - With some follow up text to the solution - <paragraph> - and a solution to - <pending_xref refdoc="solution-exercise" refdomain="std" refexplicit="0" reftarget="exercise-2" reftype="ref" refwarn="1"> - <inline classes="xref std std-ref"> - exercise-2 - <solution_node classes="solution" docname="solution-exercise" hidden="0" ids="solution-gated-2" label="solution-gated-2" names="solution-gated-2" serial_number="1" target_label="exercise-2" title="Solution to" type="solution"> - <solution_title> - Solution to - <section ids="solution-content"> - <paragraph> - This is a solution to Non-Gated Exercise 1 - <container cell_index="3" cell_metadata="{}" classes="cell" exec_count="2" nb_element="cell_code"> - <container classes="cell_input" nb_element="cell_code_source"> - <literal_block language="ipython3" xml:space="preserve"> - import numpy as np - import matplotlib.pyplot as plt - - # Fixing random state for reproducibility - np.random.seed(19680801) - - dt = 0.01 - t = np.arange(0, 30, dt) - nse1 = np.random.randn(len(t)) # white noise 1 - nse2 = np.random.randn(len(t)) # white noise 2 - - # Two signals with a coherent part at 10Hz and a random part - s1 = np.sin(2 * np.pi * 10 * t) + nse1 - s2 = np.sin(2 * np.pi * 10 * t) + nse2 - - fig, axs = plt.subplots(2, 1) - axs[0].plot(t, s1, t, s2) - axs[0].set_xlim(0, 2) - axs[0].set_xlabel('time') - axs[0].set_ylabel('s1 and s2') - axs[0].grid(True) - - cxy, f = axs[1].cohere(s1, s2, NFFT=256, Fs=1. / dt) - axs[1].set_ylabel('coherence') - - fig.tight_layout() - plt.show() - <container classes="cell_output" nb_element="cell_code_output"> - <container nb_element="mime_bundle"> - <container mime_type="text/plain"> - <literal_block classes="output text_plain" language="myst-ansi" xml:space="preserve"> - <Figure size 640x480 with 2 Axes> - <container mime_type="image/png"> - <image candidates="{'*': '_build/jupyter_execute/IMAGEHASH.png'}" uri="_build/jupyter_execute/IMAGEHASH.png"> - <paragraph> - With some follow up text to the solution - <section ids="references" names="references"> - <title> - References - <paragraph> - This is a reference to - <pending_xref refdoc="solution-exercise" refdomain="std" refexplicit="0" reftarget="solution-gated-1" reftype="ref" refwarn="1"> - <inline classes="xref std std-ref"> - solution-gated-1 - <paragraph> - This is a reference to - <pending_xref refdoc="solution-exercise" refdomain="std" refexplicit="0" reftarget="solution-gated-2" reftype="ref" refwarn="1"> - <inline classes="xref std std-ref"> - solution-gated-2 From 67473487736c08eed666658a2b637a36092aa440 Mon Sep 17 00:00:00 2001 From: Matt McKay <mmcky@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:26:05 +1000 Subject: [PATCH 5/6] FEAT: Make solution_follow_exercise collapse solutions by default Folds collapsing into what the `solution_follow_exercise` exercise style means, rather than leaving it as an unrelated switch authors must find. That style places each solution directly beneath its exercise, which is exactly the layout the reader feedback in #84 was about: an adjacent, fully visible solution is too tempting to look at. Treating the fold as part of the style means the option authors already reach for does the right thing by default. `solution_collapsed` becomes tri-state to make this expressible: unset (None) follow exercise_style - collapsed under solution_follow_exercise True always collapse False never collapse The `None` default is load-bearing, not stylistic. Sphinx cannot distinguish "unset" from "explicitly set to False" through the public config API, so with the previous `False` default there would have been no way to switch the style's implied collapsing back off. Resolution lives in `utils.solutions_are_collapsed(config)` rather than being written back into the config at `config-inited`. That keeps the raw tri-state readable, so the warning can tell whether collapsing was asked for or implied, avoids mutating config other extensions may read, and needs no rebuild-trigger handling of its own since `exercise_style` is already registered "env". The missing-togglebutton warning now adapts. An author who opted in explicitly is told to add the extension; an author who only set `exercise_style` and never asked for collapsing is told how to switch it off as well: exercise_style='solution_follow_exercise' collapses solutions by default, but 'sphinx_togglebutton' is not loaded, so they will render expanded. Add 'sphinx_togglebutton' to your extensions, or set solution_collapsed = False to keep solutions expanded. This is a behaviour change for projects on `solution_follow_exercise`, so it is documented as one: a "Changed" entry in the CHANGELOG leading with how to restore the old rendering, and new v1.3.0 release notes opening with the change and the one-line opt-out. The syntax guide gains a value table for the tri-state, and the Solution Title Styling section now lists collapsing among what the style does. Jupyter Book ships sphinx_togglebutton in its default extension list, so Jupyter Book projects need no change. Six new tests cover the interaction: the style collapsing by default, explicit False overriding it, explicit True agreeing with it without duplicating the class, both warning texts, and silence when the implied collapse is switched off. Verified: 133 tests pass; the collapsed and exercise_style suites pass together on Sphinx 6.2.1, 7.4.7 and 8.2.3; docs build adds no new warnings. Refs #84, #85 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- CHANGELOG.md | 16 +++-- docs/source/releases/index.md | 1 + docs/source/releases/v1.3.0.md | 88 ++++++++++++++++++++++++ docs/source/syntax.md | 21 ++++-- sphinx_exercise/__init__.py | 28 ++++++-- sphinx_exercise/directive.py | 13 ++-- sphinx_exercise/utils.py | 37 ++++++++++ tests/test_solution_collapsed.py | 112 ++++++++++++++++++++++++++++++- 8 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 docs/source/releases/v1.3.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 527eed5..3dc867a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,22 @@ ## Unreleased +### Changed ⚠️ + +- `exercise_style = "solution_follow_exercise"` now renders solutions **collapsed by default** ([#84](https://github.com/executablebooks/sphinx-exercise/issues/84)) + - That style places each solution directly beneath its exercise, where an expanded solution is too tempting to read; it now folds into a drop-down instead + - **To keep the previous behaviour, set `solution_collapsed = False` explicitly** + - No change for projects that do not set `exercise_style`, and no source files need editing either way + - Requires `sphinx_togglebutton`, which Jupyter Book loads by default; plain Sphinx projects should add it to `extensions` + ### New ✨ -- Added `solution_collapsed` configuration option to render all solutions folded by default ([#84](https://github.com/executablebooks/sphinx-exercise/issues/84)) - - Set to `True` to add the `dropdown` class to every solution, so readers opt in to seeing the answer +- Added `solution_collapsed` configuration option to control whether solutions render folded ([#85](https://github.com/executablebooks/sphinx-exercise/issues/85)) + - Tri-state: unset follows `exercise_style`, `True` always collapses, `False` never collapses - Works with both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs - Directive-level `:class:` values are preserved, and `:class: toggle-shown` keeps an individual solution expanded - - Requires `sphinx_togglebutton`; a warning is issued during HTML builds if it is not loaded, suppressible with `suppress_warnings = ["exercise.solution_collapsed"]` - - Default is `False`, which maintains the original behaviour + - A warning is issued during HTML builds when collapsing is in effect but `sphinx_togglebutton` is not loaded, suppressible with `suppress_warnings = ["exercise.solution_collapsed"]` + - Non-HTML builders, such as LaTeX/PDF, render solutions inline as before ## [v1.2.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.2.1) (2025-11-17) diff --git a/docs/source/releases/index.md b/docs/source/releases/index.md index a19584b..0443610 100644 --- a/docs/source/releases/index.md +++ b/docs/source/releases/index.md @@ -5,6 +5,7 @@ This section contains detailed release notes for sphinx-exercise versions. ```{toctree} :maxdepth: 1 +v1.3.0 v1.2.1 v1.2.0 v1.1.1 diff --git a/docs/source/releases/v1.3.0.md b/docs/source/releases/v1.3.0.md new file mode 100644 index 0000000..e918252 --- /dev/null +++ b/docs/source/releases/v1.3.0.md @@ -0,0 +1,88 @@ +# Release v1.3.0 + +**Release Date**: unreleased + +This release adds a `solution_collapsed` configuration option for rendering solutions folded by default, and makes collapsing part of what `exercise_style = "solution_follow_exercise"` means. + +## ⚠️ Behaviour change + +**If you set `exercise_style = "solution_follow_exercise"`, your solutions will now render collapsed by default.** + +This style places each solution directly beneath its exercise. Reader feedback on books using it was that an adjacent, fully visible solution is too tempting to look at, so the style now folds solutions into a drop-down and readers opt in to seeing the answer. + +### Keeping the previous behaviour + +Set `solution_collapsed` to `False` explicitly: + +```python +# In conf.py +exercise_style = "solution_follow_exercise" +solution_collapsed = False +``` + +Or for Jupyter Book: + +```yaml +# In _config.yml +sphinx: + config: + exercise_style: "solution_follow_exercise" + solution_collapsed: False +``` + +Nothing changes for projects that do not set `exercise_style`, and no source files need editing either way. + +### Requirements + +Collapsing is provided by [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/), which supplies the drop-down behaviour for the `dropdown` class. Jupyter Book loads it as part of its default extension list, so Jupyter Book projects need no change. + +Plain Sphinx projects should add it: + +```python +# In conf.py +extensions = [ + ... + "sphinx_togglebutton" + ... +] +``` + +If solutions would be collapsed but the extension is not loaded, the build emits a warning naming both remedies — add the extension, or set `solution_collapsed = False` — and solutions render expanded rather than silently losing content. + +## ✨ New Features + +### Collapsing solutions + +`solution_collapsed` controls whether solutions render folded, independently of the exercise style. It takes three values: + +| Value | Behaviour | +|---|---| +| unset (default) | Follow the exercise style: collapsed when `exercise_style = "solution_follow_exercise"`, expanded otherwise. | +| `True` | Always collapse solutions, whatever the exercise style. | +| `False` | Never collapse solutions, whatever the exercise style. | + +Collapsing is equivalent to adding `:class: dropdown` to every solution directive, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Classes set on an individual directive are preserved, and an explicit `:class: dropdown` is not duplicated. + +### Keeping one solution expanded + +Add `:class: toggle-shown` to an individual directive to keep it open while the rest of the project is collapsed: + +````md +```{solution} my-exercise +:class: toggle-shown + +This solution stays open even when the rest of the project is collapsed. +``` +```` + +## 📝 Notes + +**Non-HTML builders are unaffected.** LaTeX/PDF output renders solutions inline as before; the `dropdown` class is only meaningful to HTML. + +**Interactive outputs need care.** A collapsed solution is hidden by setting its height to zero rather than by removing it from the page, so outputs that measure their own size when the page loads — plotly, bokeh, ipywidgets and altair figures produced by `{code-cell}` blocks — will render at zero size inside a collapsed solution and may stay blank until the reader opens it. Static images, including matplotlib figures, are unaffected. Use `:class: toggle-shown` on solutions containing interactive outputs. + +**Suppressing the warning.** Projects supplying their own `.admonition.dropdown` styling can silence the missing-extension warning with `suppress_warnings = ["exercise.solution_collapsed"]`. + +## 📚 Documentation + +See [Collapse All Solutions](../syntax.md) in the syntax guide for full details. diff --git a/docs/source/syntax.md b/docs/source/syntax.md index 1f8bfba..8ca7496 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -413,7 +413,19 @@ sphinx: ### Collapse All Solutions -All solution directives can be rendered folded by default, so readers have to opt in to seeing the answer, by setting `solution_collapsed` to `True`. This is useful when solutions are written directly after their exercises (see the **Solution Title Styling** section below), where an inline solution is otherwise hard to look away from. +All solution directives can be rendered folded by default, so readers have to opt in to seeing the answer. This is controlled by `solution_collapsed`, which takes three values: + +| Value | Behaviour | +|---|---| +| unset (default) | Follow the exercise style: solutions are collapsed when `exercise_style = "solution_follow_exercise"`, and expanded otherwise. | +| `True` | Always collapse solutions, whatever the exercise style. | +| `False` | Never collapse solutions, whatever the exercise style. | + +The `solution_follow_exercise` style places each solution directly beneath its exercise, which is precisely the layout where an expanded solution is hard to look away from — so that style collapses solutions by default. See the **Solution Title Styling** section below. + +```{important} +If you use `exercise_style = "solution_follow_exercise"` and want your solutions to stay expanded, set `solution_collapsed = False` explicitly. +``` This option requires [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/) to be enabled, as it provides the drop-down behaviour for the `dropdown` class. For Sphinx projects, add the configuration key in the `conf.py` file: @@ -440,12 +452,12 @@ sphinx: ... ``` -Setting `solution_collapsed` to `True` is equivalent to adding `:class: dropdown` to every solution directive in your project, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Any classes you have set on an individual directive are preserved. +Collapsing is equivalent to adding `:class: dropdown` to every solution directive in your project, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Any classes you have set on an individual directive are preserved. ```{note} The `dropdown` class only affects HTML output. Other builders, such as LaTeX/PDF, render the solution inline as usual. -If `solution_collapsed` is set to `True` but `sphinx_togglebutton` is not loaded, a warning is issued during an HTML build and solutions render expanded. If your theme supplies its own `.admonition.dropdown` styling and you do not need the extension, silence the warning with `suppress_warnings = ["exercise.solution_collapsed"]`. +If solutions would be collapsed but `sphinx_togglebutton` is not loaded, a warning is issued during an HTML build and solutions render expanded. If your theme supplies its own `.admonition.dropdown` styling and you do not need the extension, silence the warning with `suppress_warnings = ["exercise.solution_collapsed"]`. ``` ```{warning} @@ -460,7 +472,7 @@ To keep an individual solution expanded while the rest of the project is collaps ```{solution} my-exercise :class: toggle-shown -This solution stays open even when `solution_collapsed = True`. +This solution stays open even when the rest of the project is collapsed. ``` ```` @@ -489,6 +501,7 @@ sphinx: When `exercise_style` is set to `"solution_follow_exercise"`: - The solution title displays just "Solution" (plain text, no hyperlink) +- **Solutions are collapsed by default**, so readers opt in to seeing the answer. Set `solution_collapsed = False` to keep them expanded, and see the **Collapse All Solutions** section above for the details - The extension validates that solutions follow their referenced exercises and warns if they don't - Solutions must be in the same document as their exercises (warnings if not) diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index 1d0b26d..5cd9d21 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -21,6 +21,7 @@ from sphinx.locale import get_translation from ._compat import findall +from .utils import solutions_are_collapsed, collapsed_is_implied_by_style from .directive import ( ExerciseDirective, ExerciseStartDirective, @@ -293,7 +294,7 @@ def check_collapsed_solutions(app: Sphinx) -> None: Projects that supply their own ".admonition.dropdown" CSS can silence this with suppress_warnings = ["exercise.solution_collapsed"]. """ - if not app.config.solution_collapsed: + if not solutions_are_collapsed(app.config): return # The dropdown class is only meaningful to HTML-family builders; LaTeX and @@ -304,10 +305,25 @@ def check_collapsed_solutions(app: Sphinx) -> None: if any(ext in app.extensions for ext in TOGGLE_EXTENSIONS): return + if collapsed_is_implied_by_style(app.config): + # The author never asked for collapsing, so tell them how to turn it + # off as well as how to make it work + message = ( + "exercise_style='solution_follow_exercise' collapses solutions by " + "default, but 'sphinx_togglebutton' is not loaded, so they will " + "render expanded. Add 'sphinx_togglebutton' to your extensions, or " + "set solution_collapsed = False to keep solutions expanded." + ) + else: + message = ( + "solution_collapsed=True requires 'sphinx_togglebutton', which is " + "not loaded, so solutions will render expanded. Add " + "'sphinx_togglebutton' to your extensions." + ) + logger.warning( - "[sphinx-exercise] solution_collapsed=True requires 'sphinx_togglebutton' " - "to be added to your extensions, otherwise solutions will render " - "expanded. See https://sphinx-togglebutton.readthedocs.io", + f"[sphinx-exercise] {message} " + "See https://sphinx-togglebutton.readthedocs.io", type="exercise", subtype="solution_collapsed", color="yellow", @@ -317,7 +333,9 @@ def check_collapsed_solutions(app: Sphinx) -> None: def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("hide_solutions", False, "env") app.add_config_value("exercise_style", "", "env") - app.add_config_value("solution_collapsed", False, "env") + # Tri-state: None (default) defers to exercise_style, True/False are + # explicit author choices. See utils.solutions_are_collapsed. + app.add_config_value("solution_collapsed", None, "env") app.connect("config-inited", init_numfig) # event order - 1 app.connect("builder-inited", check_collapsed_solutions) # event order - 2 diff --git a/sphinx_exercise/directive.py b/sphinx_exercise/directive.py index cb3f4a3..421a14d 100644 --- a/sphinx_exercise/directive.py +++ b/sphinx_exercise/directive.py @@ -18,6 +18,7 @@ from sphinx.util import logging from sphinx.util.docutils import SphinxDirective +from .utils import solutions_are_collapsed from .nodes import ( exercise_end_node, exercise_enumerable_node, @@ -267,11 +268,13 @@ def run(self) -> List[Node]: if self.options.get("class"): classes += self.options.get("class") - # Fold the solution by default when solution_collapsed is enabled. - # The "dropdown" class is consumed by sphinx-togglebutton, whose - # default selector is ".toggle, .admonition.dropdown". Authors can - # still opt an individual solution back open with :class: toggle-shown. - if self.env.app.config.solution_collapsed and "dropdown" not in classes: + # Fold the solution by default when collapsing is in effect - either + # opted into with solution_collapsed, or implied by the + # solution_follow_exercise style. The "dropdown" class is consumed by + # sphinx-togglebutton, whose default selector is + # ".toggle, .admonition.dropdown". Authors can still opt an individual + # solution back open with :class: toggle-shown. + if solutions_are_collapsed(self.env.app.config) and "dropdown" not in classes: classes.append("dropdown") # Construct Node diff --git a/sphinx_exercise/utils.py b/sphinx_exercise/utils.py index 83e33e0..201f9e5 100644 --- a/sphinx_exercise/utils.py +++ b/sphinx_exercise/utils.py @@ -2,6 +2,43 @@ from sphinx.writers.latex import LaTeXTranslator +#: The exercise style that places solutions directly after their exercises, +#: and therefore implies collapsed solutions unless the author opts out. +SOLUTION_FOLLOW_EXERCISE = "solution_follow_exercise" + + +def solutions_are_collapsed(config) -> bool: + """Whether solution directives should render folded by default. + + ``solution_collapsed`` is deliberately tri-state: + + ``True`` / ``False`` + An explicit choice by the author, which always wins. + ``None`` (the default) + Defer to ``exercise_style``. The ``solution_follow_exercise`` style puts + the solution directly beneath its exercise, which is precisely the + layout where an expanded solution is hard to look away from, so that + style implies collapsed solutions. + + A plain ``False`` default could not express this, because Sphinx cannot + distinguish "unset" from "explicitly set to False" through the public + config API - so ``solution_collapsed = False`` would be unable to switch + the style's implied collapsing back off. + """ + if config.solution_collapsed is not None: + return bool(config.solution_collapsed) + return config.exercise_style == SOLUTION_FOLLOW_EXERCISE + + +def collapsed_is_implied_by_style(config) -> bool: + """Whether collapsing came from ``exercise_style`` rather than an explicit opt-in. + + Used to tailor the "sphinx-togglebutton is missing" warning, since an author + who never asked for collapsing needs to be told how to switch it off as well + as how to make it work. + """ + return config.solution_collapsed is None and solutions_are_collapsed(config) + def find_parent(env, node, parent_tag): """Find the nearest parent node with the given tagname.""" diff --git a/tests/test_solution_collapsed.py b/tests/test_solution_collapsed.py index 2f7d1c3..5f0fcae 100644 --- a/tests/test_solution_collapsed.py +++ b/tests/test_solution_collapsed.py @@ -1,8 +1,13 @@ """Tests for the ``solution_collapsed`` configuration option. -``solution_collapsed = True`` adds the ``dropdown`` class to every solution -directive so that solutions render folded by default. The class is consumed by -sphinx-togglebutton, whose default selector is ``.toggle, .admonition.dropdown``. +Collapsing adds the ``dropdown`` class to every solution directive so solutions +render folded by default. The class is consumed by sphinx-togglebutton, whose +default selector is ``.toggle, .admonition.dropdown``. + +``solution_collapsed`` is tri-state: ``True``/``False`` are explicit author +choices that always win, while ``None`` (the default) defers to +``exercise_style`` - and the ``solution_follow_exercise`` style implies +collapsed solutions. """ import importlib.util @@ -162,3 +167,104 @@ def test_solution_collapsed_no_warning_for_latex(app, warnings): """Non-HTML builders render solutions inline, so no warning is emitted.""" app.build() assert "solution_collapsed=True requires" not in warnings(app) + + +# --- interaction with exercise_style ----------------------------------------- + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, +) +def test_follow_exercise_style_collapses_by_default(app): + """The solution_follow_exercise style implies collapsed solutions. + + That style places the solution directly beneath its exercise, which is the + layout the collapsing is meant to address, so it opts in by default. + """ + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" in classes, ( + f"exercise_style='solution_follow_exercise' should collapse solutions, " + f"got {classes}" + ) + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "exercise_style": "solution_follow_exercise", + "solution_collapsed": False, + }, +) +def test_explicit_false_overrides_the_style(app): + """An explicit solution_collapsed=False switches the style's implied collapse off. + + This is the reason the config value is tri-state: with a plain False default + Sphinx could not tell "unset" from "explicitly False", so this opt-out would + be impossible to express. + """ + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert ( + "dropdown" not in classes + ), f"solution_collapsed=False must override the style, got {classes}" + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "exercise_style": "solution_follow_exercise", + "solution_collapsed": True, + }, +) +def test_explicit_true_agrees_with_the_style(app): + """solution_collapsed=True alongside the style collapses, without duplicating.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert classes.count("dropdown") == 1, f"expected one 'dropdown', got {classes}" + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, +) +def test_style_implied_warning_mentions_the_opt_out(app, warnings): + """The implied-collapse warning tells authors how to switch it back off. + + An author who set exercise_style but never asked for collapsing needs the + opt-out, not just the "install sphinx-togglebutton" remedy. + """ + app.build() + captured = warnings(app) + assert "collapses solutions by default" in captured + assert "solution_collapsed = False" in captured + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_explicit_warning_does_not_mention_the_opt_out(app, warnings): + """An author who opted in explicitly does not need to be told to opt out.""" + app.build() + captured = warnings(app) + assert "solution_collapsed=True requires" in captured + assert "solution_collapsed = False" not in captured + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "exercise_style": "solution_follow_exercise", + "solution_collapsed": False, + }, +) +def test_no_warning_when_style_collapse_is_switched_off(app, warnings): + """Opting out of the implied collapse also silences the togglebutton warning.""" + app.build() + assert "sphinx_togglebutton" not in warnings(app) From 1a785905e489064b9251cacb9e946c63f5f70293 Mon Sep 17 00:00:00 2001 From: Matt McKay <mmcky@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:49:46 +1000 Subject: [PATCH 6/6] TST: Cover the gated collapsed path from MyST source as well The gated collapsed tests use the RST `.. solution-start::` form in the `mybook` test root. This adds the MyST ```{solution-start}``` counterpart in the `gateddirective` root, so the directive-option parsing both parsers feed into is exercised from each side. That root was unusable while its `sphinx_book_theme` setting was breaking CI on Sphinx 6 and 7, which is why the earlier gated test was written against `mybook` instead. #88 has since switched it to alabaster, so it is available again. The `mybook` fixture stays as the primary gated test: it is self-contained, it sits alongside the rest of the collapsed suite so confoverrides stay consistent, and it avoids executing the matplotlib code cells the `gateddirective` root builds. Verified: 134 tests pass. Refs #85 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- tests/test_solution_collapsed.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_solution_collapsed.py b/tests/test_solution_collapsed.py index 5f0fcae..6123152 100644 --- a/tests/test_solution_collapsed.py +++ b/tests/test_solution_collapsed.py @@ -110,6 +110,23 @@ def test_solution_collapsed_gated_default_is_off(app): assert "dropdown" not in classes, f"expected no 'dropdown' in {classes}" +@pytest.mark.sphinx( + "html", testroot="gateddirective", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_gated_myst_source(app): + """The gated path is also covered from MyST source, not just RST. + + The other gated tests here use the RST ``.. solution-start::`` form in the + 'mybook' root. This one uses the MyST ```{solution-start}``` form, so the + directive-option parsing both parsers feed into is exercised from each side. + """ + app.build() + classes = get_solution_classes(app, "solution-exercise-gated.html") + assert ( + "dropdown" in classes + ), f"MyST-sourced gated solutions should be collapsed too, got {classes}" + + @pytest.mark.sphinx( "html", testroot="mybook", confoverrides={"solution_collapsed": True} )