Refactor sphinx_gated_directives and update intro.md examples

Improved code structure and comments in sphinx_gated_directives/__init__.py, clarified logic, and removed unused/legacy code. Updated intro.md to remove commented-out directive examples and replaced them with active ones for better demonstration and testing.
This commit is contained in:
Dennis den Ouden-van der Horst 2026-01-03 10:30:39 +01:00
parent f481588509
commit 144c7a95be
2 changed files with 69 additions and 200 deletions

View file

@ -1,24 +1,34 @@
# startify_directives_all.py
"""
Create a '-start' companion for every registered directive (class-based).
~~~~~~~~~~~~~~~~~~~~
Rules:
- Skip originals whose name already ends with '-start' or '-end'.
- Skip if '<name>-start' already exists.
- Class-based originals -> register a subclass calling original run().
sphinx_gated_directives
~~~~~~~~~~~~~~~~~~~~
This package is an extension for Sphinx that creates a '-start' companion
and a '-end' companion for every registered class-based directive.
These new directives can be used to "gate" content in the documentation,
similar to how the gated directives of sphinx-exercise work.
This extension is based on the extension executablebooks/sphinx-exercise:
https://github.com/executablebooks/sphinx-exercise
Original project licensed under MIT © Executable Books Developers.
See the repository LICENSE for details.
Our implementation reuses certain ideas and code snippets (e.g., Docutils
compatibility helpers) to fit the needs of this extension.
Usage:
extensions = ["startify_directives_all"]
"""
from __future__ import annotations
from docutils.nodes import Element
from typing import Iterator
import re
import copy
import importlib
import inspect
from typing import Callable, Dict, Iterable, List, Optional
from typing import Dict, Iterable, Optional
from sphinx.transforms import SphinxTransform
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
@ -31,34 +41,14 @@ from docutils.parsers.rst import Directive
from docutils.parsers.rst import directives as du_directives
from dataclasses import dataclass, field
from typing import Dict, Optional
SUFFIX_START = "-start"
SUFFIX_END = "-end"
# ---------------------------
# Helpers: identification
# ---------------------------
def _is_class_directive(obj) -> bool:
return inspect.isclass(obj) and issubclass(obj, Directive)
# ---------------------------
# Helpers: registry access
# ---------------------------
def _get_unified_registry(app=None) -> Dict[str, object]:
"""
Build a unified {name -> implementation} mapping of directives.
Sources:
1) docutils.parsers.rst.directives._directives (loaded/added so far)
2) docutils.parsers.rst.directives._directive_registry (lazy map of
name -> (module, class) for builtins); import any missing ones.
3) Sphinx app.registry (if provided) for extension-registered directives
"""
# 1) Already-loaded directives (includes Sphinx & extensions registered via add_directive)
unified: Dict[str, object] = dict(getattr(du_directives, "_directives", {}))
@ -104,21 +94,11 @@ def _get_unified_registry(app=None) -> Dict[str, object]:
return unified
# ---------------------------
# Helpers: class generation
# ---------------------------
def _copy_option_spec(option_spec):
return copy.copy(option_spec) if isinstance(option_spec, dict) else option_spec
def make_startified_class(
orig_name: str,
base_cls: type[Directive],
) -> type[Directive]:
"""
Subclass `base_cls` to call original run().
"""
def make_start_class(orig_name: str, base_cls: type[Directive]) -> type[Directive]:
attrs = {}
for attr in (
"required_arguments",
@ -145,7 +125,7 @@ def make_startified_class(
else:
children = [children]
# create a start_node and add all result nodes as its children
start_node_instance = start_node(orig_name)
start_node_instance = start_node()
start_node_instance += children
result = [start_node_instance]
@ -153,30 +133,10 @@ def make_startified_class(
env = getattr(self.state.document.settings, "env", None)
if env is not None:
docname = env.docname
# # 1) Check whether main registry is already created, if not, create it
# if not hasattr(env, "sphinx_gated_directives_registry"):
# env.sphinx_gated_directives_registry = {}
# # 2) Check whether a sub-registry for current directive type is already created, if not, create it
# if orig_name not in env.sphinx_gated_directives_registry:
# env.sphinx_gated_directives_registry[orig_name] = {}
# # 3) Register current usage in the registry for this doc and this type
# gated_registry = env.sphinx_gated_directives_registry[orig_name]
# if docname not in gated_registry:
# gated_registry[docname] = {
# "start": [],
# "end": [],
# "sequence": [],
# "msg": [],
# }
# gated_registry[docname]["start"].append(self.lineno)
# gated_registry[docname]["sequence"].append("S")
# gated_registry[docname]["msg"].append(
# f"{self.name} at line: {self.lineno}"
# )
# 4) Check whether super registry has been created, if not, create it
# 1) Check whether super registry has been created, if not, create it
if not hasattr(env, "sphinx_gated_directives_super_registry"):
env.sphinx_gated_directives_super_registry = {}
# 5) Register current usage in the super registry
# 2) Register current usage in the super registry
super_registry = env.sphinx_gated_directives_super_registry
if docname not in super_registry:
super_registry[docname] = {
@ -199,13 +159,8 @@ def make_startified_class(
new_cls_name = f"{base_cls.__name__}_Start_For_{orig_name.replace(':', '_')}"
return type(new_cls_name, (base_cls,), attrs)
def make_endified_class(
orig_name: str,
base_cls: type[Directive],
) -> type[Directive]:
"""
Just create a subclass to obtain an end node.
"""
def make_end_class(orig_name: str, base_cls: type[Directive]) -> type[Directive]:
attrs = {}
for attr in (
"required_arguments",
@ -227,30 +182,10 @@ def make_endified_class(
env = getattr(self.state.document.settings, "env", None)
if env is not None:
docname = env.docname
# # 1) Check whether main registry is already created, if not, create it
# if not hasattr(env, "sphinx_gated_directives_registry"):
# env.sphinx_gated_directives_registry = {}
# # 2) Check whether a sub-registry for current directive type is already created, if not, create it
# if orig_name not in env.sphinx_gated_directives_registry:
# env.sphinx_gated_directives_registry[orig_name] = {}
# # 3) Register current usage in the registry for this doc and this type
# gated_registry = env.sphinx_gated_directives_registry[orig_name]
# if docname not in gated_registry:
# gated_registry[docname] = {
# "start": [],
# "end": [],
# "sequence": [],
# "msg": [],
# }
# gated_registry[docname]["end"].append(self.lineno)
# gated_registry[docname]["sequence"].append("E")
# gated_registry[docname]["msg"].append(
# f"{self.name} at line: {self.lineno}"
# )
# 4) Check whether super registry has been created, if not, create it
# 1) Check whether super registry has been created, if not, create it
if not hasattr(env, "sphinx_gated_directives_super_registry"):
env.sphinx_gated_directives_super_registry = {}
# 5) Register current usage in the super registry
# 2) Register current usage in the super registry
super_registry = env.sphinx_gated_directives_super_registry
if docname not in super_registry:
super_registry[docname] = {
@ -267,23 +202,16 @@ def make_endified_class(
)
super_registry[docname]["type"].append(orig_name)
# return []
# next line should be good enough, but raises an error as not all has been implemented
# maybe we need end nodes per directive type?
return [end_node(orig_name)]
return [end_node()]
attrs["run"] = run
new_cls_name = f"{base_cls.__name__}_End_For_{orig_name.replace(':', '_')}"
return type(new_cls_name, (base_cls,), attrs)
# ---------------------------
# Core logic
# ---------------------------
def _should_skip_name(orig_name: str) -> bool:
return orig_name.endswith(SUFFIX_START) or orig_name.endswith(SUFFIX_END)
def _register_startified_directives(app, env, docnames):
def _register_new_directives(app, env, docnames):
unified = _get_unified_registry(app)
snapshot_names = set(unified.keys())
added = 0
@ -298,27 +226,26 @@ def _register_startified_directives(app, env, docnames):
try:
if _is_class_directive(obj):
start_cls = make_startified_class(orig_name, obj)
start_cls = make_start_class(orig_name, obj) # new class based on original so all properties are inherited
app.add_directive(new_name, start_cls)
end_cls = make_endified_class(orig_name, Directive) # because it does generate anything, most simple directive
end_cls = make_end_class(orig_name, Directive) # because it does not generate anything, most simple directive possible
app.add_directive(f"{orig_name}{SUFFIX_END}", end_cls)
added += 1
else:
logger.debug(f"[startify] '{orig_name}' is not a class directive; skipping.")
logger.debug(f"[sphinx-gated-directives] '{orig_name}' is not a class directive; skipping.")
except Exception as e:
logger.warning(f"[startify] failed to register '{new_name}': {e}")
logger.warning(f"[sphinx-gated-directives] failed to register new classes for directive '{orig_name}':\n{e}")
def setup(app):
# register function to perge registries at start of build per document
# register function to purge registries at start of build per document
app.connect("env-purge-doc", purge_registries)
# At the latest possible moment, register startified directives (also creates the endified ones)
app.connect("env-before-read-docs", _register_startified_directives,priority=10000000000000000000000000000)
# At the latest possible moment, register new directives
app.connect("env-before-read-docs", _register_new_directives,priority=10000000000000000000000000000)
# Register nodes, used as placeholder for end directives
# will be resolved in the transforms to something meaningful
# during merging of start-end blocks
# Register nodes
# During transformation phase, these nodes will be removed
app.add_node(start_node)
app.add_node(end_node)
@ -329,32 +256,17 @@ def setup(app):
app.add_transform(MergeGatedDirectivesTransform)
return {
"version": "1.0",
"parallel_read_safe": True,
}
# Node classes for start and end directives
class start_node(nodes.Admonition, nodes.Element):
def __init__(self, type_of_directive: str=""):
self.type_of_directive = type_of_directive
super().__init__()
pass
class end_node(nodes.Admonition, nodes.Element):
def __init__(self, type_of_directive: str=""):
self.type_of_directive = type_of_directive
super().__init__()
pass
# purge_registry
def purge_registries(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
# only perge if needed
# if hasattr(env, "sphinx_gated_directives_registry"):
# registry = env.sphinx_gated_directives_registry
# for directive_name in registry:
# if docname in registry[directive_name]:
# del registry[directive_name][docname]
if hasattr(env, "sphinx_gated_directives_super_registry"):
super_registry = env.sphinx_gated_directives_super_registry
if docname in super_registry:
@ -366,15 +278,9 @@ class CheckGatedDirectivesTransform(SphinxTransform):
def apply(self, **kwargs):
env = self.env
# if not hasattr(env, "sphinx_gated_directives_registry"):
# return
if not hasattr(env, "sphinx_gated_directives_super_registry"):
return
# First check the super registry for a correct structure
# demands:
# 1) equal number of start and end directives
# 2) no nesting of start-end pairs (so SSEE is not allowed, only SESE)
super_registry = env.sphinx_gated_directives_super_registry
error = False
docname = self.env.docname
@ -385,17 +291,17 @@ class CheckGatedDirectivesTransform(SphinxTransform):
sequence = "".join(super_registry[docname]["sequence"])
validate_SE_result = validate_SE(sequence)
if len(start) > len(end):
msg = f"The document {docname} contains more start directives than end directives:\n {structure}\nPlease ensure each start directive has a corresponding end directive."
msg = f"[sphinx-gated-directives] The document {docname} contains more start directives than end directives:\n {structure}\nPlease ensure each start directive has a corresponding end directive."
logger.error(msg)
error = True
elif len(end) > len(start):
msg = f"The document {docname} contains more end directives than start directives:\n {structure}\nPlease ensure each end directive has a corresponding start directive."
msg = f"[sphinx-gated-directives] The document {docname} contains more end directives than start directives:\n {structure}\nPlease ensure each end directive has a corresponding start directive."
logger.error(msg)
error = True
# at this point, len(start) == len(end)
# now check for correct nesting
elif not validate_SE_result.is_valid:
msg = f"The document {docname} contains incorrectly nested start and end directives:\n {structure}\nThis is not allowed. Please correct the nesting."
msg = f"[sphinx-gated-directives] The document {docname} contains incorrectly nested start and end directives:\n {structure}\nThis is not allowed. Please correct the nesting."
logger.error(msg)
error = True
else:
@ -406,7 +312,7 @@ class CheckGatedDirectivesTransform(SphinxTransform):
end_type = [types[v] for v in validate_SE_result.pairs.values()]
for i in range(len(start_type)):
if start_type[i] != end_type[i]:
msg = f"The document {docname} contains mismatched start and end directives at lines {start[i]} and {end[i]}:\n {structure}\nPlease ensure that start and end directives match in type."
msg = f"[sphinx-gated-directives] The document {docname} contains mismatched start and end directives at lines {start[i]} and {end[i]}:\n {structure}\nPlease ensure that start and end directives match in type."
logger.error(msg)
error = True
break
@ -414,22 +320,11 @@ class CheckGatedDirectivesTransform(SphinxTransform):
if error:
raise Exception(f"[sphinx-gated-directives] An error has occurred when parsing gated directives in {docname}.\nPlease check warning messages above.")
# no check per directive type is needed if no error in super registry is raised,
# as the super registry already ensures correctness for each type.
# if nesting is allowed in the future, this part needs to be re-implemented.
class MergeGatedDirectivesTransform(SphinxTransform):
default_priority = 10
def apply(self, **kwargs):
env = self.env
# Something to do here
# idea:
# 1) Find all start_nodes
# 2) for each start_node, find the corresponding end_node (i.e. next end_node)
# 3) collect all nodes in between
# 4) Take the first node after start_node, and add all other collected nodes as its children
if not hasattr(env, "sphinx_gated_directives_super_registry"):
return
super_registry = env.sphinx_gated_directives_super_registry
@ -437,80 +332,72 @@ class MergeGatedDirectivesTransform(SphinxTransform):
if docname not in super_registry:
return
# Each start-end pair is valid (checked before), so we can proceed to merge
# find all start nodes, as an iterator
# for each start node, find the next end node within the parent of start node
start_nodes = findall(self.document, start_node)
for start_n in start_nodes:
parent = start_n.parent
# find the next end_node after start_n in parent
found_start = False
end_n = None
skip_end = 0
for child in parent.children:
if child is start_n:
found_start = True
elif found_start and isinstance(child, end_node):
elif found_start and isinstance(child, end_node) and skip_end==0:
end_n = child
break
elif found_start and isinstance(child, end_node):
skip_end -= 1
elif found_start and isinstance(child, start_node):
skip_end += 1
if end_n is None:
continue # should not happen due to prior checks
logger.error(f"[sphinx-gated-directives] Could not find matching end directive for start directive at line {start_n.line} in document {docname}. Skipping merging for this pair.")
raise Exception(f"[sphinx-gated-directives] An error has occurred when parsing gated directives in {docname}.\nPlease check warning messages above.")
# collect all nodes between start_n and end_n
start_index = parent.children.index(start_n)
end_index = parent.children.index(end_n)
between_nodes = parent.children[start_index + 1:end_index]
# We have to merge, but how is the question.
# we handle based on "experience" and "observation" of what kind of nodes are generated
# step 1: create a new container node to hold the merged content
# content of start_node
new_nodes = start_n.children
# add content in between, depending on content in start_n
if between_nodes:
has_section = False
has_caption = False
si = -1
content = new_nodes[-1] # we assume that the last child of start_n holds the main content
content = new_nodes[-1]
for sn in content.children:
si += 1
if isinstance(sn, nodes.section):
has_section = True # So probably a topic or similar (from sphinx-proof)
has_section = True
section_index = si
section_node = sn
break
elif isinstance(sn, nodes.caption):
has_caption = True # So probably a figure or similar
has_caption = True
caption_index = si
caption_node = sn
break
if has_section:
# if there are sections, we add all between nodes to this section
content[section_index] += between_nodes
elif has_caption:
# if there is a caption, we add all between nodes before the caption
n = len(content.children)
pos = n - 1
for i, bn in enumerate(between_nodes):
content.insert(pos + i, bn) # insert sets parent/doc links
content.insert(pos + i, bn)
else:
# otherwise, we add all between nodes to the main content directly\
for bn in between_nodes:
content += bn # add sets parent/doc links
content += bn
# finally, replace start_n with new_nodes, and remove end_n and between_nodes
start_pos = parent.children.index(start_n)
# remove end_n
parent.remove(end_n)
# remove start_n
parent.remove(start_n)
# remove between_nodes
for bn in between_nodes:
parent.remove(bn)
# insert new_nodes at start_pos
for i, nn in enumerate(new_nodes):
parent.insert(start_pos + i, nn) # insert new structure
parent.insert(start_pos + i, nn)
# SPDX-License-Identifier: MIT
# Adapted from: executablebooks/sphinx-exercise, file: sphinx_exercise/_compat.py
# Source: https://github.com/executablebooks/sphinx-exercise/blob/main/sphinx_exercise/_compat.py
# License: MIT — © Executable Books Developers (see repository LICENSE).
def findall(node: Element, *args, **kwargs) -> Iterator[Element]:
# findall replaces traverse in docutils v0.18
# note a difference is that findall is an iterator
@ -525,21 +412,7 @@ class SEValidationResult:
error_message: Optional[str] = None # description of the error, if any
def validate_SE(s: str) -> SEValidationResult:
"""
Validates a string containing only 'S' (open) and 'E' (close).
Returns:
SEValidationResult with:
- is_valid: True iff all brackets match and no invalid characters occur.
- pairs: dict mapping each 'S' index to the corresponding 'E' index.
(zero-based indices in the original string)
- error_index, error_message: populated on first failure.
Errors reported:
* Invalid character (not 'S' or 'E').
* 'E' encountered before any available 'S'.
* Unmatched 'S' remaining at the end.
The pairs dict is still returned for all successfully matched pairs up to the error.
"""
stack = [] # stack of indices of unmatched 'S'
stack = []
pairs: Dict[int, int] = {}
for i, ch in enumerate(s):

View file

@ -66,9 +66,7 @@ Some text after the entire parent theorem start, but before the parent theorem e
### Different directives
Crashes still:
<!-- ::::{prf:theorem-start} The parent theorem
::::{prf:theorem-start} The parent theorem
:label: parent-theorem-label3
This is a parent theorem. This text is inside the parent theorem.
@ -90,13 +88,11 @@ This is text after the child-start, before the child-end.
Finally some text after the entire child, outside the theorem start, before the end.
:::{prf:theorem-end}
::: -->
:::
### Same directives
Crashes still:
<!-- ::::{prf:theorem-start} The parent theorem
::::{prf:theorem-start} The parent theorem
:label: parent-theorem-label4
This is a parent theorem. This text is inside the parent theorem.
@ -118,7 +114,7 @@ This is text after the child-start, before the child-end.
Finally some text after the entire child, outside the theorem start, before the end.
:::{prf:theorem-end}
::: -->
:::
## Examples with code