Adding a tool¶
The rules every tool keeps¶
Staffroom stores and processes no pupil data. A tool you add keeps the same rules as the shipped ones, and the scaffold below already does:
- Nothing per row is saved. A marks file has one row per script; what a run writes is class-level: per-question statistics, a grade distribution, a chart, a sheet, a key. Never the input's rows with a column added, never a grade per script. A per-script view may be shown on screen (marks folds one away, labelled view only, never saved), but it is not written.
- No identifiers in, none out.
core.csvio.read_tabledrops any column that looks like a name, an id, a UPN, a candidate number or an email before your code sees the table. Do not add a "names" or "class" input. - Rows are scripts, not pupils. Summaries, outcomes and labels count questions or scripts ("6 questions · 10 scripts"), never "pupils".
- Write to the output folder, not beside the input. The scaffold writes to
the folder set in Settings, else
<sessions root>/outputs/<tool>/, so trying it onexamples/marks_10x.csvnever leaves a file in the repository. - Example data is invented and starts with a
# FAKE DATAline; the pre-commit hook refuses a CSV or XLSX without it.
1. Scaffold¶
just new my_tool
This creates src/staffroom/tools/my_tool/ with:
| File | Owns |
|---|---|
__init__.py |
the plugin object: metadata, run() glue (reads/writes files, calls logic) |
logic.py |
pure functions and an Options dataclass; no I/O |
panel.py |
panel_spec(): the declarative UI |
cli.py |
staff my_tool run ... and friends |
tests/ |
tests; shared fixtures come from the root conftest.py |
As generated it is a small working tool: it reads a marks file, shows one row
per question (how many scripts have a mark, the mean, the highest) and saves
that summary, nothing else, to the output folder. It passes ruff and its own
tests as generated, so your first commit gets past the hooks. It shows up
straight away:
uv run staff list
uv run staff my_tool run examples/marks_10x.csv
# Summarised 6 questions from 10 scripts into ~/TeachingSessions/outputs/my_tool/marks_10x_summary.csv
--out <folder> writes somewhere else for that run. Read the generated files
before changing them; they are short, and they show each rule above in place.
tools/marks is the reference for a report (tiles, a chart, aggregate-only
saving).
Where it sits in the sidebar¶
The sidebar groups tools by the shape of a teacher's week (GROUPS in
core/plugin.py), and the ⌘1–⌘9 shortcuts follow it top to bottom:
| Group | Shipped tools | Shortcuts |
|---|---|---|
| Lessons | (Library), classroom | ⌘K, ⌘1 |
| Sheets & quizzes | worksheet, quiz | ⌘2, ⌘3 |
| From a program | parsons, trace, bugs, fade, pseudocode | ⌘4 – ⌘8 |
| After a test | marks | ⌘9 |
The Library is the shell's own page, not a tool, so it has ⌘K rather than a
number. Four optional class attributes on the plugin say where a tool goes and
how it looks; all are read with getattr, so a tool that sets none needs no
change: it is listed under Other, after every group, A-Z, with a generic
glyph.
class MyToolPlugin:
name = "my_tool"
...
group = "From a program" # one of core.plugin.GROUPS, or a name of your own
glyph = "code" # a file in shell/assets/glyphs/ (without .svg)
order = 35 # within the group: lower first; shipped tools use 10 × their WORKFLOW_ORDER place
hidden = True # not in the sidebar or on a shortcut; still a CLI tool
group names one of the four groups; a name of your own makes a new group
after them. glyph picks one of the bundled monochrome glyphs (books,
screen, page, question, blocks, table, bug, taper, code,
chart, tool); the shell paints it in the theme's colours, so the tool
stays Qt-free. To add a glyph, drop a 24×24 line drawing that uses
currentColor for its stroke into shell/assets/glyphs/. order places the
tool among the shipped ones in its group (35 puts it between parsons and
trace in From a program). hidden is for reference and developer tools:
staff list still shows them, marked [hidden], and staffroom --tool <name>
still opens them for a screenshot.
2. Write the logic¶
Put the real work in logic.py as functions over Table (see
core/table.py). Keep them pure so tests are one-liners. What they return for
saving is class-level, one row per question (or per grade, per topic), never
one per script:
def summarise(table: Table, options: Options) -> Table:
rows = []
for question in question_columns(table.columns, options.prefix):
marks = [m for m in table.column(question) if isinstance(m, int | float)]
mean = round(sum(marks) / len(marks), 1) if marks else None
rows.append([question, len(marks), mean])
return Table(["Question", "Answered", "Mean"], rows, fake_data=table.fake_data)
Add fields to Options and the form draws itself. Field metadata can set
label, help, min, max, step. Supported types: bool, int,
float, str, Literal[...], Enum.
3. Declare the panel¶
panel.py returns a PanelSpec built only from core/panel.py:
PanelSpec(
title="Marks",
inputs=(
FilePicker(key="marks", label="Marks file", chip=True),
TablePreview(key="preview", source="marks", summary=found, compact=True),
EditableTable(
key="boundaries",
label="Grade boundaries",
columns=(Column("Grade", "text", 90), Column("Minimum", "float", 110)),
rows=(("9", 90), ("7", 70), ("4", 40), ("1", 10)),
compact=True,
),
OptionsForm(key="options", schema=Options),
),
outputs=(
StatTiles(key="headline"),
BarChart(key="distribution", label="Grades", category="Grade", value="Count"),
ResultsTable(key="questions", label="Questions, hardest first"),
),
run_label="Analyse",
main_layout="row",
)
Input keys become the keys of the inputs dict passed to run(); output keys
are what run() must put in RunResult.tables / RunResult.images. Every
output here is class-level: the headline numbers, how many scripts reached
each grade, and the questions.
The shell lays every panel out as a workbench: the inputs in a column on the
left with the run button at its foot (⌘↵ runs it), the outputs on the right.
Outputs are main views, shown as tabs in spec order, or sit below them.
An ActionTable goes below by default and everything else is a main view; set
place="below" on a secondary output (notes, a view-only table) or
place="main" on a list that is the point of the tool, as classroom's
sessions are. The run log sits behind a one-line Details toggle at the foot
of the output pane in every panel, so there is no log output to declare;
ctx.log(...) lines land there.
How the run went is not an output you declare. The result strip on top of the
output pane shows a tick or a cross, RunResult.outcome (a short line such as
"10 questions · seed 42"; the part before · is the headline; without it
the strip shows summary), one button per file in RunResult.files and a
Folder button. A file's button is named from its file name (stem / stem_key
become Sheet / Key); name one yourself with RunResult.labels
(labels={chart: "Chart"}). Raise anything the teacher should act on with
ctx.warn("…") rather than ctx.log: it is logged as Warning: … and the strip
counts it ("3 warnings ▸").
For a tool whose result is a report rather than one view, tools/marks is
the reference:
StatTiles(key)is a row of headline numbers over the main view (place="top"by default). Fill it withtables[key] = stat_table([Stat("Mean", "75%", "29.9 / 40"), ...]);Stat(..., action=True)marks the one that asks the teacher to act (the only result that takes the accent colour).PanelSpec(main_layout="row")puts the main views side by side, each in a titled card, instead of tabs; they stack when the pane is too narrow.BarChart(key, category=..., value=..., detail=..., detail_format="{:.0f}%")draws bars fromtables[key]in the theme's colours with a Table switch;texts[key]is its caption. A file for reports still comes from the tool.ResultsTable(..., bars=(BarColumn("Facility", 1.0),), tag_column="Reteach", hidden_columns=("Note",))draws an inline bar beside a 0–1 number, tags a row under its first cell (the column itself is not shown), and moves columns into the row's tooltip.collapsed=Trueon aplace="below"table folds it under its label until opened.- A wide table that must fit the screen:
ResultsTable(..., compact=True, lift_constants=True, mark_changes=True, pin_last=True)sizes columns to their values, lifts a value that never changes into one line above the table, makes a changed value bold and a carried one grey, and keeps the last column at the right edge (trace's completed table). Only the screen changes: the table the tool returns, and what it prints, keep every column. CardList(key, title="Version {Version}", details=("line {Line}", "{Bug}"), badge="What happens", tone_column="Outcome", tones=(("crash", "danger"),), code_lines=(("was", "Original"), ("now", "Changed to")))shows one card per row oftables[key], its badge always whole at the right (bugs' key).titleanddetailsare format strings over the row's columns.
On the input side, FilePicker(..., chip=True) shows the chosen file as a
chip; TablePreview(..., summary=fn, flag="MAX", compact=True) replaces the
rows with a few Note lines from fn(table) and a Preview rows link;
EditableTable(..., compact=True, choice=Choice(...)) draws a two-column
table as a grid of label and number cells with a segmented switch above it
(its value reaches run() under the choice's key); Choice also works on
its own; and OptionsForm(..., fields=(...)) shows only the named fields.
A tool that works on a program takes it with CodeSource(key="source"): a
chip naming the file over a numbered, highlighted, read-only view of it, with
Paste instead for code in no file. Its value is CodeValue(path, text);
turn it into source text with core.codeinput.code_from_inputs(inputs), which
also accepts the command line's {"source": Path} or {"pasted": str}.
CodeSource(..., choice=Choice(...)) puts a switch about the code between
the chip and the code. ResultsTable(..., line_column="Line") links a table
whose rows name program lines to the view (trace), as does a CardList with
line_column (bugs), TextView(...,
code=True) shows an output as numbered code (pseudocode), and
PanelSpec(suggest=fn) lets choices follow what was picked or pasted:
fn(inputs) is pure and returns {choice_key: value}.
A tool whose file decides what can be chosen says so with
PanelSpec(inspect=fn). The shell calls fn(inputs) when the panel opens
and whenever an input changes; it returns an Inspection of a Found per
input key: notes shown under a file (13 questions · 6 topics · no
problems, or the first problem with its line), rows that a Preview link
opens, and for a ChipChoice its chips (a bank's own topics,
ChipOption("binary", count=4)), with notes under them for a running total.
It can also name the run button (run_label="Make do-now"). fn is the
plugin's code, not logic: it may read the files the inputs name (tables
through core.csvio.read_table), must be quick, and is only a hint. A
ChipChoice's value is the tuple of chips that are on, () for none.
ResourcePicker(..., chip=True) draws the file as a chip like a code tool's.
PanelSpec(joined=("topics",)) draws an input as part of the group above it
(no hairline, a small label), and OptionsForm(..., inline=("count",
"seed"), more=("spec", "title")) puts short fields on one row and folds what
few runs need behind More. tools/quiz is the reference.
The panel's first CodeSource, ResourcePicker or (opening) FilePicker is its main
file input, and the extensions it declares (filter_ext=".py", several as
filter_ext=(".py", ".txt"), or the *.ext patterns in filters) put the
tool on the Library's use bar for files of that type: a teacher previewing a
.py sees a button that opens your tool with the file already picked. Nothing
in the library needs changing (core.panel.main_file_input). A picker that
takes any file (no filter_ext) is not offered there; hidden tools never are.
4. Wire run()¶
def run(self, inputs, ctx):
source = inputs["marks"]
if source is None:
raise ToolError("Choose a marks file first.")
table = read_table(source) # identifier columns are already gone
ctx.progress(0.3, "Analysing")
analysis = logic.analyse(table, inputs["boundaries"], inputs["options"])
folder = output_folder(inputs, ctx.config)
# Aggregates only: per question and per grade. No row of the input is written.
questions = write_table(analysis.questions, folder / f"{source.stem}_questions.csv")
grades = write_table(analysis.distribution, folder / f"{source.stem}_distribution.csv")
return RunResult(
tables={
"headline": stat_table(headline_stats(analysis)),
"distribution": analysis.distribution,
"questions": analysis.questions,
},
files=[questions, grades],
summary=f"Analysed {len(analysis.questions)} questions from {len(table)} scripts",
outcome=f"{len(analysis.questions)} questions · {len(table)} scripts",
)
output_folder is the scaffold's: config.output_dir_for(None, "<tool>"),
the output folder set in Settings, else <sessions root>/outputs/<tool>/;
never the input's folder and never the current directory. Declare the same
function on the panel as PanelSpec(writes_to=WritesTo(output_folder)) so the
line under the run button and the Open output folder chip name it, and in a
CLI resolve it the same way (the scaffold's cli.py does, with --out for
one run). ctx.output_dir is the shell's shared choice, which is the input's
own folder when Settings names none: fine for a sheet made from a program,
not for a summary of a marks file that may sit in a shared folder or in
examples/.
ctx.resources is the Resource Library (None under a bare CLI run): use
ctx.resources.search("while") or ctx.resources.pick(".py") to let the
teacher choose a file from the indexed resources instead of a raw file
dialog, and ctx.resources.open_externally(path) to hand a file to its
native app. Never read Office files yourself.
5. CLI¶
Keep cli.py thin: parse arguments, build an Options, call plugin.run,
echo result.summary. The GUI and CLI must do the same thing.
6. Test, lint, commit¶
just test
just lint
The boundary tests fail if the tool imports Qt or if logic.py does I/O.
The pre-commit hook refuses any CSV/XLSX without a # FAKE DATA first line.
git status after a run should list only src/staffroom/tools/my_tool/: a
file anywhere else (above all in examples/) means the tool wrote beside its
input. tests/test_discovery.py lists the shipped tools by sidebar group;
once you give yours a group, glyph and order, add it there.
Running pupil code¶
Never exec it in the app. Use core.runner.run_source(source, stdin=...,
timeout=...) (or run_file), which runs it in a subprocess with a hard
timeout, a temp working directory and a 1 MB cap on each of stdout and
stderr (MAX_OUTPUT_BYTES; the rest is drained and discarded, the text ends
with truncation_marker() and the outcome's stdout_truncated /
stderr_truncated say so). It returns stdout, stderr, exit code and whether
it timed out. tools/trace shows how to ship a helper script alongside the
program with extra_files. Put core.runner.limits_note(timeout) in the
panel description so the teacher sees the limits, and keep
docs/guide/running-code.md true: it is a fence against accidents, not a
sandbox.
Producing worksheets and keys¶
Build a core.export.Document, add blocks to doc.section() and to
doc.section("Answer key", answers=True), then call
export(doc, ctx.output_dir, stem, formats). That writes the sheet and a
separate key in each format with the house header and footer. Show what was
written with PagePreview(key="pages") and
pages={"pages": pages_from(files, sheet_text=..., key_text=...)} in the
result: the panel gets Sheet and Key tabs with the real PDF pages, page
thumbnails, Open PDF and Print…; a run that wrote Word only shows a note
over to_text of the sheet and key instead. Keep texts["preview"] =
to_text(...) for the command line's --preview. tools/worksheet and
tools/parsons are the references. The blocks are Heading, Paragraph, Code, Numbered,
TableBlock, HintBox, BoxedBlocks (numbered cut-out cells with dashed
cut lines, laid out in columns), AnswerGrid (empty boxes headed by
position) and PageBreak. A new block goes in core/export/model.py with a
rendering in each of pdf.py, docx.py and text.py, so every tool gets it.
Options that apply, in words¶
An options form shows one row per field, so give it what it needs to show only the rows that matter and to say them in the teacher's words:
OptionsForm(
key="options",
schema=Options,
shown_when={"laws": shown_when("topic", "boolean_simplification")},
choice_labels={"topic": TOPIC_TITLES, "formats": OUTPUT_FORMAT_LABELS},
switches=("difficulty", "formats"),
)
shown_when(field, *values) hides a field unless another field of the form
holds one of the values (and is itself shown). A hidden field keeps what the
teacher set in it, so it comes back unchanged, but run() gets the field's
default: nothing the teacher cannot see reaches the tool, the same as leaving
the flag off on the command line. choice_labels names the values of a
Literal or Enum field (a value with no label shows as itself;
OUTPUT_FORMAT_LABELS covers the shared both / docx / pdf), and switches
draws a choice as a segmented switch instead of a dropdown: keep those to two
to four short labels. The command line keeps the values. All of it lives in
panel.py; core.panel refuses a field name, a value or a loop that cannot
work when the spec is built.
A whole input can hide the same way, when it only matters for one setting:
PanelSpec(shown_when={"taught_log": shown_when("pick", "taught")}) shows
quiz's taught-log picker only while Questions from is Taught 1, 3 and 6
weeks ago. on names a form field as "<form key>.<field>", or the key of a
Choice. The shell hides the group with the hairline over it; while hidden,
run() gets the input's empty value (no file, no text, the choice's default)
and a required input is not asked for. Put such an input after the setting it
follows, so ticking the box opens it below rather than pushing the form down.
Presets for an options form¶
OptionsForm(..., presets=(preset("Year 9", top_level_only=True), ...)) puts
a Preset dropdown above the fields. A preset pins some fields of the schema
(core.panel.preset(name, **values)); choosing it sets those and leaves the
rest as the teacher had them, and editing a field afterwards moves the
dropdown to whichever preset still matches, or to Custom. The same rule is
core.panel.matching_preset(presets, options): call it in logic to print
the preset's name on the output, so a sheet says which preset it came from
even when the options were typed on the command line (trace does this).
Keep the presets in logic.py next to the options dataclass; the panel just
passes them on.
Presets for an editable table¶
EditableTable(..., presets=(("OCR J277", rows), ...), preset_action="save_preset")
shows a preset dropdown with Apply and Save as preset (with compact=True,
choosing a preset applies it and Save as preset sits in the group heading). Saving calls
plugin.act("save_preset", {"name": str, "table": Table}, ctx); persist it
in ctx.config.tool("<tool>") and build the presets tuple from config in
panel_spec() so it is there next launch. tools/marks is the reference.
Row actions (tables with buttons)¶
Declare an ActionTable(key, label, actions=(Action("diff", "Diff"), ...),
refresh_action="refresh") among the outputs and give the plugin an
act(name, row, ctx) -> RunResult method. The shell calls
act("refresh", {}, ctx) when the panel is shown and after every run or
action, and act("diff", row, ctx) when a row is selected and the button
pressed. Put the result in RunResult.texts for a TextView, or return the
refreshed table under the same key. tools/classroom is the reference.
If the widget you need does not exist¶
Do not build it in the tool. Add a dataclass to core/panel.py, a widget to
shell/widgets/, a rendering branch in shell/tool_panel.py (inputs) or
shell/widgets/output_pane.py (outputs), and a test. Then use it from the spec.