Enhance sphinx_gated_directives with configurable suffixes and override logic

Refactor the sphinx_gated_directives extension to support configurable suffixes and separator for start/end directives, and add an 'override_existing' option for more flexible directive registration. Update the registry naming and logic throughout the extension, add configuration validation, and update the book config and intro to demonstrate new exercise directives and nesting.
This commit is contained in:
Dennis den Ouden-van der Horst 2026-01-03 12:45:24 +01:00
parent 144c7a95be
commit f4ea60d42c
3 changed files with 167 additions and 46 deletions

View file

@ -21,6 +21,8 @@ sphinx:
- sphinx_apa_references
- sphinx_tudelft_theme
config:
sphinx_gated_directives:
override_existing: ['exercise']
language: en
html_js_files:
- https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js

View file

@ -42,8 +42,9 @@ from docutils.parsers.rst import directives as du_directives
from dataclasses import dataclass, field
SUFFIX_START = "-start"
SUFFIX_END = "-end"
SUFFIX_START = "start"
SUFFIX_END = "end"
SUFFIX_SEPARATOR = "-"
def _is_class_directive(obj) -> bool:
return inspect.isclass(obj) and issubclass(obj, Directive)
@ -134,24 +135,24 @@ def make_start_class(orig_name: str, base_cls: type[Directive]) -> type[Directiv
if env is not None:
docname = env.docname
# 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 = {}
if not hasattr(env, "sphinx_gated_directives_registry"):
env.sphinx_gated_directives_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] = {
registry = env.sphinx_gated_directives_registry
if docname not in registry:
registry[docname] = {
"start": [],
"end": [],
"sequence": [],
"msg": [],
"type": [],
}
super_registry[docname]["start"].append(self.lineno)
super_registry[docname]["sequence"].append("S")
super_registry[docname]["msg"].append(
registry[docname]["start"].append(self.lineno)
registry[docname]["sequence"].append("S")
registry[docname]["msg"].append(
f"{self.name} at line: {self.lineno}"
)
super_registry[docname]["type"].append(orig_name)
registry[docname]["type"].append(orig_name)
return result
@ -183,24 +184,24 @@ def make_end_class(orig_name: str, base_cls: type[Directive]) -> type[Directive]
if env is not None:
docname = env.docname
# 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 = {}
if not hasattr(env, "sphinx_gated_directives_registry"):
env.sphinx_gated_directives_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] = {
registry = env.sphinx_gated_directives_registry
if docname not in registry:
registry[docname] = {
"start": [],
"end": [],
"sequence": [],
"msg": [],
"type": [],
}
super_registry[docname]["end"].append(self.lineno)
super_registry[docname]["sequence"].append("E")
super_registry[docname]["msg"].append(
registry[docname]["end"].append(self.lineno)
registry[docname]["sequence"].append("E")
registry[docname]["msg"].append(
f"{self.name} at line: {self.lineno}"
)
super_registry[docname]["type"].append(orig_name)
registry[docname]["type"].append(orig_name)
return [end_node()]
@ -208,28 +209,73 @@ def make_end_class(orig_name: str, base_cls: type[Directive]) -> type[Directive]
new_cls_name = f"{base_cls.__name__}_End_For_{orig_name.replace(':', '_')}"
return type(new_cls_name, (base_cls,), attrs)
def _should_skip_name(orig_name: str) -> bool:
return orig_name.endswith(SUFFIX_START) or orig_name.endswith(SUFFIX_END)
def _should_skip_name(orig_name: str, cfg: dict) -> bool:
if isinstance(cfg["override_existing"],bool):
if cfg["override_existing"] is True:
return False
else:
if orig_name.endswith(f"{cfg["suffix_separator"]}{cfg["suffix_start"]}"):
return True
else:
return orig_name.endswith(f"{cfg["suffix_separator"]}{cfg["suffix_end"]}")
elif isinstance(cfg["override_existing"],list):
if orig_name in cfg["override_existing"]:
return False
else:
if orig_name.endswith(f"{cfg["suffix_separator"]}{cfg["suffix_start"]}"):
return True
else:
return orig_name.endswith(f"{cfg["suffix_separator"]}{cfg["suffix_end"]}")
def _register_new_directives(app, env, docnames):
# get options and add missing default values
cfg = app.config.sphinx_gated_directives
if "override_existing" not in cfg:
cfg["override_existing"] = False
elif isinstance(cfg["override_existing"], str):
cfg["override_existing"] = [cfg["override_existing"]]
if "suffix_start" not in cfg:
cfg["suffix_start"] = SUFFIX_START
if "suffix_end" not in cfg:
cfg["suffix_end"] = SUFFIX_END
if "suffix_separator" not in cfg:
cfg["suffix_separator"] = SUFFIX_SEPARATOR
unified = _get_unified_registry(app)
snapshot_names = set(unified.keys())
added = 0
for orig_name, obj in sorted(unified.items()):
if _should_skip_name(orig_name):
continue
new_name = f"{orig_name}{SUFFIX_START}"
new_name = f"{orig_name}{cfg["suffix_separator"]}{cfg["suffix_start"]}"
# add start and end directives if not already present, or if override is requested explicitly
if new_name in snapshot_names:
if isinstance(cfg["override_existing"], bool):
if cfg["override_existing"] is False:
continue
else:
if orig_name not in cfg["override_existing"]:
continue
end_name = f"{orig_name}{cfg["suffix_separator"]}{cfg["suffix_end"]}"
if end_name in snapshot_names:
if isinstance(cfg["override_existing"], bool):
if cfg["override_existing"] is False:
continue
else:
if orig_name not in cfg["override_existing"]:
continue
# if the original name is itself a start or end directive, skip
if _should_skip_name(orig_name,cfg):
continue
try:
if _is_class_directive(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)
app.add_directive(new_name, start_cls,override=True)
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)
app.add_directive(end_name, end_cls,override=True)
added += 1
else:
logger.debug(f"[sphinx-gated-directives] '{orig_name}' is not a class directive; skipping.")
@ -237,6 +283,11 @@ def _register_new_directives(app, env, docnames):
logger.warning(f"[sphinx-gated-directives] failed to register new classes for directive '{orig_name}':\n{e}")
def setup(app):
# Register a config value to set options for the extension
app.add_config_value("sphinx_gated_directives", {}, "env")
# Register a function to check if config value structure is correct
app.connect("config-inited", check_config_values)
# register function to purge registries at start of build per document
app.connect("env-purge-doc", purge_registries)
@ -267,10 +318,10 @@ class end_node(nodes.Admonition, nodes.Element):
def purge_registries(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
if hasattr(env, "sphinx_gated_directives_super_registry"):
super_registry = env.sphinx_gated_directives_super_registry
if docname in super_registry:
del super_registry[docname]
if hasattr(env, "sphinx_gated_directives_registry"):
registry = env.sphinx_gated_directives_registry
if docname in registry:
del registry[docname]
# Transform to check validity of start-end pairs
class CheckGatedDirectivesTransform(SphinxTransform):
@ -278,17 +329,17 @@ class CheckGatedDirectivesTransform(SphinxTransform):
def apply(self, **kwargs):
env = self.env
if not hasattr(env, "sphinx_gated_directives_super_registry"):
if not hasattr(env, "sphinx_gated_directives_registry"):
return
super_registry = env.sphinx_gated_directives_super_registry
registry = env.sphinx_gated_directives_registry
error = False
docname = self.env.docname
if docname in super_registry:
start = super_registry[docname]["start"]
end = super_registry[docname]["end"]
structure = "\n ".join(super_registry[docname]["msg"])
sequence = "".join(super_registry[docname]["sequence"])
if docname in registry:
start = registry[docname]["start"]
end = registry[docname]["end"]
structure = "\n ".join(registry[docname]["msg"])
sequence = "".join(registry[docname]["sequence"])
validate_SE_result = validate_SE(sequence)
if len(start) > len(end):
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."
@ -307,7 +358,7 @@ class CheckGatedDirectivesTransform(SphinxTransform):
else:
# At this point, every start is matched with an end
# Now check that types match in order.
types = super_registry[docname]["type"]
types = registry[docname]["type"]
start_type = [types[k] for k in validate_SE_result.pairs.keys()]
end_type = [types[v] for v in validate_SE_result.pairs.values()]
for i in range(len(start_type)):
@ -325,11 +376,11 @@ class MergeGatedDirectivesTransform(SphinxTransform):
def apply(self, **kwargs):
env = self.env
if not hasattr(env, "sphinx_gated_directives_super_registry"):
if not hasattr(env, "sphinx_gated_directives_registry"):
return
super_registry = env.sphinx_gated_directives_super_registry
registry = env.sphinx_gated_directives_registry
docname = self.env.docname
if docname not in super_registry:
if docname not in registry:
return
start_nodes = findall(self.document, start_node)
@ -445,3 +496,44 @@ def validate_SE(s: str) -> SEValidationResult:
)
return SEValidationResult(is_valid=True, pairs=pairs)
def check_config_values(app: Sphinx, config) -> None:
cfg = config.sphinx_gated_directives
if not isinstance(cfg, dict):
logger.error("[sphinx-gated-directives] The configuration value 'sphinx_gated_directives' should be a dictionary.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives'. Expected a dictionary.")
return
# Implemented options:
# - override_existing: bool (default: False), string or list of strings
override_existing = cfg.get("override_existing", False)
if not (isinstance(override_existing, bool) or isinstance(override_existing, str) or (isinstance(override_existing, list) and all(isinstance(x, str) for x in override_existing))):
logger.error("[sphinx-gated-directives] The configuration option 'override_existing' should be a boolean, a string, or a list of strings.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.override_existing'. Expected a boolean, string, or list of strings.")
# - suffix_start: string (default: 'start'), only a-z allowed, or empty string
suffix_start = cfg.get("suffix_start", SUFFIX_START)
if not isinstance(suffix_start, str):
logger.error("[sphinx-gated-directives] The configuration option 'suffix_start' should be a string.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_start'. Expected a string.")
if suffix_start != "" and (not suffix_start.islower() or not suffix_start.isalpha()):
logger.error("[sphinx-gated-directives] The configuration option 'suffix_start' should contain only lowercase letters (a-z).")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_start'. Expected only lowercase letters (a-z).")
# - suffix_end: string (default: 'end'), only a-z allowed, or empty string
suffix_end = cfg.get("suffix_end", SUFFIX_END)
if not isinstance(suffix_end, str):
logger.error("[sphinx-gated-directives] The configuration option 'suffix_end' should be a string.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_end'. Expected a string.")
if suffix_end != "" and (not suffix_end.islower() or not suffix_end.isalpha()):
logger.error("[sphinx-gated-directives] The configuration option 'suffix_end' should contain only lowercase letters (a-z).")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_end'. Expected only lowercase letters (a-z).")
# - suffix_separator: string (default: '-'), single character, no space, no underscore, no colon, or empty string
suffix_separator = cfg.get("suffix_separator", SUFFIX_SEPARATOR)
if not isinstance(suffix_separator, str):
logger.error("[sphinx-gated-directives] The configuration option 'suffix_separator' should be a string.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_separator'. Expected a string.")
if len(suffix_separator) > 1:
logger.error("[sphinx-gated-directives] The configuration option 'suffix_separator' should be a single character or an empty string.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_separator'. Expected a single character or an empty string.")
if suffix_separator in [' ', '_',':']:
logger.error("[sphinx-gated-directives] The configuration option 'suffix_separator' should not be a space, nor an underscore, nor a colon.")
raise ValueError("Invalid configuration for 'sphinx_gated_directives.suffix_separator'. Expected something other than a space, an underscore, or a colon.")

View file

@ -74,15 +74,15 @@ This is a parent theorem. This text is inside the parent theorem.
Some text outside the parent theorem start, before the child start directive.
:::{prf:axiom-start} A child theorem
:label: child-axiom-label2
:::{exercise-start} A child exercise
:label: child-exercise-label2
This is a child axiom. This text is inside the child directive.
This is a child exercise. This text is inside the child directive.
:::
This is text after the child-start, before the child-end.
:::{prf:axiom-end}
:::{exercise-end}
:::
Finally some text after the entire child, outside the theorem start, before the end.
@ -90,6 +90,12 @@ Finally some text after the entire child, outside the theorem start, before the
:::{prf:theorem-end}
:::
:::{exercise} This is an exercise outside any theorem.
:label: exercise-outside-theorem
Solve this exercise.
:::
### Same directives
::::{prf:theorem-start} The parent theorem
@ -116,6 +122,27 @@ Finally some text after the entire child, outside the theorem start, before the
:::{prf:theorem-end}
:::
### Nested exercise directives
:::{exercise-start} An outer exercise with an inner exercise between start and end
:label: outer-exercise-label
This is an outer exercise. This text is inside the outer exercise.
:::
:::{exercise-start} An inner exercise
:label: inner-exercise-label
This is an inner exercise. This text is inside the inner exercise.
:::
This is text after the inner exercise start, before the inner exercise end.
:::{exercise-end}
:::
Finally some text after the entire inner exercise, inside the outer exercise.
:::{exercise-end}
:::
## Examples with code
### Block directive with also a figure