Repository: PrairieLearn/PrairieLearn
Pull request #11855: Partial types for pl-drawing
Author: login: reteps
Labels: name: python
description: Requires Python code changes
name: element
description: Anything related to elements
Created: 2025-04-28T17:26:02Z | Merged: 2025-04-28T22:05:40Z

- While working on #11854, my entire VSCode editor was full of red, and most errors are partially resolvable with simple `@staticmethod` annotations and basic types.


Diff:
diff --git a/apps/prairielearn/elements/pl-drawing/defaults.py b/apps/prairielearn/elements/pl-drawing/defaults.py
index 3dd7d337ac4..c838f2a9e7c 100644
--- a/apps/prairielearn/elements/pl-drawing/defaults.py
+++ b/apps/prairielearn/elements/pl-drawing/defaults.py
@@ -1,4 +1,47 @@
-element_defaults = {
+from typing import TypedDict
+
+ElementDefaults = TypedDict(
+    "ElementDefaults",
+    {
+        "gradable": bool,
+        "answers-name": str,
+        "draw-error-box": bool,
+        "grid-size": int,
+        "angle-tol": int,
+        "snap-to-grid": bool,
+        "width": int,
+        "height": int,
+        "show-tolerance-hint": bool,
+        "render-scale": float,
+        "disregard-extra-elements": bool,
+    },
+)
+
+DrawingDefaults = TypedDict(
+    "DrawingDefaults",
+    {
+        "x1": int,
+        "y1": int,
+        "x2": int,
+        "y2": int,
+        "offsetx": int,
+        "offsety": int,
+        "width": int,
+        "width-rod": int,
+        "height": int,
+        "label": str,
+        "angle": int,
+        "end-angle": int,
+        "radius": int,
+        "opacity": float,
+        "stroke-width": int,
+        "selectable": bool,
+        "font-size": int,
+        "point-size": int,
+        "force-width": int,
+    },
+)
+element_defaults: ElementDefaults = {
     "gradable": False,
     "answers-name": "",
     "draw-error-box": False,
@@ -12,7 +55,7 @@
     "disregard-extra-elements": False,
 }
 
-drawing_defaults = {
+drawing_defaults: DrawingDefaults = {
     "x1": 40,
     "y1": 40,
     "x2": 80,
diff --git a/apps/prairielearn/elements/pl-drawing/elements.py b/apps/prairielearn/elements/pl-drawing/elements.py
index 1c689fa9e76..992126f33c4 100644
--- a/apps/prairielearn/elements/pl-drawing/elements.py
+++ b/apps/prairielearn/elements/pl-drawing/elements.py
@@ -1,9 +1,12 @@
+# pyright: reportUnknownParameterType=false
+
 import json
 import math
 
 import numpy as np
 import prairielearn as pl
 from defaults import drawing_defaults
+from lxml.html import HtmlElement
 
 
 def get_error_box(x1, y1, theta, tol, offset_forward, offset_backward):
@@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {}
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(ref, student, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         return True
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         return None
 
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         """Return a list of attributes that the element may contain."""
         return []
 
 
 class ControlledLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         if "draw-error-box" in el.attrib:
             obj_draw = el.attrib["draw-error-box"] == "true"
         else:
@@ -87,10 +97,12 @@ def generate(el, data):
             "offset_y": offset_y,
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         ex1, ex2 = st["x1"], st["x2"]
         ey1, ey2 = st["y1"], st["y2"]
         rx1, rx2 = ref["x1"], ref["x2"]
@@ -108,7 +120,8 @@ def grade(ref, st, tol, angtol):
             and abserr(ey2, ry1) <= ref["offset_y"] + tol
         )
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -128,7 +141,8 @@ def get_attributes():
 
 
 class ControlledCurvedLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         if "draw-error-box" in el.attrib:
             obj_draw = el.attrib["draw-error-box"] == "true"
         else:
@@ -169,10 +183,12 @@ def generate(el, data):
             "offset_control_y": offset_control_y,
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         ex1, ex2, exm = st["x1"], st["x3"], st["x2"]
         ey1, ey2, eym = st["y1"], st["y3"], st["y2"]
         rx1, rx2, rxm = ref["x1"], ref["x3"], ref["x2"]
@@ -196,7 +212,8 @@ def grade(ref, st, tol, angtol):
         )
         return b1 or b2
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -216,7 +233,8 @@ def get_attributes():
 
 
 class Roller(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "brown1")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         return {
@@ -239,7 +257,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -258,7 +277,8 @@ def get_attributes():
 
 
 class Clamped(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "black")
         return {
             "x1": pl.get_float_attrib(el, "x1", drawing_defaults["x1"]),
@@ -278,7 +298,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -294,7 +315,8 @@ def get_attributes():
 
 
 class FixedPin(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "brown1")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         obj = {
@@ -318,7 +340,8 @@ def generate(el, data):
         }
         return obj
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -337,7 +360,8 @@ def get_attributes():
 
 
 class Rod(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "white")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         return {
@@ -370,7 +394,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -391,7 +416,8 @@ def get_attributes():
 
 
 class CollarRod(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         w = pl.get_float_attrib(el, "width", 20)
         color = pl.get_color_attrib(el, "color", "white")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
@@ -423,7 +449,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -450,7 +477,8 @@ def get_attributes():
 
 
 class ThreePointRod(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "white")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         x1 = pl.get_float_attrib(el, "x1", 40)
@@ -486,7 +514,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -512,7 +541,8 @@ def get_attributes():
 
 
 class FourPointRod(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "white")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         x1 = pl.get_float_attrib(el, "x1", 40)
@@ -556,7 +586,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -587,7 +618,8 @@ def get_attributes():
 
 
 class Pulley(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "gray")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         r = pl.get_float_attrib(el, "radius", 20)
@@ -618,7 +650,8 @@ def generate(el, data):
             "fill": color,
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -638,7 +671,8 @@ def get_attributes():
 
 
 class Vector(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "red3")
         anchor_is_tail = pl.get_boolean_attrib(el, "anchor-is-tail", True)
         # This is the anchor point for Grading
@@ -706,10 +740,12 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         epos = np.array([st["left"], st["top"]]).astype(np.float64)
         eang = st["angle"]
         elen = st["width"]
@@ -754,7 +790,8 @@ def grade(ref, st, tol, angtol):
 
         return abs(relx) <= tol and -max_backward <= rely <= max_forward
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -777,7 +814,8 @@ def get_attributes():
 
 
 class PairedVector(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         grid_size = pl.get_integer_attrib(el, "grid-size", 20)
         color = pl.get_color_attrib(el, "color", "red3")
         anchor_is_tail = pl.get_boolean_attrib(el, "anchor-is-tail", True)
@@ -872,10 +910,12 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         ref2 = ref.copy()
         st2 = st.copy()
         dup_attrs = [
@@ -914,7 +954,8 @@ def grade(ref, st, tol, angtol):
             (poss[0][1] and poss[1][0]) or (poss[0][0] and poss[1][1])
         ) and angdiff < 2 * angtol
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -941,12 +982,14 @@ def get_attributes():
 
 
 class DoubleHeadedVector(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         obj = Vector.generate(el, data)
         obj["type"] = "pl-double-headed-vector"
         return obj
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -969,7 +1012,8 @@ def get_attributes():
 
 
 class ArcVector(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         disregard_sense = pl.get_boolean_attrib(el, "disregard-sense", False)
         color = pl.get_color_attrib(el, "color", "purple")
         clockwise_direction = pl.get_boolean_attrib(el, "clockwise-direction", True)
@@ -1031,10 +1075,12 @@ def generate(el, data):
             "clockwiseDirection": clockwise_direction,
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         epos = np.array([st["left"], st["top"]]).astype(np.float64)
         st_start_arrow = st["drawStartArrow"]
 
@@ -1049,7 +1095,8 @@ def grade(ref, st, tol, angtol):
         # Check if correct orientation
         return ref["disregard_sense"] or st_start_arrow == ref_start_arrow
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1072,7 +1119,8 @@ def get_attributes():
 
 
 class DistributedLoad(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "red3")
         anchor_is_tail = pl.get_boolean_attrib(el, "anchor-is-tail", True)
         # This is the anchor point for Grading
@@ -1151,10 +1199,12 @@ def generate(el, data):
             "selectable": drawing_defaults["selectable"],
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         epos = np.array([st["left"], st["top"]]).astype(np.float64)
         eang = st["angle"]
         elen = st["range"]
@@ -1203,14 +1253,15 @@ def grade(ref, st, tol, angtol):
             if ew1 != ew2:
                 return False
         else:
-            if st.get("flipped", False):
+            if st.get("flipped"):
                 ew1, ew2 = ew2, ew1
             if (rw1 < rw2 and ew1 > ew2) or (rw1 > rw2 and ew1 < ew2):
                 return False
 
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1238,7 +1289,8 @@ def get_attributes():
 
 
 class Point(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "black")
         # Error box for grading
         x1 = pl.get_float_attrib(el, "x1", 40)
@@ -1280,22 +1332,26 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return True
 
-    def grade(ref, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         epos = np.array([st["left"], st["top"]]).astype(np.float64)
         rpos = np.array([ref["left"], ref["top"]])
         # Check if correct position
         relx, rely = epos - rpos
         return abs(relx) <= tol and abs(rely) <= tol
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["x1", "y1", "radius", "label", "offsetx", "offsety", "opacity", "color"]
 
 
 class Coordinates(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "black")
         return {
             "left": pl.get_float_attrib(el, "x1", drawing_defaults["x1"]),
@@ -1323,7 +1379,8 @@ def generate(el, data):
             "selectable": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1346,7 +1403,8 @@ def get_attributes():
 
 
 class Dimensions(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "stroke-color", "black")
         offset = pl.get_float_attrib(el, "dim-offset", 0)
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
@@ -1408,7 +1466,8 @@ def generate(el, data):
             "selectable": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1433,7 +1492,8 @@ def get_attributes():
 
 
 class ArcDimensions(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "stroke-color", "black")
         return {
             "left": pl.get_float_attrib(el, "x1", drawing_defaults["x1"]),
@@ -1466,7 +1526,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1489,7 +1550,8 @@ def get_attributes():
 
 
 class Rectangle(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "green1")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         return {
@@ -1515,7 +1577,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1531,7 +1594,8 @@ def get_attributes():
 
 
 class Triangle(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "red1")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         return {
@@ -1564,7 +1628,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1581,7 +1646,8 @@ def get_attributes():
 
 
 class Circle(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         color = pl.get_color_attrib(el, "color", "grey")
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         return {
@@ -1609,7 +1675,8 @@ def generate(el, data):
             "scaling": True,
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1626,7 +1693,8 @@ def get_attributes():
 
 
 class Polygon(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         pointlist = json.loads(
             pl.get_string_attrib(
                 el,
@@ -1651,7 +1719,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "plist",
             "opacity",
@@ -1663,7 +1732,8 @@ def get_attributes():
 
 
 class Spring(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
@@ -1693,7 +1763,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1710,7 +1781,8 @@ def get_attributes():
 
 
 class Coil(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
@@ -1739,7 +1811,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1755,7 +1828,8 @@ def get_attributes():
 
 
 class Line(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
@@ -1791,7 +1865,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1807,7 +1882,8 @@ def get_attributes():
 
 
 class Arc(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         stroke_color = pl.get_color_attrib(el, "stroke-color", "black")
         theta1 = (
             pl.get_float_attrib(el, "start-angle", drawing_defaults["angle"])
@@ -1845,7 +1921,8 @@ def generate(el, data):
             "originY": "center",
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -1860,7 +1937,8 @@ def get_attributes():
 
 
 class Text(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {
             "left": pl.get_float_attrib(el, "x1", drawing_defaults["x1"]),
             "top": pl.get_float_attrib(el, "y1", drawing_defaults["y1"]),
@@ -1873,12 +1951,14 @@ def generate(el, data):
             "latex": pl.get_boolean_attrib(el, "latex", True),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["label", "latex", "font-size", "x1", "y1", "offsetx", "offsety"]
 
 
 class Axes(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         if "origin" in el.attrib:
             origin = json.loads(pl.get_string_attrib(el, "origin"))
             origin_x = origin["x"]
@@ -1913,7 +1993,8 @@ def generate(el, data):
             "evented": drawing_defaults["selectable"],
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "origin",
             "xneg",
@@ -1934,7 +2015,8 @@ def get_attributes():
 
 
 class GraphLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         curved_line = False
 
         if "origin" in el.attrib:
@@ -2037,7 +2119,8 @@ def generate(el, data):
             })
         return obj
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         curved_line = False
         if "end-points" in element.attrib:
             line = json.loads(pl.get_string_attrib(element, "end-points"))
@@ -2065,7 +2148,8 @@ def grading_name(element):
         else:
             return "pl-controlled-curved-line"
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "origin",
             "end-points",
@@ -2081,7 +2165,8 @@ def get_attributes():
 
 
 class Capacitor(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
         if "x2" in el.attrib and "y2" in el.attrib:
@@ -2116,7 +2201,8 @@ def generate(el, data):
             "polarized": pl.get_boolean_attrib(el, "polarized", False),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -2137,7 +2223,8 @@ def get_attributes():
 
 
 class Battery(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
         if "x2" in el.attrib and "y2" in el.attrib:
@@ -2171,7 +2258,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -2191,7 +2279,8 @@ def get_attributes():
 
 
 class Resistor(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
         if "x2" in el.attrib and "y2" in el.attrib:
@@ -2225,7 +2314,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -2245,7 +2335,8 @@ def get_attributes():
 
 
 class Inductor(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
         if "x2" in el.attrib and "y2" in el.attrib:
@@ -2279,7 +2370,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -2299,7 +2391,8 @@ def get_attributes():
 
 
 class Switch(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         x1 = pl.get_float_attrib(el, "x1", drawing_defaults["x1"])
         y1 = pl.get_float_attrib(el, "y1", drawing_defaults["y1"])
         if "x2" in el.attrib and "y2" in el.attrib:
@@ -2334,7 +2427,8 @@ def generate(el, data):
             ),
         }
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "x1",
             "y1",
@@ -2395,20 +2489,24 @@ def get_attributes():
 
 class UnplaceableBaseElement(BaseElement):
     # Used only to get attributes
-    def generate(element):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         raise RuntimeError("Cannot create element!")
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(element, st, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         raise NotImplementedError(
             "This element should not be graded!  If you see this message, something has gone terribly wrong!"
         )
 
 
 class DrawingElement(UnplaceableBaseElement):
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return [
             "gradable",
             "answers-name",
@@ -2427,35 +2525,42 @@ def get_attributes():
 
 
 class DrawingInitial(UnplaceableBaseElement):
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["draw-error-box"]
 
 
 class DrawingAnswer(UnplaceableBaseElement):
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["draw-error-box"]
 
 
 class DrawingGroup(UnplaceableBaseElement):
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["visible"]
 
 
 class DrawingControls(UnplaceableBaseElement):
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return []
 
 
 class DrawingControlsGroup(UnplaceableBaseElement):
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["label"]
 
 
 class DrawingControlsButton(UnplaceableBaseElement):
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return False
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         return ["type"]
 
 
@@ -2475,21 +2580,21 @@ def get_attributes():
 # Helper Functions
 
 
-def should_validate_attributes(name):
+def should_validate_attributes(name: str):
     if name in elements:
         return elements[name].validate_attributes()
     else:
         return False
 
 
-def get_attributes(name):
+def get_attributes(name: str):
     if name in elements:
         return elements[name].get_attributes()
     else:
         return []
 
 
-def generate(element, name, defaults=None):
+def generate(element: HtmlElement, name: str, defaults: dict | None = None):
     if defaults is None:
         defaults = {}
     if name in elements:
@@ -2510,13 +2615,13 @@ def generate(element, name, defaults=None):
         return {}
 
 
-def is_gradable(name):
+def is_gradable(name: str):
     if name in elements:
         return elements[name].is_gradable()
     return False
 
 
-def grade(reference, element, name, tol, angtol):
+def grade(reference: dict, element: dict, name: str, tol: float, angtol: float):
     if name in elements:
         cls = elements[name]
         if cls.is_gradable():
diff --git a/apps/prairielearn/elements/pl-drawing/pl-drawing.py b/apps/prairielearn/elements/pl-drawing/pl-drawing.py
index 0f8f3e406d7..d394baf8c2b 100644
--- a/apps/prairielearn/elements/pl-drawing/pl-drawing.py
+++ b/apps/prairielearn/elements/pl-drawing/pl-drawing.py
@@ -10,7 +10,7 @@
 import prairielearn as pl
 
 
-def union_drawing_items(e1, e2):
+def union_drawing_items(e1: list[dict] | None, e2: list[dict] | None) -> list[dict]:
     # Union two sets of drawing items, prioritizing e2 in cases of duplicates.
 
     if e1 is not None:
@@ -35,13 +35,13 @@ def union_drawing_items(e1, e2):
     return newobj
 
 
-def load_extensions(data):
+def load_extensions(data: pl.QuestionData) -> None:
     extensions = pl.load_all_extensions(data)
     for name, ext in extensions.items():
         elements.register_extension(name, ext, data)
 
 
-def check_attributes_rec(element):
+def check_attributes_rec(element: lxml.html.HtmlElement) -> None:
     # Recursively check attributes for a tree of elements
 
     name = element.tag
@@ -161,7 +161,7 @@ def prepare(element_html: str, data: pl.QuestionData) -> None:
             data["correct_answers"][name] = ans
 
 
-def render_controls(template, elem):
+def render_controls(template: str, elem: lxml.html.HtmlElement) -> str:
     if elem.tag == "pl-controls":
         markup = ""
         for el in elem:


Reviews:
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T17:31:59Z
user: login: eliotwrobson
body: Some questions. My main reservation is questions about backwards compatibility. Aside from that, changes seem pretty straightforward.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T18:24:53Z
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T18:34:37Z
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T18:36:35Z
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T18:38:38Z
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T18:39:43Z
user: login: eliotwrobson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T18:42:43Z
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T19:05:32Z
user: login: eliotwrobson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T19:40:16Z
user: login: eliotwrobson
body: One change but otherwise looks good.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-28T20:50:13Z
user: login: reteps
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T21:32:55Z

Review comments:
diff_hunk: @@ -1,9 +1,12 @@
+# pyright: reportUnknownParameterType=false
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: reteps
body: Not much value in typing `dict`, so I just disabled this.
created_at: 2025-04-28T17:31:59Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,47 @@
-element_defaults = {
+from typing import TypedDict
+
+ElementDefaults = TypedDict(
path: apps/prairielearn/elements/pl-drawing/defaults.py
user: login: eliotwrobson
body: Can you define this type using the class definition style?: https://peps.python.org/pep-0589/
created_at: 2025-04-28T18:05:02Z
author_association: COLLABORATOR
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: eliotwrobson
body: Can you double check this doesn't cause any issues with element extensions? There should be some used by example course.
created_at: 2025-04-28T18:05:53Z
author_association: COLLABORATOR
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {}
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(ref, student, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         return True
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         return None
 
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         """Return a list of attributes that the element may contain."""
         return []
 
 
 class ControlledLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict):
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: eliotwrobson
body: No return type? Should be included explicitly even if it's just `dict`.
created_at: 2025-04-28T18:06:33Z
author_association: COLLABORATOR
diff_hunk: @@ -87,10 +97,12 @@ def generate(el, data):
             "offset_y": offset_y,
         }
 
+    @staticmethod
     def is_gradable():
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: eliotwrobson
body: ```suggestion
    def is_gradable() -> bool:
```
created_at: 2025-04-28T18:06:55Z
author_association: COLLABORATOR
diff_hunk: @@ -35,13 +35,13 @@ def union_drawing_items(e1, e2):
     return newobj
 
 
-def load_extensions(data):
+def load_extensions(data: pl.QuestionData):
path: apps/prairielearn/elements/pl-drawing/pl-drawing.py
user: login: eliotwrobson
body: ```suggestion
def load_extensions(data: pl.QuestionData) -> None:
```
created_at: 2025-04-28T18:22:36Z
author_association: COLLABORATOR
diff_hunk: @@ -1,4 +1,47 @@
-element_defaults = {
+from typing import TypedDict
+
+ElementDefaults = TypedDict(
path: apps/prairielearn/elements/pl-drawing/defaults.py
user: login: reteps
body: No, it has hyphens in some properties.
created_at: 2025-04-28T18:34:37Z
author_association: MEMBER
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {}
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(ref, student, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         return True
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         return None
 
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         """Return a list of attributes that the element may contain."""
         return []
 
 
 class ControlledLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict):
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: reteps
body: What value does this add? Since the method is inherited, it will raise an error if the type isn't consistent with the `BaseElement` `generate` signature.
created_at: 2025-04-28T18:36:34Z
author_association: MEMBER
diff_hunk: @@ -87,10 +97,12 @@ def generate(el, data):
             "offset_y": offset_y,
         }
 
+    @staticmethod
     def is_gradable():
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: reteps
body: Resolving so we can discuss on https://github.com/PrairieLearn/PrairieLearn/pull/11855#discussion_r2064280008.
created_at: 2025-04-28T18:38:38Z
author_association: MEMBER
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: reteps
body: ![image](https://github.com/user-attachments/assets/3abdb782-5c06-464e-a1a1-4e88a22cfcce)

created_at: 2025-04-28T18:39:42Z
author_association: MEMBER
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {}
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(ref, student, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         return True
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         return None
 
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         """Return a list of attributes that the element may contain."""
         return []
 
 
 class ControlledLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict):
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: eliotwrobson
body: If nothing else, makes it easier for someone who's reading just this method to understand what it's supposed to return without having to look at the base class. I also think that this won't raise an error if a type that inherits from a dict is returned?

My rule of thumb is to be as explicit as possible with Python types, since the typechecking can be pretty inconsistent, and makes the code easier to read in general.
created_at: 2025-04-28T18:42:43Z
author_association: COLLABORATOR
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {}
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(ref, student, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         return True
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         return None
 
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         """Return a list of attributes that the element may contain."""
         return []
 
 
 class ControlledLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict):
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: reteps
body: Changed in [3ffec2a](https://github.com/PrairieLearn/PrairieLearn/pull/11855/commits/3ffec2aa6208ba6b813e6d255452113ca0c8b8b9). While I agree generally, this code still has lots of type-errors and needs to be rewritten, so catching stuff like a type inherited from a dict doesn't really matter to me. According to my editor, there are 69 unresolved type errors still.
created_at: 2025-04-28T19:05:32Z
author_association: MEMBER
diff_hunk: @@ -36,28 +39,35 @@ def abserr_ang(ref, x):
 
 
 class BaseElement:
-    def generate(element, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict) -> dict:
         return {}
 
-    def is_gradable():
+    @staticmethod
+    def is_gradable() -> bool:
         return False
 
-    def grade(ref, student, tol, angtol):
+    @staticmethod
+    def grade(ref: dict, st: dict, tol: float, angtol: float) -> bool:
         return True
 
-    def grading_name(element):
+    @staticmethod
+    def grading_name(element: HtmlElement) -> str | None:
         return None
 
-    def validate_attributes():
+    @staticmethod
+    def validate_attributes() -> bool:
         return True
 
-    def get_attributes():
+    @staticmethod
+    def get_attributes() -> list[str]:
         """Return a list of attributes that the element may contain."""
         return []
 
 
 class ControlledLine(BaseElement):
-    def generate(el, data):
+    @staticmethod
+    def generate(el: HtmlElement, data: dict):
path: apps/prairielearn/elements/pl-drawing/elements.py
user: login: eliotwrobson
body: That's not surprising, unfortunately the code for this element is a bit of a mess.
created_at: 2025-04-28T19:40:16Z
author_association: COLLABORATOR
diff_hunk: @@ -35,13 +35,13 @@ def union_drawing_items(e1, e2):
     return newobj
 
 
-def load_extensions(data):
+def load_extensions(data: pl.QuestionData) -> None:
     extensions = pl.load_all_extensions(data)
     for name, ext in extensions.items():
         elements.register_extension(name, ext, data)
 
 
-def check_attributes_rec(element):
+def check_attributes_rec(element: lxml.html.HtmlElement):
path: apps/prairielearn/elements/pl-drawing/pl-drawing.py
user: login: eliotwrobson
body: I think this returns none?
created_at: 2025-04-28T20:46:33Z
author_association: COLLABORATOR
diff_hunk: @@ -35,13 +35,13 @@ def union_drawing_items(e1, e2):
     return newobj
 
 
-def load_extensions(data):
+def load_extensions(data: pl.QuestionData) -> None:
     extensions = pl.load_all_extensions(data)
     for name, ext in extensions.items():
         elements.register_extension(name, ext, data)
 
 
-def check_attributes_rec(element):
+def check_attributes_rec(element: lxml.html.HtmlElement):
path: apps/prairielearn/elements/pl-drawing/pl-drawing.py
user: login: reteps
body: Resolved in [09880cb](https://github.com/PrairieLearn/PrairieLearn/pull/11855/commits/09880cbfda46aab2fa9baf0356868b970cdde83d).
created_at: 2025-04-28T21:32:55Z
author_association: MEMBER

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-28T17:26:09Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The changes introduce explicit static typing throughout the codebase related to the PrairieLearn drawing elements. TypedDicts are added to define the structure of default configuration dictionaries, and type annotations are applied to function and method signatures across multiple files. All instance methods in drawing element classes are converted to static methods, and their parameters and return types are annotated. Imports are updated to support these annotations. No internal logic or control flow is altered; the updates are limited to type safety, clarity, and code style standardization.

## Changes

| Files                                               | Change Summary                                                                                                                                                                                                                     |
|-----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| apps/prairielearn/elements/pl-drawing/defaults.py   | Added TypedDicts (`ElementDefaults`, `DrawingDefaults`) to define the structure and types of `element_defaults` and `drawing_defaults`. Annotated both dictionaries with their respective TypedDict types. No logic changes.       |
| apps/prairielearn/elements/pl-drawing/elements.py   | Added `@staticmethod` to all relevant class methods; introduced type annotations for all method and helper function parameters and return types, including use of `HtmlElement` from `lxml.html`. Updated import statements accordingly. No logic changes. |
| apps/prairielearn/elements/pl-drawing/pl-drawing.py | Added type annotations to function signatures: `union_drawing_items` now accepts and returns lists of dicts or None; `load_extensions` takes `pl.QuestionData`; `check_attributes_rec` takes `lxml.html.HtmlElement`; `render_controls` takes `str` and `lxml.html.HtmlElement` and returns `str`. No logic changes. |

</details>

<!-- walkthrough_end -->
<!-- This is an auto-generated comment: all tool run failures by coderabbit.ai -->

> [!WARNING]
> There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.
> 
> <details>
> <summary>🔧 Ruff (0.8.2)</summary>
> 
> <details>
> <summary>apps/prairielearn/elements/pl-drawing/pl-drawing.py</summary>
> 
> [1;31mruff failed[0m
>   [1mCause:[0m Failed to parse /pyproject.toml
>   [1mCause:[0m TOML parse error at line 15, column 1
>    |
> 15 | [tool.ruff.lint]
>    | ^^^^^^^^^^^^^^^^
> unknown field `pydoclint`
> 
> 
> </details>
> 
> </details>

<!-- end of auto-generated comment: all tool run failures by coderabbit.ai -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 3ffec2aa6208ba6b813e6d255452113ca0c8b8b9 and 09880cbfda46aab2fa9baf0356868b970cdde83d.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `apps/prairielearn/elements/pl-drawing/pl-drawing.py` (3 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* apps/prairielearn/elements/pl-drawing/pl-drawing.py

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (4)</summary>

* GitHub Check: build-core-images / build-images (ubuntu-24.04-arm)
* GitHub Check: build-core-images / build-images (ubuntu-24.04)
* GitHub Check: test-prairielearn
* GitHub Check: native-checks

</details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=PrairieLearn/PrairieLearn&utm_content=11855):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-28T17:29:51Z
body: ## All images

| Image | Platform | Old Size | New Size | Change |
| --- | --- | --- | --- | --- |
| [prairielearn/executor:09880cbfda46aab2fa9baf0356868b970cdde83d](https://hub.docker.com/layers/prairielearn/executor/09880cbfda46aab2fa9baf0356868b970cdde83d/images/sha256-f875bb44313a7aa0f7155031cecafbeb6c480e9ba9a67917872323e616b545d8?context=explore) | linux/amd64 | 1216.70 MB | 1216.70 MB | 0.00% |
| [prairielearn/executor:09880cbfda46aab2fa9baf0356868b970cdde83d](https://hub.docker.com/layers/prairielearn/executor/09880cbfda46aab2fa9baf0356868b970cdde83d/images/sha256-352c3f40dabdf6c948640ca96ae93d36fb46d216a67bf8ccc106f87b059ba985?context=explore) | linux/arm64 | 1230.12 MB | 1230.13 MB | 0.00% |
| [prairielearn/prairielearn:09880cbfda46aab2fa9baf0356868b970cdde83d](https://hub.docker.com/layers/prairielearn/prairielearn/09880cbfda46aab2fa9baf0356868b970cdde83d/images/sha256-bea1b8203cbfef34106819cc239009407d5aa9169fef78ff1b2aa885a0361f9e?context=explore) | linux/amd64 | 1216.70 MB | 1216.70 MB | 0.00% |
| [prairielearn/prairielearn:09880cbfda46aab2fa9baf0356868b970cdde83d](https://hub.docker.com/layers/prairielearn/prairielearn/09880cbfda46aab2fa9baf0356868b970cdde83d/images/sha256-b1584f23a89775c8ee34b1e00ff580887e7201f9d1bce3aa1b1f7ab0f5fa3cfc?context=explore) | linux/arm64 | 1230.12 MB | 1230.12 MB | 0.00% |
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-28T17:30:31Z
body: ## [Codecov](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/pull/11855?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn) Report
:white_check_mark: All modified and coverable lines are covered by tests.
:white_check_mark: Project coverage is 12.19%. Comparing base ([`f81fbaf`](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/commit/f81fbaf495b65156d9f0667a82fc5b0399d822ab?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn)) to head ([`09880cb`](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/commit/09880cbfda46aab2fa9baf0356868b970cdde83d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn)).
:warning: Report is 406 commits behind head on master.

<details><summary>Additional details and impacted files</summary>



```diff
@@           Coverage Diff           @@
##           master   #11855   +/-   ##
=======================================
  Coverage   12.19%   12.19%           
=======================================
  Files         690      690           
  Lines      105510   105510           
  Branches     1291     1291           
=======================================
  Hits        12864    12864           
  Misses      92635    92635           
  Partials       11       11           
```

| [Flag](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/pull/11855/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn) | Coverage Δ | |
|---|---|---|
| [javascript](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/pull/11855/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn) | `10.70% <ø> (ø)` | |
| [python](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/pull/11855/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn) | `81.64% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PrairieLearn/PrairieLearn/pull/11855?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PrairieLearn).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: reteps
created_at: 2025-04-28T17:31:16Z
body: ![image](https://github.com/user-attachments/assets/4d6503f6-28cd-4050-bb05-342efbe1a131)
Appears to still work, at least for the one element question I tested.
author_association: MEMBER