drillion — spaced-repetition Python tasks with a local web UI
A single-user practice tool: a catalogue of small, tagged Python tasks, each graded by its own
pytest test against a reference solution on fresh random data every sitting, scheduled by a
5-box Leitner ladder, with gated hints and a gated solution. You open it in a browser, write
solve() in the editor, press Run, and the app grades, schedules and archives the attempt.
Design brief for the UI: DESIGN.md.
The task files on disk are the source of truth; the app edits only the learner's region of each
task.py — everything above its machinery marker — and the guidance beside the editor is the
task's README.md, rendered as Markdown.
Quick start
uv run drillion # start the server on http://127.0.0.1:8765 and open the browser
uv run drillion selfcheck # prove every task passes with its own reference solution
uv run drillion doctor # say why a task folder would be skipped, exit non-zero if any would
docker compose up -d # or run the published image instead — see Docker below
Requirements: Python 3.13 and uv. Docker is optional. The frontend
(React + Vite, in web/) builds itself on start: uv run drillion runs pnpm build when web/dist
is missing or older than anything in web/src/, or than web/package.json, web/index.html or
web/vite.config.ts. Without pnpm the JSON API still serves and only / 404s.
Install from PyPI
uvx drillion # run it without installing anything
uv tool install drillion # or keep it on your PATH
No clone, no Node toolchain, no build step: the wheel carries the tasks and the built page. Tasks and progress are seeded into a writable data directory on first run, and a checkout is still used as-is when you run from one, so upgrading never touches code you have written.
Releases are cut by pushing a v<version> tag, which
.github/workflows/release.yml refuses unless the tag matches
the declared version. It publishes through PyPI's trusted publishing, so drillion stores no
upload token anywhere, and every sdist and wheel carries a
PEP 740 attestation naming the workflow run that built it:
pypi-attestations verify pypi --repository https://github.com/vazome/drillion \
pypi:drillion-0.1.0-py3-none-any.whl
## Docker
No clone and no build: a version tag publishes the image, and it carries the tasks.
```bash
docker run -d --name drillion -p 127.0.0.1:8765:8765 -v drillion:/data \
ghcr.io/vazome/drillion:latest
Then open http://127.0.0.1:8765. latest follows the newest release; name a version —
ghcr.io/vazome/drillion:0.1.0 — to stay put.
Everything the app owns, tasks/ and progress.json, lives in the named volume mounted at
/data. It outlives the container, so upgrading is docker pull and start again, and your
progress is still there. To keep those files in a directory you can open, bind-mount one and hand
the container your own uid — it runs as uid 1000 and cannot write a directory Docker made for root:
mkdir -p drillion-data
docker run -d --name drillion -p 127.0.0.1:8765:8765 \
--user "$(id -u):$(id -g)" -v "$PWD/drillion-data:/data" \
ghcr.io/vazome/drillion:latest
compose.yaml is the first of those two spelled out. It needs nothing else from the
repo: save that one file anywhere and docker compose up -d, then docker compose pull to upgrade.
Every published image is signed with a build provenance attestation, so you can ask where the one you pulled came from before you run it:
gh attestation verify oci://ghcr.io/vazome/drillion:latest -R vazome/drillion
How a session works
- Today shows due reviews first (most overdue first), then up to 2 new tasks whose prerequisites you have passed. The whole catalogue is open too — the queue is a suggestion. Not in the mood for one of them? Bury it and it is gone for the day and back tomorrow, in the same box and on the same due date. Nothing is lost by forgetting you did it, and the Buried band in Today undoes it early.
- Opening a task starts an attempt (fresh seed, active-seconds timer that pauses when the
tab is hidden). The left pane renders the task's
README.md: Why / You get / You return / Rules / Read first, with code blocks, tables, diagrams and images. The right pane is the editor with the stub. - Run saves your region into the file and runs that file's pytest test with the attempt's seed. Failures come back with the assertion lines mapped to editor line numbers.
- Hints unlock one level per 60 active seconds; the solution unlocks after 3 attempts and 10 minutes, and taking it means the pass cannot promote the card.
- Pass → computed grade → card moves on the ladder → your code is archived into
progress.json→ the file is reset to the stub, so the next review starts blank. - The spec pane carries a note — one free-text box per task, saved as you type. It is the
place for "I always forget
sortedneedskey=". The note belongs to the task, not to the sitting: it is still there after a grade, a re-attempt and an abandon. Clearing it deletes it.
State lives in progress.json (cards, open attempts, log, archived solutions, notes). It is untracked
and listed in .gitignore, so a fresh clone starts with an empty ladder, not the maintainer's own
progress. If you cloned before this changed and already have real practice in your file, pulling
past this change may show a modify/delete conflict on progress.json — keep your file. It is the
only copy of your practice history and nothing in drillion rebuilds it.
Why it's built this way
Fresh data every sitting. Each task ships a generator, so when a task comes back in 8 days the IPs, names and numbers are different. You can't recall the answer because that exact answer never existed. This is the one feature that stops spaced repetition from degrading into memorising files.
A 5-box ladder, not a fancy algorithm. Pass a task and it returns in 2 → 4 → 8 → 16 → 28
days. Only a pass moves a card: a failing run costs an attempt and nothing else, and a pass you
took the solution for grades struggled, which leaves the card exactly where it was.
The obvious choice was FSRS (what Anki uses). Tested it: with default settings a task you get right three times comes back in 46 days, then 90. It's tuned for people memorising vocabulary over years, where the cost of a lapse is one word. Fixed intervals are the more correct choice here: over a season of practice the research (Cepeda 2008) puts the optimal gap at 10–20% of the retention interval, which is a number you can just write down.
Grades are computed, not self-reported. First try under par = quick (+2 boxes). Two tries =
pass (+1). Slow, or three-plus tries = struggled (stays put). Looked at the solution = never
promotes, regardless of the tests going green. That last rule is the important one: hint-assisted
passes are how people finish a curriculum and still can't code.
Par time is the grader's, not yours. minutes: lives in each task's frontmatter because
grade_of() needs it to decide quick, and it stops at the server: it is not in the browser
payload and not on any screen. So the timer counts up and never turns a colour at some number you
were supposed to beat. Watching a clock you cannot meet is not information, it is pressure.
Hints are gated. Three levels — a nudge, then a strategy, then the same idea worked through on different data. Levels are 60 s apart because clicking through hints is the best-documented way to feel productive while learning nothing.
Reviews come before new material, capped at 2 new tasks a day. Reviews arrive interleaved rather than blocked — mixing confusable topics is the largest effect in the whole literature (d ≈ 0.83), and it will feel worse than drilling one thing at a time. That feeling is documented and wrong.
Layout
src/drillion/ the application package (`drillion` console script)
cli.py serve (default) | selfcheck; logging
settings.py DRILLION_ROOT / DRILLION_HOST / DRILLION_PORT / DRILLION_OPEN_BROWSER
state.py progress.json load/save (atomic)
catalogue.py tasks(): each task's README.md frontmatter, spec and hints
region.py the learner's region: cut/splice/stub/validate/etag, atomic file writes
scheduler.py ladder, due/new queue, grading, rescheduling
attempts.py open/run/pass/abandon/hint/solution lifecycle, active-seconds timer
runner.py pytest subprocess, failure summariser, selfcheck
api.py FastAPI JSON API + static frontend, serve()
tests/ unit + ASGI tests (plain pytest, no fixtures)
tasks/ one folder per task and _lib.py (seeded Random)
<NNN>_<name>/
README.md frontmatter (title, difficulty, tier, tags, …) + Markdown guidance
task.py the learner's region, the machinery marker, the machinery
assets/ optional images, diagrams and clips the README points at
web/ the frontend (React + Vite), served from web/dist
progress.json your cards, attempts, log and archived solutions
Dockerfile, compose.yaml, .github/workflows/ci.yml
Configuration
| variable | default | meaning |
|---|---|---|
DRILLION_ROOT |
cwd if it has tasks/, else the checkout, else a per-user data directory |
where tasks/ and progress.json live |
DRILLION_HOST |
127.0.0.1 |
bind address (0.0.0.0 inside Docker) |
DRILLION_PORT |
8765 |
port |
DRILLION_OPEN_BROWSER |
1 |
open the browser on start (0 in Docker) |
DRILLION_SEED |
— | pin the data seed when running a task by hand |
Run it from a clone and the checkout is the root: the tasks you practise are the ones in
tasks/, edited in place, and nothing is copied anywhere. Run an installed drillion with no
tasks/ in sight and the first command copies the tasks that ship inside the package into that
per-user directory, once — a root that already has tasks/ is never written over, so an upgrade
cannot touch code you saved.
The server binds to loopback, accepts only 127.0.0.1/localhost host headers, rejects bodies
over 256 KB, and runs task code only inside a pytest subprocess with a timeout.
Development
uv sync # dependencies (runtime + dev)
uv run pytest tests -q # the app's tests
uv run ruff check . # lint
uv run drillion selfcheck # every task green with its reference
uv run drillion doctor # every reason a task folder would be skipped
uv run pytest tasks/018_counter # one task by hand (NotImplementedError on a stub)
DRILLION_SEED=42 uv run pytest tasks/018_counter # same task, fixed data
selfcheck and doctor check different things. selfcheck proves the tasks the catalogue already
accepted still solve with their own reference. doctor looks at folders selfcheck never sees —
the ones the catalogue silently dropped — and reports every rule each one breaks, not just the
first, then exits non-zero if it found anything.
And the frontend:
pnpm --dir web install # once
pnpm --dir web dev # Vite on 5173, proxying /api to the server on 8765
pnpm --dir web build # emits web/dist, which the server serves at /
pnpm --dir web check 8765 # renders all 171 specs against a running server
CI runs ruff, pytest and selfcheck on every push. The rest of the frontend — the vendored design
system, and why there is no Tailwind and no router — is in web/README.md.
Contributing, including the full contract a new task is graded against, is in CONTRIBUTING.md.
Vocabulary
Every word the code, the API and the UI use is defined in CONTEXT.md, together with the synonyms to avoid. This section is the other half: how to choose a tier, a difficulty, a track or a tag when you write a new task.
task — the unit: one folder under tasks/, one spec, one solve(), one test. One noun, used
everywhere: the code, the API, the UI and these docs never reach for a synonym. There are 171.
tier — how far into the language a task reaches. Exactly one of three, and the catalogue lists them in this order:
| tier | what belongs in it | today |
|---|---|---|
core |
the language and its standard library, and every coder needs it: syntax, data structures, files and text, errors, itertools, pathlib |
139 |
advanced |
still the standard library, but you can work a long while without it: asyncio, concurrency, generators, decorators, closures, functools |
17 |
packages |
solving it needs something pip installs: requests, responses, boto3, moto, pytest, fastapi, langchain |
15 |
Tier answers "can I run this with stock Python?", so packages wins whenever a task is both — an
asyncio task that stands up a FastAPI app to have something to await is packages, not
advanced. The test is what the solution needs: a library the learner's own code imports, or
that the task is plainly about. Imports below the machinery marker are the grader's and do not
count — 14 tasks import pytest down there for pytest.approx alone and are core, while
084_fixtures is packages because its @pytest.fixture is in the learner's region.
difficulty — how hard the task is to get right the first time: easy, medium or hard.
It is not how long the task takes. Thirty minutes of unsurprising typing is easy; six lines you
can only write once you have seen the trick is hard. Anchor the call on the task's ## Rules —
rules are where the traps live — and grade a new task against the rubric all 171 were graded
against: docs/difficulty-rubric.md.
Today: 36 easy · 108 medium · 27 hard.
track — optional, at most one per task: a themed run through the catalogue that cuts across
tiers. rsample (18 tasks) is a RAG take-home broken into steps. Leave the key out unless the task
belongs to such a run.
tags — what Python you practise. Lowercase, kebab-case, 1–3 per task, and one rule decides every one of them:
A tag names a Python concept you can practise — never the task's identity, never its story.
recursion, dict-get, context-managers and bitwise are tags: each names something you could
sit down and get better at, and something a future task could also be tagged with.
flatten-array, phone-screens and take-home-task-2 are not. They name one task and could never
name another, so they are the task's identity wearing a tag's clothes.
A tag on a single task is fine — 37 of the 76 are, because 171 tasks cannot cover every concept
twice. The test is not "does more than one task have it?" but "could another task have it?".
That is what makes a tag an answer to what do I want to practise today, which is the job it does
in the catalogue filter and in the per-tag coverage table on the progress screen. So reach for an
existing tag before minting a synonym — sets not set, strings not str-stuff — and when
nothing fits, name the concept, not the task. GET /api/catalogue returns the whole vocabulary
under tags.
grade — what a pass was worth, computed by grade_of() and never self-reported:
quick (+2 boxes) · pass (+1) · struggled (stays put) · abandoned.
easy is a difficulty and never a grade; that word moved when the vocabulary landed.
Six tags were retired to get here. If an old branch or an old note still uses one:
| retired tag | where it went |
|---|---|
exercism |
source: — provenance is a field, not a concept you can practise (84 tasks carry one) |
core, data-structures |
tier: — the coarse grouping is its own key now |
whole-task |
difficulty: — it marked size, and size is not difficulty |
rsample |
track: |
basics |
functions — the concept the tasks actually taught |
focus in progress.json is a single string, and the scheduler matches it against a task's
tier, track and tags alike (scheduler.py:_facets): advanced, rsample and recursion are
all valid. It restricts which new tasks are offered — reviews and the open catalogue ignore it —
and POST /api/focus sets it.
The tasks
One folder per task, tasks/<NNN>_<name>/; copy the shape of an existing one.
<NNN> is a contiguous incremental id, 001–171, so the next task you add is 172. It is an
identity and nothing else: it encodes no difficulty, no section and no provenance — tier,
difficulty and source: carry those. Append, never insert: prereqs: points at these numbers,
so renumbering means rewriting other people's frontmatter.
README.md — YAML frontmatter, then GitHub-flavoured Markdown:
---
title: Counter — top N by frequency # the concept first, then what you build with it
difficulty: medium # easy | medium | hard
tier: core # core | advanced | packages
track: rsample # optional, omit it unless the task is part of a run
minutes: 12 # par time — the grader's input, never shown to the learner
prereqs: [18] # task numbers that gate it; [] when nothing does
tags: [counter, sorted] # Python concepts, lowercase kebab-case
source: exercism/python practice/two-fer (MIT, adapted) # optional
---
# Counter — top N by frequency
## Why / ## You get / ## You return / ## Rules / ## Read first
## Hints
### Hint 1 … ### Hint 2 … ### Hint 3
Write the keys in that order. title, difficulty, tier, minutes and tags are required.
A folder the catalogue cannot read is skipped, not reported: a half-written task must never
break the menu for the other 170. That makes a mistake look like a task that simply is not there.
Run uv run drillion doctor when a new task does not appear — it reports every rule the folder
breaks, not just the first. The rules it checks —
- a required key missing, empty, or misspelt (
tags: []counts as missing); - frontmatter that is not a closed
---block, or is not valid YAML; task.pywith no machinery marker line, or with nosolve()as the last statement of the learner's region, or that does not parse;- a hint count that is not exactly 3 —
### Hint 1,### Hint 2,### Hint 3under## Hints.
uv run drillion selfcheck counts what it found, but only among tasks the catalogue already
accepted: if it says 170/170 after you added a task, doctor is where to look. prereqs: is a
list of task numbers, not slugs. No real task carries all eight keys — the block above shows
the order, not a typical task.
The title leads with the concept, never with a puzzle name: Exercism's bob is
conditionals — classify a message into one of five replies, and the puzzle name survives in the
slug and in source:.
The number is not in the frontmatter — it is the folder's leading digits, and the API exposes
it as topic. The spec is everything from # title up to ## Hints; extra sections
(## Introduction, ## Instructions) may go anywhere before it. Exactly 3 hints, escalating, the last one worked on different data — the server
never sends one the learner has not unlocked. Headings, lists, tables, fenced code, GitHub alerts
(> [!NOTE]), Mermaid diagrams, images and muted looping clips from assets/ all render. For a
task adapted from Exercism the README carries Exercism's Markdown verbatim — never trimmed to
make room for ours — plus frontmatter source: and a closing attribution line.
task.py — code only, no docstring spec, no META, no HINTS:
from collections import Counter # the learner's imports, given code and solve()
def solve(lines, n):
raise NotImplementedError
# ══ machinery — everything below is the grader's, not yours ══
from _lib import rng
def _gen(r): ... # builds inputs from a seeded random.Random
def _reference(lines, n): ... # the correct implementation; tests compare yours to it
def test_solve(): ... # generated cases, plus canonical ones where they exist
The region contract. Everything above the marker line is the learner's: it is the text the
editor shows and the only text a save may replace. solve is the last statement in it; given code
(constants, exception classes, a toy app) goes above solve, never below. The machinery
(_gen, _reference, test_*) is never sent to the editor, and an edit that pastes the marker,
defines _reference/_gen/test_* or names _reference is refused.
Sanity check for a new task: uv run drillion selfcheck splices _reference into every file and
runs the tests — it must be green before the task is trusted.
License
MIT — see LICENSE. 84 of the 171 tasks carry Markdown adapted from Exercism's Python
track (also MIT); each names its origin in a per-file source: field and attribution footer, and
NOTICE reproduces the copyright and permission notice that travels with them.
Status
Backend, API, the folder-per-task Markdown format, the vocabulary above and all 171 tasks are done and tested, and the three screens — catalogue, task, progress — are built in light and dark. Not yet: a container smoke test in CI.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file drillion-0.1.1.tar.gz.
File metadata
- Download URL: drillion-0.1.1.tar.gz
- Upload date:
- Size: 998.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
650e7d5967370d3f46ff22a6df2f2ededcd036d9e7f3be8d4ffea99f8a5f040b
|
|
| MD5 |
849000765ae925a4da72ec7d75b403c2
|
|
| BLAKE2b-256 |
cb43d78873099d9c9a33533dd43ce779a890c50fb6f65da7a416a6af13666629
|
Provenance
The following attestation bundles were made for drillion-0.1.1.tar.gz:
Publisher:
release.yml on vazome/drillion
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
drillion-0.1.1.tar.gz -
Subject digest:
650e7d5967370d3f46ff22a6df2f2ededcd036d9e7f3be8d4ffea99f8a5f040b - Sigstore transparency entry: 2611707369
- Sigstore integration time:
-
Permalink:
vazome/drillion@cad3505fc96cd41301d0bc6a30996dbef0cb4b68 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/vazome
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cad3505fc96cd41301d0bc6a30996dbef0cb4b68 -
Trigger Event:
push
-
Statement type:
File details
Details for the file drillion-0.1.1-py3-none-any.whl.
File metadata
- Download URL: drillion-0.1.1-py3-none-any.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58e271db6e14f4d85712ded452a3234c38a6e05526c2404b14e7529c5282673e
|
|
| MD5 |
4fa9cd6c28041b9249c1269385e4dc2f
|
|
| BLAKE2b-256 |
0cca1323cf6570241da797415f6ac196d0a68d712ada4f15172ed15d6fc3a2d2
|
Provenance
The following attestation bundles were made for drillion-0.1.1-py3-none-any.whl:
Publisher:
release.yml on vazome/drillion
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
drillion-0.1.1-py3-none-any.whl -
Subject digest:
58e271db6e14f4d85712ded452a3234c38a6e05526c2404b14e7529c5282673e - Sigstore transparency entry: 2611707526
- Sigstore integration time:
-
Permalink:
vazome/drillion@cad3505fc96cd41301d0bc6a30996dbef0cb4b68 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/vazome
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cad3505fc96cd41301d0bc6a30996dbef0cb4b68 -
Trigger Event:
push
-
Statement type: