Skip to content

grip_hook.study

grip_hook.study

Anonymised quiz history across repositories, for study platforms.

Every graded quiz is appended to <git dir>/grip/history.jsonl and the repository's git dir is recorded in a registry under the grip data directory ($XDG_DATA_HOME/grip, default ~/.local/share/grip; GRIP_DATA_HOME overrides both). grip study export reads the history of every registered repository and writes one JSON document with scores only: no diff, no file paths, no question text, no rubrics, no answers, no feedback, no repository names. Nothing in this module talks to the network; the developer uploads the file themselves, if at all.

DATA_HOME_ENV module-attribute

DATA_HOME_ENV = 'GRIP_DATA_HOME'

Environment variable that overrides the data directory (used by tests).

EXPORT_VERSION module-attribute

EXPORT_VERSION = 1

Schema version written to the export document.

DEFAULT_SINCE_DAYS module-attribute

DEFAULT_SINCE_DAYS = 45

How far back grip study export looks by default.

INCLUDED_FIELDS module-attribute

INCLUDED_FIELDS: tuple[str, ...] = ('id (hash of installation id, diff digest and time)', 'repo (hash of installation id and repository path)', 'created_at', 'stage', 'provider', 'model', 'passing_score', 'score', 'passed', 'grades[].focus (the short label of what each question tested)', 'grades[].score')

What each exported quiz contains, for grip study status.

EXCLUDED_FIELDS module-attribute

EXCLUDED_FIELDS: tuple[str, ...] = ('diff content', 'file paths', 'repository names or paths', 'question text', 'rubrics', 'your answers', 'feedback text', 'verdict text', 'the change summary')

What never leaves the machine, for grip study status.

Registry

The list of repositories that have been quizzed on this machine.

Stored as repos.json next to installation-id. Only absolute git dir paths are kept, nothing else.

Source code in src/grip_hook/study.py
class Registry:
    """The list of repositories that have been quizzed on this machine.

    Stored as ``repos.json`` next to ``installation-id``. Only absolute git dir paths are
    kept, nothing else.
    """

    def __init__(self, home: Path | None = None) -> None:
        self.home = home or data_home()
        self.path = self.home / "repos.json"
        self.id_path = self.home / "installation-id"

    def list(self) -> _Paths:
        """Registered git dirs, sorted, whether or not they still exist."""
        try:
            data = json.loads(self.path.read_text("utf-8"))
        except (OSError, json.JSONDecodeError):
            return []
        repos = data.get("repos", []) if isinstance(data, dict) else []
        paths = {Path(p) for p in repos if isinstance(p, str) and Path(p).is_absolute()}
        return sorted(paths)

    def _save(self, paths: _Paths) -> None:
        self.home.mkdir(parents=True, exist_ok=True)
        payload = {"version": 1, "repos": [str(p) for p in sorted(set(paths))]}
        self.path.write_text(json.dumps(payload, indent=2) + "\n", "utf-8")

    def add(self, git_dir: Path) -> None:
        """Record ``git_dir`` (made absolute). Adding an existing entry is a no-op."""
        resolved = git_dir.resolve()
        paths = self.list()
        if resolved in paths:
            return
        self._save([*paths, resolved])

    def prune(self) -> _Paths:
        """Drop entries whose directory no longer exists and return what is left."""
        paths = self.list()
        kept = [p for p in paths if p.is_dir()]
        if kept != paths:
            self._save(kept)
        return kept

    def installation_id(self) -> str:
        """A random UUID for this machine, created on first use and then stable."""
        try:
            existing = self.id_path.read_text("utf-8").strip()
            return str(uuid.UUID(existing))
        except (OSError, ValueError):
            pass
        fresh = str(uuid.uuid4())
        self.home.mkdir(parents=True, exist_ok=True)
        self.id_path.write_text(fresh + "\n", "utf-8")
        return fresh

list

list() -> _Paths

Registered git dirs, sorted, whether or not they still exist.

Source code in src/grip_hook/study.py
def list(self) -> _Paths:
    """Registered git dirs, sorted, whether or not they still exist."""
    try:
        data = json.loads(self.path.read_text("utf-8"))
    except (OSError, json.JSONDecodeError):
        return []
    repos = data.get("repos", []) if isinstance(data, dict) else []
    paths = {Path(p) for p in repos if isinstance(p, str) and Path(p).is_absolute()}
    return sorted(paths)

add

add(git_dir: Path) -> None

Record git_dir (made absolute). Adding an existing entry is a no-op.

Source code in src/grip_hook/study.py
def add(self, git_dir: Path) -> None:
    """Record ``git_dir`` (made absolute). Adding an existing entry is a no-op."""
    resolved = git_dir.resolve()
    paths = self.list()
    if resolved in paths:
        return
    self._save([*paths, resolved])

prune

prune() -> _Paths

Drop entries whose directory no longer exists and return what is left.

Source code in src/grip_hook/study.py
def prune(self) -> _Paths:
    """Drop entries whose directory no longer exists and return what is left."""
    paths = self.list()
    kept = [p for p in paths if p.is_dir()]
    if kept != paths:
        self._save(kept)
    return kept

installation_id

installation_id() -> str

A random UUID for this machine, created on first use and then stable.

Source code in src/grip_hook/study.py
def installation_id(self) -> str:
    """A random UUID for this machine, created on first use and then stable."""
    try:
        existing = self.id_path.read_text("utf-8").strip()
        return str(uuid.UUID(existing))
    except (OSError, ValueError):
        pass
    fresh = str(uuid.uuid4())
    self.home.mkdir(parents=True, exist_ok=True)
    self.id_path.write_text(fresh + "\n", "utf-8")
    return fresh

GradeSummary

Bases: BaseModel

What one question tested and how many points it earned.

Source code in src/grip_hook/study.py
class GradeSummary(BaseModel):
    """What one question tested and how many points it earned."""

    focus: str
    score: int

QuizSummary

Bases: BaseModel

One quiz, reduced to scores and opaque identifiers.

Source code in src/grip_hook/study.py
class QuizSummary(BaseModel):
    """One quiz, reduced to scores and opaque identifiers."""

    id: str
    repo: str
    created_at: datetime
    stage: Stage
    provider: str
    model: str
    passing_score: int
    score: int
    passed: bool
    grades: list[GradeSummary]

Export

Bases: BaseModel

The document grip study export writes.

Source code in src/grip_hook/study.py
class Export(BaseModel):
    """The document ``grip study export`` writes."""

    version: int = EXPORT_VERSION
    generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
    installation_id: str
    grip_version: str = __version__
    quizzes: list[QuizSummary]

    @property
    def repo_count(self) -> int:
        """How many distinct repositories the quizzes come from."""
        return len({q.repo for q in self.quizzes})

repo_count property

repo_count: int

How many distinct repositories the quizzes come from.

data_home

data_home(env: Mapping[str, str] | None = None) -> Path

The directory holding repos.json and the installation id.

Parameters:

Name Type Description Default
env Mapping[str, str] | None

Environment mapping. Defaults to :data:os.environ.

None
Source code in src/grip_hook/study.py
def data_home(env: Mapping[str, str] | None = None) -> Path:
    """The directory holding ``repos.json`` and the installation id.

    Args:
        env: Environment mapping. Defaults to :data:`os.environ`.
    """
    environ = os.environ if env is None else env
    override = environ.get(DATA_HOME_ENV)
    if override:
        return Path(override)
    xdg = environ.get("XDG_DATA_HOME")
    base = Path(xdg) if xdg else Path.home() / ".local" / "share"
    return base / "grip"

repo_id

repo_id(installation_id: str, git_dir: Path) -> str

Opaque, per-machine identifier for a repository.

Source code in src/grip_hook/study.py
def repo_id(installation_id: str, git_dir: Path) -> str:
    """Opaque, per-machine identifier for a repository."""
    return _digest(installation_id, str(git_dir), length=16)

summarise

summarise(report: Report, installation_id: str, git_dir: Path) -> QuizSummary

Reduce report to the fields that may leave the machine.

Source code in src/grip_hook/study.py
def summarise(report: Report, installation_id: str, git_dir: Path) -> QuizSummary:
    """Reduce ``report`` to the fields that may leave the machine."""
    created_at = report.created_at
    if created_at.tzinfo is None:
        created_at = created_at.replace(tzinfo=UTC)
    focus = {i: q.focus for i, q in enumerate(report.questions)}
    grades = [
        GradeSummary(focus=focus.get(g.question_index, ""), score=g.score) for g in report.grades
    ]
    return QuizSummary(
        id=_digest(installation_id, report.diff_digest, created_at.isoformat(), length=32),
        repo=repo_id(installation_id, git_dir),
        created_at=created_at,
        stage=report.stage,
        provider=report.provider,
        model=report.model,
        passing_score=report.passing_score,
        score=report.score,
        passed=report.passed,
        grades=grades,
    )

read_history

read_history(git_dir: Path) -> list[Report]

Parse <git dir>/grip/history.jsonl. Unreadable or corrupt lines are skipped.

Source code in src/grip_hook/study.py
def read_history(git_dir: Path) -> list[Report]:
    """Parse ``<git dir>/grip/history.jsonl``. Unreadable or corrupt lines are skipped."""
    path = git_dir / "grip" / "history.jsonl"
    try:
        text = path.read_text("utf-8")
    except OSError:
        return []
    reports: list[Report] = []
    for line in text.splitlines():
        if not line.strip():
            continue
        try:
            reports.append(Report.model_validate_json(line))
        except ValidationError:
            continue
    return reports

build_export

build_export(registry: Registry, since: timedelta | None, now: datetime | None = None) -> Export

Collect every registered repository's history into one :class:Export.

Parameters:

Name Type Description Default
registry Registry

Where to find the repositories and the installation id.

required
since timedelta | None

Only quizzes newer than now - since; None for all of them.

required
now datetime | None

Reference time. Defaults to the current UTC time.

None
Source code in src/grip_hook/study.py
def build_export(
    registry: Registry,
    since: timedelta | None,
    now: datetime | None = None,
) -> Export:
    """Collect every registered repository's history into one :class:`Export`.

    Args:
        registry: Where to find the repositories and the installation id.
        since: Only quizzes newer than ``now - since``; ``None`` for all of them.
        now: Reference time. Defaults to the current UTC time.
    """
    now = now or datetime.now(UTC)
    cutoff = None if since is None else now - since
    installation_id = registry.installation_id()
    quizzes: list[QuizSummary] = []
    for git_dir in registry.prune():
        for report in read_history(git_dir):
            summary = summarise(report, installation_id, git_dir)
            if cutoff is not None and summary.created_at < cutoff:
                continue
            quizzes.append(summary)
    quizzes.sort(key=lambda q: q.created_at)
    return Export(installation_id=installation_id, quizzes=quizzes, generated_at=now)