Skip to content

Commit b157945

Browse files
committed
ENH: Add support for gated exercise directives
1 parent 574c7fe commit b157945

25 files changed

+820
-126
lines changed

sphinx_exercise/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from .directive import (
2121
ExerciseDirective,
22+
ExerciseStartDirective,
23+
ExerciseEndDirective,
2224
SolutionDirective,
2325
SolutionStartDirective,
2426
SolutionEndDirective,
@@ -30,11 +32,12 @@
3032
exercise_enumerable_node,
3133
visit_exercise_enumerable_node,
3234
depart_exercise_enumerable_node,
35+
exercise_end_node,
3336
solution_node,
34-
solution_start_node,
35-
solution_end_node,
3637
visit_solution_node,
3738
depart_solution_node,
39+
solution_start_node,
40+
solution_end_node,
3841
is_extension_node,
3942
exercise_title,
4043
exercise_subtitle,
@@ -45,8 +48,9 @@
4548
depart_exercise_latex_number_reference,
4649
)
4750
from .transforms import (
48-
CheckGatedSolutions,
51+
CheckGatedDirectives,
4952
MergeGatedSolutions,
53+
MergeGatedExercises,
5054
)
5155
from .post_transforms import (
5256
ResolveTitlesInExercises,
@@ -170,6 +174,7 @@ def setup(app: Sphinx) -> Dict[str, Any]:
170174

171175
# Internal Title Nodes that don't need visit_ and depart_ methods
172176
# as they are resolved in post_transforms to docutil and sphinx nodes
177+
app.add_node(exercise_end_node)
173178
app.add_node(solution_start_node)
174179
app.add_node(solution_end_node)
175180
app.add_node(exercise_title)
@@ -186,11 +191,14 @@ def setup(app: Sphinx) -> Dict[str, Any]:
186191
)
187192

188193
app.add_directive("exercise", ExerciseDirective)
194+
app.add_directive("exercise-start", ExerciseStartDirective)
195+
app.add_directive("exercise-end", ExerciseEndDirective)
189196
app.add_directive("solution", SolutionDirective)
190197
app.add_directive("solution-start", SolutionStartDirective)
191198
app.add_directive("solution-end", SolutionEndDirective)
192199

193-
app.add_transform(CheckGatedSolutions)
200+
app.add_transform(CheckGatedDirectives)
201+
app.add_transform(MergeGatedExercises)
194202
app.add_transform(MergeGatedSolutions)
195203

196204
app.add_post_transform(UpdateReferencesToEnumerated)

sphinx_exercise/directive.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .nodes import (
1717
exercise_node,
1818
exercise_enumerable_node,
19+
exercise_end_node,
1920
solution_node,
2021
solution_start_node,
2122
solution_end_node,
@@ -105,6 +106,9 @@ def run(self) -> List[Node]:
105106
else:
106107
node = exercise_enumerable_node()
107108

109+
if self.name == "exercise-start":
110+
node.gated = True
111+
108112
# Parse custom subtitle option
109113
if self.arguments != []:
110114
subtitle = exercise_subtitle()
@@ -283,6 +287,74 @@ def run(self) -> List[Node]:
283287
# Gated Directives
284288

285289

290+
class ExerciseStartDirective(ExerciseDirective):
291+
"""
292+
A gated directive for exercises
293+
294+
.. exercise:: <subtitle> (optional)
295+
:label:
296+
:class:
297+
:nonumber:
298+
:hidden:
299+
300+
This class is a child of ExerciseDirective so it supports
301+
all the same options as the base exercise node
302+
"""
303+
304+
name = "exercise-start"
305+
306+
def run(self):
307+
# Initialise Gated Registry
308+
if not hasattr(self.env, "sphinx_exercise_gated_registry"):
309+
self.env.sphinx_exercise_gated_registry = {}
310+
gated_registry = self.env.sphinx_exercise_gated_registry
311+
docname = self.env.docname
312+
if docname not in gated_registry:
313+
gated_registry[docname] = {
314+
"start": [],
315+
"end": [],
316+
"sequence": [],
317+
"msg": [],
318+
}
319+
gated_registry[self.env.docname]["start"].append(self.lineno)
320+
gated_registry[self.env.docname]["sequence"].append("S")
321+
gated_registry[self.env.docname]["msg"].append(
322+
f"{self.name} at line: {self.lineno}"
323+
)
324+
# Run Parent Methods
325+
return super().run()
326+
327+
328+
class ExerciseEndDirective(SphinxDirective):
329+
"""
330+
A simple gated directive to mark end of an exercise
331+
332+
.. exercise-end::
333+
"""
334+
335+
name = "exercise-end"
336+
337+
def run(self):
338+
# Initialise Gated Registry
339+
if not hasattr(self.env, "sphinx_exercise_gated_registry"):
340+
self.env.sphinx_exercise_gated_registry = {}
341+
gated_registry = self.env.sphinx_exercise_gated_registry
342+
docname = self.env.docname
343+
if docname not in gated_registry:
344+
gated_registry[docname] = {
345+
"start": [],
346+
"end": [],
347+
"sequence": [],
348+
"msg": [],
349+
}
350+
gated_registry[self.env.docname]["end"].append(self.lineno)
351+
gated_registry[self.env.docname]["sequence"].append("E")
352+
gated_registry[self.env.docname]["msg"].append(
353+
f"{self.name} at line: {self.lineno}"
354+
)
355+
return [exercise_end_node()]
356+
357+
286358
class SolutionStartDirective(SolutionDirective):
287359
"""
288360
A gated directive for solution

sphinx_exercise/nodes.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,18 @@
2323

2424

2525
class exercise_node(docutil_nodes.Admonition, docutil_nodes.Element):
26-
pass
26+
gated = False
2727

2828

2929
class exercise_enumerable_node(docutil_nodes.Admonition, docutil_nodes.Element):
30+
gated = False
3031
resolved_title = False
3132

3233

34+
class exercise_end_node(docutil_nodes.Admonition, docutil_nodes.Element):
35+
pass
36+
37+
3338
class solution_node(docutil_nodes.Admonition, docutil_nodes.Element):
3439
resolved_title = False
3540

@@ -39,7 +44,7 @@ class solution_start_node(docutil_nodes.Admonition, docutil_nodes.Element):
3944

4045

4146
class solution_end_node(docutil_nodes.Admonition, docutil_nodes.Element):
42-
resolved_title = False
47+
resolved_title = False # TODO: is this required?
4348

4449

4550
class exercise_title(docutil_nodes.title):

sphinx_exercise/transforms.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
# from sphinx.errors import ExtensionError
99

1010
from .nodes import (
11+
exercise_node,
12+
exercise_enumerable_node,
13+
exercise_end_node,
1114
solution_node,
1215
solution_start_node,
1316
solution_end_node,
@@ -16,7 +19,7 @@
1619
logger = logging.getLogger(__name__)
1720

1821

19-
class CheckGatedSolutions(SphinxTransform):
22+
class CheckGatedDirectives(SphinxTransform):
2023
"""
2124
This transform checks the structure of the gated solutions
2225
to flag any errors in input
@@ -117,3 +120,63 @@ def apply(self):
117120
# Clean up Parent Node including :solution-end:
118121
for child in parent.children[parent_start + 1 : parent_end + 1]:
119122
parent.remove(child)
123+
124+
125+
class MergeGatedExercises(SphinxTransform):
126+
"""
127+
Transform Gated Exercise Directives into single unified
128+
Directives in the Sphinx Abstract Syntax Tree
129+
130+
Note: The CheckGatedDirectives Transform should ensure the
131+
structure of the gated directives is correct before
132+
this transform is run.
133+
"""
134+
135+
default_priority = 10
136+
137+
def find_nodes(self, label, node):
138+
parent_node = node.parent
139+
parent_start, parent_end = None, None
140+
for idx1, child in enumerate(parent_node.children):
141+
if isinstance(
142+
child, (exercise_node, exercise_enumerable_node)
143+
) and label == child.get("label"):
144+
parent_start = idx1
145+
for idx2, child2 in enumerate(parent_node.children[parent_start:]):
146+
if isinstance(child2, exercise_end_node):
147+
parent_end = idx1 + idx2
148+
break
149+
break
150+
return parent_start, parent_end
151+
152+
def merge_nodes(self, node):
153+
label = node.get("label")
154+
parent_start, parent_end = self.find_nodes(label, node)
155+
if not parent_end:
156+
return
157+
parent = node.parent
158+
# Use Current Node and remove "-start" from class names and type
159+
updated_classes = [
160+
cls.replace("-start", "") for cls in node.attributes["classes"]
161+
]
162+
node.attributes["classes"] = updated_classes
163+
node.attributes["type"] = node.attributes["type"].replace("-start", "")
164+
# Attach content to section
165+
content = node.children[-1]
166+
for child in parent.children[parent_start + 1 : parent_end]:
167+
content += child
168+
# Clean up Parent Node including :exercise-end:
169+
for child in parent.children[parent_start + 1 : parent_end + 1]:
170+
parent.remove(child)
171+
172+
def apply(self):
173+
# Process all matching exercise and exercise-enumerable (gated=True)
174+
# and exercise-end nodes
175+
for node in self.document.traverse(exercise_node):
176+
if node.gated:
177+
self.merge_nodes(node)
178+
node.gated = False
179+
for node in self.document.traverse(exercise_enumerable_node):
180+
if node.gated:
181+
self.merge_nodes(node)
182+
node.gated = False

tests/books/test-gateddirective/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
# List of patterns, relative to source directory, that match files and
3636
# directories to ignore when looking for source files.
3737
# This pattern also affects html_static_path and html_extra_path.
38-
exclude_patterns = ["build", "_build", "errors_[1,2,3]*"]
38+
exclude_patterns = ["build", "_build", "solution_errors_[1,2,3]*"]
3939

4040

4141
# -- Options for HTML output -------------------------------------------------
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
jupytext:
3+
text_representation:
4+
extension: .md
5+
format_name: myst
6+
kernelspec:
7+
display_name: Python 3
8+
language: python
9+
name: python3
10+
---
11+
12+
# Gated Exercises
13+
14+
Some Gated reference exercises
15+
16+
```{exercise-start}
17+
:label: gated-exercise-1
18+
```
19+
20+
Replicate this figure using matplotlib
21+
22+
```{figure} sphx_glr_cohere_001_2_0x.png
23+
```
24+
25+
```{exercise-end}
26+
```
27+
28+
and another version with a title embedded
29+
30+
31+
```{exercise-start} Replicate Matplotlib Plot
32+
:label: gated-exercise-2
33+
```
34+
35+
```{figure} sphx_glr_cohere_001_2_0x.png
36+
```
37+
38+
```{exercise-end}
39+
```

tests/books/test-gateddirective/exercise.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ kernelspec:
99
name: python3
1010
---
1111

12-
# Exercise
12+
# Non-Gated Exercises
1313

1414
Some reference exercises
1515

tests/books/test-gateddirective/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,7 @@ kernelspec:
1616
:maxdepth: 1
1717
1818
exercise
19-
solution
19+
solution-exercise
20+
exercise-gated
21+
solution-exercise-gated
2022
```

0 commit comments

Comments
 (0)