Skip to content

Add solution_placement option to relocate solutions into a generated solutions section #87

Description

@mmcky

Sub-issue of #84, covering the second of the two options proposed there: lifting solutions out of their in-document position at build time and collecting them into a generated solutions section.

The companion sub-issue for the collapsed-solutions option is #85.

Motivation

Same reader feedback that motivates #85 — a solution sitting directly under its exercise is too tempting to read — but a stronger remedy: move the solution off the page entirely, leaving the exercise with a link to it.

Headline finding

This splits into two problems of very different difficulty, and the split should drive how the work is scoped.

Difficulty Verdict
Per-document — collect this page's solutions into a section at the end of this page Medium, low risk Recommended MVP. A prototype had every cross-reference resolve correctly.
Project-wide — collect every solution in the book onto one generated page Hard Cannot be built on the extension's current label strategy. Needs a custom Sphinx domain, not a transform. Should be a separate issue.

The single most important design constraint: do not re-home std-domain labels. Almost everything hard about the project-wide case follows from that one temptation, and the per-document case is safe precisely because it never needs to.

Research

All findings below were verified against this checkout by building real Sphinx projects (8.2.3), including working prototypes of both approaches. Post-transform priorities in this extension are UpdateReferencesToEnumerated = 5, ResolveTitlesInExercises = 20, ResolveTitlesInSolutions = 21, ResolveLinkTextToSolutions = 22; read-phase transforms are CheckGatedDirectives = 1, MergeGatedExercises = 10, MergeGatedSolutions = 10.

Use a read-phase transform, not a post-transform

The relocation belongs in a read-phase SphinxTransform at priority ~700 — after MergeGatedSolutions (10), before Sphinx's DoctreeReadEvent (880). Being inside that window buys four things for free:

  • The structure is baked into the pickled doctree, so incremental rebuild needs no special handling — the doctree becomes a pure function of its own source.
  • TocTreeCollector runs at doctree-read (880), so the generated "Solutions" heading appears in the sidebar and local toc like an authored one.
  • The extension's own doctree_read registers std labels with an unchanged docname, so nothing to fix for cross-references.
  • LaTeX and singlehtml assemble from the pickled doctrees, so they inherit the per-document structure.

A post-transform gets none of these, and has a specific trap worth spelling out: because latex and singlehtml assemble the whole project into one doctree via inline_all_toctrees and run post-transforms once with env.docname == root_doc, a per-document relocation post-transform silently becomes a project-wide one under those builders. A prototype that printed once per document under html printed exactly once under latex, appending a single \chapter{Solutions} after every chapter.

A doctree-resolved handler is equivalent in power to a post-transform and shares the same limitations.

Cross-references: within a document is safe, across documents is not

Moving a solution within the same doctree breaks nothing. Verified: after an in-document move, a same-document {ref} renders href="#sol-1", a cross-document {ref} renders href="chapter1.html#sol-1", and {numref} to the exercise resolves. HTML anchors are file-scoped and the file did not change.

Moving a solution to a different generated document breaks three classes of link with zero warnings, because Sphinx never validates intra-page anchor targets — the build reports success:

  1. Same-document {ref} on the original page now points at an anchor that has left the page.
  2. Cross-document {ref} still points at <original-doc>.html#<label>.
  3. The solution's own back-link to its exercise carries a relative URI computed against the original document, so it renders as href="#ex-1" on the appendix page instead of chapter1.html#ex-1.

Why re-homing std labels does not work

This is the non-obvious part, and it was demonstrated two independent ways on a synthetic six-document project:

Serial builds. Registering solution labels under an appendix docname produced four undefined label warnings and an empty label dict. Sphinx purges labels by the docname stored in the label tuple, so when it read solutions.rst it deleted every label claiming to belong there. Renaming the appendix so it sorted first made all four labels survive — meaning whether cross-references work at all would depend on the alphabetical filename of the appendix page, since documents are read in sorted order.

Parallel builds. With the favourable ordering that made the serial build work, the same project under -j 4 lost all four labels again. StandardDomain.merge_domaindata keeps a label only if its docname is in the chunk the worker read, so a label owned by solutions but produced while reading chap1 is dropped silently. The extension currently declares parallel_read_safe: True.

Worth noting the contrast: the extension's own env dicts survive a parallel read fine, because merge_exercises merges them unfiltered. It is only the std-domain write in doctree_read that is fragile.

solution_placement must override exercise_style

Once a solution is relocated, the simplified "Solution" title from exercise_style = "solution_follow_exercise" has no referent — you get a stack of identical admonitions. It is worse than it looks, because ResolveLinkTextToSolutions derives reference text from the same title, so every {ref} to any solution also renders as the bare word "Solution".

The title is chosen in two places: the text at directive-run time (read phase) and the structure in ResolveTitlesInSolutions, whose solution_follow_exercise branch deliberately skips the back-link that the default branch builds. Both conditions need an extra and solution_placement == "inline" clause. The rule to document: relocation forces the full hyperlinked title form.

Interaction with the #81 order validation

validate_exercise_solution_order runs on env-updated, at the end of the read phase and before every post-transform, so it always validates authored order — which is the right thing to validate. But two of its warnings become wrong under relocation: the "not in the same document" check asserts exactly the invariant a project-wide appendix abolishes, and the ordering check is meaningless once rendered order deliberately differs from authored order.

Recommendation: gate the whole validator on solution_placement == "inline". Under appendix placement, consider replacing it with a check the extension does not have today — warn when a solution's target_label matches no exercise anywhere in the project, rather than only in the same document.

There is a bonus here for the project-wide case: env-updated listener return values are extended into the set of documents Sphinx writes, and this handler currently returns None. That is the cleanest available lever for the staleness problem below, and it is already wired up.

Back-links: nothing exists today

There is no reverse mapping anywhere. target_label is written only on solution nodes; answering "which solution belongs to exercise X" requires a full scan of the registry.

The closest existing structure is env.sphinx_exercise_node_order[docname], added by #81: an ordered per-document list of {type, label, target_label, line}. It already contains every solution→exercise edge and is already purged and merged. A derived exercise_label → [(solution_label, final_docname, anchor_id)] index built from it is the natural approach.

Three cautions: it must record the solution's final docname; it must be built after the read phase completes, since partial rebuilds re-read only changed documents; and rendering the link means injecting a reference into the exercise node, which happens in ResolveTitlesInExercises (priority 20) — before ResolveTitlesInSolutions (21), so the solution's title is not yet resolved and cannot be used as link text. Either use static text ("see solution") or add a new post-transform at priority 23.

Interactions with existing options

Option Constraint
Gated {solution-start} / {solution-end} Relocation must run at priority > 10, or it strands the end marker and the intervening content. Also, MergeGatedSolutions never updates the registry, so any relocation reading registry[label]["node"] before priority 21 sees a stale, contentless start node.
:hidden: The node is registered in the registry but withheld from the doctree, so a registry-driven relocation would resurrect hidden solutions into the appendix. Drive relocation from findall(document, solution_node), never from the registry.
hide_solutions Returns [] before the node or registry entry exists, so there is nothing to relocate. hide_solutions must win — do not generate an empty "Solutions" heading.
solution_collapsed (#85) Classes travel with the node, so a collapsed solution stays collapsed after relocation. Harmless, but worth a combination test.

LaTeX / PDF

There is essentially no LaTeX-side structure to build on — the whole of latex.py is \begin{sphinxadmonition}{note} / \end{sphinxadmonition}. But LaTeX is far more tolerant of relocation than HTML, because LaTeX labels are document-global while HTML anchors are file-scoped. Verified: after an in-document move, the generated .tex still resolved both \hyperref[exercise:ex-1] and \hyperref[\detokenize{chapter1:sol-1}] correctly even though the solution had moved into a later chapter.

Three notes. Label namespaces are asymmetric — exercises use a flat global exercise:{label} while solutions use {docname}:{label}, and the solution's LaTeX label and the std-domain target are two independently computed strings that both embed a docname; rewrite one without the other and pdflatex fails after Sphinx reports a clean build. The generated section should be appended as the last child of the document's top-level section, not the document root — appending at the root produces a second <h1> in HTML and leaves no docname-bearing ancestor, which makes utils.find_parent return None. And if "appendix" is meant literally in the PDF sense, latex_appendices already exists and a project-wide solutions page should route through it rather than being injected by hand; a per-document appendix is not a LaTeX appendix at all and should just be a normal section. Worth naming the config values so that distinction is visible to users.

Incremental rebuild

purge_exercises and merge_exercises already cover the existing env stores, and any new store keyed by source docname needs the identical pair. That part is mechanical. Two things they do not cover:

A generated project-wide page goes stale. Verified: after a full build, editing chapter1.rst caused Sphinx to read and write chapter1 and index, but solutions was neither read nor written and still contained the pre-edit text. There is no dependency edge from the appendix to the chapters that feed it. Fix: return the appendix docname from the existing env-updated listener. Per-document relocation needs none of this.

The extension declares no env_version. Sphinx discards the environment when that value changes; without it, any change to the shape of sphinx_exercise_registry or sphinx_exercise_node_order will collide with users' existing environment.pickle and raise on the first incremental build after upgrade.

Proposed phasing

Phase 0 — de-risking fixes, shippable independently (small)

None of this needs the feature, and all of it is a trap the feature would otherwise spring:

  • Add env_version to setup().
  • Fix a live bug in cross-document references to solutions (details below).
  • Guard the None that find_parent can return in ResolveTitlesInSolutions.
  • Have MergeGatedSolutions update the registry entry it currently leaves stale.

Phase 1 — solution_placement = "document-appendix" (medium; the recommended MVP)

A read-phase SphinxTransform at priority 700 that, per document, lifts every solution_node into a generated section appended as the last child of the document's top-level section. Force the full hyperlinked title regardless of exercise_style; gate off the #81 validator; respect hide_solutions and :hidden:; make the heading text translatable and overridable. Verified working in a prototype — same-document refs, cross-document refs and numref all resolve, LaTeX inherits the structure, incremental rebuild needs no special handling, and the heading appears in the sidebar toc. Risk is low and confined to output layout.

Phase 2 — back-links (small, after Phase 1)

Build the reverse index from sphinx_exercise_node_order and render a "see solution" link on the exercise from a new post-transform at priority 23, so the solution title is already resolved. Naturally scoped to Phase 1's per-document case, where the target is always on the same page.

Phase 3 — solution_placement = "project-appendix" (hard; should be its own issue)

Do not attempt this as an extension of Phase 1. At minimum it needs a placeholder directive plus a user-authored host page in the toctree (the sphinx.ext.todo pattern, needed because env-get-outdated intersects with env.found_docs, so a purely synthetic docname cannot be forced outdated); a custom SolutionDomain owning solution labels with its own clear_doc, merge_domaindata and resolve_xref; back-links emitted as pending_xref rather than pre-resolved refuri; a decision on whether the original site keeps a stub anchor (much safer — it keeps existing bookmarks and same-page refs alive); env-updated returning the appendix docname to defeat staleness; and routing through latex_appendices for PDF. It also needs parallel-read and incremental-rebuild tests, which the suite does not have at all today.

Live bug found while researching this

Cross-document {ref} to a solution renders truncated link text. Reproduced on main with no relocation involved:

Reference Renders as
{ref} to sol-1 from the same document Solution to Exercise 1 (Some exercise)
{ref} to sol-1 from another document Solution to
{ref} to ex-1 from another document Exercise 1 (correct — exercises are unaffected)

Root cause: doctree_read captures the std-domain label title as section_name = node.attributes.get("title") at read time, when a solution node's title is still the unresolved default "Solution to". ResolveLinkTextToSolutions then repairs only references it matches via node.get("refid") — that is, same-document references. Cross-document references carry refuri, never enter that branch, and keep the read-time title.

This is worth fixing on its own merits, and the appendix work would inherit and amplify it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions