mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading `meta/` against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites these three knobs. What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC` is now correct (UP017), and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at 3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already stopped obeying it. ruff, ruff-format, mypy --strict and pylint all clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
237 lines
8.8 KiB
Python
237 lines
8.8 KiB
Python
"""Read Claude Code transcripts into :class:`Session` objects.
|
|
|
|
Transcripts are JSONL under ``~/.claude/projects/<slug>/<session-id>.jsonl``,
|
|
one JSON object per line, appended live. Three properties of that format drive
|
|
the design here:
|
|
|
|
1. **Lines can be malformed.** A transcript being written while it is read can
|
|
end in a partial line, and older sessions contain records this package does
|
|
not model. Every line is therefore parsed defensively and skipped on error;
|
|
losing one line is always better than aborting a weekly report.
|
|
2. **A tool call and its result are in different messages.** ``tool_use`` blocks
|
|
carry the name and input, ``tool_result`` blocks carry the payload and only a
|
|
``tool_use_id`` back-reference. Attributing result size to the right tool
|
|
requires holding an id map for the length of the file.
|
|
3. **Order matters.** The image-cost model needs to know how many turns followed
|
|
each image, so :func:`iter_events` preserves the interleaving rather than
|
|
returning separate lists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from python_pkg.token_audit.model import IMAGE_SUFFIXES, Session, ToolCall, Turn
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
# Divisor turning rendered characters into an approximate token count. Four is
|
|
# the usual English-text rule of thumb and is only ever used for ranking tools,
|
|
# never for the reconciled totals.
|
|
CHARS_PER_TOKEN = 4
|
|
|
|
# Median billed size of one screenshot, measured from real transcripts by
|
|
# isolating turns whose only new content was a single image (n=484, median
|
|
# 2,566, p90 2,692). Anthropic bills images by pixel area, so a constant is a
|
|
# far better estimate than the base64 payload length, which is ~20x too high.
|
|
# Halving a screenshot's dimensions cuts this roughly fourfold.
|
|
IMAGE_TOKENS = 2566
|
|
|
|
|
|
def _load_line(line: str) -> dict[str, Any] | None:
|
|
"""Parse one JSONL line, returning ``None`` if it is not a usable record."""
|
|
try:
|
|
record = json.loads(line)
|
|
except ValueError, UnicodeDecodeError:
|
|
return None
|
|
return record if isinstance(record, dict) else None
|
|
|
|
|
|
def _blocks(record: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Return the content blocks of a record's message, if it has any."""
|
|
message = record.get("message")
|
|
if not isinstance(message, dict):
|
|
return []
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
return []
|
|
return [block for block in content if isinstance(block, dict)]
|
|
|
|
|
|
def _usage(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int]] | None:
|
|
"""Return an assistant record's message and its integer usage fields.
|
|
|
|
The message is returned alongside the usage block so callers that need both
|
|
(the model id lives on the message) never have to re-validate its type.
|
|
"""
|
|
message = record.get("message")
|
|
if not isinstance(message, dict):
|
|
return None
|
|
usage = message.get("usage")
|
|
if not isinstance(usage, dict):
|
|
return None
|
|
return message, {k: v for k, v in usage.items() if isinstance(v, int)}
|
|
|
|
|
|
def _model(message: dict[str, Any]) -> str:
|
|
"""Return the model id that served a record, or a placeholder.
|
|
|
|
Only ever called after :func:`_usage` has confirmed the message is a dict,
|
|
so it takes the message itself rather than re-checking the record.
|
|
"""
|
|
model = message.get("model")
|
|
return model if isinstance(model, str) else "unknown"
|
|
|
|
|
|
def _result_tokens(block: dict[str, Any], *, is_image: bool = False) -> int:
|
|
"""Estimate how many tokens a tool result occupied in context.
|
|
|
|
Text is charged by length, but an **image is not**. An image arrives as a
|
|
base64 blob whose character count has nothing to do with its billed size —
|
|
Anthropic charges by pixel area. Measuring images by payload length
|
|
overstates them by roughly 20x (a 240KB PNG looks like ~82k tokens against
|
|
a real cost near 2.5k), which is enough to make screenshots look like the
|
|
dominant cost driver when they are not. Validated against real transcripts
|
|
by isolating turns whose only new content was one image: the context grew
|
|
~1.6-3k tokens, never the tens of thousands the payload length implied.
|
|
"""
|
|
content = block.get("content")
|
|
if content is None:
|
|
return 0
|
|
if is_image:
|
|
return IMAGE_TOKENS
|
|
return len(json.dumps(content)) // CHARS_PER_TOKEN
|
|
|
|
|
|
def iter_events(path: Path) -> Iterator[tuple[str, object]]:
|
|
"""Yield ``("turn", Turn)`` and ``("tool", ToolCall)`` in transcript order.
|
|
|
|
Emitting a single ordered stream — rather than two lists — is what lets
|
|
:mod:`imagecost` count how many turns each image survived for.
|
|
"""
|
|
pending: dict[str, dict[str, Any]] = {}
|
|
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
for line in handle:
|
|
record = _load_line(line)
|
|
if record is None:
|
|
continue
|
|
for block in _blocks(record):
|
|
kind = block.get("type")
|
|
if kind == "tool_use":
|
|
call_id = block.get("id")
|
|
if isinstance(call_id, str):
|
|
pending[call_id] = block
|
|
elif kind == "tool_result":
|
|
call = _pair_result(block, pending)
|
|
if call is not None:
|
|
yield "tool", call
|
|
found = _usage(record)
|
|
if found is not None:
|
|
message, usage = found
|
|
yield "turn", _build_turn(record, message, usage)
|
|
|
|
|
|
def _pair_result(
|
|
block: dict[str, Any],
|
|
pending: dict[str, dict[str, Any]],
|
|
) -> ToolCall | None:
|
|
"""Join a ``tool_result`` back to the ``tool_use`` that produced it."""
|
|
use = pending.pop(block.get("tool_use_id", ""), None)
|
|
if use is None:
|
|
return None
|
|
tool_input = use.get("input")
|
|
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
path = tool_input.get("file_path")
|
|
skill = tool_input.get("skill")
|
|
call_path = path if isinstance(path, str) else None
|
|
return ToolCall(
|
|
name=str(use.get("name") or "unknown"),
|
|
result_tokens=_result_tokens(block, is_image=_looks_like_image(call_path)),
|
|
path=call_path,
|
|
skill=skill if isinstance(skill, str) else None,
|
|
)
|
|
|
|
|
|
def _looks_like_image(path: str | None) -> bool:
|
|
"""Whether a tool-result path refers to an image, by extension."""
|
|
if path is None:
|
|
return False
|
|
dot = path.rfind(".")
|
|
return dot != -1 and path[dot:].lower() in IMAGE_SUFFIXES
|
|
|
|
|
|
def _build_turn(
|
|
record: dict[str, Any],
|
|
message: dict[str, Any],
|
|
usage: dict[str, int],
|
|
) -> Turn:
|
|
"""Build a :class:`Turn` from an assistant record's usage block."""
|
|
context = usage.get("cache_read_input_tokens", 0) + usage.get(
|
|
"cache_creation_input_tokens",
|
|
0,
|
|
)
|
|
message_id = message.get("id")
|
|
content = message.get("content")
|
|
tool_calls = (
|
|
sum(
|
|
1
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
)
|
|
if isinstance(content, list)
|
|
else 0
|
|
)
|
|
return Turn(
|
|
usage=usage,
|
|
context=context,
|
|
model=_model(message),
|
|
is_sidechain=bool(record.get("isSidechain")),
|
|
message_id=message_id if isinstance(message_id, str) else "",
|
|
tool_calls=tool_calls,
|
|
)
|
|
|
|
|
|
def _first_cwd(path: Path) -> str | None:
|
|
"""Return the working directory a transcript was recorded in."""
|
|
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
for line in handle:
|
|
record = _load_line(line)
|
|
if record is not None:
|
|
cwd = record.get("cwd")
|
|
if isinstance(cwd, str):
|
|
return cwd
|
|
return None
|
|
|
|
|
|
def load_session(path: Path) -> Session:
|
|
"""Read one transcript file into a :class:`Session`."""
|
|
session = Session(session_id=path.stem, path=str(path), cwd=_first_cwd(path))
|
|
for kind, event in iter_events(path):
|
|
if kind == "turn" and isinstance(event, Turn):
|
|
session.turns.append(event)
|
|
elif isinstance(event, ToolCall):
|
|
session.tools.append(event)
|
|
return session
|
|
|
|
|
|
def find_transcripts(
|
|
root: Path,
|
|
since: float,
|
|
until: float | None = None,
|
|
) -> list[Path]:
|
|
"""Return transcripts modified inside the window, newest first.
|
|
|
|
Modification time is the selector because it is the only per-file timestamp
|
|
available without opening the file, and a session's mtime is when it was
|
|
last active — exactly the notion of "used this week" the report wants.
|
|
"""
|
|
found: list[tuple[float, Path]] = []
|
|
for path in sorted(root.glob("*/*.jsonl")):
|
|
mtime = path.stat().st_mtime
|
|
if mtime >= since and (until is None or mtime <= until):
|
|
found.append((mtime, path))
|
|
return [path for _, path in sorted(found, reverse=True)]
|