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:
- Same-document
{ref} on the original page now points at an anchor that has left the page.
- Cross-document
{ref} still points at <original-doc>.html#<label>.
- 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.
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.
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 areCheckGatedDirectives= 1,MergeGatedExercises= 10,MergeGatedSolutions= 10.Use a read-phase transform, not a post-transform
The relocation belongs in a read-phase
SphinxTransformat priority ~700 — afterMergeGatedSolutions(10), before Sphinx'sDoctreeReadEvent(880). Being inside that window buys four things for free:TocTreeCollectorruns atdoctree-read(880), so the generated "Solutions" heading appears in the sidebar and local toc like an authored one.doctree_readregisters std labels with an unchanged docname, so nothing to fix for cross-references.A post-transform gets none of these, and has a specific trap worth spelling out: because
latexandsinglehtmlassemble the whole project into one doctree viainline_all_toctreesand run post-transforms once withenv.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 underhtmlprinted exactly once underlatex, appending a single\chapter{Solutions}after every chapter.A
doctree-resolvedhandler 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}rendershref="#sol-1", a cross-document{ref}rendershref="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:
{ref}on the original page now points at an anchor that has left the page.{ref}still points at<original-doc>.html#<label>.href="#ex-1"on the appendix page instead ofchapter1.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 labelwarnings and an empty label dict. Sphinx purges labels by the docname stored in the label tuple, so when it readsolutions.rstit 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 4lost all four labels again.StandardDomain.merge_domaindatakeeps a label only if its docname is in the chunk the worker read, so a label owned bysolutionsbut produced while readingchap1is dropped silently. The extension currently declaresparallel_read_safe: True.Worth noting the contrast: the extension's own env dicts survive a parallel read fine, because
merge_exercisesmerges them unfiltered. It is only the std-domain write indoctree_readthat is fragile.solution_placementmust overrideexercise_styleOnce a solution is relocated, the simplified
"Solution"title fromexercise_style = "solution_follow_exercise"has no referent — you get a stack of identical admonitions. It is worse than it looks, becauseResolveLinkTextToSolutionsderives 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, whosesolution_follow_exercisebranch deliberately skips the back-link that the default branch builds. Both conditions need an extraand solution_placement == "inline"clause. The rule to document: relocation forces the full hyperlinked title form.Interaction with the #81 order validation
validate_exercise_solution_orderruns onenv-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'starget_labelmatches no exercise anywhere in the project, rather than only in the same document.There is a bonus here for the project-wide case:
env-updatedlistener return values are extended into the set of documents Sphinx writes, and this handler currently returnsNone. 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_labelis 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 derivedexercise_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) — beforeResolveTitlesInSolutions(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
{solution-start}/{solution-end}MergeGatedSolutionsnever updates the registry, so any relocation readingregistry[label]["node"]before priority 21 sees a stale, contentless start node.:hidden:findall(document, solution_node), never from the registry.hide_solutions[]before the node or registry entry exists, so there is nothing to relocate.hide_solutionsmust win — do not generate an empty "Solutions" heading.solution_collapsed(#85)LaTeX / PDF
There is essentially no LaTeX-side structure to build on — the whole of
latex.pyis\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.texstill 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 makesutils.find_parentreturnNone. And if "appendix" is meant literally in the PDF sense,latex_appendicesalready 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_exercisesandmerge_exercisesalready 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.rstcaused Sphinx to read and writechapter1andindex, butsolutionswas 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 existingenv-updatedlistener. 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 ofsphinx_exercise_registryorsphinx_exercise_node_orderwill collide with users' existingenvironment.pickleand 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:
env_versiontosetup().Nonethatfind_parentcan return inResolveTitlesInSolutions.MergeGatedSolutionsupdate the registry entry it currently leaves stale.Phase 1 —
solution_placement = "document-appendix"(medium; the recommended MVP)A read-phase
SphinxTransformat priority 700 that, per document, lifts everysolution_nodeinto a generated section appended as the last child of the document's top-level section. Force the full hyperlinked title regardless ofexercise_style; gate off the #81 validator; respecthide_solutionsand:hidden:; make the heading text translatable and overridable. Verified working in a prototype — same-document refs, cross-document refs andnumrefall 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_orderand 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.todopattern, needed becauseenv-get-outdatedintersects withenv.found_docs, so a purely synthetic docname cannot be forced outdated); a customSolutionDomainowning solution labels with its ownclear_doc,merge_domaindataandresolve_xref; back-links emitted aspending_xrefrather than pre-resolvedrefuri; a decision on whether the original site keeps a stub anchor (much safer — it keeps existing bookmarks and same-page refs alive);env-updatedreturning the appendix docname to defeat staleness; and routing throughlatex_appendicesfor 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 onmainwith no relocation involved:{ref}tosol-1from the same documentSolution to Exercise 1 (Some exercise){ref}tosol-1from another documentSolution to{ref}toex-1from another documentExercise 1(correct — exercises are unaffected)Root cause:
doctree_readcaptures the std-domain label title assection_name = node.attributes.get("title")at read time, when a solution node's title is still the unresolved default"Solution to".ResolveLinkTextToSolutionsthen repairs only references it matches vianode.get("refid")— that is, same-document references. Cross-document references carryrefuri, 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.