Overview
Goal
Build a small "job scheduler" that ingests tasks with priorities and dependencies and emits a valid
run order -- exercising a heap (priority), a dict/set (lookup and unresolved-dependency
tracking), a queue-driven traversal (Kahn's algorithm, BFS's dependency-ordering cousin), and cycle
detection, all in one runnable program with a pytest suite. Every mechanism this capstone combines
was already taught individually somewhere in the Beginner, Intermediate, or Advanced tiers of this
topic; this is a light consolidation, not a new set of ideas.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
flowchart LR
A["tasks.json<br/>id, priority, deps"]:::blue
B["parse_tasks<br/>O(n)"]:::orange
C["build_graph<br/>O(n + e)"]:::teal
D["schedule<br/>Kahn's + heap tie-break<br/>O((n + e) log n)"]:::purple
E["run order<br/>or SchedulerCycleError"]:::brown
A --> B --> C --> D --> E
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
Concepts exercised
-
heapqpriority queue (co-12) --schedule()'s ready-task heap, tie-broken by priority -
dict/setlookups (co-08, co-09) -- the id-to-Taskmap, the adjacency map, and in-degree counters - BFS over an adjacency dict (co-21, co-05) -- Kahn's algorithm's queue-driven dependency relaxation, generalized to a heap for the priority tie-break
- cycle detection (co-21) -- a dependency cycle is detected as a by-product of Kahn's algorithm itself, at no extra asymptotic cost
- Big-O reasoning documented per operation (co-01, co-02) -- every function's docstring states its
exact complexity, reproduced in
scheduler.py's module docstring
All colocated code lives under learning/capstone/code/: the scheduler logic and CLI entry point in
scheduler.py, a sample task graph in tasks.json, and the test suite in test_scheduler.py. Every
listing below is the complete, verbatim file -- nothing on this page is truncated or paraphrased.
Step 1: A fresh venv, installed with pytest
Every step below assumes a project-local virtual environment, exactly like this topic's other venv
usage. Create one inside learning/capstone/code/ and install pytest into it.
Verify
$ python3 -m venv .venv
$ .venv/bin/pip install pytest
[... pip install output ...]
$ .venv/bin/pip show pytest
Name: pytest
Version: 9.1.1
...A version string printed by pip show pytest, with exit code 0, confirms the venv is real and
pytest is installed into it -- not the system Python.
Step 2: scheduler.py -- parse tasks and build the dependency graph
exercises co-01, co-02, co-08, co-22
parse_tasks turns raw JSON dicts into typed, immutable Task objects (a frozen @dataclass,
Example 22's recursion-friendly discipline applied to data instead of a function); build_graph
turns that flat collection into the same dict-of-lists adjacency map Example 56 introduced, plus
an in-degree counter per task -- the count of that task's still-unresolved dependencies.
learning/capstone/code/scheduler.py (complete file)
"""Capstone: job scheduler -- priority + dependency task ordering.
Ingests tasks shaped {id, priority, deps} and emits a valid run order: every
task's dependencies run before it, and among tasks with no remaining
dependencies, the higher-priority task always runs first. Combines a heap
(priority), a dict/set (lookup + seen-tracking), a queue/BFS-style traversal
(dependency relaxation via Kahn's algorithm), and cycle detection in one
runnable program.
Big-O per phase (n = number of tasks, e = number of dependency edges):
- ``parse_tasks``: O(n) -- one pass building the id -> Task lookup dict.
- ``build_graph``: O(n + e) -- one pass initializing every task's adjacency
list and in-degree counter, plus one pass per dependency edge to link
each task to the tasks that depend on it.
- ``schedule`` (Kahn's algorithm, heap-ordered instead of plain-queue-ordered):
O((n + e) log n) -- every task is pushed and popped from the priority
heap exactly once (O(log n) per push/pop), and every dependency edge is
relaxed exactly once, when its source task is emitted.
- Cycle detection: folded into ``schedule`` at O(1) marginal cost -- if the
emitted order's length is less than the total task count, whatever tasks
never reached in-degree 0 are stuck in a cycle (or depend on one).
"""
from __future__ import annotations
import heapq
import json
from dataclasses import dataclass
from pathlib import Path
class SchedulerCycleError(Exception):
"""Raised when the task graph contains a dependency cycle."""
@dataclass(frozen=True)
class Task:
"""One schedulable unit of work."""
id: str
priority: int # higher number = more urgent; breaks ties among ready tasks
deps: tuple[str, ...] # ids of tasks that must complete before this one can run
def parse_tasks(raw_tasks: list[dict[str, object]]) -> dict[str, Task]:
"""Build an id -> Task lookup from raw dicts -- O(n)."""
tasks: dict[str, Task] = {} # => the id -> Task map every later phase queries by id
for raw in raw_tasks: # => one pass over the input, O(n)
task_id = str(raw["id"])
priority = int(raw["priority"]) # type: ignore[arg-type]
deps = tuple(str(d) for d in raw.get("deps", [])) # type: ignore[union-attr]
tasks[task_id] = Task(id=task_id, priority=priority, deps=deps)
return tasks
def build_graph(tasks: dict[str, Task]) -> tuple[dict[str, list[str]], dict[str, int]]:
"""Build the adjacency map (dep -> tasks depending on it) and each task's
in-degree (its unresolved dependency count) -- O(n + e)."""
adjacency: dict[str, list[str]] = {tid: [] for tid in tasks} # => O(n) init
in_degree: dict[str, int] = {tid: 0 for tid in tasks} # => O(n) init
for task in tasks.values(): # => O(n) outer pass
for dep in task.deps: # => O(e) total across all tasks combined
if dep not in tasks: # => O(1) average dict membership check
raise KeyError(f"{task.id!r} depends on unknown task {dep!r}")
adjacency[dep].append(task.id) # => dep unlocks task.id once dep finishes
in_degree[task.id] += 1 # => task.id has one more unresolved dependency
return adjacency, in_degree
def schedule(tasks: dict[str, Task]) -> list[str]:
"""Kahn's topological sort, with a max-priority heap tie-break -- O((n + e) log n).
heapq is a MIN-heap, so pushing (-priority, id) makes the heap always pop
the HIGHEST-priority ready task first, exactly like Example 41's negation trick.
"""
adjacency, in_degree = build_graph(tasks) # => O(n + e)
ready: list[tuple[int, str]] = [
(-tasks[tid].priority, tid) for tid in tasks if in_degree[tid] == 0
] # => seeds the heap with every task that has NO dependencies at all
heapq.heapify(
ready
) # => O(k) where k = len(ready), turns the list into a valid heap
order: list[str] = [] # => the emitted run order, respecting deps and priority ties
while (
ready
): # => drains the heap -- each task pushed/popped at most once, O(log n) each
_, task_id = heapq.heappop(ready) # => always the highest-priority ready task
order.append(task_id)
for dependent in adjacency[
task_id
]: # => relax every edge OUT of task_id, O(e) total
in_degree[dependent] -= 1 # => task_id no longer blocks dependent
if (
in_degree[dependent] == 0
): # => dependent has NO unresolved deps left -- ready
heapq.heappush(ready, (-tasks[dependent].priority, dependent))
if len(order) != len(
tasks
): # => O(1): fewer emissions than tasks means a cycle exists
stuck = sorted(
tid for tid in tasks if tid not in order
) # => O(n log n), diagnostics only
raise SchedulerCycleError(
f"dependency cycle detected among: {', '.join(stuck)}"
)
return order # => a complete, valid run order -- every dep precedes its dependents
def load_tasks(path: Path) -> dict[str, Task]:
"""Read a JSON file of raw task dicts and parse it into Task objects."""
with path.open() as f: # => `with` guarantees the file handle closes
raw_tasks = json.load(f) # => parses the whole file as one JSON value
return parse_tasks(raw_tasks)
def main() -> None:
"""CLI entry point: schedule the sample tasks.json next to this file."""
tasks_path = (
Path(__file__).parent / "tasks.json"
) # => resolves relative to THIS file
tasks = load_tasks(tasks_path)
order = schedule(tasks)
print(" -> ".join(order)) # => prints the run order as an arrow-joined chain
if (
__name__ == "__main__"
): # => only runs main() when invoked directly, not when imported
main()learning/capstone/code/tasks.json (complete file)
[
{ "id": "compile", "priority": 3, "deps": [] },
{ "id": "lint", "priority": 5, "deps": [] },
{ "id": "unit_test", "priority": 4, "deps": ["compile"] },
{ "id": "integration_test", "priority": 2, "deps": ["compile", "lint"] },
{ "id": "package", "priority": 1, "deps": ["unit_test", "integration_test"] },
{ "id": "deploy", "priority": 1, "deps": ["package"] }
]Key takeaway: parse_tasks and build_graph are both plain, linear-pass functions over dicts
-- there is nothing algorithmically clever in this step; the heap-driven ordering (Step 3) is where
the interesting work happens, on top of the plain data this step prepares.
Step 3: schedule() -- Kahn's algorithm with a heap-based priority tie-break
exercises co-05, co-08, co-12, co-21
schedule() (shown in full in Step 2's listing) is Kahn's topological sort -- the same
in-degree-plus-queue algorithm Example 72 taught -- except the plain queue is replaced with a
min-heap of (-priority, id) tuples, so that whenever more than one task is simultaneously ready
(zero remaining dependencies), the highest-priority one is emitted first. The -priority
negation is Example 41's max-heap-via-negation trick, applied directly.
learning/capstone/code/test_scheduler.py (complete file)
"""pytest coverage for scheduler.py -- an acyclic fixture and a cyclic fixture."""
import pytest
from scheduler import SchedulerCycleError, parse_tasks, schedule
def test_acyclic_order_respects_dependencies() -> None:
"""Every dependency must be emitted strictly before every task that depends on it."""
raw_tasks: list[dict[str, object]] = [
{"id": "compile", "priority": 3, "deps": []},
{"id": "lint", "priority": 5, "deps": []},
{"id": "unit_test", "priority": 4, "deps": ["compile"]},
{"id": "integration_test", "priority": 2, "deps": ["compile", "lint"]},
{"id": "package", "priority": 1, "deps": ["unit_test", "integration_test"]},
{"id": "deploy", "priority": 1, "deps": ["package"]},
]
tasks = parse_tasks(raw_tasks)
order = schedule(tasks)
assert len(order) == len(tasks) # every task appears exactly once
assert set(order) == set(tasks) # no task is missing or duplicated
positions = {task_id: index for index, task_id in enumerate(order)}
for task in tasks.values():
for dep in task.deps:
assert positions[dep] < positions[task.id] # dep runs strictly before task
def test_acyclic_order_breaks_ties_by_priority() -> None:
"""Among tasks with no remaining dependencies, the higher-priority task runs first."""
raw_tasks: list[dict[str, object]] = [
{"id": "compile", "priority": 3, "deps": []},
{"id": "lint", "priority": 5, "deps": []},
]
tasks = parse_tasks(raw_tasks)
order = schedule(tasks)
# Both tasks are ready at the same time (no deps); priority 5 beats priority 3.
assert order == ["lint", "compile"]
def test_cyclic_graph_raises_scheduler_cycle_error() -> None:
"""A dependency cycle must raise a clear, dedicated error, not silently drop tasks."""
raw_tasks: list[dict[str, object]] = [
{"id": "a", "priority": 1, "deps": ["c"]},
{"id": "b", "priority": 1, "deps": ["a"]},
{"id": "c", "priority": 1, "deps": ["b"]}, # a -> b -> c -> a: a cycle
]
tasks = parse_tasks(raw_tasks)
with pytest.raises(SchedulerCycleError):
schedule(tasks)
def test_cyclic_graph_error_names_the_stuck_tasks() -> None:
"""The raised error message names every task caught in (or blocked by) the cycle."""
raw_tasks: list[dict[str, object]] = [
{"id": "a", "priority": 1, "deps": ["b"]},
{"id": "b", "priority": 1, "deps": ["a"]},
{"id": "independent", "priority": 1, "deps": []}, # not part of the cycle
]
tasks = parse_tasks(raw_tasks)
with pytest.raises(SchedulerCycleError) as excinfo:
schedule(tasks)
assert "a" in str(excinfo.value)
assert "b" in str(excinfo.value)
assert "independent" not in str(excinfo.value) # the healthy task is not implicatedVerify
$ .venv/bin/pytest -q
.... [100%]
4 passed in 0.00stest_acyclic_order_breaks_ties_by_priority is the test that specifically proves the heap tie-break
works: compile (priority 3) and lint (priority 5) both start with zero dependencies, and the
emitted order confirms lint -- the higher-priority task -- is scheduled first.
Key takeaway: Kahn's algorithm plus a heap swap (queue -> min-heap of negated-priority tuples) is all it takes to add priority-aware tie-breaking to a plain dependency-respecting topological sort -- the dependency-respecting guarantee itself is untouched by that swap.
Step 4: Cycle detection
exercises co-21
schedule() detects a cycle for free, as a direct consequence of Kahn's algorithm's own termination
condition: if any tasks are stuck at a nonzero in-degree when the heap empties, those tasks (and
whatever waits on them) can never become "ready," which is only possible if they sit on -- or depend
on -- a cycle. No separate cycle-detection pass (like Example 73's three-color DFS) is needed here.
Verify (a deliberately cyclic three-task fixture: a depends on b, b depends on a, plus one
healthy, unrelated task):
from scheduler import parse_tasks, schedule, SchedulerCycleError
raw = [
{"id": "a", "priority": 1, "deps": ["b"]},
{"id": "b", "priority": 1, "deps": ["a"]},
{"id": "independent", "priority": 1, "deps": []},
]
tasks = parse_tasks(raw)
try:
schedule(tasks)
except SchedulerCycleError as err:
print(f"SchedulerCycleError: {err}")Output (genuinely captured):
SchedulerCycleError: dependency cycle detected among: a, bKey takeaway: the error message names exactly the tasks caught in the cycle (a and b) and
excludes the unrelated, healthy independent task -- the diagnostic is specific enough to act on, not
a generic "something went wrong."
Why it matters: getting cycle detection "for free" out of an existing algorithm, rather than as a bolted-on separate pass, is a direct payoff of understanding Kahn's algorithm's termination condition deeply enough to recognize what an incomplete run actually means.
Step 5: Run it end to end
python3 scheduler.py, run from inside learning/capstone/code/, schedules the sample
tasks.json shown in Step 2 and prints the resulting run order.
Run: python3 scheduler.py
Output (genuinely captured):
lint -> compile -> unit_test -> integration_test -> package -> deployTracing this order against tasks.json: lint (priority 5) and compile (priority 3) both start
ready (no deps), and lint's higher priority puts it first. Once compile finishes, unit_test
becomes ready; once both compile and lint finish, integration_test becomes ready too --
unit_test (priority 4) outranks integration_test (priority 2), so it is emitted first among that
pair. package waits for both unit_test and integration_test, and deploy waits for package --
exactly the dependency chain tasks.json declares, with every priority tie broken correctly along
the way.
Acceptance criteria
python3 scheduler.py, run fromlearning/capstone/code/, exits0and prints the exact run order shown in Step 5's Output block..venv/bin/pytest -qreports4 passedagainsttest_scheduler.py, covering both the dependency-respecting order, the priority tie-break, and both cyclic-fixture cases (a bare raise and a raise whose message names the stuck tasks).- A dependency cycle raises
SchedulerCycleErrornaming exactly the tasks caught in the cycle, never silently dropping tasks from the emitted order. - Every phase's Big-O is documented in
scheduler.py's module docstring:parse_tasksO(n),build_graphO(n + e),scheduleO((n + e) log n), cycle detection at O(1) marginal cost. - Every listing on this page (
scheduler.py,tasks.json,test_scheduler.py) is the complete file, runnable exactly as shown -- nothing here is a fragment that depends on code the page does not also show.
Done bar
This capstone is runnable end to end: a reader who copies the three files above into a
learning/capstone/code/-shaped directory, creates a venv, installs pytest, and runs python3 scheduler.py there reaches the identical output block shown in Step 5, verified against a real
CPython 3.14.3 interpreter run (not merely described). Every mechanism combined here -- a heapq
priority queue (co-12), dict/set lookups (co-08, co-09), a queue/BFS-style traversal generalized
by Kahn's algorithm (co-21, co-05), and cycle detection folded into that same traversal -- traces to a
worked example already taught earlier in this topic's Beginner, Intermediate, or Advanced tiers; no
new algorithmic idea was needed to write this capstone.
← Previous: Advanced Examples · Next: Drilling →
Last updated July 13, 2026