From f4ea60d42c4af8378e5e778a3a31d3b0365de38e Mon Sep 17 00:00:00 2001 From: Dennis den Ouden-van der Horst Date: Sat, 3 Jan 2026 12:45:24 +0100 Subject: [PATCH] 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. --- book/_config.yml | 2 + book/_ext/sphinx_gated_directives/__init__.py | 176 +++++++++++++----- book/intro.md | 35 +++- 3 files changed, 167 insertions(+), 46 deletions(-) diff --git a/book/_config.yml b/book/_config.yml index 1e55627..8f88cff 100644 --- a/book/_config.yml +++ b/book/_config.yml @@ -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 diff --git a/book/_ext/sphinx_gated_directives/__init__.py b/book/_ext/sphinx_gated_directives/__init__.py index e956b0b..c650d97 100644 --- a/book/_ext/sphinx_gated_directives/__init__.py +++ b/book/_ext/sphinx_gated_directives/__init__.py @@ -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.") + \ No newline at end of file diff --git a/book/intro.md b/book/intro.md index 3a415f8..4df190b 100644 --- a/book/intro.md +++ b/book/intro.md @@ -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