Skip to content

cass.actions

Orchestration layer: config discovery, pull flows, setup checks. The CLI and viewer call into this package and never below it directly.

cass.actions

Actions — business logic for cass.

Pull orchestration and configuration.

get_config

get_config() -> Config

Return the lazily-loaded project config.

assignments

Assignment sync: reconcile declared assignments and groups with the live course.

MissingAssignmentGroupsError

MissingAssignmentGroupsError(missing: list[str])

Bases: RuntimeError

Assignment groups are missing on Canvas and creating them wasn't asked for.

sync_assignment_groups

sync_assignment_groups(
    client: CanvasClient, wanted: list[str], *, create: bool
) -> tuple[list[tuple[str, str, str]], dict[str, int]]

Resolve assignment groups by name and create the missing ones when asked.

Groups match by name, case-insensitively. Writes always go through client; dry runs pass a dry-run client.

Raises:

Type Description
MissingAssignmentGroupsError

Some groups are missing and create is false. Nothing has been written.

Returns:

Type Description
list[tuple[str, str, str]]

("create", "group", name) rows for the groups this call creates,

dict[str, int]

and the Canvas id of every wanted group by lower-cased name (a dry-run

tuple[list[tuple[str, str, str]], dict[str, int]]

placeholder for a group this call creates).

sync_assignments

sync_assignments(
    client: CanvasClient,
    specs: list[CanvasAssignmentSpec],
    group_ids: dict[str, int],
    tz: str,
    *,
    created: list[CanvasAssignmentResponse] | None = None,
) -> list[tuple[str, str, str]]

Reconcile assignment specs against live assignments, matched by name.

Names match case-insensitively. An existing assignment gets its points, due date and published state updated where the spec sets them and they differ; its group and submission types are left alone. A spec without published leaves the live assignment as it is, and a new assignment stays unpublished. Writes always go through client; dry runs pass a dry-run client.

Parameters:

Name Type Description Default
group_ids dict[str, int]

Canvas group ids by lower-cased name, from sync_assignment_groups; used only for new assignments.

required
tz str

Course time zone, for due dates written without an offset.

required
created list[CanvasAssignmentResponse] | None

When given, each assignment this call creates is appended to it.

None

Returns:

Type Description
list[tuple[str, str, str]]

(action, "assignment", name) rows: create, update (with the

list[tuple[str, str, str]]

changed fields), or skip.

canvas_records

JSON records that combine several Canvas objects, for --json output.

Field names are Canvas's own (msgspec.to_builtins honours renames such as content-type).

file_tree_records

file_tree_records(
    folders: list[CanvasFolder], files: list[CanvasFile]
) -> list[dict[str, Any]]

Every folder, then every file, each tagged with kind.

folder_records

folder_records(
    folders: list[CanvasFolder],
) -> list[dict[str, Any]]

Every folder with its path relative to the course files root added.

quiz_detail_record

quiz_detail_record(
    quiz: CanvasQuiz,
    questions: list[CanvasQuizQuestion],
    *,
    from_statistics: bool,
) -> dict[str, Any]

A quiz with its questions and where the questions were read from.

module_detail_record

module_detail_record(
    module: CanvasModule, items: list[CanvasModuleItem]
) -> dict[str, Any]

A module with its items.

config

Config discovery, loading, and writing for cass.

Uses an explicit file when one is given (set_config_path / --config, else $CASS_CONFIG); otherwise searches for cass.toml starting from the current directory and walking upward. Config is loaded lazily on first access via get_config().

ModuleItemSpec dataclass

ModuleItemSpec(
    type: str,
    title: str = "",
    file: str = "",
    source: Path | None = None,
    url: str = "",
    indent: int = 0,
    published: bool | None = None,
)

A module item declared under [[canvas.modules.items]].

CanvasModuleSpec dataclass

CanvasModuleSpec(
    name: str,
    published: bool | None = None,
    items: list[ModuleItemSpec] | None = None,
)

Desired state for a Canvas module declared in cass.toml.

published is None when the entry leaves it out: sync leaves the live module as it is. items is None when the module has no items key (never reconciled) and [] when it declares items = [] (an empty module).

CanvasAssignmentSpec dataclass

CanvasAssignmentSpec(
    name: str,
    points: float = 0.0,
    submission_types: list[str] = (
        lambda: ["online_url"]
    )(),
    due_at: str = "",
    published: bool | None = None,
    group: str = "",
)

Desired state for a Canvas assignment declared in cass.toml.

published is None when the entry leaves it out: sync leaves the live assignment as it is, and creates a new one unpublished.

CanvasQuizRef dataclass

CanvasQuizRef(file: Path)

A quiz file declared in cass.toml, resolved relative to the config file.

canonical_item_type

canonical_item_type(name: str) -> str | None

Return the canonical module item type for name (case-insensitive).

parse_canvas_course_url

parse_canvas_course_url(raw: str) -> tuple[str, int] | None

Extract (base_url, course_id) from a Canvas course URL.

set_config_path

set_config_path(path: Path | None) -> None

Point config discovery at an explicit cass.toml (--config).

Pass None to return to walking up from the working directory.

Raises:

Type Description
SystemExit

If path does not exist.

find_project_root

find_project_root(
    start: Path | None = None,
    config_path: Path | None = None,
) -> Path

Find the project root.

With config_path (or a path from set_config_path or $CASS_CONFIG) the root is that file's directory. Otherwise walk up from start (default: cwd) looking for cass.toml.

config_file

config_file() -> Path

Return the config file in use (explicit path or <root>/cass.toml).

config_file_path

config_file_path() -> Path | None

Return the path to the config file, or None if it doesn't exist.

load_config

load_config(path: Path | None = None) -> Config

Read a config file; the one in use (config_file()) by default.

Loading an explicit path leaves the cached project config untouched.

Raises:

Type Description
SystemExit

If the file is missing or has no [canvas] section.

get_config

get_config() -> Config

Return the lazily-loaded project config.

reset_config

reset_config() -> None

Clear the cached config (for re-reading after writes).

read_config_data

read_config_data(path: Path) -> dict[str, Any]

Load a TOML config file into a mutable dict.

format_toml_value

format_toml_value(value: object) -> str

Render a scalar or flat list as a TOML value.

update_config

update_config(
    path: Path,
    *,
    canvas_base_url: str | None = None,
    canvas_course_id: int | None = None,
    canvas_time_zone: str | None = None,
) -> None

Update Canvas settings in cass.toml while preserving other data.

A leftover [classroom] table from older versions is removed on every rewrite.

write_config

write_config(
    path: Path,
    canvas_base_url: str = "",
    canvas_course_id: int = 0,
) -> None

Write a cass.toml file.

doctor

Prerequisite checks for cass setup (cass init doctor).

check_prerequisites

check_prerequisites() -> list[Check]

Run all setup checks and return results.

enrollments

Enrollment dates and late-enrollee selection.

"Enrolled at" is the earliest created_at across all of a user's enrollments (any type, any state, deleted included), so a student who dropped and re-enrolled keeps their original date.

EnrollmentState

Bases: StrEnum

Values accepted by people list --state.

LateEnrollee dataclass

LateEnrollee(user_id: int, name: str, enrolled_at: str)

A current student who first enrolled after a cutoff.

fetch_all_enrollments

fetch_all_enrollments(
    client: CanvasClient,
) -> list[CanvasEnrollment]

Fetch every enrollment of every type and state (no scores).

enrollment_dates

enrollment_dates(
    enrollments: Iterable[CanvasEnrollment],
) -> dict[int, str]

Map user id to the earliest enrollment created_at.

Enrollments without created_at are ignored.

is_after

is_after(iso: str | None, cutoff: str) -> bool

Whether the instant iso is strictly later than cutoff.

Compares moments, not spellings (Z versus a local offset). A missing iso is never after anything.

select_people

select_people(
    users: list[CanvasUser],
    dates: dict[int, str],
    cutoff: str = "",
) -> list[CanvasUser]

Keep users first enrolled after cutoff; an empty cutoff keeps all.

roster

roster(
    client: CanvasClient,
    *,
    states: Sequence[str] = (),
    enrolled_after: str = "",
) -> tuple[list[CanvasUser], dict[int, str]]

The people to list and every user's enrolled-at date.

Parameters:

Name Type Description Default
client CanvasClient

Canvas client.

required
states Sequence[str]

Enrollment states to list; empty means Canvas's default.

()
enrolled_after str

A user-entered date or time, read in the course time zone; keeps people of any role first enrolled after it.

''

Returns:

Type Description
tuple[list[CanvasUser], dict[int, str]]

The selected users, sorted by sortable name, and the enrolled-at map.

Raises:

Type Description
RuntimeError

If enrolled_after is not a recognized time (before any request).

person_record

person_record(
    user: CanvasUser, enrolled_at: str | None
) -> dict[str, object]

A user as a plain dict plus the cass enrolled_at key.

late_enrollees

late_enrollees(
    enrollments: list[CanvasEnrollment], cutoff: str
) -> dict[int, LateEnrollee]

Current students (active or invited) first enrolled after cutoff.

files

Course file operations — uploads with duplicate policies, folder subtrees.

OnDuplicate

Bases: StrEnum

What to do when an upload's name already exists in the folder.

OnConflict

Bases: StrEnum

What to do when a moved or renamed file collides with an existing name.

FileUpload dataclass

FileUpload(file: CanvasFile, skipped: bool)

Result of an upload: the Canvas file, and whether the upload was skipped.

upload_course_file

upload_course_file(
    client: CanvasClient,
    local_path: str,
    *,
    folder: str = "",
    name: str | None = None,
    on_duplicate: OnDuplicate = OnDuplicate.OVERWRITE,
    unpublished: bool = False,
) -> FileUpload

Upload a local file, applying the duplicate policy.

SKIP leaves an existing file untouched only when it has the same name and size; the same name with a different size is overwritten, and a missing folder means the file is uploaded.

Parameters:

Name Type Description Default
client CanvasClient

Canvas client.

required
local_path str

Path to the local file.

required
folder str

Destination folder path; "" is the course files root.

''
name str | None

Name in Canvas; defaults to the local file name.

None
on_duplicate OnDuplicate

Duplicate policy.

OVERWRITE
unpublished bool

Lock the uploaded file so students cannot see it.

False

Returns:

Type Description
FileUpload

The Canvas file and whether the upload was skipped.

folder_subtree

folder_subtree(
    folders: list[CanvasFolder],
    files: list[CanvasFile],
    folder: CanvasFolder,
) -> tuple[list[CanvasFolder], list[CanvasFile]]

Select a folder, its descendants, and the files inside them.

module_items

Module item rules: command-line arguments, content lookup, planning and applying.

ModuleItemLookupError

Bases: RuntimeError

Declared module items could not be planned; no item request was sent.

ContentIndex dataclass

ContentIndex(
    assignments: list[CanvasAssignmentResponse],
    quizzes: list[CanvasQuiz],
    pages: list[CanvasPage],
    folders: list[CanvasFolder],
    files: list[CanvasFile],
    local_sizes: dict[Path, int] = dict(),
    uploaded: dict[str, CanvasFile] = dict(),
)

Course content a module item can point at, fetched once per run.

file_path
file_path(file: CanvasFile) -> str

Path of file below the course files root (root files: just the name).

add_created
add_created(
    item_type: str, content_id: int, title: str
) -> None

Make an Assignment or Quiz created this run resolvable by title.

Does nothing when content_id is already in the index.

ContentRef dataclass

ContentRef(
    item_type: str,
    content_id: int | None,
    page_url: str = "",
    external_url: str = "",
    title: str = "",
    file: CanvasFile | None = None,
    file_path: str = "",
)

The Canvas content a declared module item points at.

FileUploadPlan dataclass

FileUploadPlan(
    source: Path,
    path: str,
    existing: CanvasFile | None,
    published: bool | None,
    local_size: int = 0,
)

A local file to upload before its module item is created or kept.

ItemChange dataclass

ItemChange(
    action: str,
    spec: ModuleItemSpec | None,
    live: CanvasModuleItem | None,
    ref: ContentRef | None,
    changed: list[str] = list(),
    moved: bool = False,
    upload: FileUploadPlan | None = None,
)

What sync does to one module item.

ModuleItemPlan dataclass

ModuleItemPlan(
    items: list[ItemChange], order_changed: bool
)

Planned item changes for one module.

items lists declared items in file order, then extras in their current order, then removes.

has_changes property
has_changes: bool

True when applying the plan would send any request.

ModuleItemApplyError

ModuleItemApplyError(
    cause: BaseException, applied: list[str]
)

Bases: RuntimeError

A request failed while applying a module item plan.

Attributes:

Name Type Description
applied

Descriptions of the steps completed before the failure.

ModuleSyncPlan dataclass

ModuleSyncPlan(
    name: str, module_id: int, plan: ModuleItemPlan
)

One module's item plan within a sync.

SyncItemsPlan dataclass

SyncItemsPlan(
    index: ContentIndex,
    modules: list[ModuleSyncPlan],
    rows: list[tuple[str, str, str]],
    counts: dict[str, int],
)

Item plans for every module that declares items, in config order.

has_changes property
has_changes: bool

True when applying would send any request.

check_item_add_args

check_item_add_args(
    item_type: str,
    *,
    content_id: int | None,
    page_url: str | None,
    url: str | None,
    title: str | None,
) -> str

Check the reference flags given for item_type.

Returns:

Type Description
str

The canonical item type.

Raises:

Type Description
ValueError

If the type is unknown, a required flag is missing, or a flag does not belong to the type.

check_item_update_args

check_item_update_args(
    *,
    title: str | None,
    indent: int | None,
    published: bool | None,
) -> None

Check that items update was given something to change.

Raises:

Type Description
ValueError

If no option is given.

normalize_url

normalize_url(url: str) -> str

An ExternalUrl in the form Canvas stores it, for comparing URLs.

Canvas percent-encodes a URL that holds non-ASCII characters (and drops a trailing / from it). An ASCII URL is stored as given, apart from surrounding whitespace.

fetch_content_index

fetch_content_index(
    client: CanvasClient, *, sources: Iterable[Path] = ()
) -> ContentIndex

Read the sizes of local sources, then fetch the course content once.

Raises:

Type Description
ModuleItemLookupError

If a source is missing or is not a file; nothing has been fetched by then.

resolve_content

resolve_content(
    spec: ModuleItemSpec, index: ContentIndex
) -> ContentRef

Find the Canvas content a declared item points at.

Titles match case-insensitively; Files match by path.

Raises:

Type Description
ValueError

With the problem, if the content is missing or ambiguous.

plan_module_items

plan_module_items(
    declared: list[ModuleItemSpec],
    live_items: list[CanvasModuleItem],
    index: ContentIndex,
    *,
    prune: bool,
    module_name: str = "",
) -> ModuleItemPlan

Compare declared module items with the live ones. No I/O.

Parameters:

Name Type Description Default
declared list[ModuleItemSpec]

Items in the order the config file lists them.

required
live_items list[CanvasModuleItem]

Items in the order Canvas lists them (their position field is not trusted).

required
index ContentIndex

Course content and local source sizes.

required
prune bool

Remove undeclared items instead of keeping them as extras.

required
module_name str

Names the module in error messages.

''

Raises:

Type Description
RuntimeError

Listing every declared item whose content is missing or ambiguous.

plan_module_spec_items

plan_module_spec_items(
    module: CanvasModuleSpec,
    live_items: list[CanvasModuleItem],
    index: ContentIndex,
    *,
    prune: bool,
) -> ModuleItemPlan | None

Plan module's items, or return None when it has no items key.

A module without items is never item-reconciled.

item_label

item_label(change: ItemChange) -> str

Name an item for a plan row.

Declared items use the desired title (a File its path); undeclared live items use their current type and title.

item_plan_rows

item_plan_rows(
    module_name: str, plan: ModuleItemPlan
) -> list[tuple[str, str, str]]

Table rows (action, kind, description) for a module's item plan.

One row per upload and per non-keep item, then a count of kept items.

apply_module_item_plan

apply_module_item_plan(
    client: CanvasClient,
    module_id: int,
    plan: ModuleItemPlan,
    index: ContentIndex,
) -> list[str]

Apply plan to module module_id: uploads, creates, updates, removes, then positions, in that order.

Sends nothing at all when the plan has no changes. Uploaded files are added to index.uploaded, so a later plan reuses them.

Returns:

Type Description
list[str]

Descriptions of the applied steps, in order.

Raises:

Type Description
ModuleItemApplyError

When a request fails; it lists the steps completed before the failure. Nothing is rolled back.

plan_sync_items

plan_sync_items(
    client: CanvasClient,
    modules: list[CanvasModuleSpec],
    module_ids: dict[str, int],
    *,
    created_assignments: list[CanvasAssignmentResponse],
    created_quizzes: list[CanvasQuiz],
    prune: bool,
) -> SyncItemsPlan | None

Plan the items of every module that declares them, before any write.

Fetches the content index once, makes the assignments and quizzes this sync created resolvable by title, then plans each module in config order.

Parameters:

Name Type Description Default
modules list[CanvasModuleSpec]

Module specs from cass.toml.

required
module_ids dict[str, int]

Canvas id for each module name (placeholders in a dry run).

required
created_assignments list[CanvasAssignmentResponse]

Assignments this sync created.

required
created_quizzes list[CanvasQuiz]

Quizzes this sync created.

required
prune bool

Remove undeclared items instead of keeping them as extras.

required

Returns:

Type Description
SyncItemsPlan | None

The plan, or None (with no requests sent) when no module declares

SyncItemsPlan | None

items.

Raises:

Type Description
ModuleItemLookupError

Listing the lookup problems of every module at once.

apply_sync_items

apply_sync_items(
    client: CanvasClient, plan: SyncItemsPlan
) -> None

Apply each module's item plan in config order; stop at the first failure.

Raises:

Type Description
ModuleItemApplyError

When a request fails.

modules

Module sync: reconcile declared modules with the live course.

sync_modules

sync_modules(
    client: CanvasClient, specs: list[CanvasModuleSpec]
) -> tuple[list[tuple[str, str, str]], dict[str, int]]

Create missing modules and set each module's published state.

Modules match by name, case-insensitively. A spec without published leaves the live module as it is; a module it creates stays unpublished. Writes always go through client; dry runs pass a dry-run client.

Returns:

Type Description
list[tuple[str, str, str]]

(action, "module", name) rows (create, update or skip), and the

dict[str, int]

Canvas id of each declared module by its declared name (a dry-run

tuple[list[tuple[str, str, str]], dict[str, int]]

placeholder for a module this call creates).

pull

Pull orchestration — fetch Canvas data into the local database.

pull_students

pull_students(cfg: Config, console: Console) -> None

CLI step: pull the Canvas roster.

pull_assignments

pull_assignments(cfg: Config, console: Console) -> None

CLI step: pull Canvas assignments.

pull_submissions

pull_submissions(cfg: Config, console: Console) -> None

CLI step: pull Canvas submissions for every stored assignment.

pull_all

pull_all(
    cfg: Config, on_progress: ProgressCallback | None = None
) -> None

Run a full pull: course name, students, assignments, submissions.

Shared by cass pull and the viewer's progress page. Progress is reported through on_progress(step, detail) with steps students, assignments, submissions, and done.

Raises:

Type Description
RuntimeError

when local Canvas edits would be overwritten.

quiz_plan

Question planning — compare a quiz file's questions with a live quiz.

plan_quiz_questions matches each file question to a live question (by id, then unique name, then relative order) and records what must change. It does no I/O. apply_question_plan then sends the plan to Canvas, the only function here that calls the client.

QuestionChange dataclass

QuestionChange(
    action: str,
    spec: QuestionSpec | None,
    live: CanvasQuizQuestion | None,
    group: str = "",
    changed: list[str] = list(),
    relinks_answers: bool = False,
)

What happens to one question.

action is "add", "update", "delete", "keep" or "extra" (on Canvas, not in the file, left alone).

GroupChange dataclass

GroupChange(
    action: str,
    spec: GroupSpec | None,
    live: CanvasQuizGroup | None,
    changed: list[str] = list(),
)

What happens to one question group (same actions as questions).

name property
name: str

The group's name, from the file when it has one.

QuestionPlan dataclass

QuestionPlan(
    quiz_id: int,
    quiz_type: str,
    questions: list[QuestionChange],
    groups: list[GroupChange],
    order: list[tuple[str, int | str]],
    group_members: dict[str, list[int]],
    order_changed: bool,
    reordered_groups: list[str],
    student_attempts: int = 0,
)

Every question and group change needed to make a quiz match its file.

questions lists file questions first (questions[i] is spec.questions[i]), then deletes and extras in quiz order. order is the desired top-level layout: ("question", index into questions) or ("group", name), extras last.

has_changes property
has_changes: bool

True when applying the plan would send anything to Canvas.

QuestionApplyError

QuestionApplyError(cause: object, applied: list[str])

Bases: RuntimeError

A plan stopped part-way; applied lists the steps that succeeded.

match_live_answers

match_live_answers(
    texts: list[str], live: CanvasQuizQuestion
) -> list[CanvasQuizAnswer | None]

The live answer each of texts keeps, by equal text; None for a new one.

Each live answer is used at most once, so duplicate texts pair in order.

plan_quiz_questions

plan_quiz_questions(
    spec: QuizSpec, state: QuizState, *, prune: bool
) -> QuestionPlan

Plan the question and group changes that make state match spec.

Parameters:

Name Type Description Default
spec QuizSpec

Desired quiz, from a quiz file.

required
state QuizState

The quiz as it is on Canvas.

required
prune bool

Delete live questions and emptied groups the file leaves out; otherwise they are kept as extras.

required

Raises:

Type Description
RuntimeError

A file id is not on the quiz, two live groups share a name, or a question to update has a type outside UPDATABLE_QUESTION_TYPES.

format_question_plan

format_question_plan(plan: QuestionPlan) -> list[str]

One line per question and group, each starting with its action.

A final reorder questions line appears whenever the plan changes anything, since every apply reorders the quiz.

question_plan_warning

question_plan_warning(plan: QuestionPlan) -> str | None

A warning when students have finished attempts, else None.

plan_summary

plan_summary(plan: QuestionPlan) -> str

Question counts by action, plus group changes when there are any.

For example "1 added, 1 updated, 0 deleted, 3 kept, 0 extra" or "0 added, 0 updated, 0 deleted, 3 kept, 0 extra; groups: 1 updated".

question_update_fields

question_update_fields(
    change: QuestionChange,
    quiz_type: str,
    group_id: int | None,
) -> dict[str, object]

Canvas fields to send for an "update" change: the whole question.

Canvas fills every field an update leaves out from a copy of the question saved when it was created, so a partial update would undo earlier edits. Each update therefore sends text, name, type, points, answers and the live question-level feedback (QUESTION_COMMENT_FIELDS).

Answers keep their Canvas ids: unchanged answers go back as the live answers; changed ones are the file's answers. A file answer whose text matches a live answer keeps that answer's id, HTML and feedback, since the file sets only text and correctness.

Parameters:

Name Type Description Default
change QuestionChange

An update change; its spec holds the desired values.

required
quiz_type str

Canvas quiz type, which decides default points.

required
group_id int | None

Canvas id of the desired group, or None for no group. Sent only when the question moves into a group; Canvas ignores quiz_group_id: null, so the quiz-level reorder in apply_question_plan takes a question out of its group.

required

apply_question_plan

apply_question_plan(
    client: CanvasClient, quiz_id: int, plan: QuestionPlan
) -> list[str]

Send plan to Canvas in a fixed order and re-save the quiz.

Order:

  1. Create groups.
  2. Add, update and delete questions.
  3. Update groups.
  4. Reorder the quiz, whenever the plan changes anything. This keeps top-level positions contiguous, which canvas_layout relies on, and takes questions out of groups, since Canvas ignores quiz_group_id: null on update.
  5. Reorder groups.
  6. Delete groups. Canvas deletes a group's questions with the group, so the questions the file keeps must already be out of it.
  7. Re-save the quiz (only if anything was sent).

Returns:

Type Description
list[str]

A description of each step applied.

Raises:

Type Description
QuestionApplyError

A request failed. Nothing after it was sent; the error lists the steps that had already succeeded.

quiz_responses

Quiz responses — every student's answers to a classic quiz or survey.

Canvas's student analysis report is wide: one "<id>: <text>" column per question, each followed by its score column. parse_student_analysis reshapes it to one QuizResponse per student x attempt x question, and add_timing fills in what the report lacks: each student's due date (after overrides and extensions) and whether Canvas closed the attempt itself.

QuizResponse dataclass

QuizResponse(
    student: str,
    user_id: int | None,
    sis_id: str,
    section: str,
    attempt: int | None,
    submitted_at: str,
    question_id: int,
    position: int,
    question: str,
    answer: str,
    score: float | None,
    due_at: str | None = None,
    late: bool | None = None,
    started_at: str | None = None,
    auto_submitted: bool | None = None,
)

One student's answer to one question on one attempt.

Timing fields are None when unknown: started_at and auto_submitted exist only for a student's latest attempt.

row
row(
    columns: tuple[str, ...] = RESPONSE_COLUMNS,
) -> list[str]

Values for columns (default RESPONSE_COLUMNS) as CSV strings.

parse_student_analysis

parse_student_analysis(text: str) -> list[QuizResponse]

Reshape a student analysis report CSV to one row per answer.

The latest-only report has no attempt column; its responses get attempt=None until add_timing fills it in. Submission times are normalized to ISO 8601 UTC (...Z).

Parameters:

Name Type Description Default
text str

Report CSV as downloaded from Canvas.

required

Returns:

Type Description
list[QuizResponse]

Responses in report order, questions in report column order.

add_timing

add_timing(
    responses: list[QuizResponse],
    *,
    due_by_user: dict[int, str | None],
    quiz_submissions: list[CanvasQuizSubmission],
    default_due: str | None = None,
    tz: str = "UTC",
) -> list[QuizResponse]

Fill due dates, lateness, and auto-submission flags in place.

  • due_at: the student's own due date (cached_due_date, which respects overrides and extensions, and may be None), else default_due for students missing from due_by_user.
  • late: submitted_at > due_at when both are known.
  • attempt: for latest-only reports, the student's latest finished attempt. When the student has opened a newer attempt and not submitted it, the report holds the one before it.
  • started_at / auto_submitted: from the student's latest quiz submission, on that attempt only, so they stay empty while a newer attempt is open. auto_submitted means Canvas closed the attempt (finished_at >= end_at): a time limit ran out, or an attempt left open was force-submitted when the course concluded.
  • submitted_at, due_at and started_at are rewritten as ISO 8601 with tz's offset (Z for UTC).

Returns:

Type Description
list[QuizResponse]

The same list, for chaining.

fetch_quiz_responses

fetch_quiz_responses(
    client: CanvasClient,
    quiz: CanvasQuiz,
    *,
    all_attempts: bool = False,
) -> list[QuizResponse]

Download, reshape, and time-stamp every student's answers to a quiz.

Times are written in the course time zone.

Parameters:

Name Type Description Default
client CanvasClient

Canvas client for the quiz's course.

required
quiz CanvasQuiz

Classic quiz or survey.

required
all_attempts bool

Include every attempt, not only each student's latest.

False

quizzes

Quiz authoring — quiz files, specs, and Canvas settings diffs.

A quiz file is TOML: settings at the top level, questions as [[questions]]. load_quiz_file validates it into a QuizSpec; dump_quiz_file writes one back so export and create --from round-trip.

AnswerSpec dataclass

AnswerSpec(text: str, correct: bool = False)

One answer on a question; correct becomes Canvas weight 100.

GroupSpec dataclass

GroupSpec(
    name: str,
    pick_count: int = 1,
    points_per_item: float | None = None,
)

A question group: Canvas draws pick_count of its questions per attempt.

QuestionSpec dataclass

QuestionSpec(
    text: str,
    type: str = "essay_question",
    name: str = "",
    points: float | None = None,
    answers: list[AnswerSpec] = list(),
    group: str = "",
    id: int | None = None,
)

One question in a quiz file.

QuizSpec dataclass

QuizSpec(
    title: str,
    quiz_type: str,
    group: str = "",
    points: float | None = None,
    description: str | None = None,
    unlock_at: str | None = None,
    due_at: str | None = None,
    lock_at: str | None = None,
    attempts: int | None = None,
    time_limit: int | None = None,
    hide_results: str | None = None,
    scoring_policy: str | None = None,
    shuffle_answers: bool | None = None,
    one_question_at_a_time: bool | None = None,
    published: bool | None = None,
    groups: list[GroupSpec] = list(),
    questions: list[QuestionSpec] = list(),
)

Desired settings and questions for a Canvas Classic quiz.

A setting left as None means "leave as is": updates never send it, and create_quiz fills it from QUIZ_CREATE_DEFAULTS. An empty group and a None points are likewise never sent.

QuizState dataclass

QuizState(
    quiz: CanvasQuiz,
    questions: list[CanvasQuizQuestion],
    groups: list[CanvasQuizGroup],
    from_statistics: bool = False,
    student_attempts: int = 0,
)

A quiz as it is on Canvas: settings, questions in quiz order, groups.

question_type_alias

question_type_alias(canvas_type: str) -> str

Short quiz-file name for a Canvas question type, or the type unchanged.

with_create_defaults

with_create_defaults(spec: QuizSpec) -> QuizSpec

A copy of spec with each unset setting filled from QUIZ_CREATE_DEFAULTS.

default_question_points

default_question_points(quiz_type: str) -> float

Points a question gets when the file leaves them out.

is_default_question_name

is_default_question_name(name: str) -> bool

True for Canvas's default question names ("Question", "Question 3") and "".

file_layout

file_layout(spec: QuizSpec) -> list[tuple[str, int | str]]

Top-level quiz entries in file order.

("question", i) is an ungrouped question at spec.questions[i]; ("group", name) appears once, at its first question's place.

group_members

group_members(spec: QuizSpec) -> dict[str, list[int]]

Group name → indices into spec.questions, in file order.

question_form_fields

question_form_fields(
    question: QuestionSpec, quiz_type: str
) -> dict[str, object]

Canvas question fields for question (answers only when it has any).

answer_form_fields

answer_form_fields(
    answers: list[AnswerSpec],
) -> list[dict[str, object]]

Canvas answer fields for answers; a correct answer gets weight 100.

group_form_fields

group_form_fields(
    group: GroupSpec, quiz_type: str
) -> dict[str, object]

Canvas body for creating group (points per item default by quiz type).

load_quiz_file

load_quiz_file(path: Path) -> QuizSpec

Read and validate a quiz TOML file.

Raises:

Type Description
SystemExit

Naming the file and offending key for any problem.

dump_quiz_file

dump_quiz_file(spec: QuizSpec, path: Path | None) -> str

Render spec as quiz-file TOML, writing it to path when given.

canvas_layout

canvas_layout(
    questions: list[CanvasQuizQuestion],
    groups: list[CanvasQuizGroup],
) -> list[tuple[str, int]]

Top-level quiz entries in Canvas order.

The questions API reports every question's position as null, so the order is rebuilt from what Canvas does report: questions in API list order, and each group's position from the group endpoint.

With n top-level entries (ungrouped questions plus groups), each group takes its position clamped to 1..n, in (position, id) order; a group whose slot is taken moves to the next free one. Groups with a null position go last. Ungrouped questions fill the remaining slots in list order. The result is exact when top-level positions run 1..n, which every cass apply leaves.

Parameters:

Name Type Description Default
questions list[CanvasQuizQuestion]

Questions in the order the questions API lists them.

required
groups list[CanvasQuizGroup]

The quiz's question groups.

required

ordered_questions

ordered_questions(
    questions: list[CanvasQuizQuestion],
    groups: list[CanvasQuizGroup],
) -> list[CanvasQuizQuestion]

All questions in quiz order, each group expanded in place.

Parameters:

Name Type Description Default
questions list[CanvasQuizQuestion]

Questions in the order the questions API lists them; a group's members keep that order.

required
groups list[CanvasQuizGroup]

The quiz's question groups.

required

group_name_of

group_name_of(
    question: CanvasQuizQuestion,
    groups: Sequence[CanvasQuizGroup],
) -> str

Name of the group holding question, or "" when it has none.

groups_by_name

groups_by_name(
    groups: Sequence[CanvasQuizGroup], quiz_title: str
) -> dict[str, CanvasQuizGroup]

Live question groups keyed by name.

Raises:

Type Description
RuntimeError

Two groups share a name.

fetch_quiz_state

fetch_quiz_state(
    client: CanvasClient,
    quiz_id: int,
    *,
    allow_statistics: bool = False,
    include_submissions: bool = False,
) -> QuizState

Read a quiz, its questions in quiz order, and its question groups.

Parameters:

Name Type Description Default
client CanvasClient

Canvas client.

required
quiz_id int

Canvas quiz ID.

required
allow_statistics bool

Accept questions rebuilt from quiz statistics (concluded courses); otherwise that raises.

False
include_submissions bool

Also count finished student attempts.

False

Raises:

Type Description
RuntimeError

Questions came from statistics and that is not allowed.

quiz_spec_from_canvas

quiz_spec_from_canvas(
    quiz: CanvasQuiz,
    questions: list[CanvasQuizQuestion],
    group_name: str,
    tz: str,
    *,
    groups: Sequence[CanvasQuizGroup] = (),
) -> QuizSpec

Build a spec from a live quiz, with times in the course zone.

Questions come out in quiz order, carrying their Canvas ids and group names.

Raises:

Type Description
RuntimeError

Two groups share a name.

quiz_form_fields

quiz_form_fields(
    spec: QuizSpec,
    tz: str,
    *,
    assignment_group_id: int | None = None,
) -> dict[str, object]

Canvas quiz fields for spec (Canvas names, without the quiz[] wrapper).

Settings left as None are omitted. Empty strings clear a field on Canvas; time_limit 0 is sent as "".

quiz_settings_diff

quiz_settings_diff(
    spec: QuizSpec,
    live: CanvasQuiz,
    tz: str,
    *,
    group_id: int | None = None,
) -> dict[str, object]

Canvas fields whose live value differs from spec.

Only the settings spec sets are compared. Times compare by instant, so an offset-only difference is not a change. Questions are never compared.

strip_html

strip_html(text: str) -> str

Drop tags, unescape entities, and collapse whitespace.

Block-level tags become spaces so adjacent paragraphs do not run together; inline tags such as <b> vanish without adding a space.

sync_quizzes

sync_quizzes(
    client: CanvasClient,
    specs: list[QuizSpec],
    group_ids: dict[str, int],
    tz: str,
    *,
    created: list[CanvasQuiz] | None = None,
) -> list[tuple[str, str, str]]

Reconcile quiz specs against live quizzes, matched by title.

Questions on an existing quiz are never compared or modified. Writes always go through client; dry runs pass a dry-run client.

Parameters:

Name Type Description Default
created list[CanvasQuiz] | None

When given, each quiz this call creates is appended to it.

None

Returns:

Type Description
list[tuple[str, str, str]]

(action, "quiz", name) rows: create, update (with the changed

list[tuple[str, str, str]]

fields), or skip.

skill

Install the agent skill that ships inside the cass package.

default_skills_dir

default_skills_dir() -> Path

Return Claude Code's user skills directory, ~/.claude/skills.

skill_files

skill_files() -> list[tuple[str, bytes]]

Return every file in the packaged skill as (relative path, contents).

install_skill

install_skill(target_dir: Path, *, replace: bool) -> Path

Copy the packaged skill to target_dir/cass and return that path.

Parameters:

Name Type Description Default
target_dir Path

Skills directory to install into; created if missing.

required
replace bool

Delete an existing target_dir/cass first.

required

Raises:

Type Description
FileExistsError

The target exists and replace is false.

status

Project status: config, auth, and local database overview.

StatusCounts dataclass

StatusCounts(
    students: int, assignments: int, submissions: int
)

Row counts for the main Canvas tables.

ProjectStatus dataclass

ProjectStatus(
    config_path: str,
    course_url: str | None,
    course_id: int | None,
    auth_source: str | None,
    db_path: str | None,
    course_name: str | None,
    counts: StatusCounts | None,
    pending_changes: int | None,
)

Snapshot of a project's configuration and local database.

count_pending

count_pending(
    pending: dict[
        str, dict[str, dict[str, dict[str, object]]]
    ],
) -> int

Count pending column changes across all tables and rows.

project_status

project_status(config_path: Path) -> ProjectStatus

Collect the status of the project whose config is at config_path.

submissions

Plan and apply excuse and clear-late on submissions.

The planners are pure. Only the targets_* fetchers, submission_rows, plan_submission_command and apply_submission_changes talk to Canvas.

SubmissionTarget dataclass

SubmissionTarget(
    assignment: CanvasAssignmentResponse,
    submission: CanvasSubmissionResponse,
    student_name: str,
    enrolled_at: str | None = None,
)

One student's submission on one assignment, considered for a change.

SubmissionChange dataclass

SubmissionChange(
    action: str, target: SubmissionTarget, reason: str = ""
)

A planned action for a target: excuse, clear-late or skip.

SubmissionPlan dataclass

SubmissionPlan(
    changes: list[SubmissionChange],
    rows: list[tuple[str, str, str, str]],
    counts: dict[str, int],
    warnings: list[str],
)

Everything an excuse or clear-late command shows and applies.

SubmissionApplyError

SubmissionApplyError(
    cause: BaseException, applied: list[str]
)

Bases: RuntimeError

A request failed while applying submission changes.

Attributes:

Name Type Description
applied

Descriptions of the changes sent before the failure.

is_protected_grade

is_protected_grade(sub: CanvasSubmissionResponse) -> bool

Whether the submission holds a score a teacher could have entered.

Canvas's missing-submission policy writes its score with missing set and no grader_id; those are what late enrollees need excused. A teacher's grade, including partial credit on a missing submission, has a grader and stays protected. So does a policy score on a submission a teacher marked Missing by hand in the gradebook: Canvas records that teacher as the grader.

due_before_enrollment

due_before_enrollment(
    sub: CanvasSubmissionResponse, enrolled_at: str
) -> bool

Whether the student's own due date fell strictly before they enrolled.

pick_students

pick_students(
    submissions: list[CanvasSubmissionResponse],
    student_ids: Sequence[int],
    assignment_name: str,
) -> list[CanvasSubmissionResponse]

Select submissions for student_ids, in that order, once each.

Raises:

Type Description
RuntimeError

If any ID has no submission on the assignment.

plan_excuse

plan_excuse(
    targets: list[SubmissionTarget],
    *,
    include_graded: bool = False,
) -> list[SubmissionChange]

Plan excusing each target, skipping excused and (by default) graded ones.

is_graded_as_group

is_graded_as_group(
    assignment: CanvasAssignmentResponse,
) -> bool

Whether Canvas grades assignment once for each whole group.

plan_clear_late

plan_clear_late(
    targets: list[SubmissionTarget],
) -> list[SubmissionChange]

Plan clearing the late status of late targets.

Skipped: assignments graded as a group, where Canvas would write the status to every group member, and excused submissions, which setting a late status would un-excuse.

change_rows

change_rows(
    changes: list[SubmissionChange], tz: str
) -> list[tuple[str, str, str, str]]

Table rows of (action, assignment, student, detail) for display.

change_counts

change_counts(
    changes: list[SubmissionChange],
) -> dict[str, int]

Count changes by action, in first-seen order.

submission_rows

submission_rows(
    client: CanvasClient,
    assignment: CanvasAssignmentResponse,
    *,
    student_ids: Sequence[int] = (),
) -> list[CanvasSubmissionResponse]

Submissions on assignment for listing.

With student_ids, those students in that order; otherwise everyone, sorted by sortable name.

Raises:

Type Description
RuntimeError

If any ID has no submission on the assignment.

submissions_may_be_pending

submissions_may_be_pending(
    assignment: CanvasAssignmentResponse,
) -> bool

True when assignment may be too new for Canvas to have its submissions.

Canvas creates a new assignment's submissions a few seconds after the assignment, so listing them straight away comes back empty.

targets_for_students

targets_for_students(
    client: CanvasClient,
    assignment: CanvasAssignmentResponse,
    student_ids: Sequence[int],
) -> list[SubmissionTarget]

Targets for explicit students on one assignment.

Raises:

Type Description
RuntimeError

If any ID has no submission on the assignment.

targets_enrolled_after

targets_enrolled_after(
    client: CanvasClient,
    cutoff: str,
    *,
    assignment: CanvasAssignmentResponse | None = None,
) -> list[SubmissionTarget]

Targets for students first enrolled after cutoff.

Keeps each late enrollee's submissions on published assignments whose due date (the student's own) fell before they enrolled. Ordered by student name, user ID, due date, then assignment name.

Raises:

Type Description
RuntimeError

If assignment is given and unpublished.

check_target_options

check_target_options(
    *,
    has_assignment: bool,
    student_ids: Sequence[int],
    enrolled_after: str,
) -> None

Require exactly one of student_ids and enrolled_after.

Raises:

Type Description
ValueError

On a missing or conflicting choice, or students without an assignment.

RuntimeError

If enrolled_after is not a recognized time.

plan_submission_command

plan_submission_command(
    client: CanvasClient,
    kind: SubmissionCommand,
    *,
    assignment: CanvasAssignmentResponse | None,
    student_ids: Sequence[int],
    enrolled_after: str,
    include_graded: bool,
) -> SubmissionPlan

Select targets and plan an excuse or clear-late command.

Parameters:

Name Type Description Default
client CanvasClient

Canvas client.

required
kind SubmissionCommand

The command.

required
assignment CanvasAssignmentResponse | None

The assignment to limit to; required with student_ids.

required
student_ids Sequence[int]

Explicit students (--student).

required
enrolled_after str

A user-entered date or time (--enrolled-after), read in the course time zone.

required
include_graded bool

Excuse graded submissions too (excuse only).

required

Raises:

Type Description
ValueError

On a bad combination of target options (before any request).

RuntimeError

On an unknown student, an unparseable date (before any request), or an unpublished assignment with --enrolled-after.

apply_submission_changes

apply_submission_changes(
    client: CanvasClient, changes: list[SubmissionChange]
) -> list[str]

Send each non-skip change, in order, one request each.

Returns:

Type Description
list[str]

A description per change sent, such as "excuse HW1: Ann".

Clear-late sends late_policy_status=none, which clears late and missing and restores deducted points on the current submission. Canvas resets the status when the student submits again, so a later late resubmission is marked late again.

Raises:

Type Description
SubmissionApplyError

When a request fails; it lists the changes sent before the failure. Nothing is rolled back. Other errors, such as an unreadable response to a request that was sent, propagate as is.