Skip to content

Commit ee0f065

Browse files
authored
👌 IMPROVE: Bug fix, latex support and some refactoring #33
* proper enumeration * bug fixes and latex support * writing post-transforms and node output handling * edits * if not number for enumerable node * handling of refs * test file * handled nodes * test corrections * creating different transforms * readding test * some refactoring * created global variables, and moved code around
1 parent 78bf69b commit ee0f065

20 files changed

+417
-345
lines changed

sphinx_exercise/__init__.py

Lines changed: 174 additions & 233 deletions
Large diffs are not rendered by default.

sphinx_exercise/directive.py

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from sphinx.util.docutils import SphinxDirective
1414
from docutils.parsers.rst import directives
15-
from .local_nodes import enumerable_node, unenumerable_node, linked_node
15+
from .nodes import exercise_node, exercise_unenumerable_node, solution_node
1616
from docutils import nodes
1717
from sphinx.util import logging
1818

@@ -25,42 +25,42 @@ class CustomDirective(SphinxDirective):
2525
name = ""
2626

2727
def run(self) -> List[Node]:
28-
if self.name == "solution" and self.env.app.config.hide_solutions:
28+
env = self.env
29+
typ = self.name
30+
if typ == "solution" and env.app.config.hide_solutions:
2931
return []
3032

31-
serial_no = self.env.new_serialno()
33+
serial_no = env.new_serialno()
3234

33-
if not hasattr(self.env, "exercise_list"):
34-
self.env.exercise_list = {}
35+
if not hasattr(env, "exercise_list"):
36+
env.exercise_list = {}
3537

36-
classes, class_name = [self.name], self.options.get("class", "")
38+
classes, class_name = [typ], self.options.get("class", "")
3739
if class_name:
3840
classes.extend(class_name)
3941

40-
title_text, title = "", ""
41-
if self.name == "exercise":
42-
if "nonumber" in self.options:
43-
title_text = f"{self.name.title()} "
42+
title_text = ""
43+
if typ == "exercise":
44+
title_text = f"{self.name.title()} "
4445

4546
if self.arguments != []:
46-
title_text += f"({self.arguments[0]})"
47-
title += self.arguments[0]
47+
title_text = f"({self.arguments[0]})"
4848
else:
4949
title_text = f"{self.name.title()} to "
5050
target_label = self.arguments[0]
5151

5252
textnodes, messages = self.state.inline_text(title_text, self.lineno)
5353

54-
section = nodes.section(ids=[f"{self.name}-content"])
54+
section = nodes.section(ids=[f"{typ}-content"])
5555
self.state.nested_parse(self.content, self.content_offset, section)
5656

57-
if self.name == "exercise":
57+
if typ == "exercise":
5858
if "nonumber" in self.options:
59-
node = unenumerable_node()
59+
node = exercise_unenumerable_node()
6060
else:
61-
node = enumerable_node()
61+
node = exercise_node()
6262
else:
63-
node = linked_node()
63+
node = solution_node()
6464

6565
node += nodes.title(title_text, "", *textnodes)
6666
node += section
@@ -70,13 +70,13 @@ def run(self) -> List[Node]:
7070
self.options["noindex"] = False
7171
else:
7272
self.options["noindex"] = True
73-
label = f"{self.env.docname}-{self.name}-{serial_no}"
73+
label = f"{env.docname}-{typ}-{serial_no}"
7474

7575
# Duplicate label warning
76-
if not label == "" and label in self.env.exercise_list.keys():
77-
docpath = self.env.doc2path(self.env.docname)
76+
if not label == "" and label in env.exercise_list.keys():
77+
docpath = env.doc2path(env.docname)
7878
path = docpath[: docpath.rfind(".")]
79-
other_path = self.env.doc2path(self.env.exercise_list[label]["docname"])
79+
other_path = env.doc2path(env.exercise_list[label]["docname"])
8080
msg = f"duplicate label: {label}; other instance in {other_path}"
8181
logger.warning(msg, location=path, color="red")
8282
return []
@@ -87,20 +87,21 @@ def run(self) -> List[Node]:
8787
node["classes"].extend(classes)
8888
node["ids"].append(label)
8989
node["label"] = label
90-
node["docname"] = self.env.docname
90+
node["docname"] = env.docname
91+
node["title"] = title_text
92+
node["type"] = typ
9193
node["hidden"] = True if "hidden" in self.options else False
9294
node.document = self.state.document
9395

94-
if self.name == "solution":
96+
if typ == "solution":
9597
node["target_label"] = target_label
9698

9799
self.add_name(node)
98-
99-
self.env.exercise_list[label] = {
100-
"type": self.name,
101-
"docname": self.env.docname,
100+
env.exercise_list[label] = {
101+
"type": typ,
102+
"docname": env.docname,
102103
"node": node,
103-
"title": title,
104+
"title": title_text,
104105
"hidden": node.get("hidden", bool),
105106
}
106107

sphinx_exercise/local_nodes.py

Lines changed: 0 additions & 69 deletions
This file was deleted.

sphinx_exercise/nodes.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""
2+
sphinx_exercise.nodes
3+
~~~~~~~~~~~~~~~~~~~~~
4+
5+
Enumerable and unenumerable nodes
6+
7+
:copyright: Copyright 2020 by the QuantEcon team, see AUTHORS
8+
:licences: see LICENSE for details
9+
"""
10+
from docutils.nodes import Node
11+
from sphinx.util import logging
12+
from docutils import nodes as docutil_nodes
13+
from sphinx.writers.latex import LaTeXTranslator
14+
from .utils import get_node_number, find_parent
15+
16+
logger = logging.getLogger(__name__)
17+
18+
CR = "\n"
19+
latex_admonition_start = CR + "\\begin{sphinxadmonition}{note}"
20+
latex_admonition_end = "\\end{sphinxadmonition}" + CR
21+
22+
23+
class exercise_node(docutil_nodes.Admonition, docutil_nodes.Element):
24+
pass
25+
26+
27+
class solution_node(docutil_nodes.Admonition, docutil_nodes.Element):
28+
pass
29+
30+
31+
class exercise_unenumerable_node(docutil_nodes.Admonition, docutil_nodes.Element):
32+
pass
33+
34+
35+
def visit_enumerable_node(self, node: Node) -> None:
36+
if isinstance(self, LaTeXTranslator):
37+
docname = find_parent(self.builder.env, node, "section")
38+
self.body.append("\\label{" + f"{docname}:{node.attributes['label']}" + "}")
39+
self.body.append(latex_admonition_start)
40+
else:
41+
for title in node.traverse(docutil_nodes.title):
42+
if "Exercise" in title.astext():
43+
title[0] = docutil_nodes.Text("")
44+
self.body.append(self.starttag(node, "div", CLASS="admonition"))
45+
46+
47+
def depart_enumerable_node(self, node: Node) -> None:
48+
typ = node.attributes.get("type", "")
49+
if isinstance(self, LaTeXTranslator):
50+
number = get_node_number(self, node, typ)
51+
idx = list_rindex(self.body, latex_admonition_start) + 2
52+
self.body.insert(idx, f"{typ.title()} {number} ")
53+
self.body.append(latex_admonition_end)
54+
else:
55+
number = get_node_number(self, node, typ)
56+
if number:
57+
idx = self.body.index(f"{typ.title()} {number} ")
58+
self.body[idx] = f"{typ.title()} {number} "
59+
self.body.append("</div>")
60+
61+
62+
def visit_exercise_unenumerable_node(self, node: Node) -> None:
63+
if isinstance(self, LaTeXTranslator):
64+
docname = find_parent(self.builder.env, node, "section")
65+
self.body.append("\\label{" + f"{docname}:{node.attributes['label']}" + "}")
66+
self.body.append(latex_admonition_start)
67+
else:
68+
for title in node.traverse(docutil_nodes.title):
69+
if "Exercise" in title.astext():
70+
title[0] = docutil_nodes.Text("")
71+
self.body.append(self.starttag(node, "div", CLASS="admonition"))
72+
73+
74+
def depart_exercise_unenumerable_node(self, node: Node) -> None:
75+
typ = node.attributes.get("type", "")
76+
if isinstance(self, LaTeXTranslator):
77+
idx = list_rindex(self.body, latex_admonition_start) + 2
78+
self.body.insert(idx, f"{typ.title()} ")
79+
self.body.append(latex_admonition_end)
80+
else:
81+
idx = list_rindex(self.body, '<p class="admonition-title">') + 1
82+
element = f"<span>{typ.title()} </span>"
83+
self.body.insert(idx, element)
84+
self.body.append("</div>")
85+
86+
87+
def visit_solution_node(self, node: Node) -> None:
88+
if isinstance(self, LaTeXTranslator):
89+
docname = find_parent(self.builder.env, node, "section")
90+
self.body.append("\\label{" + f"{docname}:{node.attributes['label']}" + "}")
91+
self.body.append(latex_admonition_start)
92+
else:
93+
self.body.append(self.starttag(node, "div", CLASS="admonition"))
94+
95+
96+
def depart_solution_node(self, node: Node) -> None:
97+
typ = node.attributes.get("type", "")
98+
if isinstance(self, LaTeXTranslator):
99+
idx = list_rindex(self.body, latex_admonition_start) + 2
100+
self.body.pop(idx)
101+
self.body.insert(idx, f"{typ.title()} ")
102+
self.body.append(latex_admonition_end)
103+
else:
104+
number = get_node_number(self, node, typ)
105+
idx = self.body.index(f"{typ.title()} {number} ")
106+
self.body.pop(idx)
107+
self.body.append("</div>")
108+
109+
110+
def is_exercise_node(node):
111+
return isinstance(node, exercise_node)
112+
113+
114+
def is_unenumerable_node(node):
115+
return isinstance(node, exercise_unenumerable_node)
116+
117+
118+
def is_solution_node(node):
119+
return isinstance(node, solution_node)
120+
121+
122+
def is_extension_node(node):
123+
return (
124+
is_exercise_node(node) or is_unenumerable_node(node) or is_solution_node(node)
125+
)
126+
127+
128+
def rreplace(s, old, new, occurrence):
129+
# taken from https://stackoverflow.com/a/2556252
130+
li = s.rsplit(old, occurrence)
131+
return new.join(li)
132+
133+
134+
def list_rindex(li, x) -> int:
135+
"""Getting the last occurence of an item in a list."""
136+
for i in reversed(range(len(li))):
137+
if li[i] == x:
138+
return i
139+
raise ValueError("{} is not in list".format(x))
140+
141+
142+
NODE_TYPES = {
143+
"exercise": {"node": exercise_node, "type": "exercise"},
144+
"solution": {"node": solution_node, "type": "solution"},
145+
}

sphinx_exercise/utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from sphinx.writers.latex import LaTeXTranslator
2+
from docutils import nodes as docutil_nodes
3+
4+
5+
def find_parent(env, node, parent_tag):
6+
"""Find the nearest parent node with the given tagname."""
7+
while True:
8+
node = node.parent
9+
if node is None:
10+
return None
11+
# parent should be a document in toc
12+
if (
13+
"docname" in node.attributes
14+
and env.titles[node.attributes["docname"]].astext().lower()
15+
in node.attributes["names"]
16+
):
17+
return node.attributes["docname"]
18+
19+
if node.tagname == parent_tag:
20+
return node.attributes["docname"]
21+
22+
return None
23+
24+
25+
def get_node_number(self, node, typ) -> str:
26+
"""Get the number for the directive node for HTML."""
27+
ids = node.attributes.get("ids", [])[0]
28+
if isinstance(self, LaTeXTranslator):
29+
docname = find_parent(self.builder.env, node, "section")
30+
else:
31+
docname = node.attributes.get("docname", "")
32+
# Latex does not have builder.fignumbers
33+
fignumbers = self.builder.env.toc_fignumbers.get(docname, {})
34+
number = fignumbers.get(typ, {}).get(ids, ())
35+
return ".".join(map(str, number))
36+
37+
38+
def has_math_child(node):
39+
""" Check if a parent node as a math child node. """
40+
for item in node:
41+
if isinstance(item, docutil_nodes.math):
42+
return True
43+
return False
44+
45+
46+
def get_refuri(node):
47+
""" Check both refuri and refid, to see which one is available. """
48+
id_ = ""
49+
if node.get("refuri", ""):
50+
id_ = node.get("refuri", "")
51+
52+
if node.get("refid", ""):
53+
id_ = node.get("refid", "")
54+
55+
return id_.split("#")[-1]

tests/test_build.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ def test_warnings(app, warnings):
2424
"_enum_numref_notitle.rst:6: WARNING: invalid numfig_format: some text"
2525
in warnings(app)
2626
)
27-
assert "WARNING: invalid numfig_format: some text {name}" in warnings(app)
2827
assert (
2928
"_enum_numref_title.rst:6: WARNING: invalid numfig_format: some text"
3029
in warnings(app)

0 commit comments

Comments
 (0)