Split code_tutor cli and verifier, deleting two lint suppressions

cli.py 345 -> 222 and _verifier.py 347 -> 228, via _cli_checks.py and
_verifier_support.py.

The cli seam cut straight through code covered by its per-file-ignores.
Rather than copy S607 and PERF203 onto a second entry, both are fixed:
systemctl is resolved with shutil.which, and the readiness poll no
longer carries a try/except in its body now that the probe is its own
function. Both ignores are deleted from meta/pyproject.toml and ruff
passes without them - the repo has two fewer suppressions than before.

The which() fix adds a systemctl-is-None branch, so the pass count goes
216 -> 217: one new test for it, no existing test touched. Coverage is
back at 100% by covering the branch, not excluding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 17:57:38 +02:00
parent c113a3a192
commit 1d1938ce23
9 changed files with 452 additions and 282 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Split code_tutor cli.py and _verifier.py under the cap, removing two lint suppressions",
"objective": "Bring python_pkg/code_tutor/cli.py (345 lines) and _verifier.py (347) under the repo-wide 250-line cap. cli.py's split separated code covered by its per-file-ignores, so rather than copying S607 and PERF203 onto a second file the underlying lint is fixed and both entries are deleted.",
"acceptance_criteria": [
"cli.py, _cli_checks.py, _verifier.py and _verifier_support.py are each at most 250 lines.",
"The S607 and PERF203 entries are removed from meta/pyproject.toml's per-file-ignores for code_tutor/cli.py, and ruff passes on the result. The repo's total suppression count goes down by two; it must not go up.",
"The code_tutor suite reports 217 passing tests: the 216 baseline plus exactly one new test covering the shutil.which(\"systemctl\") is None branch that the S607 fix introduced.",
"The package keeps 100% branch coverage of the code_tutor source modules, with the new branch covered by a real test rather than an exclusion.",
"Every patch string is repointed per test by which function is under test: the _ensure_ollama_running and _ensure_fresh_plan tests move to _cli_checks, the analyze/study/status/drill command tests keep cli.",
"No existing assertion is changed and no test is deleted, skipped or weakened."
],
"out_of_scope": [
"Changing the observable behaviour of the Ollama startup path: it must still probe, start via systemctl, poll to the same timeout and print the same messages.",
"Removing the B008 ignore, which is the standard Typer dependency-injection pattern and stays with the command definitions in cli.py.",
"Splitting the code_tutor test modules, which are separate commits."
],
"verifier": "timeout 90 env PYTHONPATH=. python3 -m pytest python_pkg/code_tutor/tests -q (expects exactly 217 passed), plus pre-commit run ruff --files python_pkg/code_tutor/cli.py passing with the two ignores deleted, plus pre-commit run pytest-coverage confirming 100%."
}

View File

@ -0,0 +1,52 @@
{
"intent": "Bring code_tutor's cli.py and _verifier.py under the 250-line cap, and fix the two lint rules the cli split would otherwise have forced onto a second suppression entry.",
"scope": [
"python_pkg/code_tutor/_cli_checks.py, _verifier_support.py (new)",
"python_pkg/code_tutor/cli.py (345 -> 222), _verifier.py (347 -> 228)",
"meta/pyproject.toml (two per-file-ignores removed)",
"python_pkg/code_tutor/tests/test_cli.py, test_verifier.py (import and patch paths, plus one new test)",
"Non-goal: no existing assertion changed, no test deleted, skipped or weakened"
],
"changes": [
"Fixed S607 by resolving systemctl through shutil.which and invoking the absolute path, and PERF203 by extracting the API probe into _ollama_reachable so the readiness poll is a plain loop with no try/except in its body. Both ignores are deleted from meta/pyproject.toml rather than copied to a new entry: the repo's suppression count drops by two.",
"The shutil.which fix introduced a systemctl-is-None branch, so test_ensure_ollama_running_no_systemctl was added to cover it. That is the only reason the pass count moves from 216 to 217.",
"Moved the Ollama checks, _ensure_fresh_plan and the codebase-discovery helpers into _cli_checks.py, taking requests, subprocess, time, shutil and json with them. cli.py imports the four entry points back because the command tests patch them there.",
"Moved the verifier's prompt constants, _QUESTIONS, _class_header, _read_snippet and _parse_verdict into _verifier_support.py. The Verifier class stays in _verifier, which is what cli and _session construct.",
"Repointed 17 patch strings in test_cli.py per test: the seven _ensure_ollama_running and _ensure_fresh_plan tests name _cli_checks, the command tests keep cli."
],
"verification": [
{
"command": "timeout 90 env PYTHONPATH=. python3 -m pytest python_pkg/code_tutor/tests -q",
"result": "pass",
"evidence": "217 passed - the 216 baseline plus exactly one new test. No existing test was lost; the delta is fully accounted for by the branch the S607 fix added."
},
{
"command": "pre-commit run ruff --files python_pkg/code_tutor/cli.py (with S607 and PERF203 deleted from per-file-ignores)",
"result": "pass",
"evidence": "Passed. This is the check that proves the lint was actually fixed rather than relocated - with the ignores gone, ruff has nothing left to suppress."
},
{
"command": "pre-commit run pytest-coverage --files python_pkg/code_tutor/cli.py",
"result": "fail then pass",
"evidence": "First run reported _cli_checks.py at 97%, missing lines 55-56 - the new systemctl-is-None branch. Covered with a real test rather than an exclusion, per the coverage rule, and the hook then passed."
},
{
"command": "PYTHONPATH=. python3 -c \"<assert every patched name resolves on cli and _cli_checks>\"",
"result": "pass",
"evidence": "Caught that cli.codebase_fingerprint, cli.subprocess and cli.time no longer exist after the move, which is what identified the 17 patch strings needing repointing before the suite was run."
},
{
"command": "wc -l python_pkg/code_tutor/cli.py python_pkg/code_tutor/_cli_checks.py python_pkg/code_tutor/_verifier.py python_pkg/code_tutor/_verifier_support.py",
"result": "pass",
"evidence": "222, 175, 228 and 142 lines, all under the 250-line cap."
}
],
"risks": [
"The Ollama startup path is not exercised against a real systemd here; only its mocked behaviour is pinned. The refactor preserves the call sequence (probe, start, poll to the same deadline, same messages), and the four existing tests plus the new one cover every branch, but a live run was not performed.",
"_verifier_support._QUESTIONS was missed on the first pass of the import block and only surfaced as a NameError in the suite. Any further split of a module with module-level tables should grep for every capitalised or underscore-prefixed module global, not just the functions."
],
"rollback": [
"git revert the commit; cli.py and _verifier.py return to 345 and 347 lines, the two new modules disappear, and the S607/PERF203 ignores come back.",
"After rollback, re-run the package suite and confirm it reports 216 passed - the added test reverts with the branch it covers."
]
}

View File

@ -51,10 +51,6 @@ unfixable = []
"**/code_tutor/cli.py" = [
"B008", # typer.Argument(...) as a parameter default is the standard Typer
# dependency-injection pattern, not a mutable-default bug.
"S607", # invoking "systemctl" by name (not full path) is fine for a
# local developer tool that assumes a normal PATH.
"PERF203", # the try/except inside the Ollama readiness-poll loop is
# intentional: retry once per second until the server answers.
]
"**/tests/**/*.py" = [
"ARG", # @patch decorators inject mock params that aren't always referenced;

View File

@ -0,0 +1,175 @@
"""Ollama readiness and plan-freshness checks for the CLI commands.
Split out of :mod:`python_pkg.code_tutor.cli` to keep it under the 250-line
cap. ``cli`` imports all three entry points back, because the command tests
patch them on ``cli``; ``requests``, ``subprocess`` and ``time`` travel here
with the code that calls them, and the Ollama tests patch them on this module.
"""
from __future__ import annotations
import json
from pathlib import Path
import shutil
import subprocess
import time
from typing import TYPE_CHECKING, cast
import requests
from python_pkg.code_tutor._analyzer import extract_items
from python_pkg.code_tutor._deps import codebase_fingerprint
from python_pkg.code_tutor._plan_builder import build_plan
from python_pkg.code_tutor._progress import save_plan
if TYPE_CHECKING:
from rich.console import Console
from python_pkg.code_tutor._progress import PlanData
_OLLAMA_API = "http://localhost:11434/api/tags"
_OLLAMA_START_TIMEOUT = 30
def _ollama_reachable() -> bool:
"""Return True when the Ollama API answers.
Keeping the probe in its own function is what lets the readiness poll below
stay a plain loop with no try/except in its body.
"""
try:
requests.get(_OLLAMA_API, timeout=2)
except requests.exceptions.RequestException:
return False
return True
def _start_ollama_service(console: Console) -> bool:
"""Run ``systemctl start ollama``. Returns False when the call fails.
Args:
console: Rich console for status messages.
"""
systemctl = shutil.which("systemctl")
if systemctl is None:
console.print("[red]systemctl not found.[/red]")
return False
try:
subprocess.run(
[systemctl, "start", "ollama"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
console.print(
f"[red]systemctl start ollama failed: {exc.stderr.decode().strip()}[/red]"
)
return False
return True
def _ensure_ollama_running(console: Console) -> bool:
"""Start the Ollama systemd service if it is not already reachable.
Tries ``systemctl start ollama`` and polls the API for up to
``_OLLAMA_START_TIMEOUT`` seconds.
Args:
console: Rich console for status messages.
Returns:
True when Ollama is reachable, False after timeout.
"""
if _ollama_reachable():
return True
console.print("[yellow]Ollama not running -- starting via systemctl...[/yellow]")
if not _start_ollama_service(console):
return False
deadline = time.monotonic() + _OLLAMA_START_TIMEOUT
while time.monotonic() < deadline:
if _ollama_reachable():
console.print("[green]Ollama is up.[/green]")
return True
time.sleep(1)
console.print("[red]Ollama did not become ready in time.[/red]")
return False
def _ensure_fresh_plan(codebase: Path, plan: PlanData, console: Console) -> PlanData:
"""Rebuild *plan* if the codebase source files have changed since it was created.
Compares the plan's stored fingerprint against the current one. When they
differ, re-runs the full analyze pipeline, saves the new plan to disk, and
returns it. Plans without a stored fingerprint (created before this feature)
are returned unchanged.
Args:
codebase: Root directory of the codebase.
plan: Plan dict loaded from ``plan.json``.
console: Rich console for status messages.
Returns:
The original plan when up-to-date, or a freshly built ``PlanData`` when
the fingerprint has changed.
"""
saved = plan.get("source_fingerprint", "")
if not saved:
return plan
current = codebase_fingerprint(codebase)
if current == saved:
return plan
console.print(
"[yellow]Plan is stale -- source files changed. Rebuilding...[/yellow]"
)
items = extract_items(codebase)
if not items:
console.print("[red]No extractable items found -- keeping existing plan.[/red]")
return plan
new_plan = build_plan(codebase, items)
save_plan(codebase, new_plan)
n = new_plan["total_items"]
console.print(f"[green]Plan rebuilt: {n} items.[/green]")
return cast("PlanData", new_plan)
def _find_codebase_for_file(file: Path) -> Path | None:
"""Search all saved plans and return the codebase that contains *file*.
Args:
file: Absolute path to the source file.
Returns:
The codebase ``Path`` whose plan contains *file*, or ``None``.
"""
config_root = Path.home() / ".config" / "code_tutor"
if not config_root.exists():
return None
for plan_file in sorted(config_root.glob("*/plan.json")):
result = _check_plan_file(plan_file, file)
if result is not None:
return result
return None
def _check_plan_file(plan_file: Path, file: Path) -> Path | None:
"""Return the codebase path from *plan_file* if it contains *file*, else None.
Args:
plan_file: Path to a ``plan.json`` file.
file: Absolute path to look up.
Returns:
The codebase ``Path`` when *file* is relative to it, or ``None``.
"""
try:
data = json.loads(plan_file.read_text(encoding="utf-8"))
codebase = Path(str(data.get("codebase_path", "")))
file.relative_to(codebase)
except (ValueError, KeyError, OSError):
return None
return codebase

View File

@ -6,9 +6,6 @@ The LLM explanation is NEVER shown before the user attempts to explain.
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import time
from typing import TYPE_CHECKING
@ -18,6 +15,15 @@ from rich.text import Text
from python_pkg.code_tutor._challenge_flows import run_coding_challenge
from python_pkg.code_tutor._progress import LessonRecord
from python_pkg.code_tutor._verifier_support import (
_EXPLAIN_SYSTEM,
_IMPROVEMENT_Q,
_MAX_ATTEMPTS,
_QUESTIONS,
_SYSTEM_PROMPT,
_parse_verdict,
_read_snippet,
)
if TYPE_CHECKING:
from collections.abc import Callable
@ -27,131 +33,6 @@ if TYPE_CHECKING:
from python_pkg.code_tutor._analyzer import CodeItem
from python_pkg.code_tutor._llm import Backend
_MAX_ATTEMPTS = 3
_SYSTEM_PROMPT = (
"You are a fair but rigorous code tutor assessing whether a student"
" genuinely understands a piece of code.\n\n"
"Grade their explanation PASS or FAIL.\n\n"
"PASS when the student demonstrates understanding of:\n"
" - what problem the code solves (purpose)\n"
" - what goes in (inputs/parameters and their roles)\n"
" - what comes out or happens (return value or side effect)\n"
" - any key behavior that would surprise a reader"
" (edge cases, error handling, non-obvious constraints)\n\n"
"You do NOT require verbatim phrasing from the code or docstring.\n"
"Correct paraphrasing counts as understanding.\n"
"ONLY cite a gap if it is genuinely absent from the student's answer"
" -- do NOT cite a gap the student already covered in different words.\n\n"
"FAIL only if the student is vague, factually wrong, or missed a key"
" behavioral detail that materially changes how the code is used.\n"
'"It does some stuff" = FAIL. "It processes data" = FAIL.'
' But "it reads the first 512 bytes and checks for a null byte" = PASS'
" on the behavior criterion, even if they didn't mention OSError.\n\n"
"Respond with valid JSON only, no other text:\n"
'{"verdict": "PASS" | "FAIL",'
' "gap": "<one sentence on the specific missing piece, or empty string on PASS>"}'
)
_EXPLAIN_SYSTEM = (
"You are a code tutor."
" Explain the following code clearly and concisely in 3-5 sentences."
)
_QUESTIONS: tuple[tuple[str, str], ...] = (
("Purpose", "What does this code do?"),
("Inputs", "What are the inputs (parameters / arguments)?"),
("Outputs", "What does it output or do as a side effect?"),
("Why", "Why does it exist? What problem does it solve?"),
)
_IMPROVEMENT_Q = "What would you improve or simplify here? (Enter to skip)"
def _class_header(lines: list[str], class_name: str, before_line: int) -> str:
"""Find the class definition for *class_name* and return a short header.
Searches backward from *before_line* for ``class <class_name>``. Returns
the class signature plus up to 4 following lines (docstring / key attrs).
Args:
lines: All source lines of the file (0-indexed).
class_name: Name of the enclosing class.
before_line: 1-based line number of the method; search stops here.
Returns:
A short string summary of the class, or ``""`` if not found.
"""
pattern = re.compile(rf"^class\s+{re.escape(class_name)}\b")
for i in range(min(before_line - 1, len(lines)) - 1, -1, -1):
if pattern.match(lines[i]):
snippet_end = min(i + 5, before_line - 1)
return "\n".join(lines[i:snippet_end])
return ""
def _read_snippet(item: CodeItem, codebase_path: str) -> str:
"""Read source lines for *item* from *codebase_path*.
When the item belongs to a class, a short class header is prepended so
the user has context about what ``self`` refers to.
Args:
item: Code item whose file and line range to read.
codebase_path: Absolute path of the codebase root.
Returns:
The extracted source lines as a single string, or a placeholder
message when the file cannot be read.
"""
method_src = ""
try:
text = (Path(codebase_path) / item.file).read_text(
encoding="utf-8", errors="replace"
)
lines = text.splitlines()
start = max(0, item.start_line - 1)
end = min(len(lines), item.end_line)
method_src = "\n".join(lines[start:end])
if item.class_name:
header = _class_header(lines, item.class_name, item.start_line)
if header:
return (
f"# class {item.class_name} (context):\n{header}"
f"\n\n# method:\n{method_src}"
)
except OSError:
return f"(source unavailable for {item.file})"
return method_src
def _parse_verdict(raw: str) -> tuple[str, str]:
"""Parse the LLM's JSON verdict, tolerating markdown code fences.
Args:
raw: Raw text returned by the judge LLM.
Returns:
Tuple of ``(verdict, gap)`` where *verdict* is ``"PASS"`` or
``"FAIL"`` and *gap* is a one-sentence explanation of what was wrong.
Returns ``("FAIL", ...)`` on any parse failure.
"""
clean = re.sub(r"```(?:json)?\s*", "", raw, flags=re.DOTALL).strip()
start = clean.find("{")
end = clean.rfind("}") + 1
if start == -1 or end == 0:
return "FAIL", "Could not parse response from judge."
try:
data = json.loads(clean[start:end])
except json.JSONDecodeError:
return "FAIL", "Could not parse response from judge."
verdict = str(data.get("verdict", "FAIL")).upper()
if verdict not in {"PASS", "FAIL"}:
verdict = "FAIL"
gap = str(data.get("gap", "No specific gap identified."))
return verdict, gap
class Verifier:
"""Runs Socratic lessons: user explains first, then the LLM judges.

View File

@ -0,0 +1,142 @@
"""Prompts and source-snippet helpers for the lesson verifier.
Split out of :mod:`python_pkg.code_tutor._verifier` to keep it under the
250-line cap. ``Verifier`` itself stays there, because that is the name
``cli`` and ``_session`` construct and the tests patch.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from python_pkg.code_tutor._analyzer import CodeItem
_MAX_ATTEMPTS = 3
_SYSTEM_PROMPT = (
"You are a fair but rigorous code tutor assessing whether a student"
" genuinely understands a piece of code.\n\n"
"Grade their explanation PASS or FAIL.\n\n"
"PASS when the student demonstrates understanding of:\n"
" - what problem the code solves (purpose)\n"
" - what goes in (inputs/parameters and their roles)\n"
" - what comes out or happens (return value or side effect)\n"
" - any key behavior that would surprise a reader"
" (edge cases, error handling, non-obvious constraints)\n\n"
"You do NOT require verbatim phrasing from the code or docstring.\n"
"Correct paraphrasing counts as understanding.\n"
"ONLY cite a gap if it is genuinely absent from the student's answer"
" -- do NOT cite a gap the student already covered in different words.\n\n"
"FAIL only if the student is vague, factually wrong, or missed a key"
" behavioral detail that materially changes how the code is used.\n"
'"It does some stuff" = FAIL. "It processes data" = FAIL.'
' But "it reads the first 512 bytes and checks for a null byte" = PASS'
" on the behavior criterion, even if they didn't mention OSError.\n\n"
"Respond with valid JSON only, no other text:\n"
'{"verdict": "PASS" | "FAIL",'
' "gap": "<one sentence on the specific missing piece, or empty string on PASS>"}'
)
_EXPLAIN_SYSTEM = (
"You are a code tutor."
" Explain the following code clearly and concisely in 3-5 sentences."
)
_QUESTIONS: tuple[tuple[str, str], ...] = (
("Purpose", "What does this code do?"),
("Inputs", "What are the inputs (parameters / arguments)?"),
("Outputs", "What does it output or do as a side effect?"),
("Why", "Why does it exist? What problem does it solve?"),
)
_IMPROVEMENT_Q = "What would you improve or simplify here? (Enter to skip)"
def _class_header(lines: list[str], class_name: str, before_line: int) -> str:
"""Find the class definition for *class_name* and return a short header.
Searches backward from *before_line* for ``class <class_name>``. Returns
the class signature plus up to 4 following lines (docstring / key attrs).
Args:
lines: All source lines of the file (0-indexed).
class_name: Name of the enclosing class.
before_line: 1-based line number of the method; search stops here.
Returns:
A short string summary of the class, or ``""`` if not found.
"""
pattern = re.compile(rf"^class\s+{re.escape(class_name)}\b")
for i in range(min(before_line - 1, len(lines)) - 1, -1, -1):
if pattern.match(lines[i]):
snippet_end = min(i + 5, before_line - 1)
return "\n".join(lines[i:snippet_end])
return ""
def _read_snippet(item: CodeItem, codebase_path: str) -> str:
"""Read source lines for *item* from *codebase_path*.
When the item belongs to a class, a short class header is prepended so
the user has context about what ``self`` refers to.
Args:
item: Code item whose file and line range to read.
codebase_path: Absolute path of the codebase root.
Returns:
The extracted source lines as a single string, or a placeholder
message when the file cannot be read.
"""
method_src = ""
try:
text = (Path(codebase_path) / item.file).read_text(
encoding="utf-8", errors="replace"
)
lines = text.splitlines()
start = max(0, item.start_line - 1)
end = min(len(lines), item.end_line)
method_src = "\n".join(lines[start:end])
if item.class_name:
header = _class_header(lines, item.class_name, item.start_line)
if header:
return (
f"# class {item.class_name} (context):\n{header}"
f"\n\n# method:\n{method_src}"
)
except OSError:
return f"(source unavailable for {item.file})"
return method_src
def _parse_verdict(raw: str) -> tuple[str, str]:
"""Parse the LLM's JSON verdict, tolerating markdown code fences.
Args:
raw: Raw text returned by the judge LLM.
Returns:
Tuple of ``(verdict, gap)`` where *verdict* is ``"PASS"`` or
``"FAIL"`` and *gap* is a one-sentence explanation of what was wrong.
Returns ``("FAIL", ...)`` on any parse failure.
"""
clean = re.sub(r"```(?:json)?\s*", "", raw, flags=re.DOTALL).strip()
start = clean.find("{")
end = clean.rfind("}") + 1
if start == -1 or end == 0:
return "FAIL", "Could not parse response from judge."
try:
data = json.loads(clean[start:end])
except json.JSONDecodeError:
return "FAIL", "Could not parse response from judge."
verdict = str(data.get("verdict", "FAIL")).upper()
if verdict not in {"PASS", "FAIL"}:
verdict = "FAIL"
gap = str(data.get("gap", "No specific gap identified."))
return verdict, gap

View File

@ -9,12 +9,8 @@ Commands:
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import time
from typing import cast
import requests
from rich.console import Console
@ -22,11 +18,14 @@ from rich.table import Table
import typer
from python_pkg.code_tutor._analyzer import extract_items
from python_pkg.code_tutor._deps import codebase_fingerprint
from python_pkg.code_tutor._cli_checks import (
_ensure_fresh_plan,
_ensure_ollama_running,
_find_codebase_for_file,
)
from python_pkg.code_tutor._llm import OllamaBackend
from python_pkg.code_tutor._plan_builder import build_plan
from python_pkg.code_tutor._progress import (
PlanData,
append_session_record,
config_dir,
item_from_data,
@ -44,91 +43,6 @@ _OLLAMA_API = "http://localhost:11434/api/tags"
_OLLAMA_START_TIMEOUT = 30
def _ensure_ollama_running(console: Console) -> bool:
"""Start the Ollama systemd service if it is not already reachable.
Tries ``systemctl start ollama`` and polls the API for up to
``_OLLAMA_START_TIMEOUT`` seconds.
Args:
console: Rich console for status messages.
Returns:
True when Ollama is reachable, False after timeout.
"""
try:
requests.get(_OLLAMA_API, timeout=2)
except requests.exceptions.RequestException:
pass
else:
return True
console.print("[yellow]Ollama not running -- starting via systemctl...[/yellow]")
try:
subprocess.run(
["systemctl", "start", "ollama"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
console.print(
f"[red]systemctl start ollama failed: {exc.stderr.decode().strip()}[/red]"
)
return False
deadline = time.monotonic() + _OLLAMA_START_TIMEOUT
while time.monotonic() < deadline:
try:
requests.get(_OLLAMA_API, timeout=2)
except requests.exceptions.RequestException:
time.sleep(1)
else:
console.print("[green]Ollama is up.[/green]")
return True
console.print("[red]Ollama did not become ready in time.[/red]")
return False
def _ensure_fresh_plan(codebase: Path, plan: PlanData, console: Console) -> PlanData:
"""Rebuild *plan* if the codebase source files have changed since it was created.
Compares the plan's stored fingerprint against the current one. When they
differ, re-runs the full analyze pipeline, saves the new plan to disk, and
returns it. Plans without a stored fingerprint (created before this feature)
are returned unchanged.
Args:
codebase: Root directory of the codebase.
plan: Plan dict loaded from ``plan.json``.
console: Rich console for status messages.
Returns:
The original plan when up-to-date, or a freshly built ``PlanData`` when
the fingerprint has changed.
"""
saved = plan.get("source_fingerprint", "")
if not saved:
return plan
current = codebase_fingerprint(codebase)
if current == saved:
return plan
console.print(
"[yellow]Plan is stale -- source files changed. Rebuilding...[/yellow]"
)
items = extract_items(codebase)
if not items:
console.print("[red]No extractable items found -- keeping existing plan.[/red]")
return plan
new_plan = build_plan(codebase, items)
save_plan(codebase, new_plan)
n = new_plan["total_items"]
console.print(f"[green]Plan rebuilt: {n} items.[/green]")
return cast("PlanData", new_plan)
@app.command()
def analyze(
codebase: Path = typer.Argument(
@ -304,43 +218,5 @@ def drill(
append_session_record(codebase, record)
def _find_codebase_for_file(file: Path) -> Path | None:
"""Search all saved plans and return the codebase that contains *file*.
Args:
file: Absolute path to the source file.
Returns:
The codebase ``Path`` whose plan contains *file*, or ``None``.
"""
config_root = Path.home() / ".config" / "code_tutor"
if not config_root.exists():
return None
for plan_file in sorted(config_root.glob("*/plan.json")):
result = _check_plan_file(plan_file, file)
if result is not None:
return result
return None
def _check_plan_file(plan_file: Path, file: Path) -> Path | None:
"""Return the codebase path from *plan_file* if it contains *file*, else None.
Args:
plan_file: Path to a ``plan.json`` file.
file: Absolute path to look up.
Returns:
The codebase ``Path`` when *file* is relative to it, or ``None``.
"""
try:
data = json.loads(plan_file.read_text(encoding="utf-8"))
codebase = Path(str(data.get("codebase_path", "")))
file.relative_to(codebase)
except (ValueError, KeyError, OSError):
return None
return codebase
if __name__ == "__main__":
sys.exit(app())

View File

@ -9,13 +9,13 @@ from unittest.mock import MagicMock, patch
import requests
from typer.testing import CliRunner
from python_pkg.code_tutor.cli import (
from python_pkg.code_tutor._cli_checks import (
_check_plan_file,
_ensure_fresh_plan,
_ensure_ollama_running,
_find_codebase_for_file,
app,
)
from python_pkg.code_tutor.cli import app
if TYPE_CHECKING:
from pathlib import Path
@ -58,22 +58,38 @@ def _make_plan(codebase: Path, fingerprint: str = "abc") -> dict:
def test_ensure_ollama_running_already_up() -> None:
mock_console = MagicMock()
mock_resp = MagicMock()
with patch("python_pkg.code_tutor.cli.requests.get", return_value=mock_resp):
with patch(
"python_pkg.code_tutor._cli_checks.requests.get", return_value=mock_resp
):
result = _ensure_ollama_running(mock_console)
assert result is True
def test_ensure_ollama_running_no_systemctl() -> None:
"""A machine without systemctl should say so, not raise."""
mock_console = MagicMock()
with (
patch(
"python_pkg.code_tutor._cli_checks.requests.get",
side_effect=requests.exceptions.ConnectionError(),
),
patch("python_pkg.code_tutor._cli_checks.shutil.which", return_value=None),
):
result = _ensure_ollama_running(mock_console)
assert result is False
def test_ensure_ollama_running_systemctl_fails() -> None:
import subprocess
mock_console = MagicMock()
with (
patch(
"python_pkg.code_tutor.cli.requests.get",
"python_pkg.code_tutor._cli_checks.requests.get",
side_effect=requests.exceptions.ConnectionError(),
),
patch(
"python_pkg.code_tutor.cli.subprocess.run",
"python_pkg.code_tutor._cli_checks.subprocess.run",
side_effect=subprocess.CalledProcessError(1, "systemctl", stderr=b"failed"),
),
):
@ -93,9 +109,12 @@ def test_ensure_ollama_running_starts_then_up() -> None:
return MagicMock()
with (
patch("python_pkg.code_tutor.cli.requests.get", side_effect=fake_get),
patch("python_pkg.code_tutor.cli.subprocess.run", return_value=mock_subprocess),
patch("python_pkg.code_tutor.cli.time.sleep"),
patch("python_pkg.code_tutor._cli_checks.requests.get", side_effect=fake_get),
patch(
"python_pkg.code_tutor._cli_checks.subprocess.run",
return_value=mock_subprocess,
),
patch("python_pkg.code_tutor._cli_checks.time.sleep"),
):
result = _ensure_ollama_running(mock_console)
assert result is True
@ -113,13 +132,16 @@ def test_ensure_ollama_running_times_out() -> None:
raise requests.exceptions.ConnectionError
with (
patch("python_pkg.code_tutor.cli.requests.get", side_effect=fake_get),
patch("python_pkg.code_tutor.cli.subprocess.run", return_value=mock_subprocess),
patch("python_pkg.code_tutor._cli_checks.requests.get", side_effect=fake_get),
patch(
"python_pkg.code_tutor.cli.time.monotonic",
"python_pkg.code_tutor._cli_checks.subprocess.run",
return_value=mock_subprocess,
),
patch(
"python_pkg.code_tutor._cli_checks.time.monotonic",
side_effect=time_values,
),
patch("python_pkg.code_tutor.cli.time.sleep"),
patch("python_pkg.code_tutor._cli_checks.time.sleep"),
):
result = _ensure_ollama_running(mock_console)
assert result is False
@ -146,7 +168,9 @@ def test_ensure_fresh_plan_no_fingerprint(tmp_path: Path) -> None:
def test_ensure_fresh_plan_up_to_date(tmp_path: Path) -> None:
plan = _make_plan(tmp_path, fingerprint="same")
mock_console = MagicMock()
with patch("python_pkg.code_tutor.cli.codebase_fingerprint", return_value="same"):
with patch(
"python_pkg.code_tutor._cli_checks.codebase_fingerprint", return_value="same"
):
result = _ensure_fresh_plan(tmp_path, plan, mock_console)
assert result is plan
@ -155,8 +179,10 @@ def test_ensure_fresh_plan_stale_no_items(tmp_path: Path) -> None:
plan = _make_plan(tmp_path, fingerprint="old")
mock_console = MagicMock()
with (
patch("python_pkg.code_tutor.cli.codebase_fingerprint", return_value="new"),
patch("python_pkg.code_tutor.cli.extract_items", return_value=[]),
patch(
"python_pkg.code_tutor._cli_checks.codebase_fingerprint", return_value="new"
),
patch("python_pkg.code_tutor._cli_checks.extract_items", return_value=[]),
):
result = _ensure_fresh_plan(tmp_path, plan, mock_console)
assert result is plan
@ -169,10 +195,14 @@ def test_ensure_fresh_plan_stale_rebuilds(tmp_path: Path) -> None:
mock_items = [MagicMock()]
with (
patch("python_pkg.code_tutor.cli.codebase_fingerprint", return_value="new"),
patch("python_pkg.code_tutor.cli.extract_items", return_value=mock_items),
patch("python_pkg.code_tutor.cli.build_plan", return_value=new_plan),
patch("python_pkg.code_tutor.cli.save_plan"),
patch(
"python_pkg.code_tutor._cli_checks.codebase_fingerprint", return_value="new"
),
patch(
"python_pkg.code_tutor._cli_checks.extract_items", return_value=mock_items
),
patch("python_pkg.code_tutor._cli_checks.build_plan", return_value=new_plan),
patch("python_pkg.code_tutor._cli_checks.save_plan"),
):
result = _ensure_fresh_plan(tmp_path, plan, mock_console)
assert result["source_fingerprint"] == "new"

View File

@ -6,8 +6,8 @@ from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.code_tutor._analyzer import CodeItem
from python_pkg.code_tutor._verifier import (
Verifier,
from python_pkg.code_tutor._verifier import Verifier
from python_pkg.code_tutor._verifier_support import (
_class_header,
_parse_verdict,
_read_snippet,