Created
September 28, 2023 10:08
-
-
Save minrk/2234020a647643ae88c63b20d3008e0b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Extension for adding points in an assignment | |
Keeps me sane, stressing that my point totals don't add up. | |
Defines one role: | |
- `points` displays points and records number in total | |
And two directives: | |
- `pointreport` displays a total of points and bonus points for the current document | |
- `overallpointreport` displays a total of points and bonus points for all assignment | |
""" | |
from enum import Enum | |
from itertools import chain | |
from docutils import nodes | |
from docutils.parsers.rst import Directive | |
from sphinx.util.docutils import SphinxDirective | |
class PointReportNode(nodes.General, nodes.Element): | |
pass | |
class PointReport(Directive): | |
"""Directive where the point report should go""" | |
def run(self): | |
return [PointReportNode("")] | |
class OverallPointReportNode(nodes.General, nodes.Element): | |
pass | |
class OverallPointReport(SphinxDirective): | |
"""TODO: overall point report""" | |
def run(self): | |
return [OverallPointReportNode("")] | |
class PointFlags(Enum): | |
all = "all" | |
bonus = "bonus" | |
# TODO: generic for other people? | |
in4110_only = "4110" | |
bonus_3110 = "bonus_3110" | |
def point_role(name, rawtext, text, lineno, inliner, options=None, content=None): | |
"""Role for individual task points | |
Replaces a number with bold "X point[s]" and bonus points / course-specific notes. | |
""" | |
env = inliner.document.settings.env | |
points_str, *_rest = text.split(None, 1) | |
if _rest: | |
label = _rest[0] | |
try: | |
kind = PointFlags(label) | |
except ValueError: | |
raise ValueError( | |
f"point label must be one of {[item.value for item in PointFlags]}, not {label!r}" | |
) from None | |
else: | |
kind = PointFlags.all | |
points = float(points_str) | |
env._task_points[env.docname].append( | |
{ | |
"docname": env.docname, | |
"lineno": lineno, | |
"points": points, | |
"kind": kind, | |
} | |
) | |
s = "s" if points != 1 else "" | |
if kind == PointFlags.all: | |
label = f"point{s}" | |
elif kind == PointFlags.in4110_only: | |
label = f"point{s}, IN4110 only" | |
elif kind == PointFlags.bonus: | |
label = f"bonus point{s}" | |
elif kind == PointFlags.bonus_3110: | |
label = f"point{s}, IN4110 required, bonus for IN3110" | |
text = f"({points:g} {label})" | |
return [nodes.strong(text, text)], [] | |
def _sum_points(env, docname=None): | |
"""Collect sum of points | |
Returns dict of sums for each course and bonus points for each course. | |
""" | |
if docname: | |
point_generator = env._task_points.get(docname, []) | |
else: | |
point_generator = chain(env._task_points.values()) | |
point_totals = {kind: 0 for kind in PointFlags} | |
for point_info in point_generator: | |
point_totals[point_info["kind"]] += point_info["points"] | |
# TODO: configurable courses? | |
return { | |
"3110": point_totals[PointFlags.all], | |
"3110_bonus": point_totals[PointFlags.bonus] | |
+ point_totals[PointFlags.bonus_3110], | |
"4110": point_totals[PointFlags.all] | |
+ point_totals[PointFlags.in4110_only] | |
+ point_totals[PointFlags.bonus_3110], | |
"4110_bonus": point_totals[PointFlags.bonus], | |
} | |
def process_point_nodes(app, doctree, fromdocname): | |
"""Collect point sums to generate point reports""" | |
env = app.builder.env | |
for node in doctree.findall(PointReportNode): | |
content = [] | |
points = _sum_points(env, docname=fromdocname) | |
p = nodes.paragraph() | |
content.append(p) | |
# Examples: | |
# Total Points: 15 | |
# Total Points: 15 (+ 5 bonus) | |
# Total Points (3110): 15 (+ 5 bonus) | |
# Total Points (4110): 20 | |
if ( | |
points["3110"] == points["4110"] | |
and points["4110_bonus"] == points["3110_bonus"] | |
): | |
p += nodes.strong("Total points: ", "Total points: ") | |
p += nodes.Text(f"{points['3110']:g}") | |
if points["3110_bonus"]: | |
p += nodes.Text(f" (+ {points['3110_bonus']:g} bonus points)") | |
else: | |
p += nodes.strong("Total points (IN3110):", "Total points (IN3110): ") | |
p += nodes.Text(f"{points['3110']:g}") | |
if points["3110_bonus"]: | |
p += nodes.Text(f" (+ {points['3110_bonus']:g} bonus points)") | |
p = nodes.paragraph() | |
content.append(p) | |
p += nodes.strong("Total points (IN4110):", "Total points (IN4110): ") | |
p += nodes.Text(f"{points['4110']:g}") | |
if points["4110_bonus"]: | |
p += nodes.Text(f" (+ {points['4110_bonus']:g} bonus points)") | |
node.replace_self(content) | |
def init_points(app, env, docname): | |
"""Initialize points data for a doc | |
avoids re-loading points from serialized env across runs | |
""" | |
if not hasattr(env, "_task_points"): | |
env._task_points = {} | |
env._task_points[docname] = [] | |
def setup(app): | |
app.add_node(PointReportNode) | |
app.add_directive("pointreport", PointReport) | |
app.add_node(OverallPointReportNode) | |
app.add_directive("overallpointreport", OverallPointReportNode) | |
app.add_role("points", point_role) | |
app.connect("env-purge-doc", init_points) | |
app.connect("doctree-resolved", process_point_nodes) | |
return { | |
"version": "0.1", | |
"parallel_read_safe": True, | |
"parallel_write_safe": True, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A typical assignment looks like:
which produces: