diff --git a/level_7_concurrency_and_background_work/missions/01_blocking_vs_background_work/check.py b/level_7_concurrency_and_background_work/missions/01_blocking_vs_background_work/check.py index 520bdb5..b9c8e60 100644 --- a/level_7_concurrency_and_background_work/missions/01_blocking_vs_background_work/check.py +++ b/level_7_concurrency_and_background_work/missions/01_blocking_vs_background_work/check.py @@ -20,6 +20,7 @@ def update_progress(mission_id: str) -> None: try: + import task as _task_mod from task import time_tournament # type: ignore[import] except ImportError as exc: print(f"❌ Cannot import time_tournament from task.py: {exc}") @@ -34,6 +35,46 @@ def update_progress(mission_id: str) -> None: except Exception: pass # any real error = student modified it, continue to actual checks +# ── behavioral spy: time_tournament must actually call _svc.simulate_tournament ── +# +# A fake that just does `return {"battles": n, "time_seconds": 0.0, ...}` +# without ever touching the service would still satisfy a shape-only check. +# Wrap the real simulate_tournament with a spy that records how it was +# called, but still delegates to the real implementation — so the timing +# and result stay genuine. +if not hasattr(_task_mod, "_svc"): + print("❌ task.py: expected module-level _svc (SimulationService) not found") + raise SystemExit(1) + +_spy_calls: list[int] = [] +_original_simulate = _task_mod._svc.simulate_tournament + + +def _spy_simulate(n: int): + _spy_calls.append(n) + return _original_simulate(n) + + +_task_mod._svc.simulate_tournament = _spy_simulate +try: + _spy_calls.clear() + SPY_N = 7 + spy_result = time_tournament(SPY_N) + if not _spy_calls: + print(f"❌ time_tournament({SPY_N}) never called _svc.simulate_tournament()") + print(" You can't measure a blocking call you don't actually make.") + raise SystemExit(1) + if SPY_N not in _spy_calls: + print(f"❌ time_tournament({SPY_N}) called simulate_tournament with {_spy_calls}, " + f"expected it to pass battles={SPY_N} through") + raise SystemExit(1) + if spy_result.get("battles") != SPY_N: + print(f"❌ time_tournament({SPY_N}) returned battles={spy_result.get('battles')!r}, expected {SPY_N}") + raise SystemExit(1) +finally: + _task_mod._svc.simulate_tournament = _original_simulate +print(f"✓ time_tournament() actually calls _svc.simulate_tournament(battles) — not hardcoded") + # run the timing check result = time_tournament(50) for key in ("battles", "time_seconds", "battles_per_second"): diff --git a/level_7_concurrency_and_background_work/missions/05_job_status_repository/check.py b/level_7_concurrency_and_background_work/missions/05_job_status_repository/check.py index 4ddf1de..8b1efcf 100644 --- a/level_7_concurrency_and_background_work/missions/05_job_status_repository/check.py +++ b/level_7_concurrency_and_background_work/missions/05_job_status_repository/check.py @@ -101,7 +101,8 @@ def update_progress(mission_id: str) -> None: app.dependency_overrides[get_worker] = lambda: check_worker client = TestClient(app, raise_server_exceptions=False) -r = client.post("/tournaments", json={"battles": 20}) +BATTLES = 20 +r = client.post("/tournaments", json={"battles": BATTLES}) if r.status_code != 202: print(f"❌ POST /tournaments should return 202, got {r.status_code}") app.dependency_overrides.clear() @@ -114,7 +115,29 @@ def update_progress(mission_id: str) -> None: print(f"❌ Job should be completed immediately with SyncWorker, got {r.json().get('status')}") app.dependency_overrides.clear() raise SystemExit(1) -print("✓ API: POST /tournaments → 202, job completes instantly (SyncWorker)") + +# The result must reflect the actual tournament that ran — not just any dict +# that happens to make the status look "completed". total_battles is +# deterministic (it's the request's battles count); hero_wins/monster_wins +# are random per-battle, so only their sum (not exact values) is checked. +result = r.json().get("result") +if not isinstance(result, dict): + print(f"❌ Job result should be a dict, got {result!r}") + app.dependency_overrides.clear() + raise SystemExit(1) +if result.get("total_battles") != BATTLES: + print(f"❌ result['total_battles'] should be {BATTLES}, got {result.get('total_battles')!r}") + print(" The result must come from the real simulate_tournament() call, not a fabricated dict.") + app.dependency_overrides.clear() + raise SystemExit(1) +hero_wins = result.get("hero_wins") +monster_wins = result.get("monster_wins") +if not isinstance(hero_wins, int) or not isinstance(monster_wins, int) or hero_wins + monster_wins != BATTLES: + print(f"❌ result hero_wins ({hero_wins!r}) + monster_wins ({monster_wins!r}) should equal {BATTLES}") + app.dependency_overrides.clear() + raise SystemExit(1) +print(f"✓ API: POST /tournaments → 202, job completes instantly (SyncWorker), " + f"result matches the real simulation: {result}") r = client.get("/jobs/nonexistent") if r.status_code != 404: diff --git a/level_7_concurrency_and_background_work/missions/06_thread_pool_for_blocking_io/README.md b/level_7_concurrency_and_background_work/missions/06_thread_pool_for_blocking_io/README.md index 7bb8848..4e986e1 100644 --- a/level_7_concurrency_and_background_work/missions/06_thread_pool_for_blocking_io/README.md +++ b/level_7_concurrency_and_background_work/missions/06_thread_pool_for_blocking_io/README.md @@ -43,7 +43,7 @@ with ThreadPoolExecutor(max_workers=workers) as pool: ## Key Insight - **Threads help I/O-bound work**: while one thread waits for a file read or write, another thread can start its own I/O. This overlaps I/O operations. -- **Threads don't help CPU-bound work**: multiple threads on one CPU core don't run in parallel because the GIL (Global Interpreter Lock) prevents true parallelism. Use `ProcessPoolExecutor` for CPU-bound tasks (Mission 07). +- **Threads don't help CPU-bound work**: for CPU-bound pure-Python work, the GIL (Global Interpreter Lock) prevents multiple threads in the same process from running Python bytecode in parallel. Use `ProcessPoolExecutor` for CPU-bound tasks (Mission 07). ## How to Check diff --git a/level_7_concurrency_and_background_work/missions/08_testing_background_work/check.py b/level_7_concurrency_and_background_work/missions/08_testing_background_work/check.py index 94a5e8c..5529395 100644 --- a/level_7_concurrency_and_background_work/missions/08_testing_background_work/check.py +++ b/level_7_concurrency_and_background_work/missions/08_testing_background_work/check.py @@ -1,6 +1,17 @@ -"""Check: Mission 08 — Testing Background Work.""" +"""Check: Mission 08 — Testing Background Work. + +Structural checks confirm SyncWorker is used, no sleep()/threading.Event, and +at least 6 test functions exist. The real proof of competence is behavioral: +the student's test suite must pass against the correct code, then FAIL +against each of six independent, single-behavior mutants — one per required +behavior from the README (new job pending, submit completes, exception +stores error, result stored, two jobs independent, unknown job returns +None). A suite of tests that make real calls but assert nothing meaningful +(`assert True`) kills none of these mutants. +""" import json import os +import shutil import subprocess import sys from pathlib import Path @@ -22,6 +33,27 @@ def update_progress(mission_id: str) -> None: PROGRESS_FILE.write_text(json.dumps(progress, indent=2)) +def _clear_pycache() -> None: + for d in mission.rglob("__pycache__"): + shutil.rmtree(d, ignore_errors=True) + + +def _run_pytest() -> subprocess.CompletedProcess: + env = os.environ.copy() + env["PYTHONPATH"] = str(mission) + env["PYTHONDONTWRITEBYTECODE"] = "1" + _clear_pycache() + try: + return subprocess.run( + [sys.executable, "-m", "pytest", "task/tests/test_background.py", "-q", "--tb=line"], + capture_output=True, text=True, cwd=str(mission), env=env, + ) + finally: + _clear_pycache() + + +# ── test file exists ────────────────────────────────────────────────────────── + test_file = mission / "task" / "tests" / "test_background.py" if not test_file.exists(): print("❌ task/tests/test_background.py not found") @@ -29,40 +61,168 @@ def update_progress(mission_id: str) -> None: src = test_file.read_text() -# SyncWorker used +# ── SyncWorker used ─────────────────────────────────────────────────────────── + if "SyncWorker" not in src: print("❌ SyncWorker not imported/used in test_background.py") raise SystemExit(1) print("✓ SyncWorker used in tests") -# no time.sleep +# ── no time.sleep / threading.Event ────────────────────────────────────────── + code_lines = [ln for ln in src.splitlines() if not ln.lstrip().startswith("#")] -if "time.sleep" in "\n".join(code_lines): +code_src = "\n".join(code_lines) +if "time.sleep" in code_src: print("❌ time.sleep() found — use SyncWorker for deterministic tests") raise SystemExit(1) print("✓ No time.sleep() — deterministic tests") -# at least 6 test functions -test_fn_count = sum(1 for ln in src.splitlines() - if ln.lstrip().startswith("def test_") and not ln.lstrip().startswith("#")) +# ── at least 6 test functions ───────────────────────────────────────────────── + +test_fn_count = sum( + 1 for ln in src.splitlines() + if ln.lstrip().startswith("def test_") and not ln.lstrip().startswith("#") +) if test_fn_count < 6: print(f"❌ Found {test_fn_count} test functions — write at least 6") raise SystemExit(1) print(f"✓ {test_fn_count} test functions found") -# pytest passes -env = os.environ.copy() -env["PYTHONPATH"] = str(mission) -result = subprocess.run( - [sys.executable, "-m", "pytest", "task/tests/test_background.py", "-q", "--tb=short"], - capture_output=True, text=True, cwd=str(mission), env=env, -) +# ── Gate 1: the suite must pass against the correct code ───────────────────── + +result = _run_pytest() if result.returncode != 0: - print("❌ pytest fails:") + print("❌ pytest fails against the correct code:") print(result.stdout[-2000:]) print(result.stderr[-500:]) raise SystemExit(1) -print("✓ All background tests pass") +print("✓ All background tests pass against the correct code") + +# ── Gate 2: the suite must FAIL against each single-behavior mutant ────────── +# +# One mutant per behavior the README's test table asks for. Each mutates +# task/jobs.py's InMemoryJobRepository or task/workers.py's SyncWorker — the +# two components the mission's own tests exercise directly (not through +# BackgroundWorker, which the mission explicitly says not to use in tests). + +JOBS_FILE = mission / "task" / "jobs.py" +WORKERS_FILE = mission / "task" / "workers.py" + +for path in (JOBS_FILE, WORKERS_FILE): + if not path.exists(): + print(f"❌ {path.relative_to(mission)} not found") + raise SystemExit(1) + +originals = {p: p.read_text() for p in (JOBS_FILE, WORKERS_FILE)} + +_SYNC_WORKER_BLOCK = ( + ' """Runs jobs inline (no threading). Use in tests for determinism."""\n' + " def __init__(self, repo: JobRepository) -> None:\n" + " self._repo = repo\n" + "\n" + " def submit(self, job_id: str, fn: Callable[[], Any]) -> None:\n" + " self._repo.set_status(job_id, JobStatus.running)\n" + " try:\n" + " result = fn()\n" + " self._repo.set_result(job_id, result)\n" + " self._repo.set_status(job_id, JobStatus.completed)\n" + " except Exception as exc:\n" + " self._repo.set_error(job_id, str(exc))\n" + " self._repo.set_status(job_id, JobStatus.failed)" +) + +MUTANTS: list[tuple[str, Path, str, str]] = [ + ( + "a new job starts as something other than pending", + JOBS_FILE, + " def create(self, job_id: str) -> Job:\n" + " job = Job(id=job_id, status=JobStatus.pending)\n" + " with self._lock:\n" + " self._jobs[job_id] = job", + " def create(self, job_id: str) -> Job:\n" + " job = Job(id=job_id, status=JobStatus.completed)\n" + " with self._lock:\n" + " self._jobs[job_id] = job", + ), + ( + "SyncWorker.submit() never marks the job completed (stuck on 'running')", + WORKERS_FILE, + _SYNC_WORKER_BLOCK, + _SYNC_WORKER_BLOCK.replace( + " self._repo.set_result(job_id, result)\n" + " self._repo.set_status(job_id, JobStatus.completed)\n", + " self._repo.set_result(job_id, result)\n", + ), + ), + ( + "a raised exception is swallowed — job looks completed instead of failed", + WORKERS_FILE, + _SYNC_WORKER_BLOCK, + _SYNC_WORKER_BLOCK.replace( + " except Exception as exc:\n" + " self._repo.set_error(job_id, str(exc))\n" + " self._repo.set_status(job_id, JobStatus.failed)", + " except Exception:\n" + " self._repo.set_status(job_id, JobStatus.completed)", + ), + ), + ( + "the job completes but its result is never stored", + WORKERS_FILE, + _SYNC_WORKER_BLOCK, + _SYNC_WORKER_BLOCK.replace( + " self._repo.set_result(job_id, result)\n", + "", + ), + ), + ( + "two independent jobs end up sharing the same result", + JOBS_FILE, + " def set_result(self, job_id: str, result: dict) -> None:\n" + " with self._lock:\n" + " if job := self._jobs.get(job_id):\n" + " job.result = result", + " def set_result(self, job_id: str, result: dict) -> None:\n" + " with self._lock:\n" + " for job in self._jobs.values():\n" + " job.result = result", + ), + ( + "an unknown job_id returns a fabricated job instead of None", + JOBS_FILE, + " def get(self, job_id: str) -> Job | None:\n" + " with self._lock:\n" + " return self._jobs.get(job_id)", + " def get(self, job_id: str) -> Job | None:\n" + " with self._lock:\n" + " return self._jobs.get(job_id) or Job(id=job_id, status=JobStatus.completed, result={})", + ), +] + +try: + for label, path, old, new in MUTANTS: + original_src = originals[path] + if old not in original_src: + print(f"❌ internal check error: mutant anchor not found for: {label}") + raise SystemExit(1) + path.write_text(original_src.replace(old, new, 1)) + result = _run_pytest() + path.write_text(original_src) + if result.returncode == 0: + print(f"❌ Your tests did not catch this bug: {label}.") + print(" A correct test suite for this mission must fail when this " + "behavior breaks — check the actual status/result/error, not just that calls don't crash.") + raise SystemExit(1) + print(f"✓ tests correctly fail when: {label}") +finally: + for path, original_src in originals.items(): + path.write_text(original_src) + _clear_pycache() + +for path, original_src in originals.items(): + if path.read_text() != original_src: + print(f"❌ internal check error: {path.relative_to(mission)} was not restored correctly — please re-run") + raise SystemExit(1) update_progress("08_testing_background_work") print("\n✅ Mission 08 complete! Deterministic tests for background work.") diff --git a/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/README.md b/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/README.md index 0f49adf..30f0fa4 100644 --- a/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/README.md +++ b/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/README.md @@ -36,4 +36,12 @@ Then write at least **6 tests** in `tests/test_api.py` covering: uv run python check.py ``` -All 9 gates must pass. +All gates must pass. + +--- + +Quality habits still apply — run Ruff on the code you changed before considering the final project complete: + +```bash +uv run python -m ruff check api/ tests/ +``` diff --git a/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/check.py b/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/check.py index bc25332..2c5a51e 100644 --- a/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/check.py +++ b/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/check.py @@ -233,6 +233,19 @@ def update_progress(project_id: str) -> None: raise SystemExit(1) print("✓ ProcessPoolTournamentWorker contains no lambda — pickle-safe") + # ── Gate 12: ruff check on the code you wrote (Level 5/6 habits still apply) ── + repo_root = Path(__file__).parents[3] + _ruff_scope = [str(project / "api"), str(project / "tests")] + _ruff_result = subprocess.run( + [sys.executable, "-m", "ruff", "check", *_ruff_scope, "--config", str(repo_root / "pyproject.toml")], + capture_output=True, text=True, + ) + if _ruff_result.returncode != 0: + print("❌ ruff check api/ tests/ fails — fix the lint errors above:") + print(_ruff_result.stdout) + raise SystemExit(1) + print("✓ ruff check api/ tests/ passes") + update_progress("03_concurrent_tournament_runner") print() print("✅ Boss fight complete! Concurrent Tournament Runner operational.") diff --git a/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/data/jobs/.gitignore b/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/data/jobs/.gitignore new file mode 100644 index 0000000..db9ec4d --- /dev/null +++ b/level_7_concurrency_and_background_work/projects/03_concurrent_tournament_runner/data/jobs/.gitignore @@ -0,0 +1,2 @@ +*.json +!.gitkeep