Skip to content

Instantly share code, notes, and snippets.

@minrk
Created September 28, 2023 10:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save minrk/2234020a647643ae88c63b20d3008e0b to your computer and use it in GitHub Desktop.
Save minrk/2234020a647643ae88c63b20d3008e0b to your computer and use it in GitHub Desktop.
"""
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,
}
@minrk
Copy link
Author

minrk commented Sep 28, 2023

A typical assignment looks like:

# Assignment 

:::{pointreport}
:::

- Task 1: {points}`2`
- Task 2: {points}`2 bonus`
- Task 3: {points}`1`

which produces:

Screenshot 2023-09-28 at 12 13 55

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment