coverage.capture

The per-board capture.json artifact (spec §3).

A capture stores line/branch data in committed-code coordinates, anchored to the base_commit whose numbering they mean, with per-file blob SHAs as the rebase-tolerant validity anchor.

otto.coverage.capture.model.CAPTURE_FORMAT_VERSION = 2

capture.json schema version, bumped on breaking on-disk changes.

Version 2 renames the git-anchor field from pin to base_commit (JSON key and Python attribute alike) — clearer terminology for the commit whose line numbering a capture’s coordinates mean. There is no migration shim: a file that does not declare this exact version fails loud in Capture.load() with a message telling the caller to re-capture, rather than silently mis-reading the renamed key under its old name (or, worse, tripping pydantic’s extra="forbid" on the now-unknown pin key with an unfriendly traceback).

class otto.coverage.capture.model.CaptureFileCov(*, blob: str | None = None, lines: dict[int, int] = <factory>, branches: dict[int, list[tuple[int, int, int | None]]] = <factory>)

Bases: BaseModel

Coverage for one source file, keyed in base_commit coordinates.

blob : str | None
lines : dict[int, int]
branches : dict[int, list[tuple[int, int, int | None]]]
class otto.coverage.capture.model.Capture(*, schema: int = 2, tier: str, base_commit: str, dirty_remap: bool = False, captured_at: str = '', tester: dict[str, str] | None = None, ticket: str | None = None, note: str | None = None, labs: list[str] = <factory>, board: str = '', display_name: str | None = None, files: dict[str, ~otto.coverage.capture.model.CaptureFileCov] = <factory>)

Bases: BaseModel

One board’s retrieval result — the universal capture artifact.

schema_version : int
tier : str
base_commit : str
dirty_remap : bool
captured_at : str
tester : dict[str, str] | None
ticket : str | None
note : str | None
labs : list[str]
board : str
display_name : str | None
files : dict[str, CaptureFileCov]
save(path: Path) None

Serialize this capture to path as indented JSON (by alias).

classmethod load(path: Path) Capture

Deserialize a Capture from path (unknown keys rejected).

Raises:

ValueError – The file’s "schema" key does not match CAPTURE_FORMAT_VERSION (including files with no "schema" key at all — everything predating this capture-format version). There is no migration shim; the message tells the caller to re-capture.

otto.coverage.capture.model.parse_info(info_path: Path) dict[str, tuple[dict[int, int], dict[int, list[tuple[int, int, int | None]]]]]

Minimal SF/DA/BRDA parser: source path → (line hits, branch triples).

Branch triples are (block, branch, taken) where taken is None for lcov’s - (branch never reached) and an int count otherwise — preserving the never-reached/reached-but-not-taken distinction through to the capture file.

otto.coverage.capture.model.build_capture(*, info_path: Path, tier: str, repo_root: Path, board: str, labs: list[str], tester: dict[str, str] | None = None, ticket: str | None = None, note: str | None = None, display_name: str | None = None, now: datetime | None = None) Capture

Build a Capture anchored to base_commit from an lcov .info file.

Parameters:

display_name – Host display name to annotate onto the capture; board stays the staging-dir/host-id name.

Map line numbers across a -U0 unified diff.

One engine, two uses (spec §6): retrieval-time dirty-tree correction (NEW = modified working tree → OLD = HEAD) and report-time manual validity (OLD = capture anchored at base_commit → NEW = current source). Lines inside a hunk on either side have no counterpart and map to None.

class otto.coverage.capture.remap.Hunk(old_start: int, old_count: int, new_start: int, new_count: int)

Bases: object

One @@ -a,b +c,d @@ header (counts default to 1 when omitted).

old_start : int
old_count : int
new_start : int
new_count : int
otto.coverage.capture.remap.parse_u0_hunks(diff_text: str) list[Hunk]

Parse hunk headers out of a single-file -U0 diff.

class otto.coverage.capture.remap.LineRemapper(hunks: list[Hunk])

Bases: object

Bidirectional line mapping across the hunks of one file’s diff.

For each hunk, “has line moved past this hunk yet” is decided by a threshold computed from the other side’s fields translated through the cumulative offset – not from this hunk’s own same-side start + count. That distinction only matters for a zero-count side (a pure insertion has old_count == 0; a pure deletion has new_count == 0): git’s “position is the line before” convention means a zero-count side’s own start is not a reliable same-side boundary marker (some hunks encode it as old_start + offset, the line right at the gap; others as one less, the last untouched line before the gap – both occur in practice). Translating the always- unambiguous opposite side through the running offset sidesteps the ambiguity entirely: old_start + old_count (resp. new_start + new_count) is well-defined regardless of which convention the other side’s zero-count marker happens to follow.

new_to_old(line: int) int | None

OLD-side line for NEW-side line, or None inside a changed hunk.

old_to_new(line: int) int | None

NEW-side line for OLD-side line, or None when changed/deleted.

Thin subprocess wrappers around the git plumbing coverage needs.

Everything is synchronous and side-effect-free on the repo (read-only commands only). Callers pass the sut repo root; a non-repo raises GitUnavailableError with a clean message.

exception otto.coverage.capture.gitio.GitUnavailableError

Bases: RuntimeError

Raised when git cannot answer (not a repo / git missing).

otto.coverage.capture.gitio.head_commit(repo_root: Path) str

Return the current HEAD commit SHA.

otto.coverage.capture.gitio.is_dirty(repo_root: Path) bool

Return True if there are uncommitted changes.

otto.coverage.capture.gitio.blob_sha(repo_root: Path, relpath: Path, rev: str = 'HEAD') str | None

Return the SHA of a blob at a path/revision, or None if not found.

The ./ prefix makes git resolve the path against repo_root (our cwd) — a bare REV:<path> resolves against the repo toplevel, which is wrong whenever repo_root is a subdirectory of a larger repo (e.g. a sut checked out inside another project).

otto.coverage.capture.gitio.hash_object(repo_root: Path, path: Path) str

Return the SHA1 hash of a file object.

otto.coverage.capture.gitio.blob_exists(repo_root: Path, sha: str) bool

Return True if a blob exists in the repository.

otto.coverage.capture.gitio.cat_blob(repo_root: Path, sha: str) bytes

Return the contents of a blob.

otto.coverage.capture.gitio.diff_worktree_file_u0(repo_root: Path, relpath: Path) str

Return unified diff (U0, whitespace-insensitive) of HEAD vs worktree file.

-w (--ignore-all-space) suppresses whitespace-only line modifications so a reformat/reindent does not invalidate manual coverage anchored to the untouched code. Safe for the line remapper: a whitespace-only modification is 1 line -> 1 line and shifts no counts, so hiding it loses no hunk-offset information (unlike --ignore-blank-lines, which would hide count-changing hunks). The SUTs are C/C++, where intra-string whitespace is not coverage- relevant, so the one behavioural case -w also equates (spacing inside a string literal) is an accepted, immaterial false-valid.

otto.coverage.capture.gitio.diff_no_index_u0(path_a: Path, path_b: Path) str

Return unified diff (U0, whitespace-insensitive) between two files outside a repo.

-w matches diff_worktree_file_u0() so the report-time anchor chain (base_commit blob vs current file) ignores whitespace-only edits the same way the dirty-tree remap does. git diff --no-index exits 1 when the files differ — that is success here; with -w a whitespace-only difference exits 0 with empty output (hunkless), which the remapper treats as verbatim.

The committed in-repo manual-capture store (spec §3).

otto.coverage.capture.store_dir.manual_store_dir(repo_root: Path) Path

Directory storing committed manual capture records.

otto.coverage.capture.store_dir.write_manual_capture(capture: Capture, repo_root: Path) Path

Write a capture to the manual store and return its path.

otto.coverage.capture.store_dir.load_manual_captures(repo_root: Path) list[Capture]

Load all manual captures from repo_root, sorted by filename.

Per-board capture.json production from fetched .gcda counters.

Turns the raw .gcda counters collected by otto test --cov into a Capture per board, anchored to base_commit. For each board directory under cov_dir this:

  1. Resolves the board’s toolchain and gcno (build) source root from the .otto_cov_meta.json sidecar via the otto.coverage.reporter helpers.

  2. Runs capture() for that board alone, producing <board>/board.info.

  3. Auto-discovers path mappings and rewrites the embedded SF: paths to their local, repo_root-relative form, producing <board>/board.resolved.info.

  4. Builds and saves <board>/capture.json via build_capture().

The raw .gcda, board.info, and board.resolved.info all stay on disk as debug artifacts (spec decision 18).

async otto.coverage.capture.produce.produce_captures(cov_dir: Path, *, tier: str, repo_root: Path, labs: list[str], tester: dict[str, str] | None = None, ticket: str | None = None, note: str | None = None, display_names: dict[str, str] | None = None) list[Path]

Produce a capture.json anchored to base_commit for each board dir under cov_dir.

A board dir is any direct subdirectory of cov_dir containing at least one .gcda file (recursively). Boards with no .gcda files are skipped with a warning.

Parameters:
  • cov_dir – Coverage directory written by otto test --cov, containing per-board subdirs and a .otto_cov_meta.json sidecar.

  • tier – Coverage tier name to annotate onto each capture.

  • repo_root – SUT git repo root, used for base_commit/blob resolution and as the path-remapping target.

  • labs – Lab identifiers to annotate onto each capture.

  • tester – Optional tester identity to annotate onto each capture.

  • ticket – Optional ticket reference to annotate onto each capture.

  • note – Optional free-text note to annotate onto each capture.

  • display_names – Board-dir name (host id) → host display name; boards without an entry are annotated None.

Returns:

Paths of the capture.json files written, one per board, in board-name sort order.

Raises:

otto.coverage.capture.gitio.GitUnavailableError – If repo_root is not a git repository.