Add blackbox package: session flight recorder with HTMX dashboard (#39)

* feat(blackbox): add package with models, CLI, and HTMX dashboard

* test(blackbox): add comprehensive test coverage for dashboard

* feat(blackbox): cache session scanning via watcher invalidation

* docs(blackbox): add README and use fastapi[standard] for dev server

* refactor(blackbox): extract presentation logic into formatter classes

* refactor(blackbox): extract classify_error helpers

* feat(blackbox): wire analytics into session detail view

Show token usage, tool breakdowns, and session stats in a
collapsible panel when viewing a session.

* feat(blackbox): add codeflash plugin detection

Detect codeflash agent names, skills, and commands in transcripts.
Surface language, optimization domain, and capability badges in
the analytics panel.

* refactor(blackbox): remove underscore prefixes from internal functions

* chore: add ty python-version to root pyproject.toml

* chore(blackbox): fix lint errors in test files

* style(blackbox): apply ruff formatting to analytics

* feat(blackbox): add Playwright E2E tests for dashboard

Refactor app.py to expose create_app() factory accepting a projects_dir
override, enabling tests to run against fixture data instead of the real
~/.claude/projects/ directory. Routes now read projects_dir from
app.state instead of the module-level constant.

Add 26 Playwright tests across 5 files covering dashboard loading,
session list, session detail with filters and analytics, sidebar
collapse/localStorage persistence, and SSE log streaming. All tests
pass on chromium, firefox, and webkit (78 total).

CI gets a new e2e-blackbox job with a browser matrix strategy running
all three engines in parallel, conditional on blackbox path changes,
with trace upload on failure.

* fix(ci): sync only blackbox package in e2e job

* fix(ci): exclude e2e tests from unit test job

The test job doesn't install Playwright browsers, so e2e tests error
when pytest collects them. Ignore tests/e2e/ directories in the test
job — those are handled by the dedicated e2e-blackbox job.
This commit is contained in:
Kevin Turcios 2026-04-28 19:58:43 -05:00 committed by GitHub
parent 2ff9431656
commit 0ad5e60523
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 5855 additions and 23 deletions

View file

@ -66,18 +66,49 @@ jobs:
- name: Test changed packages - name: Test changed packages
run: | run: |
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
uv run pytest packages/ -v uv run pytest packages/ -v --ignore=packages/blackbox/tests/e2e
else else
CHANGED='${{ needs.changes.outputs.packages }}' CHANGED='${{ needs.changes.outputs.packages }}'
for pkg in $(echo "$CHANGED" | jq -r '.[]'); do for pkg in $(echo "$CHANGED" | jq -r '.[]'); do
echo "::group::Testing $pkg" echo "::group::Testing $pkg"
uv run pytest "packages/$pkg" -v uv run pytest "packages/$pkg" -v --ignore="packages/$pkg/tests/e2e"
echo "::endgroup::" echo "::endgroup::"
done done
fi fi
env: env:
CI: "true" CI: "true"
e2e-blackbox:
needs: changes
if: >-
github.event_name == 'push'
|| contains(fromJSON(needs.changes.outputs.packages), 'blackbox')
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
with:
python-version: "3.12"
enable-cache: true
- run: uv sync --package blackbox
- name: Install Playwright browsers
run: uv run playwright install --with-deps ${{ matrix.browser }}
- name: Run E2E tests (${{ matrix.browser }})
run: uv run pytest packages/blackbox/tests/e2e/ -v --browser ${{ matrix.browser }} --tracing=retain-on-failure
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces-${{ matrix.browser }}
path: test-results/
retention-days: 7
version: version:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -0,0 +1,61 @@
# blackbox
A flight data recorder for AI coding agent sessions.
## Why "blackbox"?
Aircraft carry black boxes (flight data recorders) that silently capture
everything during a flight, then become invaluable when you need to
understand what happened. This package does the same for AI coding agent
sessions: it watches, records, and lets you replay what the agent did,
how it spent tokens, where it got stuck, and whether the session achieved
its goal.
Currently supports Claude Code. Codex and Gemini support is planned.
## What it does
**Dashboard** -- a local HTMX web UI for browsing session transcripts
in real time.
- Sidebar with all sessions from `~/.claude/projects/`, sorted by recency
- Live session detection via filesystem watching (green dot indicator)
- Streaming log view with filter presets (all, compact, important, errors)
- Tool call previews, error highlighting, user message formatting
**Analytics models** -- structured data types for session-level metrics,
weekly trends, project breakdowns, and recommendations. These feed into
the analysis pipeline (in progress) that will produce session digests
and surface patterns across sessions.
## Usage
```bash
blackbox serve # open dashboard at http://localhost:7100
blackbox serve --port 8080 # custom port
blackbox serve --no-open # don't auto-open browser
```
## Package structure
```
src/blackbox/
cli.py # CLI entry point (serve command)
models.py # All domain models (attrs frozen classes)
dashboard/
app.py # FastAPI instance + lifespan
routes.py # API endpoints + SSE log streaming
rendering.py # HTML rendering, filtering, formatting
transcript.py # JSONL transcript parser + session scanner
watcher.py # Watchdog-based live session detection + cache
templates/ # Jinja2 templates (Tailwind + HTMX)
```
## Development
```bash
uv sync
uv run fastapi dev src/blackbox/dashboard/app.py # hot reload on :8000
uv run pytest tests/ -v
uv run ruff check src/ tests/
```

View file

@ -0,0 +1,95 @@
[project]
name = "blackbox"
version = "0.1.0"
description = "Flight data recorder for AI coding agent sessions"
requires-python = ">=3.12"
dependencies = [
"attrs>=24.2.0",
"danom>=0.13.0",
"fastapi[standard]>=0.115.0",
"jinja2>=3.1.0",
"sse-starlette>=2.0.0",
"uvicorn>=0.30.0",
"watchdog>=4.0.0",
]
[project.scripts]
blackbox = "blackbox.cli:main"
[build-system]
requires = ["uv_build>=0.7.2,<0.8"]
build-backend = "uv_build"
[tool.uv.sources]
danom = { git = "https://github.com/KRRT7/danom.git", branch = "feat/add-py-typed" }
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-cov>=6.2.1",
"ruff>=0.15.12",
"interrogate>=1.7.0",
"pytest-asyncio>=1.3.0",
"ty>=0.0.33",
"pytest-playwright>=0.7.2",
]
typing = [
"mypy>=1.20.2",
]
[tool.ty.environment]
python-version = "3.12"
[tool.mypy]
strict = true
warn_return_any = true
warn_unused_configs = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = [
"e2e: end-to-end browser tests (requires playwright)",
]
[tool.coverage.run]
source = ["blackbox"]
branch = true
[tool.coverage.report]
show_missing = true
skip_empty = true
[tool.interrogate]
fail-under = 100
verbose = 2
[tool.ruff]
line-length = 120
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"A",
"ANN",
"ARG",
"ASYNC240",
"COM812",
"D",
"E501",
"EM",
"FBT",
"ISC001",
"PLR2004",
"RET504",
"S",
"SIM300",
"TC003",
"TRY003",
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = [
"PLC0415",
"SIM300",
"SLF001",
]

View file

@ -0,0 +1,23 @@
"""Blackbox — flight data recorder for AI coding agent sessions."""
from __future__ import annotations
from blackbox.models import (
ProjectStats,
Recommendation,
SessionAudit,
SessionDigest,
SessionEvent,
SessionMeta,
WeekStats,
)
__all__ = [
"ProjectStats",
"Recommendation",
"SessionAudit",
"SessionDigest",
"SessionEvent",
"SessionMeta",
"WeekStats",
]

View file

@ -0,0 +1,393 @@
"""Extract structured analytics from Claude Code session transcripts."""
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from typing import Any
from blackbox.dashboard.transcript import ts_to_epoch
from blackbox.models import (
CODEFLASH_AGENT_PREFIXES,
CODEFLASH_COMMANDS,
CODEFLASH_SKILLS,
CodeflashSession,
SessionMeta,
)
EDIT_TOOLS = {"Edit", "Write", "NotebookEdit"}
FILE_EXTENSIONS: dict[str, str] = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".tsx": "typescript",
".jsx": "javascript",
".go": "go",
".rs": "rust",
".java": "java",
".rb": "ruby",
".sh": "shell",
".bash": "shell",
".zsh": "shell",
".toml": "toml",
".yaml": "yaml",
".yml": "yaml",
".json": "json",
".md": "markdown",
".html": "html",
".css": "css",
}
def extract_meta(path: Path) -> SessionMeta | None: # noqa: C901, PLR0912, PLR0915
"""Extract a SessionMeta from a raw .jsonl transcript."""
session_id = path.stem
project_path = path.parent.name
timestamps: list[float] = []
user_messages = 0
assistant_messages = 0
tool_calls = 0
tool_counts: Counter[str] = Counter()
tool_errors = 0
tool_error_categories: Counter[str] = Counter()
tool_error_details: list[tuple[str, str]] = []
input_tokens = 0
output_tokens = 0
cache_read_tokens = 0
cache_creation_tokens = 0
files_modified: set[str] = set()
lines_added = 0
lines_removed = 0
git_commits = 0
git_branch: str | None = None
user_interruptions = 0
compactions = 0
subagents_spawned = 0
thinking_blocks = 0
web_searches = 0
web_fetches = 0
permission_mode: str | None = None
languages: Counter[str] = Counter()
first_prompt = ""
pending_tools: dict[str, str] = {}
codeflash_agents: set[str] = set()
codeflash_skills: set[str] = set()
codeflash_commands: set[str] = set()
teams_created = 0
try:
text = path.read_text()
except OSError:
return None
for line in text.splitlines():
if not line.strip():
continue
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
ts = ts_to_epoch(raw.get("timestamp"))
if ts:
timestamps.append(ts)
entry_type = raw.get("type", "")
if entry_type == "permission-mode":
permission_mode = raw.get("permissionMode")
continue
if entry_type == "summary":
compactions += 1
continue
if git_branch is None and raw.get("gitBranch"):
git_branch = raw["gitBranch"]
if entry_type == "user":
msg = raw.get("message", {})
if not isinstance(msg, dict):
continue
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
user_messages += 1
if not first_prompt:
first_prompt = content[:120]
elif isinstance(content, list):
has_tool_result = any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content)
if has_tool_result:
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_result":
continue
tool_use_id = block.get("tool_use_id", "")
is_error = block.get("is_error", False)
if is_error:
tool_errors += 1
tool_name = pending_tools.get(tool_use_id, "unknown")
category = classify_error(tool_name, block, raw)
tool_error_categories[category] += 1
stderr = ""
tur = raw.get("toolUseResult", {})
if isinstance(tur, dict):
stderr = tur.get("stderr", "")
detail_text = stderr or str(block.get("content", ""))[:200]
tool_error_details.append((category, detail_text))
else:
user_messages += 1
if not first_prompt:
texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
first_prompt = " ".join(texts)[:120]
interrupted = False
tur = raw.get("toolUseResult", {})
if isinstance(tur, dict):
interrupted = tur.get("interrupted", False)
if interrupted:
user_interruptions += 1
elif entry_type == "assistant":
msg = raw.get("message", {})
if not isinstance(msg, dict):
continue
usage = msg.get("usage", {})
if usage:
input_tokens += usage.get("input_tokens", 0)
output_tokens += usage.get("output_tokens", 0)
cache_read_tokens += usage.get("cache_read_input_tokens", 0)
cache_creation_tokens += usage.get("cache_creation_input_tokens", 0)
stu = usage.get("server_tool_use", {})
if stu:
web_searches += stu.get("web_search_requests", 0)
web_fetches += stu.get("web_fetch_requests", 0)
content = msg.get("content", [])
if not isinstance(content, list):
assistant_messages += 1
continue
has_text = False
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type", "")
if btype == "text" and block.get("text", "").strip():
has_text = True
elif btype == "thinking":
thinking_blocks += 1
elif btype == "tool_use":
tool_name = block.get("name", "unknown")
tool_calls += 1
tool_counts[tool_name] += 1
tool_id = block.get("id", "")
if tool_id:
pending_tools[tool_id] = tool_name
if tool_name == "Agent":
subagents_spawned += 1
if tool_name == "TeamCreate":
teams_created += 1
tool_input = block.get("input", {})
if isinstance(tool_input, dict):
track_file_changes(
tool_name,
tool_input,
files_modified,
languages,
)
lines_a, lines_r = count_diff_lines(tool_name, tool_input)
lines_added += lines_a
lines_removed += lines_r
if tool_name == "Bash":
cmd = tool_input.get("command", "")
if isinstance(cmd, str) and "git commit" in cmd and "--amend" not in cmd:
git_commits += 1
if tool_name == "Agent":
agent_name = tool_input.get("name", "") or tool_input.get("subagent_type", "")
if agent_name in CODEFLASH_AGENT_PREFIXES:
codeflash_agents.add(agent_name)
if tool_name == "Skill":
skill_name = tool_input.get("skill", "")
if skill_name in CODEFLASH_SKILLS:
codeflash_skills.add(skill_name)
if skill_name in CODEFLASH_COMMANDS:
codeflash_commands.add(skill_name)
if has_text:
assistant_messages += 1
if not timestamps:
return None
start_time = min(timestamps)
end_time = max(timestamps)
return SessionMeta(
session_id=session_id,
project_path=project_path,
transcript_path=str(path),
start_time=start_time,
end_time=end_time,
duration_s=end_time - start_time,
user_messages=user_messages,
assistant_messages=assistant_messages,
tool_calls=tool_calls,
tool_counts=dict(tool_counts),
tool_errors=tool_errors,
tool_error_categories=dict(tool_error_categories),
tool_error_details=tuple(tool_error_details),
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_creation_tokens=cache_creation_tokens,
languages=dict(languages),
files_modified=len(files_modified),
lines_added=lines_added,
lines_removed=lines_removed,
git_commits=git_commits,
git_branch=git_branch,
user_interruptions=user_interruptions,
compactions=compactions,
subagents_spawned=subagents_spawned,
thinking_blocks=thinking_blocks,
web_searches=web_searches,
web_fetches=web_fetches,
permission_mode=permission_mode,
first_prompt=first_prompt,
codeflash=detect_codeflash(codeflash_agents, codeflash_skills, codeflash_commands, teams_created),
)
def classify_error(tool_name: str, block: dict[str, Any], raw: dict[str, Any]) -> str:
"""Classify a tool error into a category based on tool name and error content."""
tur = raw.get("toolUseResult", {})
stderr = ""
if isinstance(tur, dict):
stderr = tur.get("stderr", "")
error_text = (stderr or str(block.get("content", ""))).lower()
if tool_name == "Edit":
return "edit_failed"
if tool_name == "Bash":
return classify_bash_error(error_text)
if tool_name in ("Read", "Write"):
return classify_file_error(error_text)
return "tool_error"
def classify_bash_error(error_text: str) -> str:
if "permission denied" in error_text:
return "permission_denied"
if "command not found" in error_text:
return "command_not_found"
return "command_failed"
def classify_file_error(error_text: str) -> str:
if "not found" in error_text or "no such file" in error_text:
return "file_not_found"
return "file_error"
def track_file_changes(
tool_name: str,
tool_input: dict[str, Any],
files: set[str],
languages: Counter[str],
) -> None:
"""Track which files were modified and what languages are involved."""
if tool_name not in EDIT_TOOLS:
return
fp = tool_input.get("file_path", "")
if not fp:
return
files.add(fp)
ext = Path(fp).suffix.lower()
lang = FILE_EXTENSIONS.get(ext)
if lang:
languages[lang] += 1
def count_diff_lines(tool_name: str, tool_input: dict[str, Any]) -> tuple[int, int]:
"""Estimate lines added/removed from Edit and Write tool inputs."""
if tool_name == "Edit":
old = tool_input.get("old_string", "")
new = tool_input.get("new_string", "")
if isinstance(old, str) and isinstance(new, str):
old_lines = old.count("\n") + (1 if old else 0)
new_lines = new.count("\n") + (1 if new else 0)
added = max(0, new_lines - old_lines)
removed = max(0, old_lines - new_lines)
return added, removed
if tool_name == "Write":
content = tool_input.get("content", "")
if isinstance(content, str):
return content.count("\n") + 1, 0
return 0, 0
def detect_codeflash(
agents: set[str],
skills: set[str],
commands: set[str],
teams_created: int,
) -> CodeflashSession | None:
"""Build a CodeflashSession if any codeflash plugin signals were detected."""
if not agents and not skills and not commands:
return None
language = infer_language(agents)
domain = infer_domain(agents)
return CodeflashSession(
is_codeflash=True,
language=language,
agents_used=tuple(sorted(agents)),
skills_invoked=tuple(sorted(skills)),
commands_invoked=tuple(sorted(commands)),
teams_created=teams_created,
optimization_domain=domain,
has_researcher="codeflash-researcher" in agents,
has_reviewer="codeflash-review" in agents,
has_ci_handler=any(a.endswith("-ci") for a in agents),
has_pr_prep=any(a.endswith("-pr-prep") for a in agents),
)
LANGUAGE_AGENT_MARKERS: dict[str, str] = {
"codeflash-python": "python",
"codeflash-javascript": "javascript",
"codeflash-java": "java",
}
def infer_language(agents: set[str]) -> str | None:
"""Infer the target language from which language-specific agents were invoked."""
for marker, lang in LANGUAGE_AGENT_MARKERS.items():
if marker in agents:
return lang
for agent in agents:
if agent.startswith("codeflash-js-"):
return "javascript"
if agent.startswith("codeflash-java-"):
return "java"
return None
DOMAIN_AGENT_SUFFIXES: dict[str, str] = {
"-cpu": "cpu",
"-memory": "memory",
"-async": "async",
"-structure": "structure",
"-deep": "deep",
"-bundle": "bundle",
}
def infer_domain(agents: set[str]) -> str | None:
"""Infer the optimization domain from specialist agents used."""
for agent in agents:
for suffix, domain in DOMAIN_AGENT_SUFFIXES.items():
if agent.endswith(suffix):
return domain
return None

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import argparse
import sys
import webbrowser
from danom import Err, safe
@safe
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="blackbox",
description="Flight data recorder for AI coding agent sessions",
)
subparsers = parser.add_subparsers(dest="command", required=True)
serve_parser = subparsers.add_parser("serve", help="Launch the session dashboard")
serve_parser.add_argument("--port", type=int, default=7100)
serve_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
return parser.parse_args(argv)
@safe
def run(args: argparse.Namespace) -> None:
if args.command == "serve":
run_serve(args)
else:
msg = f"Unknown command: {args.command}"
raise ValueError(msg)
def run_serve(args: argparse.Namespace) -> None:
import uvicorn # noqa: PLC0415
from blackbox.dashboard.app import app # noqa: PLC0415
if not args.no_open:
import threading # noqa: PLC0415
def open_browser() -> None:
import time # noqa: PLC0415
time.sleep(1.0)
webbrowser.open(f"http://localhost:{args.port}")
threading.Thread(target=open_browser, daemon=True).start()
uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning")
def main() -> None:
args = parse_args().unwrap()
result = run(args)
if isinstance(result, Err):
print(f"Error: {result.error}", file=sys.stderr) # noqa: T201
sys.exit(1)

View file

@ -0,0 +1,37 @@
"""FastAPI + HTMX dashboard for browsing Claude Code session transcripts."""
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI
from blackbox.dashboard.routes import PROJECTS_DIR, router
from blackbox.dashboard.watcher import SessionWatcher
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def create_app(projects_dir: Path | None = None) -> FastAPI:
"""Create the dashboard FastAPI app, optionally overriding the projects directory."""
actual_dir = projects_dir or PROJECTS_DIR
@asynccontextmanager
async def lifespan(the_app: FastAPI) -> AsyncIterator[None]:
"""Start/stop the session watcher around the app lifecycle."""
watcher = SessionWatcher(actual_dir)
watcher.start()
the_app.state.watcher = watcher
the_app.state.projects_dir = actual_dir
yield
watcher.stop()
application = FastAPI(title="blackbox", lifespan=lifespan)
application.include_router(router)
return application
app = create_app()

View file

@ -0,0 +1,180 @@
"""HTML rendering helpers for log entries."""
from __future__ import annotations
import re
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from blackbox.models import LogEntry
FILTER_PRESETS: dict[str, set[str] | None] = {
"all": None,
"compact": None,
"important": {"status", "assistant", "error", "info"},
"errors": {"error"},
}
SKIP_LEVELS = {
"delta",
"stream",
"block_stop",
"block_start",
"thinking_delta",
"tool_start",
}
def fmt_time(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=UTC).strftime("%H:%M:%S")
def fmt_duration(started: float, finished: float | None) -> str:
end = finished or time.time()
secs = int(end - started)
if secs < 0:
return "0s"
if secs < 60:
return f"{secs}s"
mins, secs = divmod(secs, 60)
if mins < 60:
return f"{mins}m{secs:02d}s"
hrs, mins = divmod(mins, 60)
return f"{hrs}h{mins:02d}m"
def fmt_relative(ts: float) -> str:
delta = time.time() - ts
if delta < 60:
return "just now"
if delta < 3600:
return f"{int(delta / 60)}m ago"
if delta < 86400:
return f"{int(delta / 3600)}h ago"
return f"{int(delta / 86400)}d ago"
def passes_filter(
entry: LogEntry,
filter_name: str,
allowed: set[str] | None,
) -> bool:
stripped = entry.message.strip()
if not stripped:
return False
if filter_name == "all":
return True
if entry.level in SKIP_LEVELS:
return False
if allowed is not None and entry.level not in allowed:
return False
return not (entry.level == "assistant" and stripped == "(thinking)")
def esc(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>")
BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
def esc_md(text: str) -> str:
escaped = esc(text)
return BOLD_RE.sub(r'<strong class="text-white">\1</strong>', escaped)
def shorten_paths(text: str) -> str:
text = re.sub(r"/(?:private/)?tmp/[^\s\"']+/?", "", text)
return text
SOURCE_CLASSES = {
"claude": "bg-blue-500",
"user": "bg-green-500",
"system": "bg-surface-700",
}
SOURCE_LABELS = {
"claude": "CLU",
"user": "USR",
"system": "SYS",
}
def tool_call_html(preview: str) -> str:
shortened = shorten_paths(preview)
lines = shortened.split("\n")
if len(lines) <= 3:
return f'<span class="text-gray-500">{esc(shortened)}</span>'
summary = esc(lines[0])
rest = esc("\n".join(lines[1:]))
return (
f'<span class="text-gray-500">{summary}'
f'<details class="inline"><summary class="cursor-pointer'
f' text-gray-600 text-xs ml-1">+{len(lines) - 1}'
f" lines</summary>"
f'<pre class="text-xs mt-1 text-gray-600'
f' whitespace-pre-wrap">{rest}</pre>'
f"</details></span>"
)
def render_log_html(entry: LogEntry) -> str: # noqa: C901, PLR0912
ts = fmt_time(entry.timestamp)
src_cls = SOURCE_CLASSES.get(entry.source, "bg-gray-600")
src_label = SOURCE_LABELS.get(entry.source, entry.source[:3].upper())
if entry.level == "tool_call":
tool = entry.data.get("tool", "tool")
preview = entry.data.get("input_preview", entry.message)
badge_cls = "bg-amber-500"
badge_label = esc(tool[:12])
elif entry.level == "tool_result":
badge_cls = "bg-gray-700"
badge_label = "RES"
else:
badge_cls = src_cls
badge_label = src_label
if entry.source == "user" and entry.level == "info":
msg = f'<span class="text-green-300 font-medium">{esc_md(entry.message)}</span>'
elif entry.level == "tool_call":
preview = entry.data.get("input_preview", "")
msg = tool_call_html(preview)
elif entry.level == "tool_result":
text = entry.message[:500]
if len(entry.message) > 500:
text += "..."
msg = f'<span class="text-gray-500">{esc(shorten_paths(text))}</span>'
elif entry.level == "assistant":
if entry.message.strip() == "(thinking)":
msg = f'<span class="text-gray-500 italic">{esc(entry.message)}</span>'
else:
msg = f'<span class="text-gray-100">{esc_md(entry.message)}</span>'
elif entry.level == "error":
msg = f'<span class="text-red-400 font-medium">{esc(entry.message)}</span>'
else:
msg = f'<span class="text-gray-300">{esc(entry.message)}</span>'
extra_div_classes = ""
if entry.level == "assistant" and entry.message.strip() == "(thinking)":
extra_div_classes = " border-t border-surface-800 mt-2 pt-1"
is_tool = entry.level in ("tool_call", "tool_result")
opacity = " opacity-60" if is_tool else ""
indent = " pl-4" if is_tool else ""
return (
f'<div class="flex gap-2 py-0.5 px-2 hover:bg-surface-800/50'
f" rounded group{extra_div_classes}{opacity}{indent}"
f'">'
f'<span class="text-gray-600 shrink-0 text-xs'
f' leading-5">{ts}</span>'
f'<span class="{badge_cls} text-white px-1.5 rounded'
f' text-xs leading-5 font-mono shrink-0 self-start">'
f"{badge_label}</span>"
f'<span class="leading-5 break-all">{msg}</span>'
f"</div>"
)

View file

@ -0,0 +1,170 @@
"""Route handlers for the session dashboard."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING
import attrs
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sse_starlette.sse import EventSourceResponse, ServerSentEvent # type: ignore[attr-defined]
from blackbox.analytics import extract_meta
from blackbox.dashboard.rendering import (
FILTER_PRESETS,
fmt_duration,
fmt_relative,
fmt_time,
passes_filter,
render_log_html,
)
from blackbox.dashboard.transcript import parse_transcript, parse_transcript_tail, scan_sessions
from blackbox.models import SessionInfo
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from blackbox.dashboard.watcher import SessionWatcher
from blackbox.models import LogEntry
TEMPLATES_DIR = Path(__file__).parent / "templates"
PROJECTS_DIR = Path.home() / ".claude" / "projects"
HISTORY_BATCH = 200
router = APIRouter()
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.globals["fmt_time"] = fmt_time # type: ignore[assignment] # ty: ignore[invalid-assignment]
templates.env.globals["fmt_duration"] = fmt_duration # type: ignore[assignment] # ty: ignore[invalid-assignment]
templates.env.globals["fmt_relative"] = fmt_relative # type: ignore[assignment] # ty: ignore[invalid-assignment]
def mark_live(sessions: list[SessionInfo], watcher: SessionWatcher) -> list[SessionInfo]:
live_ids = watcher.live_session_ids()
if not live_ids:
return sessions
return [attrs.evolve(s, is_live=True) if s.session_id in live_ids else s for s in sessions]
@router.get("/", response_class=HTMLResponse)
async def index(request: Request, session: str = "") -> HTMLResponse:
watcher: SessionWatcher = request.app.state.watcher
sessions = mark_live(watcher.get_sessions(scan_sessions), watcher)
return templates.TemplateResponse(
request,
"index.html",
context={"sessions": sessions, "selected_id": session},
)
@router.get("/sessions", response_class=HTMLResponse)
async def session_list(request: Request, selected: str = "") -> HTMLResponse:
watcher: SessionWatcher = request.app.state.watcher
sessions = mark_live(watcher.get_sessions(scan_sessions), watcher)
return templates.TemplateResponse(
request,
"partials/session_list.html",
context={"sessions": sessions, "selected_id": selected},
)
@router.get("/sessions/{project_path}/{session_id}", response_class=HTMLResponse)
async def session_detail(
request: Request,
project_path: str,
session_id: str,
filter: str = "compact",
) -> HTMLResponse:
projects_dir: Path = request.app.state.projects_dir
transcript_path = projects_dir / project_path / f"{session_id}.jsonl"
if not transcript_path.exists():
return HTMLResponse(
'<div class="text-center text-gray-500 py-12">Session not found</div>',
)
info = build_session_info(transcript_path, session_id, project_path)
meta = extract_meta(transcript_path)
return templates.TemplateResponse(
request,
"partials/session_detail.html",
context={
"session": info,
"meta": meta,
"filter": filter,
"filters": list(FILTER_PRESETS.keys()),
},
)
def build_session_info(path: Path, session_id: str, project_path: str) -> SessionInfo:
"""Build a SessionInfo from a transcript file for the detail view."""
from blackbox.dashboard.transcript import decode_project_name, quick_session_info # noqa: PLC0415
info = quick_session_info(path, session_id, project_path, decode_project_name(project_path))
if info:
return info
return SessionInfo(
session_id=session_id,
project_path=project_path,
project_name=project_path,
transcript_path=str(path),
started_at=path.stat().st_mtime,
)
def filter_and_render(entries: list[LogEntry], filter_name: str, allowed: set[str] | None) -> list[str]:
return [
html
for entry in entries
if passes_filter(entry, filter_name, allowed)
for html in [render_log_html(entry)]
if html
]
async def log_stream(
transcript_path: Path,
filter_name: str,
) -> AsyncIterator[ServerSentEvent]:
allowed = FILTER_PRESETS.get(filter_name)
entries = await asyncio.to_thread(parse_transcript, transcript_path)
batch: list[str] = []
for entry in entries:
if passes_filter(entry, filter_name, allowed):
html = render_log_html(entry)
if html:
batch.append(html)
if len(batch) >= HISTORY_BATCH:
yield ServerSentEvent(data="\n".join(batch), event="log")
batch = []
if batch:
yield ServerSentEvent(data="\n".join(batch), event="log")
offset = transcript_path.stat().st_size
while True:
await asyncio.sleep(1.0)
try:
current_size = transcript_path.stat().st_size
except OSError:
break
if current_size <= offset:
continue
new_entries, offset = await asyncio.to_thread(parse_transcript_tail, transcript_path, offset)
rendered = filter_and_render(new_entries, filter_name, allowed)
if rendered:
yield ServerSentEvent(data="\n".join(rendered), event="log")
@router.get("/sessions/{project_path}/{session_id}/logs")
async def session_logs(
request: Request,
project_path: str,
session_id: str,
filter: str = "compact",
) -> EventSourceResponse:
projects_dir: Path = request.app.state.projects_dir
transcript_path = projects_dir / project_path / f"{session_id}.jsonl"
return EventSourceResponse(log_stream(transcript_path, filter))

View file

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blackbox</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
surface: { 950: '#0c1117', 900: '#111820', 800: '#1a2332', 700: '#243044' },
accent: { 300: '#ffe066', 400: '#ffd43b', 500: '#fab005', 600: '#e6a000' },
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
}
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body { background: #0c1117; }
.glow { box-shadow: 0 0 20px rgba(250, 176, 5, 0.15), 0 0 60px rgba(250, 176, 5, 0.05); }
.glow-text { text-shadow: 0 0 30px rgba(255, 212, 59, 0.6), 0 0 60px rgba(250, 176, 5, 0.3); }
.pulse-dot { animation: pulse-dot 2s ease-in-out infinite; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.fade-in { animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #1a2332; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #243044; }
#log-container { word-break: break-word; overflow-wrap: anywhere; }
.log-line { white-space: pre-wrap; }
#sidebar.collapsed { width: 3.5rem; }
#sidebar.collapsed .sidebar-full { display: none; }
#sidebar.collapsed .sidebar-collapsed { display: flex; }
</style>
</head>
<body class="bg-surface-950 text-gray-200 min-h-screen font-sans antialiased">
{% block content %}{% endblock %}
<script>
(function() {
var sidebar = document.getElementById('sidebar');
if (!sidebar) return;
function collapse() {
sidebar.classList.add('collapsed');
localStorage.setItem('sidebar-collapsed', '1');
}
function expand() {
sidebar.classList.remove('collapsed');
localStorage.setItem('sidebar-collapsed', '0');
}
if (localStorage.getItem('sidebar-collapsed') === '1') collapse();
document.addEventListener('click', function(e) {
var btn = e.target.closest('#collapse-btn');
if (btn) { collapse(); return; }
btn = e.target.closest('#expand-btn');
if (btn) { expand(); }
});
})();
(function() {
var es = null;
var currentUrl = null;
var follow = true;
function connectSSE(url, container) {
if (es) { es.close(); es = null; }
currentUrl = url;
follow = true;
container.innerHTML = '';
container.addEventListener('scroll', function() {
follow = (container.scrollTop + container.clientHeight >= container.scrollHeight - 60);
});
es = new EventSource(url);
es.addEventListener('log', function(e) {
container.insertAdjacentHTML('beforeend', e.data);
if (follow) container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
});
es.addEventListener('done', function(e) {
container.insertAdjacentHTML('beforeend', e.data);
es.close(); es = null;
});
es.onerror = function() { es.close(); es = null; };
}
new MutationObserver(function() {
var el = document.getElementById('log-container');
if (!el) return;
var url = el.getAttribute('data-sse-url');
if (url && url !== currentUrl) {
connectSSE(url, el);
}
}).observe(document.getElementById('session-detail'), { childList: true });
})();
</script>
</body>
</html>

View file

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block content %}
<div class="flex h-screen overflow-hidden">
<!-- Sidebar: Session List -->
<aside id="sidebar" class="w-80 shrink-0 border-r border-surface-800 flex flex-col bg-surface-950 transition-all duration-200">
<!-- Header (expanded) -->
<a href="/" class="sidebar-full block px-5 py-4 border-b border-surface-800 hover:bg-surface-900 transition-colors">
<div class="flex items-center gap-2.5">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="3" fill="#ffd43b" stroke="#fab005" stroke-width="1"/>
<circle cx="12" cy="12" r="4" fill="#0c1117"/>
</svg>
<span class="text-sm font-semibold tracking-wider text-accent-400">blackbox</span>
</div>
<p class="text-xs text-gray-600 mt-1 font-mono">session recorder</p>
</a>
<!-- Collapsed header -->
<div class="sidebar-collapsed hidden flex-col items-center border-b border-surface-800">
<a href="/" class="flex justify-center py-3 w-full hover:bg-surface-900 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="3" fill="#ffd43b" stroke="#fab005" stroke-width="1"/>
<circle cx="12" cy="12" r="4" fill="#0c1117"/>
</svg>
</a>
<button id="expand-btn"
class="w-full flex justify-center py-2
text-accent-400 hover:text-accent-300
bg-surface-800 hover:bg-surface-700
transition-colors cursor-pointer"
title="Expand sidebar">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.22 4.22a.75.75 0 011.06 0l3.25 3.25a.75.75 0 010 1.06l-3.25 3.25a.75.75 0 01-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 010-1.06z"/>
</svg>
</button>
</div>
<!-- Session list (HTMX polled) -->
<div id="session-list-container" class="sidebar-full flex-1 overflow-y-auto"
data-selected-session="{{ selected_id }}"
hx-get="/sessions"
hx-vals="js:{selected: document.getElementById('session-list-container').dataset.selectedSession || ''}"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
</div>
<!-- Collapsed spacer -->
<div class="sidebar-collapsed hidden flex-1"></div>
<!-- Footer -->
<div class="border-t border-surface-800 text-xs text-gray-600 font-mono">
<div class="sidebar-full flex items-center px-4 py-2">
<span>
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500 mr-1"></span>
watching
</span>
</div>
<button id="collapse-btn"
class="sidebar-full w-full flex items-center justify-center gap-1.5 px-4 py-2
text-gray-400 hover:text-accent-400 bg-surface-900
hover:bg-surface-800 border-t border-surface-700
transition-colors cursor-pointer"
title="Collapse sidebar">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.78 4.22a.75.75 0 010 1.06L7.06 8l2.72 2.72a.75.75 0 11-1.06 1.06L5.47 8.53a.75.75 0 010-1.06l3.25-3.25a.75.75 0 011.06 0z"/>
</svg>
<span class="text-xs">Collapse</span>
</button>
</div>
</aside>
<!-- Main: Session Detail -->
<main id="session-detail" class="flex-1 flex flex-col overflow-hidden">
{% if selected_id %}
<div hx-get="/sessions/{{ selected_id }}"
hx-trigger="load"
hx-target="#session-detail"
hx-swap="innerHTML">
</div>
{% else %}
<!-- Empty state -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="mb-4">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" class="mx-auto opacity-40">
<rect x="3" y="3" width="18" height="18" rx="3" fill="#ffd43b" stroke="#fab005" stroke-width="0.5"/>
<circle cx="12" cy="12" r="4" fill="#0c1117"/>
</svg>
</div>
<p class="text-gray-600 text-sm">Select a session to review</p>
<p class="text-gray-700 text-xs mt-2 font-mono">sessions appear as Claude Code writes them</p>
</div>
</div>
{% endif %}
</main>
</div>
{% endblock %}

View file

@ -0,0 +1,189 @@
<!-- Session header -->
<div class="border-b border-surface-800 px-6 py-4 bg-surface-950 fade-in">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-gray-100">
{{ session.project_name }}
</h2>
<span class="text-xs text-gray-600 font-mono">{{ session.session_id[:8] }}</span>
</div>
<div class="flex items-center gap-4 mt-1 text-xs font-mono text-gray-500">
<span>{{ fmt_time(session.started_at) }}</span>
{% if session.finished_at %}
<span>{{ fmt_duration(session.started_at, session.finished_at) }}</span>
{% else %}
<span class="text-yellow-400">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 pulse-dot mr-1"></span>
active
</span>
{% endif %}
{% if session.message_count %}
<span>{{ session.message_count }} messages</span>
{% endif %}
</div>
{% if session.first_prompt %}
<div class="mt-1.5 text-xs text-gray-400 truncate max-w-2xl">
{{ session.first_prompt }}
</div>
{% endif %}
</div>
</div>
<!-- Filter bar -->
<div class="flex gap-1 mt-3">
{% for f in filters %}
<button
hx-get="/sessions/{{ session.project_path }}/{{ session.session_id }}?filter={{ f }}"
hx-target="#session-detail"
hx-swap="innerHTML"
class="px-3 py-1 rounded-full text-xs font-medium transition-all duration-150
{% if f == filter %}
bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30
{% else %}
text-gray-500 hover:text-gray-300 hover:bg-surface-800
{% endif %}">
{{ f | capitalize }}
</button>
{% endfor %}
</div>
</div>
{% if meta %}
<!-- Analytics panel (collapsed by default) -->
<details class="border-b border-surface-800 bg-surface-900/50 fade-in group">
<summary class="px-6 py-2 cursor-pointer text-xs text-gray-500 hover:text-gray-300 select-none flex items-center gap-2 transition-colors">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
analytics
<span class="text-gray-600 font-mono">{{ "{:,}".format(meta.input_tokens + meta.output_tokens) }} tokens / {{ meta.tool_calls }} tools{% if meta.tool_errors %} <span class="text-red-400">({{ meta.tool_errors }} err)</span>{% endif %}</span>
</summary>
<div class="px-6 pb-3">
{% if meta.codeflash %}
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 rounded text-xs font-medium bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30">
codeflash
</span>
{% if meta.codeflash.language %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30">
{{ meta.codeflash.language }}
</span>
{% endif %}
{% if meta.codeflash.optimization_domain %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30">
{{ meta.codeflash.optimization_domain }}
</span>
{% endif %}
{% if meta.codeflash.has_researcher %}
<span class="px-1.5 py-0.5 rounded text-xs text-gray-500 bg-surface-800">researcher</span>
{% endif %}
{% if meta.codeflash.has_reviewer %}
<span class="px-1.5 py-0.5 rounded text-xs text-gray-500 bg-surface-800">reviewer</span>
{% endif %}
{% if meta.codeflash.has_ci_handler %}
<span class="px-1.5 py-0.5 rounded text-xs text-gray-500 bg-surface-800">CI</span>
{% endif %}
{% if meta.codeflash.has_pr_prep %}
<span class="px-1.5 py-0.5 rounded text-xs text-gray-500 bg-surface-800">PR prep</span>
{% endif %}
</div>
{% endif %}
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-x-6 gap-y-2 text-xs font-mono">
<div>
<span class="text-gray-600">tokens</span>
<div class="text-gray-300">{{ "{:,}".format(meta.input_tokens) }} in / {{ "{:,}".format(meta.output_tokens) }} out</div>
</div>
<div>
<span class="text-gray-600">tools</span>
<div class="text-gray-300">{{ meta.tool_calls }} calls
{% if meta.tool_errors %}<span class="text-red-400">({{ meta.tool_errors }} errors)</span>{% endif %}
</div>
</div>
<div>
<span class="text-gray-600">messages</span>
<div class="text-gray-300">{{ meta.user_messages }} user / {{ meta.assistant_messages }} assistant</div>
</div>
{% if meta.files_modified %}
<div>
<span class="text-gray-600">files</span>
<div class="text-gray-300">{{ meta.files_modified }} modified <span class="text-green-400">+{{ meta.lines_added }}</span>/<span class="text-red-400">-{{ meta.lines_removed }}</span></div>
</div>
{% endif %}
{% if meta.git_commits %}
<div>
<span class="text-gray-600">git</span>
<div class="text-gray-300">{{ meta.git_commits }} commits{% if meta.git_branch %} on {{ meta.git_branch }}{% endif %}</div>
</div>
{% endif %}
{% if meta.cache_read_tokens %}
<div>
<span class="text-gray-600">cache</span>
<div class="text-gray-300">{{ "{:.0%}".format(meta.cache_hit_rate) }} hit rate</div>
</div>
{% endif %}
{% if meta.compactions %}
<div>
<span class="text-gray-600">compactions</span>
<div class="text-yellow-400">{{ meta.compactions }}</div>
</div>
{% endif %}
{% if meta.thinking_blocks %}
<div>
<span class="text-gray-600">thinking</span>
<div class="text-gray-300">{{ meta.thinking_blocks }} blocks</div>
</div>
{% endif %}
{% if meta.subagents_spawned %}
<div>
<span class="text-gray-600">subagents</span>
<div class="text-gray-300">{{ meta.subagents_spawned }}</div>
</div>
{% endif %}
{% if meta.web_searches or meta.web_fetches %}
<div>
<span class="text-gray-600">web</span>
<div class="text-gray-300">{{ meta.web_searches }} searches / {{ meta.web_fetches }} fetches</div>
</div>
{% endif %}
{% if meta.user_interruptions %}
<div>
<span class="text-gray-600">interruptions</span>
<div class="text-yellow-400">{{ meta.user_interruptions }}</div>
</div>
{% endif %}
{% if meta.permission_mode %}
<div>
<span class="text-gray-600">mode</span>
<div class="text-gray-300">{{ meta.permission_mode }}</div>
</div>
{% endif %}
</div>
{% if meta.tool_counts %}
<div class="mt-2 flex flex-wrap gap-1.5">
{% for tool, count in meta.tool_counts|dictsort(by='value', reverse=true) %}
{% if loop.index <= 8 %}
<span class="px-1.5 py-0.5 rounded text-xs font-mono
{% if tool in ['Edit', 'Write', 'NotebookEdit'] %}text-green-400 bg-green-500/10
{% elif tool == 'Bash' %}text-orange-400 bg-orange-500/10
{% elif tool == 'Agent' %}text-purple-400 bg-purple-500/10
{% elif tool in ['Read', 'Grep', 'Glob'] %}text-blue-400 bg-blue-500/10
{% else %}text-gray-400 bg-surface-800
{% endif %}">
{{ tool }}={{ count }}
</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endif %}
<!-- Log stream -->
<div id="log-container"
data-sse-url="/sessions/{{ session.project_path }}/{{ session.session_id }}/logs?filter={{ filter }}"
class="flex-1 overflow-y-auto px-4 py-3 font-mono text-sm space-y-0 bg-surface-950">
</div>

View file

@ -0,0 +1,49 @@
{% for s in sessions %}
<div class="px-3 py-3 cursor-pointer transition-all duration-150
{% if s.session_id == selected_id %}bg-surface-800 border-l-2 border-accent-400{% else %}border-l-2 border-transparent hover:bg-surface-900 hover:border-surface-700{% endif %}"
hx-get="/sessions/{{ s.project_path }}/{{ s.session_id }}"
hx-target="#session-detail"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('session-list-container').dataset.selectedSession = '{{ s.session_id }}'; history.replaceState(null, '', '/?session={{ s.session_id }}');">
<div class="flex items-center gap-2 mb-1">
{% if s.is_live %}
<span class="w-1.5 h-1.5 rounded-full bg-yellow-400 pulse-dot"></span>
<span class="text-xs font-medium text-yellow-400 uppercase tracking-wider">Live</span>
{% else %}
<span class="w-1.5 h-1.5 rounded-full bg-gray-600"></span>
{% endif %}
<span class="text-xs text-gray-600 ml-auto font-mono">
{{ fmt_relative(s.started_at) }}
{% if s.finished_at %}
&middot; {{ fmt_duration(s.started_at, s.finished_at) }}
{% endif %}
</span>
</div>
<div class="text-sm text-gray-300 font-medium truncate">
{{ s.project_name }}
</div>
{% if s.first_prompt %}
<div class="text-xs text-gray-500 mt-0.5 truncate font-mono">
{{ s.first_prompt }}
</div>
{% endif %}
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-gray-600 font-mono">{{ s.session_id[:8] }}</span>
{% if s.message_count %}
<span class="text-xs text-gray-600">{{ s.message_count }} msgs</span>
{% endif %}
</div>
</div>
{% endfor %}
{% if not sessions %}
<div class="px-5 py-12 text-center">
<p class="text-gray-600 text-sm">No sessions found</p>
<p class="text-gray-700 text-xs mt-1 font-mono">Waiting for Claude Code sessions...</p>
</div>
{% endif %}

View file

@ -0,0 +1,279 @@
"""Parse Claude Code .jsonl transcripts into LogEntry objects."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from blackbox.models import LogEntry, SessionInfo
def ts_to_epoch(ts: str | None) -> float:
if not ts:
return 0.0
from datetime import UTC, datetime # noqa: PLC0415
try:
dt = datetime.fromisoformat(ts)
return dt.replace(tzinfo=UTC if dt.tzinfo is None else dt.tzinfo).timestamp()
except (ValueError, AttributeError):
return 0.0
def extract_text_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
block.get("text", "") for block in content if isinstance(block, dict) and block.get("type") == "text"
)
return ""
def extract_tool_uses(content: Any) -> list[dict[str, Any]]:
if not isinstance(content, list):
return []
return [block for block in content if isinstance(block, dict) and block.get("type") == "tool_use"]
def extract_tool_results(content: Any) -> list[dict[str, Any]]:
if not isinstance(content, list):
return []
return [block for block in content if isinstance(block, dict) and block.get("type") == "tool_result"]
def parse_transcript(path: Path) -> list[LogEntry]:
"""Parse a Claude Code .jsonl transcript into a list of LogEntry objects."""
entries: list[LogEntry] = []
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
parsed = parse_entry(raw)
entries.extend(parsed)
return entries
def parse_transcript_tail(path: Path, offset: int) -> tuple[list[LogEntry], int]:
"""Parse only new bytes appended after *offset*. Returns (entries, new_offset)."""
with path.open("rb") as f:
f.seek(offset)
tail = f.read()
new_offset = offset + len(tail)
entries: list[LogEntry] = []
for line in tail.decode("utf-8", errors="replace").splitlines():
if not line.strip():
continue
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
entries.extend(parse_entry(raw))
return entries, new_offset
def parse_entry(raw: dict[str, Any]) -> list[LogEntry]:
entry_type = raw.get("type", "")
ts = ts_to_epoch(raw.get("timestamp"))
message = raw.get("message", {})
if entry_type == "user":
return parse_user_entry(ts, message, raw)
if entry_type == "assistant":
return parse_assistant_entry(ts, message)
if entry_type == "system":
text = extract_text_content(message.get("content", "")) if isinstance(message, dict) else str(message)
if text:
return [LogEntry(timestamp=ts, source="system", level="info", message=text)]
return []
def parse_user_entry(ts: float, message: Any, raw: dict[str, Any]) -> list[LogEntry]:
if not isinstance(message, dict):
return []
content = message.get("content", "")
entries: list[LogEntry] = []
tool_results = extract_tool_results(content)
if tool_results:
for tr in tool_results:
result_text = tr.get("content", "")
if isinstance(result_text, list):
result_text = " ".join(b.get("text", "") for b in result_text if isinstance(b, dict))
is_error = tr.get("is_error", False)
tool_use_result = raw.get("toolUseResult", {})
if not isinstance(tool_use_result, dict):
tool_use_result = {}
stdout = tool_use_result.get("stdout", "")
stderr = tool_use_result.get("stderr", "")
display = stdout or result_text or ""
if is_error and stderr:
display = stderr
level = "error" if is_error else "tool_result"
entries.append(LogEntry(timestamp=ts, source="claude", level=level, message=display[:2000]))
return entries
text = extract_text_content(content)
if text:
entries.append(LogEntry(timestamp=ts, source="user", level="info", message=text))
return entries
def parse_assistant_entry(ts: float, message: Any) -> list[LogEntry]:
if not isinstance(message, dict):
return []
content = message.get("content", [])
if not isinstance(content, list):
return [LogEntry(timestamp=ts, source="claude", level="assistant", message=str(content))] if content else []
entries: list[LogEntry] = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type", "")
if btype == "text":
text = block.get("text", "")
if text:
entries.append(LogEntry(timestamp=ts, source="claude", level="assistant", message=text))
elif btype == "tool_use":
tool_name = block.get("name", "tool")
tool_input = block.get("input", {})
preview = tool_input_preview(tool_name, tool_input)
entries.append(
LogEntry(
timestamp=ts,
source="claude",
level="tool_call",
message=f"{tool_name}: {preview}",
data={"tool": tool_name, "input_preview": preview},
)
)
elif btype == "thinking":
entries.append(LogEntry(timestamp=ts, source="claude", level="assistant", message="(thinking)"))
return entries
def tool_input_preview(tool_name: str, tool_input: dict[str, Any]) -> str:
if tool_name == "Bash":
return str(tool_input.get("command", ""))
if tool_name in ("Read", "Write"):
return str(tool_input.get("file_path", ""))
if tool_name == "Edit":
fp = tool_input.get("file_path", "")
old = str(tool_input.get("old_string", ""))[:80]
return f"{fp}\n{old}..."
if tool_name == "Agent":
return str(tool_input.get("description", tool_input.get("prompt", "")))[:200]
if tool_name == "Skill":
return str(tool_input.get("skill", ""))
return json.dumps(tool_input, default=str)[:200]
def scan_sessions(projects_dir: Path) -> list[SessionInfo]:
"""Scan ~/.claude/projects/ for session transcripts."""
sessions: list[SessionInfo] = []
if not projects_dir.is_dir():
return sessions
for project_dir in sorted(projects_dir.iterdir()):
if not project_dir.is_dir():
continue
project_name = decode_project_name(project_dir.name)
for jsonl in sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
session_id = jsonl.stem
info = quick_session_info(jsonl, session_id, project_dir.name, project_name)
if info:
sessions.append(info)
sessions.sort(key=lambda s: s.started_at, reverse=True)
return sessions
def decode_project_name(encoded: str) -> str:
parts = encoded.split("-")
if len(parts) >= 2 and parts[0] == "":
meaningful = [p for p in parts if p not in ("Users", "private", "tmp", "")]
if meaningful:
return "/".join(meaningful[-2:]) if len(meaningful) >= 2 else meaningful[-1]
return encoded
def quick_session_info( # noqa: C901, PLR0912
path: Path,
session_id: str,
encoded_project: str,
project_name: str,
) -> SessionInfo | None:
"""Read just enough of the transcript to build sidebar metadata."""
first_prompt = ""
started_at = 0.0
finished_at = 0.0
message_count = 0
cwd = ""
try:
with path.open() as f:
for i, line in enumerate(f):
if not line.strip():
continue
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
ts = ts_to_epoch(raw.get("timestamp"))
if ts and (started_at == 0.0 or ts < started_at):
started_at = ts
if ts and ts > finished_at:
finished_at = ts
if raw.get("type") == "user":
message_count += 1
msg = raw.get("message", {})
if isinstance(msg, dict) and not first_prompt:
content = msg.get("content", "")
text = extract_text_content(content)
if text and not any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in (content if isinstance(content, list) else [])
):
first_prompt = text[:120]
if not cwd:
cwd = raw.get("cwd", "")
if i > 500:
break
except OSError:
return None
if started_at == 0.0:
return None
# Use file mtime for finished_at — always accurate even for resumed
# sessions, and avoids reading the entire file for long transcripts.
try:
mtime = path.stat().st_mtime
if mtime > started_at:
finished_at = mtime
except OSError:
pass
display_name = project_name
if cwd:
parts = Path(cwd).parts
if len(parts) >= 2:
display_name = "/".join(parts[-2:])
return SessionInfo(
session_id=session_id,
project_path=encoded_project,
project_name=display_name,
transcript_path=str(path),
started_at=started_at,
finished_at=finished_at if finished_at > started_at else None,
first_prompt=first_prompt,
message_count=message_count,
)

View file

@ -0,0 +1,85 @@
"""Watchdog-based live session discovery for ~/.claude/projects/."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
if TYPE_CHECKING:
from watchdog.events import FileSystemEvent
from blackbox.models import SessionInfo
LIVE_THRESHOLD_S = 30.0
class SessionWatcher(FileSystemEventHandler):
"""Watches the Claude Code projects directory for transcript changes.
Tracks which session files have been recently modified so the dashboard
can mark them as "live" in the sidebar. Also caches the session list
and invalidates it when any transcript file changes.
"""
def __init__(self, projects_dir: Path) -> None:
self._projects_dir = projects_dir
self._lock = threading.Lock()
self._last_modified: dict[str, float] = {}
self._observer: Any = None
self._cached_sessions: list[SessionInfo] | None = None
def start(self) -> None:
if not self._projects_dir.is_dir():
return
self._observer = Observer()
self._observer.schedule(self, str(self._projects_dir), recursive=True)
self._observer.daemon = True
self._observer.start()
def stop(self) -> None:
if self._observer is not None:
self._observer.stop()
self._observer.join(timeout=2.0)
self._observer = None
def on_modified(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
path = Path(str(event.src_path))
if path.suffix != ".jsonl":
return
session_id = path.stem
with self._lock:
self._last_modified[session_id] = time.time()
self._cached_sessions = None
def on_created(self, event: FileSystemEvent) -> None:
self.on_modified(event)
def on_deleted(self, event: FileSystemEvent) -> None:
if not event.is_directory and Path(str(event.src_path)).suffix == ".jsonl":
with self._lock:
self._cached_sessions = None
def live_session_ids(self) -> set[str]:
now = time.time()
with self._lock:
expired = [sid for sid, ts in self._last_modified.items() if now - ts > LIVE_THRESHOLD_S]
for sid in expired:
del self._last_modified[sid]
return {sid for sid, ts in self._last_modified.items() if now - ts <= LIVE_THRESHOLD_S}
def get_sessions(self, scan_fn: Callable[[Path], list[SessionInfo]]) -> list[SessionInfo]:
with self._lock:
if self._cached_sessions is not None:
return self._cached_sessions
sessions = scan_fn(self._projects_dir)
with self._lock:
self._cached_sessions = sessions
return sessions

View file

@ -0,0 +1,266 @@
"""Text formatting for analytics models."""
from __future__ import annotations
import json
import attrs
from blackbox.models import (
CodeflashSession,
ProjectStats,
Recommendation,
SessionAudit,
SessionDigest,
SessionMeta,
arrow,
sparkline,
)
class MetaFormatter:
"""Formats a SessionMeta for display."""
def __init__(self, meta: SessionMeta) -> None:
self.meta = meta
def summary(self) -> str:
"""Format as a human-readable summary."""
m = self.meta
lines = [
f"Session {m.session_id[:8]} ({m.duration_minutes:.0f}min):",
f" Messages: {m.user_messages} user / {m.assistant_messages} assistant",
f" Tools: {m.tool_calls} calls ({m.tool_errors} errors)",
f" Tokens: {m.input_tokens:,} in / {m.output_tokens:,} out (cache hit {m.cache_hit_rate:.0%})",
]
if m.git_commits:
lines.append(f" Git: {m.git_commits} commits on {m.git_branch or 'unknown'}")
if m.files_modified:
lines.append(f" Files: {m.files_modified} modified (+{m.lines_added}/-{m.lines_removed})")
if m.compactions:
lines.append(f" Compactions: {m.compactions}")
if m.user_interruptions:
lines.append(f" Interruptions: {m.user_interruptions}")
if m.thinking_blocks:
lines.append(f" Thinking blocks: {m.thinking_blocks}")
if m.web_searches or m.web_fetches:
lines.append(f" Web: {m.web_searches} searches / {m.web_fetches} fetches")
if m.permission_mode:
lines.append(f" Permission mode: {m.permission_mode}")
top = sorted(m.tool_counts.items(), key=lambda x: x[1], reverse=True)[:5]
if top:
lines.append(f" Top tools: {', '.join(f'{n}={c}' for n, c in top)}")
return "\n".join(lines)
class AuditFormatter:
"""Formats a SessionAudit for display."""
def __init__(self, audit: SessionAudit) -> None:
self.audit = audit
def summary(self) -> str:
"""Format as a human-readable summary."""
a = self.audit
lines = [
f"Audit for {a.session_id[:8]}:",
f" Outcome: {a.outcome} | Satisfaction: {a.satisfaction}",
f" Type: {a.session_type}",
]
if a.goal_categories:
goals = ", ".join(
f"{k}({v})" for k, v in sorted(a.goal_categories.items(), key=lambda x: x[1], reverse=True)[:3]
)
lines.append(f" Goals: {goals}")
if a.friction_counts:
frictions = ", ".join(
f"{k}({v})" for k, v in sorted(a.friction_counts.items(), key=lambda x: x[1], reverse=True)[:3]
)
lines.append(f" Friction: {frictions}")
if a.user_instructions:
lines.append(f" Instructions: {len(a.user_instructions)} extracted")
if a.summary:
lines.append(f" Summary: {a.summary[:120]}")
return "\n".join(lines)
class RecommendationFormatter:
"""Formats a Recommendation for display."""
def __init__(self, rec: Recommendation) -> None:
self.rec = rec
def summary(self) -> str:
"""Format as a human-readable summary."""
return f"{self.rec.suggestion}\n Evidence: {self.rec.evidence}"
class ProjectFormatter:
"""Formats a ProjectStats for display."""
def __init__(self, project: ProjectStats) -> None:
self.project = project
def summary(self) -> str:
"""Format as a human-readable summary."""
p = self.project
marker = " [!]" if p.is_outlier else ""
lines = [
f"{p.project_name}{marker}: {p.session_count} sessions, "
f"{p.success_rate:.0%} success, "
f"{p.avg_tool_errors:.1f} errors/session, "
f"{p.avg_duration_s / 60:.0f}min avg"
]
if p.top_error_categories:
lines.append(f" Errors: {' '.join(f'{n}({c})' for n, c in p.top_error_categories[:3])}")
if p.top_friction:
lines.append(f" Friction: {' '.join(f'{n}({c})' for n, c in p.top_friction[:3])}")
return "\n".join(lines)
class DigestFormatter:
"""Formats a SessionDigest for display."""
def __init__(self, digest: SessionDigest) -> None:
self.digest = digest
def summary(self) -> str:
"""Format as a human-readable summary."""
lines: list[str] = []
self.render_overview(lines)
self.render_trends(lines)
self.render_projects(lines)
self.render_recommendations(lines)
return "\n".join(lines)
def to_json(self) -> str:
"""Serialize to JSON."""
return json.dumps(attrs.asdict(self.digest), indent=2, default=str)
def render_overview(self, lines: list[str]) -> None:
"""Render the overview section."""
d = self.digest
lines.append(f"Session Digest ({d.session_count} sessions)")
lines.append("")
lines.append(
f" {d.success_rate:.0%} success rate | "
f"{d.avg_duration_s / 60:.1f}min avg | "
f"{d.avg_tool_errors:.1f} errors/session"
)
lines.append(f" Avg tokens: {d.avg_input_tokens:,.0f} in / {d.avg_output_tokens:,.0f} out")
lines.append(f" Avg tool calls: {d.avg_tool_calls:.1f}")
if d.outcome_distribution:
lines.append("")
lines.append("Outcomes:")
for outcome, count in sorted(d.outcome_distribution.items(), key=lambda x: x[1], reverse=True):
pct = count / max(d.session_count, 1) * 100
lines.append(f" {outcome}: {count} ({pct:.0f}%)")
if d.satisfaction_distribution:
lines.append("")
lines.append("Satisfaction:")
for sat, count in sorted(d.satisfaction_distribution.items(), key=lambda x: x[1], reverse=True):
pct = count / max(d.session_count, 1) * 100
lines.append(f" {sat}: {count} ({pct:.0f}%)")
if d.top_friction:
lines.append("")
lines.append("Top friction:")
for name, count in d.top_friction[:5]:
lines.append(f" {name}: {count}")
def render_trends(self, lines: list[str]) -> None:
"""Render the trends section."""
d = self.digest
if not d.weeks:
return
lines.append("")
lines.append("Trends")
lines.append(
f" Success rate: {d.rolling_success_rate:.0%} avg "
f"({arrow(d.success_rate_change)} {d.success_rate_change:+.0%})"
)
lines.append(
f" Error rate: {d.rolling_error_rate:.1f}/session avg "
f"({arrow(d.error_rate_change, invert=True)} {d.error_rate_change:+.1f})"
)
lines.append(
f" Duration: {d.rolling_duration_s / 60:.0f}min avg "
f"({arrow(d.duration_change, invert=True)} {d.duration_change / 60:+.0f}min)"
)
if len(d.weeks) >= 2:
lines.append(
f" Success: [{sparkline([w.success_rate for w in d.weeks])}] "
f"Errors: [{sparkline([w.avg_errors_per_session for w in d.weeks])}]"
)
if d.error_category_deltas:
lines.append("")
lines.append(" Error category trends:")
for cat, pct, rolling, latest_count in d.error_category_deltas:
lines.append(
f" {cat}: {arrow(pct, invert=True)} {pct:+.0%} ({rolling:.0f}/wk -> {latest_count:.0f})"
)
lines.append("")
lines.append(" Weekly breakdown:")
lines.extend(
f" {w.week}: {w.session_count} sessions, "
f"{w.success_rate:.0%} success, "
f"{w.avg_errors_per_session:.1f} errors, "
f"{w.avg_duration_s / 60:.0f}min avg"
for w in d.weeks
)
def render_projects(self, lines: list[str]) -> None:
"""Render the projects section."""
d = self.digest
if not d.projects:
return
lines.append("")
lines.append(f"Projects ({len(d.projects)})")
lines.extend(f" {ProjectFormatter(p).summary()}" for p in d.projects)
def render_recommendations(self, lines: list[str]) -> None:
"""Render the recommendations section."""
d = self.digest
if not d.recommendations:
return
lines.append("")
lines.append("Recommendations")
for i, rec in enumerate(d.recommendations, 1):
lines.append(f" {i}. {RecommendationFormatter(rec).summary()}")
class CodeflashFormatter:
"""Formats a CodeflashSession for display."""
def __init__(self, cf: CodeflashSession) -> None:
self.cf = cf
def summary(self) -> str:
"""Format as a human-readable summary."""
if not self.cf.is_codeflash:
return "Not a codeflash session"
c = self.cf
lines = ["Codeflash plugin session"]
if c.language:
lines[0] += f" ({c.language})"
pairs = [
(c.optimization_domain, f" Domain: {c.optimization_domain}"),
(c.agents_used, f" Agents: {', '.join(c.agents_used)}"),
(c.skills_invoked, f" Skills: {', '.join(c.skills_invoked)}"),
(c.commands_invoked, f" Commands: {', '.join(c.commands_invoked)}"),
(c.teams_created, f" Teams created: {c.teams_created}"),
]
lines.extend(text for cond, text in pairs if cond)
capabilities = self.capabilities()
if capabilities:
lines.append(f" Capabilities: {', '.join(capabilities)}")
return "\n".join(lines)
def capabilities(self) -> list[str]:
c = self.cf
mapping = [
(c.has_researcher, "researcher"),
(c.has_reviewer, "reviewer"),
(c.has_ci_handler, "CI"),
(c.has_pr_prep, "PR prep"),
]
return [name for flag, name in mapping if flag]

View file

@ -0,0 +1,261 @@
from __future__ import annotations
from typing import Any
import attrs
# ---------------------------------------------------------------------------
# Dashboard models
# ---------------------------------------------------------------------------
@attrs.frozen
class LogEntry:
"""A single renderable log event."""
timestamp: float
source: str # "claude", "user", "system"
level: str # "assistant", "tool_call", "tool_result", "status", "error", "info"
message: str
data: dict[str, Any] = attrs.Factory(dict)
@attrs.frozen
class SessionInfo:
"""Lightweight metadata for the sidebar session list."""
session_id: str
project_path: str
project_name: str
transcript_path: str
started_at: float
finished_at: float | None = None
first_prompt: str = ""
message_count: int = 0
is_live: bool = False
# ---------------------------------------------------------------------------
# Analytics models
# ---------------------------------------------------------------------------
SPARK_CHARS = " _.~*"
def sparkline(values: list[float]) -> str:
if len(values) < 2:
return ""
lo, hi = min(values), max(values)
if hi == lo:
return SPARK_CHARS[2] * len(values)
scale = len(SPARK_CHARS) - 1
return "".join(SPARK_CHARS[round((v - lo) / (hi - lo) * scale)] for v in values)
def arrow(delta: float, *, invert: bool = False) -> str:
if abs(delta) < 0.05:
return "="
positive = delta > 0
if invert:
positive = not positive
return "^" if positive else "v"
@attrs.frozen
class SessionEvent:
timestamp: str | None
speaker: str # "user" | "assistant" | "system"
text: str
tool_name: str | None
file_path: str | None
command: str | None
is_error: bool
error_category: str | None
attachment_type: str | None
@attrs.frozen
class SessionMeta:
session_id: str
project_path: str
transcript_path: str
start_time: float
end_time: float
duration_s: float
user_messages: int
assistant_messages: int
tool_calls: int
tool_counts: dict[str, int] = attrs.Factory(dict)
tool_errors: int = 0
tool_error_categories: dict[str, int] = attrs.Factory(dict)
tool_error_details: tuple[tuple[str, str], ...] = ()
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_creation_tokens: int = 0
languages: dict[str, int] = attrs.Factory(dict)
files_modified: int = 0
lines_added: int = 0
lines_removed: int = 0
git_commits: int = 0
git_branch: str | None = None
user_interruptions: int = 0
compactions: int = 0
subagents_spawned: int = 0
thinking_blocks: int = 0
web_searches: int = 0
web_fetches: int = 0
permission_mode: str | None = None
first_prompt: str = ""
codeflash: CodeflashSession | None = None
@property
def duration_minutes(self) -> float:
return self.duration_s / 60
@property
def total_tokens(self) -> int:
return self.input_tokens + self.output_tokens
@property
def cache_hit_rate(self) -> float:
total = self.input_tokens + self.cache_read_tokens + self.cache_creation_tokens
return self.cache_read_tokens / total if total else 0.0
@attrs.frozen
class SessionAudit:
session_id: str
goal_categories: dict[str, int] = attrs.Factory(dict)
outcome: str = "unclear"
satisfaction: str = "neutral"
friction_counts: dict[str, int] = attrs.Factory(dict)
session_type: str = "single_task"
user_instructions: tuple[str, ...] = ()
summary: str = ""
@attrs.frozen
class Recommendation:
suggestion: str
evidence: str
frequency: float
source_sessions: int
@attrs.frozen
class WeekStats:
week: str
session_count: int
success_rate: float
avg_errors_per_session: float
avg_duration_s: float
error_category_counts: dict[str, int] = attrs.Factory(dict)
@attrs.define
class ProjectStats:
project_path: str
project_name: str
session_count: int
success_rate: float
avg_tool_errors: float
avg_duration_s: float
top_error_categories: tuple[tuple[str, int], ...]
top_friction: tuple[tuple[str, int], ...]
is_outlier: bool = False
@attrs.frozen
class SessionDigest:
session_count: int
date_range: tuple[float, float]
success_rate: float
outcome_distribution: dict[str, int] = attrs.Factory(dict)
satisfaction_distribution: dict[str, int] = attrs.Factory(dict)
top_friction: tuple[tuple[str, int], ...] = ()
avg_duration_s: float = 0.0
avg_input_tokens: float = 0.0
avg_output_tokens: float = 0.0
avg_tool_calls: float = 0.0
avg_tool_errors: float = 0.0
weeks: tuple[WeekStats, ...] = ()
rolling_success_rate: float = 0.0
rolling_error_rate: float = 0.0
rolling_duration_s: float = 0.0
success_rate_change: float = 0.0
error_rate_change: float = 0.0
duration_change: float = 0.0
error_category_deltas: tuple[tuple[str, float, float, float], ...] = ()
projects: tuple[ProjectStats, ...] = ()
recommendations: tuple[Recommendation, ...] = ()
# ---------------------------------------------------------------------------
# Codeflash plugin detection
# ---------------------------------------------------------------------------
CODEFLASH_AGENT_PREFIXES = (
"codeflash",
"codeflash-python",
"codeflash-deep",
"codeflash-cpu",
"codeflash-memory",
"codeflash-async",
"codeflash-structure",
"codeflash-setup",
"codeflash-scan",
"codeflash-ci",
"codeflash-pr-prep",
"codeflash-researcher",
"codeflash-review",
"codeflash-javascript",
"codeflash-js-deep",
"codeflash-js-cpu",
"codeflash-js-memory",
"codeflash-js-async",
"codeflash-js-structure",
"codeflash-js-bundle",
"codeflash-js-setup",
"codeflash-js-scan",
"codeflash-js-ci",
"codeflash-js-pr-prep",
"codeflash-java",
"codeflash-java-deep",
"codeflash-java-cpu",
"codeflash-java-memory",
"codeflash-java-async",
"codeflash-java-structure",
"codeflash-java-setup",
"codeflash-java-scan",
"codeflash-java-ci",
"codeflash-java-pr-prep",
)
CODEFLASH_SKILLS = (
"codeflash-optimize",
"memray-profiling",
)
CODEFLASH_COMMANDS = (
"codex-review",
"codex-setup",
"codex-status",
)
@attrs.frozen
class CodeflashSession:
"""Plugin-specific metadata detected from a codeflash agent session."""
is_codeflash: bool = False
language: str | None = None
agents_used: tuple[str, ...] = ()
skills_invoked: tuple[str, ...] = ()
commands_invoked: tuple[str, ...] = ()
teams_created: int = 0
optimization_domain: str | None = None
has_researcher: bool = False
has_reviewer: bool = False
has_ci_handler: bool = False
has_pr_prep: bool = False

View file

View file

View file

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
from blackbox.models import SessionAudit, SessionMeta
def make_meta(**kw: Any) -> SessionMeta:
defaults: dict[str, Any] = {
"session_id": "abcd1234-5678-9012-3456-789012345678",
"project_path": "/tmp/project",
"transcript_path": "/tmp/project/.claude/sessions/abc.jsonl",
"start_time": 1700000000.0,
"end_time": 1700003600.0,
"duration_s": 3600.0,
"user_messages": 10,
"assistant_messages": 12,
"tool_calls": 25,
}
defaults.update(kw)
return SessionMeta(**defaults)
def make_audit(**kw: Any) -> SessionAudit:
defaults: dict[str, Any] = {
"session_id": "abcd1234-5678-9012-3456-789012345678",
"outcome": "mostly_achieved",
"satisfaction": "satisfied",
}
defaults.update(kw)
return SessionAudit(**defaults)
def pair(
meta_kw: dict[str, Any] | None = None,
audit_kw: dict[str, Any] | None = None,
) -> tuple[SessionMeta, SessionAudit]:
return make_meta(**(meta_kw or {})), make_audit(**(audit_kw or {}))

View file

View file

@ -0,0 +1,188 @@
"""Fixtures for Playwright end-to-end tests."""
from __future__ import annotations
import json
import socket
import threading
import time
from collections.abc import Iterator
from typing import TYPE_CHECKING
import pytest
import uvicorn
if TYPE_CHECKING:
from pathlib import Path
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
SESSION_A_ID = "sess-aaaa1111-2222-3333-4444-555566667777"
SESSION_B_ID = "sess-bbbb1111-2222-3333-4444-555566667777"
PROJECT_A_DIR = "-Users-alice-Desktop-work-myapp"
PROJECT_B_DIR = "-Users-bob-code-webapp"
def _jsonl(*entries: dict) -> str:
"""Serialize entries as newline-delimited JSON."""
return "\n".join(json.dumps(e) for e in entries) + "\n"
RICH_SESSION = _jsonl(
{
"type": "user",
"timestamp": "2025-03-15T10:00:00Z",
"message": {"content": "Help me optimize this function for better performance"},
"cwd": "/Users/alice/Desktop/work/myapp",
},
{
"type": "assistant",
"timestamp": "2025-03-15T10:00:05Z",
"message": {
"content": [{"type": "text", "text": "Let me look at the code and find optimization opportunities."}],
"usage": {"input_tokens": 500, "output_tokens": 120, "cache_read_input_tokens": 200},
},
},
{
"type": "assistant",
"timestamp": "2025-03-15T10:00:08Z",
"message": {
"content": [
{
"type": "tool_use",
"id": "tu_read_1",
"name": "Read",
"input": {"file_path": "/Users/alice/Desktop/work/myapp/main.py"},
}
],
"usage": {"input_tokens": 100, "output_tokens": 30},
},
},
{
"type": "user",
"timestamp": "2025-03-15T10:00:09Z",
"message": {
"content": [
{"type": "tool_result", "tool_use_id": "tu_read_1", "content": "def sort_items(items):\n pass"}
]
},
},
{
"type": "assistant",
"timestamp": "2025-03-15T10:00:15Z",
"message": {
"content": [
{
"type": "tool_use",
"id": "tu_bash_1",
"name": "Bash",
"input": {"command": "uv run pytest tests/ -v"},
}
],
"usage": {"input_tokens": 200, "output_tokens": 50},
},
},
{
"type": "user",
"timestamp": "2025-03-15T10:00:20Z",
"message": {
"content": [{"type": "tool_result", "tool_use_id": "tu_bash_1", "content": "FAILED", "is_error": True}]
},
"toolUseResult": {"stderr": "AssertionError: expected 42 got 0"},
},
{
"type": "assistant",
"timestamp": "2025-03-15T10:00:25Z",
"message": {
"content": [
{"type": "thinking", "thinking": "I need to fix the test."},
{"type": "text", "text": "The test failed. Let me fix the implementation."},
],
"usage": {"input_tokens": 300, "output_tokens": 80},
},
},
{
"type": "user",
"timestamp": "2025-03-15T10:01:00Z",
"message": {"content": "That looks great, thanks!"},
},
)
MINIMAL_SESSION = _jsonl(
{
"type": "user",
"timestamp": "2025-03-15T09:00:00Z",
"message": {"content": "What is this project about?"},
"cwd": "/Users/bob/code/webapp",
},
)
def _get_free_port() -> int:
"""Find a free TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="session")
def projects_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a temp directory tree with fixture session transcripts."""
root = tmp_path_factory.mktemp("projects")
project_a = root / PROJECT_A_DIR
project_a.mkdir()
(project_a / f"{SESSION_A_ID}.jsonl").write_text(RICH_SESSION)
project_b = root / PROJECT_B_DIR
project_b.mkdir()
(project_b / f"{SESSION_B_ID}.jsonl").write_text(MINIMAL_SESSION)
return root
@pytest.fixture(scope="session")
def live_server(projects_dir: Path) -> Iterator[str]:
"""Start the dashboard on a random free port and yield the base URL."""
from blackbox.dashboard.app import create_app
port = _get_free_port()
application = create_app(projects_dir=projects_dir)
config = uvicorn.Config(application, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
break
except OSError:
time.sleep(0.1)
else:
msg = "Live server did not start in time"
raise RuntimeError(msg)
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=5.0)
@pytest.fixture(scope="session")
def base_url(live_server: str) -> str:
"""Provide the base URL for pytest-playwright's page.goto()."""
return live_server
@pytest.fixture
def dashboard(page: Page, base_url: str) -> Page:
"""Navigate to the dashboard index and wait for session list to load."""
page.goto(base_url)
page.locator("#session-list-container").wait_for(state="attached")
page.locator("#session-list-container > div").first.wait_for(state="visible", timeout=10_000)
return page

View file

@ -0,0 +1,35 @@
"""Smoke tests: dashboard loads and renders basic structure."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
class TestDashboardLoads:
"""Dashboard renders its core layout on first load."""
def test_page_title(self, dashboard: Page) -> None:
"""Page title is 'Blackbox'."""
assert "Blackbox" == dashboard.title()
def test_brand_visible(self, dashboard: Page) -> None:
"""Sidebar brand text is visible."""
expect(dashboard.locator("#sidebar .sidebar-full span.text-accent-400")).to_have_text("blackbox")
def test_empty_state_without_selection(self, page: Page, base_url: str) -> None:
"""Empty state shown when no session is selected."""
page.goto(base_url)
expect(page.get_by_text("Select a session to review")).to_be_visible()
def test_session_list_loads_via_htmx(self, dashboard: Page) -> None:
"""Session list container is populated by the HTMX load trigger."""
items = dashboard.locator("#session-list-container > div")
assert items.count() >= 2

View file

@ -0,0 +1,77 @@
"""Tests for session detail view, filters, and analytics."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from tests.e2e.conftest import PROJECT_A_DIR, SESSION_A_ID
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
class TestSessionDetail:
"""Clicking a session loads the detail view."""
def test_clicking_session_loads_detail(self, dashboard: Page) -> None:
"""Session detail header appears after clicking a session."""
dashboard.get_by_text("work/myapp").first.click()
expect(dashboard.locator("#session-detail h2")).to_be_visible(timeout=10_000)
def test_detail_shows_project_name(self, dashboard: Page) -> None:
"""Session detail header shows the project name."""
dashboard.get_by_text("work/myapp").first.click()
expect(dashboard.locator("#session-detail h2")).to_contain_text("work/myapp")
def test_detail_shows_session_id_prefix(self, dashboard: Page) -> None:
"""Session detail header shows the 8-char session ID prefix."""
dashboard.get_by_text("work/myapp").first.click()
detail = dashboard.locator("#session-detail")
expect(detail.get_by_text("sess-aaa")).to_be_visible(timeout=10_000)
def test_filter_buttons_visible(self, dashboard: Page) -> None:
"""Filter buttons (Compact, All, etc.) are visible after loading a session."""
dashboard.get_by_text("work/myapp").first.click()
detail = dashboard.locator("#session-detail")
expect(detail.get_by_role("button", name="Compact")).to_be_visible(timeout=10_000)
expect(detail.get_by_role("button", name="All")).to_be_visible()
def test_filter_button_active_state(self, dashboard: Page) -> None:
"""The default filter (compact) has the active accent styling."""
dashboard.get_by_text("work/myapp").first.click()
compact_btn = dashboard.locator("#session-detail button", has_text="Compact").first
expect(compact_btn).to_be_visible(timeout=10_000)
expect(compact_btn).to_have_class(re.compile(r"text-accent-400"))
def test_switching_filter_changes_active_button(self, dashboard: Page) -> None:
"""Clicking 'All' makes it active and removes active from 'Compact'."""
dashboard.get_by_text("work/myapp").first.click()
all_btn = dashboard.locator("#session-detail button", has_text="All").first
expect(all_btn).to_be_visible(timeout=10_000)
all_btn.click()
expect(all_btn).to_have_class(re.compile(r"text-accent-400"))
def test_analytics_panel_exists(self, dashboard: Page) -> None:
"""Analytics details element is present for sessions with metadata."""
dashboard.get_by_text("work/myapp").first.click()
analytics = dashboard.locator("#session-detail details")
expect(analytics).to_be_visible(timeout=10_000)
def test_analytics_panel_expands(self, dashboard: Page) -> None:
"""Clicking the analytics summary expands the panel to show token counts."""
dashboard.get_by_text("work/myapp").first.click()
summary = dashboard.locator("#session-detail details summary")
expect(summary).to_be_visible(timeout=10_000)
summary.click()
expect(dashboard.get_by_text("tokens", exact=True)).to_be_visible()
def test_session_not_found(self, page: Page, base_url: str) -> None:
"""Navigating to a non-existent session shows an error."""
page.goto(f"{base_url}/?session={PROJECT_A_DIR}/{SESSION_A_ID.replace('aaaa', 'zzzz')}")
expect(page.get_by_text("Session not found")).to_be_visible(timeout=10_000)

View file

@ -0,0 +1,42 @@
"""Tests for the session list sidebar."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
class TestSessionList:
"""Session list sidebar displays session metadata correctly."""
def test_project_names_visible(self, dashboard: Page) -> None:
"""Project names from fixture data appear in the session list."""
expect(dashboard.get_by_text("work/myapp")).to_be_visible()
expect(dashboard.get_by_text("code/webapp")).to_be_visible()
def test_first_prompt_shown(self, dashboard: Page) -> None:
"""First user prompt is displayed in the session item."""
expect(dashboard.get_by_text("Help me optimize this function")).to_be_visible()
def test_session_id_prefix_shown(self, dashboard: Page) -> None:
"""The 8-char session ID prefix is visible."""
expect(dashboard.get_by_text("sess-aaa")).to_be_visible()
expect(dashboard.get_by_text("sess-bbb")).to_be_visible()
def test_message_count_shown(self, dashboard: Page) -> None:
"""Message count badge is visible for sessions with messages."""
expect(dashboard.get_by_text("4 msgs")).to_be_visible()
def test_session_list_refreshes_on_poll(self, dashboard: Page) -> None:
"""The HTMX poll fires and the list remains populated."""
with dashboard.expect_response("**/sessions*"):
dashboard.wait_for_timeout(5500)
items = dashboard.locator("#session-list-container > div")
assert items.count() >= 2

View file

@ -0,0 +1,50 @@
"""Tests for sidebar collapse/expand and localStorage persistence."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
class TestSidebar:
"""Sidebar collapse/expand behavior and localStorage persistence."""
def test_collapse_button(self, dashboard: Page) -> None:
"""Clicking collapse hides the sidebar full content."""
dashboard.locator("#collapse-btn").click()
expect(dashboard.locator("#sidebar")).to_have_class(re.compile(r"collapsed"))
def test_expand_button(self, dashboard: Page) -> None:
"""Clicking expand restores the sidebar."""
dashboard.locator("#collapse-btn").click()
expect(dashboard.locator("#sidebar")).to_have_class(re.compile(r"collapsed"))
dashboard.locator("#expand-btn").click()
sidebar_classes = dashboard.locator("#sidebar").get_attribute("class") or ""
assert "collapsed" not in sidebar_classes
def test_collapse_persists_to_localstorage(self, dashboard: Page) -> None:
"""Collapsing sets localStorage sidebar-collapsed to '1'."""
dashboard.locator("#collapse-btn").click()
value = dashboard.evaluate("() => localStorage.getItem('sidebar-collapsed')")
assert "1" == value
def test_state_restored_on_reload(self, dashboard: Page, base_url: str) -> None:
"""Collapsed state persists across page reloads."""
dashboard.locator("#collapse-btn").click()
dashboard.goto(base_url)
expect(dashboard.locator("#sidebar")).to_have_class(re.compile(r"collapsed"))
def test_expand_clears_localstorage(self, dashboard: Page) -> None:
"""Expanding sets localStorage sidebar-collapsed to '0'."""
dashboard.locator("#collapse-btn").click()
dashboard.locator("#expand-btn").click()
value = dashboard.evaluate("() => localStorage.getItem('sidebar-collapsed')")
assert "0" == value

View file

@ -0,0 +1,41 @@
"""Tests for SSE log streaming in the session detail view."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
class TestSSEStreaming:
"""SSE log events render log entries in the DOM."""
def _load_session(self, dashboard: Page) -> None:
"""Click the rich session to trigger SSE streaming."""
dashboard.get_by_text("work/myapp").first.click()
dashboard.locator("#log-container").wait_for(state="attached", timeout=10_000)
def test_log_entries_appear(self, dashboard: Page) -> None:
"""Log entries are inserted into the log container via SSE."""
self._load_session(dashboard)
container = dashboard.locator("#log-container")
expect(container).not_to_be_empty(timeout=15_000)
def test_log_container_has_children(self, dashboard: Page) -> None:
"""Log container receives child elements from SSE events."""
self._load_session(dashboard)
dashboard.locator("#log-container > *").first.wait_for(state="visible", timeout=15_000)
assert dashboard.locator("#log-container > *").count() >= 1
def test_sse_connection_established(self, dashboard: Page) -> None:
"""The SSE URL is set on the log container's data attribute."""
self._load_session(dashboard)
url = dashboard.locator("#log-container").get_attribute("data-sse-url")
assert url is not None
assert "/logs" in url

View file

@ -0,0 +1,693 @@
"""Tests for analytics extraction and codeflash detection."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from blackbox.analytics import (
classify_error,
count_diff_lines,
detect_codeflash,
extract_meta,
infer_domain,
infer_language,
track_file_changes,
)
def _ts(offset: int = 0) -> str:
return f"2026-04-28T12:00:{offset:02d}Z"
def _write_jsonl(path: Path, entries: list[dict[str, Any]]) -> None:
path.write_text("\n".join(json.dumps(e) for e in entries) + "\n")
# ---------------------------------------------------------------------------
# extract_meta basics
# ---------------------------------------------------------------------------
class TestExtractMeta:
def test_returns_none_for_empty_file(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "empty.jsonl"
p.parent.mkdir()
p.write_text("")
assert extract_meta(p) is None
def test_returns_none_for_missing_file(self, tmp_path: Path) -> None:
assert extract_meta(tmp_path / "missing.jsonl") is None
def test_returns_none_for_no_timestamps(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(p, [{"type": "system", "message": "hello"}])
assert extract_meta(p) is None
def test_basic_session(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "abc123.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "user",
"timestamp": _ts(0),
"message": {"content": "optimize this function"},
},
{
"type": "assistant",
"timestamp": _ts(10),
"message": {
"content": [{"type": "text", "text": "I'll help you."}],
"usage": {"input_tokens": 500, "output_tokens": 200},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.session_id == "abc123"
assert meta.project_path == "proj"
assert meta.user_messages == 1
assert meta.assistant_messages == 1
assert meta.input_tokens == 500
assert meta.output_tokens == 200
assert "optimize this function" in meta.first_prompt
def test_counts_tool_calls(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "/a.py"}},
{
"type": "tool_use",
"id": "t2",
"name": "Edit",
"input": {"file_path": "/a.py", "old_string": "x", "new_string": "y"},
},
],
"usage": {"input_tokens": 100, "output_tokens": 50},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.tool_calls == 2
assert meta.tool_counts == {"Read": 1, "Edit": 1}
def test_counts_git_commits(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Bash",
"input": {"command": "git commit -m 'fix things'"},
}
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.git_commits == 1
def test_amend_not_counted_as_commit(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Bash",
"input": {"command": "git commit --amend -m 'fix'"},
}
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.git_commits == 0
def test_counts_compactions(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{"type": "user", "timestamp": _ts(0), "message": {"content": "hi"}},
{"type": "summary", "timestamp": _ts(5)},
{"type": "summary", "timestamp": _ts(10)},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.compactions == 2
def test_counts_thinking_blocks(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{"type": "thinking", "thinking": "let me think..."},
{"type": "text", "text": "here's my answer"},
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.thinking_blocks == 1
def test_tracks_permission_mode(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{"type": "permission-mode", "timestamp": _ts(0), "permissionMode": "bypassPermissions"},
{"type": "user", "timestamp": _ts(1), "message": {"content": "go"}},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.permission_mode == "bypassPermissions"
def test_tracks_web_usage(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [{"type": "text", "text": "searching"}],
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"server_tool_use": {"web_search_requests": 2, "web_fetch_requests": 1},
},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.web_searches == 2
assert meta.web_fetches == 1
def test_skips_invalid_json_lines(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
p.write_text(
json.dumps({"type": "user", "timestamp": _ts(0), "message": {"content": "hi"}})
+ "\nnot valid json\n"
+ json.dumps(
{
"type": "assistant",
"timestamp": _ts(1),
"message": {"content": [{"type": "text", "text": "ok"}], "usage": {}},
}
)
+ "\n"
)
meta = extract_meta(p)
assert meta is not None
assert meta.user_messages == 1
assert meta.assistant_messages == 1
def test_tracks_tool_errors(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls /nope"}}],
"usage": {},
},
},
{
"type": "user",
"timestamp": _ts(1),
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"is_error": True,
"content": "command not found",
}
],
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.tool_errors == 1
assert meta.tool_error_categories["command_not_found"] == 1
# ---------------------------------------------------------------------------
# classify_error
# ---------------------------------------------------------------------------
class TestClassifyError:
def test_edit_always_edit_failed(self) -> None:
assert "edit_failed" == classify_error("Edit", {}, {})
def test_bash_permission_denied(self) -> None:
block = {"content": "Permission denied"}
assert "permission_denied" == classify_error("Bash", block, {})
def test_bash_command_not_found(self) -> None:
block = {"content": "command not found"}
assert "command_not_found" == classify_error("Bash", block, {})
def test_bash_generic_failure(self) -> None:
block = {"content": "exit code 1"}
assert "command_failed" == classify_error("Bash", block, {})
def test_read_file_not_found(self) -> None:
block = {"content": "no such file"}
assert "file_not_found" == classify_error("Read", block, {})
def test_write_file_not_found(self) -> None:
block = {"content": "not found"}
assert "file_not_found" == classify_error("Write", block, {})
def test_read_generic_error(self) -> None:
block = {"content": "some io error"}
assert "file_error" == classify_error("Read", block, {})
def test_unknown_tool(self) -> None:
assert "tool_error" == classify_error("CustomTool", {}, {})
def test_stderr_from_tool_use_result(self) -> None:
raw = {"toolUseResult": {"stderr": "Permission denied"}}
assert "permission_denied" == classify_error("Bash", {"content": ""}, raw)
# ---------------------------------------------------------------------------
# track_file_changes
# ---------------------------------------------------------------------------
class TestTrackFileChanges:
def test_tracks_edit_tool(self) -> None:
from collections import Counter
files: set[str] = set()
langs = Counter[str]()
track_file_changes("Edit", {"file_path": "/app/main.py"}, files, langs)
assert "/app/main.py" in files
assert langs["python"] == 1
def test_ignores_non_edit_tools(self) -> None:
from collections import Counter
files: set[str] = set()
langs = Counter[str]()
track_file_changes("Read", {"file_path": "/app/main.py"}, files, langs)
assert len(files) == 0
def test_unknown_extension(self) -> None:
from collections import Counter
files: set[str] = set()
langs = Counter[str]()
track_file_changes("Write", {"file_path": "/app/data.xyz"}, files, langs)
assert "/app/data.xyz" in files
assert len(langs) == 0
# ---------------------------------------------------------------------------
# count_diff_lines
# ---------------------------------------------------------------------------
class TestCountDiffLines:
def test_edit_adds_lines(self) -> None:
assert (2, 0) == count_diff_lines("Edit", {"old_string": "a\n", "new_string": "a\nb\nc\n"})
def test_edit_removes_lines(self) -> None:
assert (0, 2) == count_diff_lines("Edit", {"old_string": "a\nb\nc\n", "new_string": "a\n"})
def test_write_counts_all_lines(self) -> None:
assert (3, 0) == count_diff_lines("Write", {"content": "a\nb\nc"})
def test_other_tools_zero(self) -> None:
assert (0, 0) == count_diff_lines("Read", {})
# ---------------------------------------------------------------------------
# detect_codeflash
# ---------------------------------------------------------------------------
class TestDetectCodeflash:
def test_returns_none_when_no_signals(self) -> None:
assert detect_codeflash(set(), set(), set(), 0) is None
def test_detects_from_agents(self) -> None:
cf = detect_codeflash({"codeflash-python", "codeflash-deep"}, set(), set(), 0)
assert cf is not None
assert cf.is_codeflash
assert cf.language == "python"
assert cf.optimization_domain == "deep"
assert "codeflash-deep" in cf.agents_used
assert "codeflash-python" in cf.agents_used
def test_detects_from_skills(self) -> None:
cf = detect_codeflash(set(), {"codeflash-optimize"}, set(), 0)
assert cf is not None
assert cf.is_codeflash
assert "codeflash-optimize" in cf.skills_invoked
def test_detects_from_commands(self) -> None:
cf = detect_codeflash(set(), set(), {"codex-review"}, 0)
assert cf is not None
assert "codex-review" in cf.commands_invoked
def test_tracks_teams(self) -> None:
cf = detect_codeflash({"codeflash"}, set(), set(), 3)
assert cf is not None
assert cf.teams_created == 3
def test_detects_researcher(self) -> None:
cf = detect_codeflash({"codeflash-researcher"}, set(), set(), 0)
assert cf is not None
assert cf.has_researcher
def test_detects_reviewer(self) -> None:
cf = detect_codeflash({"codeflash-review"}, set(), set(), 0)
assert cf is not None
assert cf.has_reviewer
def test_detects_ci_handler(self) -> None:
cf = detect_codeflash({"codeflash-ci"}, set(), set(), 0)
assert cf is not None
assert cf.has_ci_handler
def test_detects_pr_prep(self) -> None:
cf = detect_codeflash({"codeflash-pr-prep"}, set(), set(), 0)
assert cf is not None
assert cf.has_pr_prep
def test_infers_javascript_from_prefix(self) -> None:
cf = detect_codeflash({"codeflash-js-cpu"}, set(), set(), 0)
assert cf is not None
assert cf.language == "javascript"
assert cf.optimization_domain == "cpu"
def test_infers_java_from_prefix(self) -> None:
cf = detect_codeflash({"codeflash-java-memory"}, set(), set(), 0)
assert cf is not None
assert cf.language == "java"
assert cf.optimization_domain == "memory"
def test_memory_domain(self) -> None:
cf = detect_codeflash({"codeflash-memory"}, set(), set(), 0)
assert cf is not None
assert cf.optimization_domain == "memory"
def test_async_domain(self) -> None:
cf = detect_codeflash({"codeflash-async"}, set(), set(), 0)
assert cf is not None
assert cf.optimization_domain == "async"
def test_structure_domain(self) -> None:
cf = detect_codeflash({"codeflash-structure"}, set(), set(), 0)
assert cf is not None
assert cf.optimization_domain == "structure"
def test_bundle_domain(self) -> None:
cf = detect_codeflash({"codeflash-js-bundle"}, set(), set(), 0)
assert cf is not None
assert cf.optimization_domain == "bundle"
# ---------------------------------------------------------------------------
# _infer_language / _infer_domain
# ---------------------------------------------------------------------------
class TestInferLanguage:
def test_python_from_marker(self) -> None:
assert "python" == infer_language({"codeflash-python"})
def test_javascript_from_marker(self) -> None:
assert "javascript" == infer_language({"codeflash-javascript"})
def test_javascript_from_js_prefix(self) -> None:
assert "javascript" == infer_language({"codeflash-js-deep"})
def test_java_from_marker(self) -> None:
assert "java" == infer_language({"codeflash-java"})
def test_java_from_prefix(self) -> None:
assert "java" == infer_language({"codeflash-java-cpu"})
def test_none_for_generic_agent(self) -> None:
assert infer_language({"codeflash"}) is None
def test_none_for_empty(self) -> None:
assert infer_language(set()) is None
class TestInferDomain:
def test_cpu(self) -> None:
assert "cpu" == infer_domain({"codeflash-cpu"})
def test_memory(self) -> None:
assert "memory" == infer_domain({"codeflash-memory"})
def test_deep(self) -> None:
assert "deep" == infer_domain({"codeflash-deep"})
def test_async(self) -> None:
assert "async" == infer_domain({"codeflash-async"})
def test_structure(self) -> None:
assert "structure" == infer_domain({"codeflash-structure"})
def test_bundle(self) -> None:
assert "bundle" == infer_domain({"codeflash-js-bundle"})
def test_none_for_router_only(self) -> None:
assert infer_domain({"codeflash-python"}) is None
def test_none_for_empty(self) -> None:
assert infer_domain(set()) is None
# ---------------------------------------------------------------------------
# extract_meta codeflash integration
# ---------------------------------------------------------------------------
class TestExtractMetaCodeflash:
def test_non_codeflash_session_has_none(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{"type": "user", "timestamp": _ts(0), "message": {"content": "hello"}},
{
"type": "assistant",
"timestamp": _ts(1),
"message": {
"content": [{"type": "text", "text": "hi"}],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.codeflash is None
def test_detects_codeflash_agent_spawn(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Agent",
"input": {"name": "codeflash-python", "prompt": "optimize"},
}
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.codeflash is not None
assert meta.codeflash.is_codeflash
assert meta.codeflash.language == "python"
assert "codeflash-python" in meta.codeflash.agents_used
def test_detects_codeflash_skill(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Skill",
"input": {"skill": "codeflash-optimize"},
}
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.codeflash is not None
assert "codeflash-optimize" in meta.codeflash.skills_invoked
def test_detects_team_creates(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{"type": "tool_use", "id": "t1", "name": "TeamCreate", "input": {}},
{
"type": "tool_use",
"id": "t2",
"name": "Agent",
"input": {"name": "codeflash-deep", "prompt": "go"},
},
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
assert meta.codeflash is not None
assert meta.codeflash.teams_created == 1
def test_detects_multiple_agents(self, tmp_path: Path) -> None:
p = tmp_path / "proj" / "sess.jsonl"
p.parent.mkdir()
_write_jsonl(
p,
[
{
"type": "assistant",
"timestamp": _ts(0),
"message": {
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Agent",
"input": {"name": "codeflash-python", "prompt": "start"},
},
{
"type": "tool_use",
"id": "t2",
"name": "Agent",
"input": {"name": "codeflash-deep", "prompt": "optimize"},
},
{
"type": "tool_use",
"id": "t3",
"name": "Agent",
"input": {"name": "codeflash-researcher", "prompt": "research"},
},
{
"type": "tool_use",
"id": "t4",
"name": "Agent",
"input": {"name": "codeflash-review", "prompt": "review"},
},
],
"usage": {},
},
},
],
)
meta = extract_meta(p)
assert meta is not None
cf = meta.codeflash
assert cf is not None
assert cf.language == "python"
assert cf.optimization_domain == "deep"
assert cf.has_researcher
assert cf.has_reviewer
assert len(cf.agents_used) == 4

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from argparse import Namespace
import pytest
from blackbox.cli import main, parse_args, run
class TestParseArgs:
def test_serve_defaults(self) -> None:
args = parse_args(["serve"]).unwrap()
assert "serve" == args.command
assert 7100 == args.port
assert args.no_open is False
def test_serve_custom_port(self) -> None:
args = parse_args(["serve", "--port", "8080"]).unwrap()
assert 8080 == args.port
def test_serve_no_open(self) -> None:
args = parse_args(["serve", "--no-open"]).unwrap()
assert args.no_open is True
def test_no_command_errors(self) -> None:
with pytest.raises(SystemExit):
parse_args([])
class TestRun:
def test_serve_launches_uvicorn(self, monkeypatch: pytest.MonkeyPatch) -> None:
called_with: dict[str, object] = {}
def fake_uvicorn_run(app: object, **kwargs: object) -> None:
called_with["app"] = app
called_with.update(kwargs)
monkeypatch.setattr("uvicorn.run", fake_uvicorn_run)
args = parse_args(["serve", "--no-open"]).unwrap()
run(args).unwrap()
assert "127.0.0.1" == called_with["host"]
assert 7100 == called_with["port"]
def test_unknown_command(self) -> None:
args = Namespace(command="bogus")
result = run(args)
assert not result.is_ok()
class TestMain:
def test_main_serve(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.argv", ["blackbox", "serve", "--no-open"])
monkeypatch.setattr("uvicorn.run", lambda *a, **kw: None)
main()

View file

@ -0,0 +1,333 @@
from __future__ import annotations
import json
from typing import Any
from blackbox.formatting import (
AuditFormatter,
DigestFormatter,
MetaFormatter,
ProjectFormatter,
RecommendationFormatter,
)
from blackbox.models import (
ProjectStats,
Recommendation,
SessionAudit,
SessionDigest,
WeekStats,
)
from tests.conftest import make_meta
# ---------------------------------------------------------------------------
# MetaFormatter
# ---------------------------------------------------------------------------
class TestMetaFormatter:
def test_basic(self) -> None:
text = MetaFormatter(make_meta(input_tokens=5000, output_tokens=2000, tool_errors=2)).summary()
assert "abcd1234" in text
assert "60min" in text
assert "10 user / 12 assistant" in text
assert "25 calls (2 errors)" in text
assert "5,000 in / 2,000 out" in text
def test_with_git(self) -> None:
assert "5 commits on main" in MetaFormatter(make_meta(git_commits=5, git_branch="main")).summary()
def test_git_without_branch(self) -> None:
assert "unknown" in MetaFormatter(make_meta(git_commits=1, git_branch=None)).summary()
def test_with_files(self) -> None:
text = MetaFormatter(make_meta(files_modified=3, lines_added=100, lines_removed=20)).summary()
assert "3 modified" in text
assert "+100/-20" in text
def test_without_files(self) -> None:
assert "modified" not in MetaFormatter(make_meta(files_modified=0)).summary()
def test_with_compactions(self) -> None:
assert "Compactions: 3" in MetaFormatter(make_meta(compactions=3)).summary()
def test_without_compactions(self) -> None:
assert "Compactions" not in MetaFormatter(make_meta(compactions=0)).summary()
def test_with_interruptions(self) -> None:
assert "Interruptions: 2" in MetaFormatter(make_meta(user_interruptions=2)).summary()
def test_without_interruptions(self) -> None:
assert "Interruptions" not in MetaFormatter(make_meta(user_interruptions=0)).summary()
def test_top_tools_capped_at_5(self) -> None:
meta = make_meta(tool_counts={"Read": 20, "Edit": 15, "Bash": 10, "Write": 5, "Grep": 3, "X": 1})
text = MetaFormatter(meta).summary()
assert "Read=20" in text
assert "X=1" not in text
def test_no_top_tools_when_empty(self) -> None:
assert "Top tools" not in MetaFormatter(make_meta(tool_counts={})).summary()
def test_thinking_blocks_shown_when_nonzero(self) -> None:
assert "Thinking blocks: 5" in MetaFormatter(make_meta(thinking_blocks=5)).summary()
def test_thinking_blocks_hidden_when_zero(self) -> None:
assert "Thinking blocks" not in MetaFormatter(make_meta(thinking_blocks=0)).summary()
def test_web_shown_when_nonzero(self) -> None:
text = MetaFormatter(make_meta(web_searches=3, web_fetches=1)).summary()
assert "Web: 3 searches / 1 fetches" in text
def test_web_hidden_when_zero(self) -> None:
assert "Web:" not in MetaFormatter(make_meta(web_searches=0, web_fetches=0)).summary()
def test_permission_mode_shown_when_set(self) -> None:
text = MetaFormatter(make_meta(permission_mode="bypassPermissions")).summary()
assert "Permission mode: bypassPermissions" in text
def test_permission_mode_hidden_when_none(self) -> None:
assert "Permission mode" not in MetaFormatter(make_meta(permission_mode=None)).summary()
# ---------------------------------------------------------------------------
# AuditFormatter
# ---------------------------------------------------------------------------
class TestAuditFormatter:
def test_basic(self) -> None:
a = SessionAudit(
session_id="abcd1234-5678",
outcome="success",
satisfaction="positive",
session_type="debugging",
)
text = AuditFormatter(a).summary()
assert "abcd1234" in text
assert "Outcome: success" in text
assert "Satisfaction: positive" in text
assert "Type: debugging" in text
def test_with_goals(self) -> None:
a = SessionAudit(session_id="x", goal_categories={"bugfix": 5, "refactor": 3})
text = AuditFormatter(a).summary()
assert "Goals:" in text
assert "bugfix(5)" in text
def test_without_goals(self) -> None:
assert "Goals" not in AuditFormatter(SessionAudit(session_id="x", goal_categories={})).summary()
def test_with_friction(self) -> None:
a = SessionAudit(session_id="x", friction_counts={"permission_denied": 4})
assert "permission_denied(4)" in AuditFormatter(a).summary()
def test_without_friction(self) -> None:
assert "Friction" not in AuditFormatter(SessionAudit(session_id="x")).summary()
def test_with_instructions(self) -> None:
a = SessionAudit(session_id="x", user_instructions=("use pytest", "no comments"))
assert "Instructions: 2 extracted" in AuditFormatter(a).summary()
def test_without_instructions(self) -> None:
assert "Instructions" not in AuditFormatter(SessionAudit(session_id="x")).summary()
def test_summary_truncated_at_120(self) -> None:
a = SessionAudit(session_id="x", summary="x" * 200)
text = AuditFormatter(a).summary()
summary_line = next(line for line in text.split("\n") if "Summary" in line)
assert len(summary_line.split("Summary: ")[1]) == 120
# ---------------------------------------------------------------------------
# RecommendationFormatter
# ---------------------------------------------------------------------------
class TestRecommendationFormatter:
def test_basic(self) -> None:
r = Recommendation(suggestion="do X", evidence="50% failure", frequency=0.5, source_sessions=5)
text = RecommendationFormatter(r).summary()
assert "do X" in text
assert "50% failure" in text
# ---------------------------------------------------------------------------
# ProjectFormatter
# ---------------------------------------------------------------------------
class TestProjectFormatter:
def make(self, **kw: Any) -> ProjectStats:
defaults: dict[str, Any] = {
"project_path": "/proj/myapp",
"project_name": "myapp",
"session_count": 10,
"success_rate": 0.9,
"avg_tool_errors": 2.5,
"avg_duration_s": 600.0,
"top_error_categories": (),
"top_friction": (),
}
defaults.update(kw)
return ProjectStats(**defaults)
def test_basic(self) -> None:
text = ProjectFormatter(self.make()).summary()
assert "myapp: 10 sessions" in text
assert "90% success" in text
def test_outlier_marker(self) -> None:
assert "[!]" in ProjectFormatter(self.make(is_outlier=True)).summary()
def test_error_categories_shown(self) -> None:
p = self.make(top_error_categories=(("edit_failed", 8), ("command_failed", 3)))
assert "Errors: edit_failed(8)" in ProjectFormatter(p).summary()
def test_friction_shown(self) -> None:
p = self.make(top_friction=(("user_rejected", 4),))
assert "Friction: user_rejected(4)" in ProjectFormatter(p).summary()
def test_no_sub_lines_when_clean(self) -> None:
text = ProjectFormatter(self.make()).summary()
assert len(text.strip().split("\n")) == 1
# ---------------------------------------------------------------------------
# DigestFormatter
# ---------------------------------------------------------------------------
class TestDigestFormatter:
def make(self, **kw: Any) -> SessionDigest:
defaults: dict[str, Any] = {"session_count": 10, "date_range": (100.0, 500.0), "success_rate": 0.8}
defaults.update(kw)
return SessionDigest(**defaults)
def test_includes_count(self) -> None:
assert "42 sessions" in DigestFormatter(self.make(session_count=42)).summary()
def test_success_rate(self) -> None:
assert "80% success rate" in DigestFormatter(self.make(success_rate=0.8)).summary()
def test_outcome_distribution(self) -> None:
digest = self.make(
session_count=10,
outcome_distribution={"fully_achieved": 7, "unclear": 3},
)
text = DigestFormatter(digest).summary()
assert "fully_achieved: 7 (70%)" in text
def test_no_trends_without_weeks(self) -> None:
assert "Trends" not in DigestFormatter(self.make()).summary()
def test_trends_with_weeks(self) -> None:
w = WeekStats(
week="2026-W17", session_count=5, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
)
digest = self.make(weeks=(w,), rolling_success_rate=0.7)
text = DigestFormatter(digest).summary()
assert "Trends" in text
assert "2026-W17" in text
def test_no_projects_without_data(self) -> None:
assert "Projects" not in DigestFormatter(self.make()).summary()
def test_no_recommendations_without_data(self) -> None:
assert "Recommendations" not in DigestFormatter(self.make()).summary()
def test_with_recommendations(self) -> None:
r = Recommendation(suggestion="Fix the thing", evidence="50% failure", frequency=0.5, source_sessions=10)
text = DigestFormatter(self.make(recommendations=(r,))).summary()
assert "Recommendations" in text
assert "1. Fix the thing" in text
def test_satisfaction_distribution(self) -> None:
digest = self.make(
session_count=10,
satisfaction_distribution={"happy": 6, "neutral": 4},
)
text = DigestFormatter(digest).summary()
assert "Satisfaction:" in text
assert "happy: 6" in text
def test_top_friction(self) -> None:
digest = self.make(top_friction=(("tool_failed", 12), ("blocked", 3)))
text = DigestFormatter(digest).summary()
assert "Top friction:" in text
assert "tool_failed: 12" in text
def test_sparkline_with_two_weeks(self) -> None:
w1 = WeekStats(
week="2026-W16", session_count=3, success_rate=0.5, avg_errors_per_session=2.0, avg_duration_s=600.0
)
w2 = WeekStats(
week="2026-W17", session_count=4, success_rate=0.9, avg_errors_per_session=0.5, avg_duration_s=400.0
)
text = DigestFormatter(self.make(weeks=(w1, w2), rolling_success_rate=0.7)).summary()
assert "Success: [" in text
assert "Errors: [" in text
def test_error_category_deltas(self) -> None:
w = WeekStats(
week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
)
digest = self.make(
weeks=(w,),
error_category_deltas=(("command_failed", 0.5, 4.0, 6.0),),
)
text = DigestFormatter(digest).summary()
assert "Error category trends:" in text
assert "command_failed" in text
def test_with_projects(self) -> None:
p = ProjectStats(
project_path="/proj/myapp",
project_name="myapp",
session_count=5,
success_rate=0.8,
avg_tool_errors=1.0,
avg_duration_s=300.0,
top_error_categories=(),
top_friction=(),
)
text = DigestFormatter(self.make(projects=(p,))).summary()
assert "Projects (1)" in text
assert "myapp" in text
class TestDigestToJson:
def make(self, **kw: Any) -> SessionDigest:
defaults: dict[str, Any] = {"session_count": 10, "date_range": (100.0, 500.0), "success_rate": 0.8}
defaults.update(kw)
return SessionDigest(**defaults)
def test_valid_json(self) -> None:
j = DigestFormatter(self.make(session_count=5)).to_json()
parsed = json.loads(j)
assert parsed["session_count"] == 5
def test_with_nested_weeks(self) -> None:
w = WeekStats(
week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
)
j = DigestFormatter(self.make(weeks=(w,))).to_json()
parsed = json.loads(j)
assert len(parsed["weeks"]) == 1
assert parsed["weeks"][0]["week"] == "2026-W17"
def test_with_nested_projects(self) -> None:
p = ProjectStats(
project_path="/p",
project_name="p",
session_count=5,
success_rate=0.8,
avg_tool_errors=1.0,
avg_duration_s=300.0,
top_error_categories=(),
top_friction=(),
is_outlier=False,
)
j = DigestFormatter(self.make(projects=(p,))).to_json()
parsed = json.loads(j)
assert len(parsed["projects"]) == 1
assert parsed["projects"][0]["project_name"] == "p"

View file

@ -0,0 +1,305 @@
from __future__ import annotations
import json
from typing import Any
import attrs
import pytest
from blackbox.models import (
ProjectStats,
Recommendation,
SessionAudit,
SessionDigest,
SessionEvent,
WeekStats,
arrow,
sparkline,
)
from tests.conftest import make_audit, make_meta
# ---------------------------------------------------------------------------
# sparkline and arrow
# ---------------------------------------------------------------------------
class TestSparkline:
def test_empty_or_single_returns_empty(self) -> None:
assert "" == sparkline([])
assert "" == sparkline([1.0])
def test_ascending_produces_increasing_chars(self) -> None:
result = sparkline([0.0, 0.5, 1.0])
assert len(result) == 3
assert result[0] <= result[-1]
def test_descending_produces_decreasing_chars(self) -> None:
result = sparkline([1.0, 0.5, 0.0])
assert result[0] >= result[-1]
def test_constant_values_produce_middle_char(self) -> None:
result = sparkline([5.0, 5.0, 5.0])
assert len(result) == 3
assert len(set(result)) == 1
def test_two_values_uses_full_range(self) -> None:
result = sparkline([0.0, 1.0])
assert len(result) == 2
assert result[0] != result[-1]
class TestArrow:
def test_near_zero_delta_returns_equals(self) -> None:
assert "=" == arrow(0.0)
assert "=" == arrow(0.04)
assert "=" == arrow(-0.04)
def test_positive_delta_returns_up(self) -> None:
assert "^" == arrow(0.1)
def test_negative_delta_returns_down(self) -> None:
assert "v" == arrow(-0.1)
def test_invert_flips_positive(self) -> None:
assert "v" == arrow(0.1, invert=True)
def test_invert_flips_negative(self) -> None:
assert "^" == arrow(-0.1, invert=True)
def test_invert_near_zero_still_equals(self) -> None:
assert "=" == arrow(0.0, invert=True)
# ---------------------------------------------------------------------------
# SessionEvent
# ---------------------------------------------------------------------------
class TestSessionEvent:
def test_construction(self) -> None:
e = SessionEvent(
timestamp="2024-01-01T00:00:00Z",
speaker="user",
text="hello",
tool_name=None,
file_path=None,
command=None,
is_error=False,
error_category=None,
attachment_type=None,
)
assert e.speaker == "user"
assert e.text == "hello"
assert not e.is_error
def test_frozen(self) -> None:
e = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
with pytest.raises(attrs.exceptions.FrozenInstanceError):
e.speaker = "assistant" # type: ignore[misc]
def test_equality(self) -> None:
e1 = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
e2 = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
assert e1 == e2
def test_attrs_asdict(self) -> None:
e = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
d = attrs.asdict(e)
assert d["speaker"] == "user"
assert d["text"] == "hi"
assert json.dumps(d) # JSON-serializable
# ---------------------------------------------------------------------------
# SessionMeta — properties
# ---------------------------------------------------------------------------
class TestSessionMetaProperties:
def test_duration_minutes(self) -> None:
assert make_meta(duration_s=3600.0).duration_minutes == 60.0
def test_duration_minutes_zero(self) -> None:
assert make_meta(duration_s=0.0).duration_minutes == 0.0
def test_total_tokens(self) -> None:
assert make_meta(input_tokens=1000, output_tokens=500).total_tokens == 1500
def test_total_tokens_default(self) -> None:
assert make_meta().total_tokens == 0
def test_cache_hit_rate(self) -> None:
meta = make_meta(input_tokens=500, cache_read_tokens=300, cache_creation_tokens=200)
assert meta.cache_hit_rate == 0.3
def test_cache_hit_rate_zero_tokens(self) -> None:
assert make_meta().cache_hit_rate == 0.0
def test_cache_hit_rate_full(self) -> None:
meta = make_meta(input_tokens=0, cache_read_tokens=1000, cache_creation_tokens=0)
assert meta.cache_hit_rate == 1.0
class TestSessionMetaFrozen:
def test_frozen(self) -> None:
meta = make_meta()
with pytest.raises(attrs.exceptions.FrozenInstanceError):
meta.session_id = "new" # type: ignore[misc]
class TestSessionMetaAsDict:
def test_returns_dict(self) -> None:
d = attrs.asdict(make_meta())
assert isinstance(d, dict)
assert d["session_id"] == "abcd1234-5678-9012-3456-789012345678"
assert d["duration_s"] == 3600.0
def test_includes_optional_fields(self) -> None:
d = attrs.asdict(make_meta(git_branch="feature", git_commits=3))
assert d["git_branch"] == "feature"
assert d["git_commits"] == 3
# ---------------------------------------------------------------------------
# SessionAudit
# ---------------------------------------------------------------------------
class TestSessionAuditDefaults:
def test_defaults(self) -> None:
a = SessionAudit(session_id="x")
assert a.outcome == "unclear"
assert a.satisfaction == "neutral"
assert a.session_type == "single_task"
assert a.goal_categories == {}
assert a.friction_counts == {}
assert a.user_instructions == ()
assert a.summary == ""
def test_frozen(self) -> None:
a = SessionAudit(session_id="x")
with pytest.raises(attrs.exceptions.FrozenInstanceError):
a.outcome = "success" # type: ignore[misc]
class TestSessionAuditAsDict:
def test_returns_dict(self) -> None:
a = make_audit()
d = attrs.asdict(a)
assert isinstance(d, dict)
assert d["outcome"] == "mostly_achieved"
def test_reflects_values(self) -> None:
a = make_audit(outcome="success", satisfaction="positive", session_type="multi_task")
d = attrs.asdict(a)
assert d["outcome"] == "success"
assert d["session_type"] == "multi_task"
# ---------------------------------------------------------------------------
# ProjectStats — mutable is_outlier
# ---------------------------------------------------------------------------
class TestProjectStats:
def make(self, **kw: Any) -> ProjectStats:
defaults: dict[str, Any] = {
"project_path": "/proj/myapp",
"project_name": "myapp",
"session_count": 10,
"success_rate": 0.9,
"avg_tool_errors": 2.5,
"avg_duration_s": 600.0,
"top_error_categories": (),
"top_friction": (),
}
defaults.update(kw)
return ProjectStats(**defaults)
def test_is_outlier_default_false(self) -> None:
assert not self.make().is_outlier
def test_is_outlier_mutable(self) -> None:
p = self.make()
p.is_outlier = True
assert p.is_outlier
# ---------------------------------------------------------------------------
# WeekStats + Recommendation
# ---------------------------------------------------------------------------
class TestWeekStats:
def test_frozen(self) -> None:
w = WeekStats(
week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
)
with pytest.raises(attrs.exceptions.FrozenInstanceError):
w.session_count = 5 # type: ignore[misc]
def test_default_error_counts(self) -> None:
w = WeekStats(
week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
)
assert w.error_category_counts == {}
class TestRecommendation:
def test_frozen(self) -> None:
r = Recommendation(suggestion="do X", evidence="50%", frequency=0.5, source_sessions=5)
with pytest.raises(attrs.exceptions.FrozenInstanceError):
r.suggestion = "do Y" # type: ignore[misc]
# ---------------------------------------------------------------------------
# SessionDigest
# ---------------------------------------------------------------------------
class TestSessionDigest:
def make(self, **kw: Any) -> SessionDigest:
defaults: dict[str, Any] = {"session_count": 10, "date_range": (100.0, 500.0), "success_rate": 0.8}
defaults.update(kw)
return SessionDigest(**defaults)
def test_attrs_asdict(self) -> None:
d = attrs.asdict(self.make())
assert d["session_count"] == 10
assert d["success_rate"] == 0.8
def test_frozen(self) -> None:
d = self.make()
with pytest.raises(attrs.exceptions.FrozenInstanceError):
d.session_count = 99 # type: ignore[misc]
def test_json_serializable(self) -> None:
j = json.dumps(attrs.asdict(self.make(session_count=5)), indent=2, default=str)
parsed = json.loads(j)
assert parsed["session_count"] == 5
def test_json_with_nested_weeks(self) -> None:
w = WeekStats(
week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
)
j = json.dumps(attrs.asdict(self.make(weeks=(w,))), indent=2, default=str)
parsed = json.loads(j)
assert len(parsed["weeks"]) == 1
assert parsed["weeks"][0]["week"] == "2026-W17"
def test_json_with_nested_projects(self) -> None:
p = ProjectStats(
project_path="/p",
project_name="p",
session_count=5,
success_rate=0.8,
avg_tool_errors=1.0,
avg_duration_s=300.0,
top_error_categories=(),
top_friction=(),
is_outlier=False,
)
j = json.dumps(attrs.asdict(self.make(projects=(p,))), indent=2, default=str)
parsed = json.loads(j)
assert len(parsed["projects"]) == 1
assert parsed["projects"][0]["project_name"] == "p"

View file

@ -0,0 +1,268 @@
from __future__ import annotations
import time
from blackbox.dashboard.rendering import (
esc,
esc_md,
fmt_duration,
fmt_relative,
fmt_time,
passes_filter,
render_log_html,
shorten_paths,
tool_call_html,
)
from blackbox.models import LogEntry
# ---------------------------------------------------------------------------
# fmt_time
# ---------------------------------------------------------------------------
class TestFmtTime:
def test_epoch_zero(self) -> None:
assert "00:00:00" == fmt_time(0.0)
def test_known_timestamp(self) -> None:
assert "01:46:40" == fmt_time(1_000_000_000.0)
def test_fractional_seconds_truncated(self) -> None:
assert "00:00:00" == fmt_time(0.999)
# ---------------------------------------------------------------------------
# fmt_duration
# ---------------------------------------------------------------------------
class TestFmtDuration:
def test_zero_seconds(self) -> None:
assert "0s" == fmt_duration(100.0, 100.0)
def test_seconds_only(self) -> None:
assert "45s" == fmt_duration(0.0, 45.0)
def test_minutes_and_seconds(self) -> None:
assert "2m30s" == fmt_duration(0.0, 150.0)
def test_hours_and_minutes(self) -> None:
assert "1h30m" == fmt_duration(0.0, 5400.0)
def test_negative_clamps_to_zero(self) -> None:
assert "0s" == fmt_duration(100.0, 50.0)
def test_none_finished_uses_current_time(self) -> None:
result = fmt_duration(time.time() - 10, None)
assert result.endswith("s")
def test_exactly_60_seconds(self) -> None:
assert "1m00s" == fmt_duration(0.0, 60.0)
def test_exactly_one_hour(self) -> None:
assert "1h00m" == fmt_duration(0.0, 3600.0)
# ---------------------------------------------------------------------------
# fmt_relative
# ---------------------------------------------------------------------------
class TestFmtRelative:
def test_just_now(self) -> None:
assert "just now" == fmt_relative(time.time())
def test_minutes_ago(self) -> None:
assert "5m ago" == fmt_relative(time.time() - 300)
def test_hours_ago(self) -> None:
assert "2h ago" == fmt_relative(time.time() - 7200)
def test_days_ago(self) -> None:
assert "3d ago" == fmt_relative(time.time() - 259200)
# ---------------------------------------------------------------------------
# esc / esc_md
# ---------------------------------------------------------------------------
class TestEsc:
def test_ampersand(self) -> None:
assert "a &amp; b" == esc("a & b")
def test_angle_brackets(self) -> None:
assert "&lt;div&gt;" == esc("<div>")
def test_newlines_become_br(self) -> None:
assert "a<br>b" == esc("a\nb")
def test_combined(self) -> None:
assert "&lt;b&gt;hi&lt;/b&gt;<br>&amp;" == esc("<b>hi</b>\n&")
class TestEscMd:
def test_bold_converted(self) -> None:
result = esc_md("hello **world**")
assert '<strong class="text-white">world</strong>' in result
def test_html_still_escaped(self) -> None:
result = esc_md("<script>**bold**")
assert "&lt;script&gt;" in result
assert '<strong class="text-white">bold</strong>' in result
def test_no_bold(self) -> None:
assert "plain text" == esc_md("plain text")
# ---------------------------------------------------------------------------
# shorten_paths
# ---------------------------------------------------------------------------
class TestShortenPaths:
def test_removes_tmp_paths(self) -> None:
assert "file: " == shorten_paths("file: /tmp/abc123/foo.py")
def test_removes_private_tmp_paths(self) -> None:
assert "file: " == shorten_paths("file: /private/tmp/abc123/bar.py")
def test_no_match_unchanged(self) -> None:
assert "/home/user/code" == shorten_paths("/home/user/code")
# ---------------------------------------------------------------------------
# passes_filter
# ---------------------------------------------------------------------------
class TestPassesFilter:
def make(self, level: str = "info", message: str = "hi", source: str = "user") -> LogEntry:
return LogEntry(timestamp=0.0, source=source, level=level, message=message)
def test_empty_message_rejected(self) -> None:
assert not passes_filter(self.make(message=""), "all", None)
def test_whitespace_only_rejected(self) -> None:
assert not passes_filter(self.make(message=" "), "all", None)
def test_all_filter_accepts_everything(self) -> None:
assert passes_filter(self.make(), "all", None)
def test_skip_levels_rejected_in_compact(self) -> None:
for level in ("delta", "stream", "block_stop", "block_start", "thinking_delta", "tool_start"):
assert not passes_filter(self.make(level=level), "compact", None)
def test_allowed_set_filters(self) -> None:
allowed = {"error"}
assert passes_filter(self.make(level="error"), "errors", allowed)
assert not passes_filter(self.make(level="info"), "errors", allowed)
def test_thinking_rejected_in_compact(self) -> None:
entry = self.make(level="assistant", message="(thinking)")
assert not passes_filter(entry, "compact", None)
def test_assistant_non_thinking_accepted(self) -> None:
entry = self.make(level="assistant", message="Hello there")
assert passes_filter(entry, "compact", None)
def test_skip_levels_pass_in_all_mode(self) -> None:
entry = self.make(level="delta", message="x")
assert passes_filter(entry, "all", None)
# ---------------------------------------------------------------------------
# tool_call_html
# ---------------------------------------------------------------------------
class TestToolCallHtml:
def test_short_preview_no_details(self) -> None:
html = tool_call_html("ls -la")
assert "<details" not in html
assert "ls -la" in html
def test_three_lines_no_details(self) -> None:
html = tool_call_html("line1\nline2\nline3")
assert "<details" not in html
def test_long_preview_has_details(self) -> None:
text = "\n".join(f"line {i}" for i in range(10))
html = tool_call_html(text)
assert "<details" in html
assert "+9 lines" in html
def test_tmp_paths_shortened(self) -> None:
html = tool_call_html("/tmp/abc123/foo.py")
assert "/tmp/" not in html
# ---------------------------------------------------------------------------
# render_log_html
# ---------------------------------------------------------------------------
class TestRenderLogHtml:
def make(self, **kw: object) -> LogEntry:
defaults = {
"timestamp": 1_000_000_000.0,
"source": "claude",
"level": "assistant",
"message": "hello",
"data": {},
}
defaults.update(kw)
return LogEntry(**defaults) # type: ignore[arg-type]
def test_user_message_green(self) -> None:
html = render_log_html(self.make(source="user", level="info"))
assert "text-green-300" in html
def test_assistant_message(self) -> None:
html = render_log_html(self.make(level="assistant", message="hi"))
assert "text-gray-100" in html
def test_thinking_italic(self) -> None:
html = render_log_html(self.make(level="assistant", message="(thinking)"))
assert "italic" in html
def test_error_red(self) -> None:
html = render_log_html(self.make(level="error", message="fail"))
assert "text-red-400" in html
def test_tool_call_amber_badge(self) -> None:
html = render_log_html(
self.make(
level="tool_call",
message="Bash: ls",
data={"tool": "Bash", "input_preview": "ls"},
)
)
assert "bg-amber-500" in html
assert "Bash" in html
def test_tool_result_has_res_badge(self) -> None:
html = render_log_html(self.make(level="tool_result", message="output"))
assert "RES" in html
def test_tool_result_truncates_long_messages(self) -> None:
html = render_log_html(self.make(level="tool_result", message="x" * 600))
assert "..." in html
def test_contains_timestamp(self) -> None:
html = render_log_html(self.make())
assert "01:46:40" in html
def test_source_badges(self) -> None:
for source, label in [("claude", "CLU"), ("user", "USR"), ("system", "SYS")]:
html = render_log_html(self.make(source=source, level="info"))
assert label in html
def test_tool_levels_have_indent_and_opacity(self) -> None:
html = render_log_html(self.make(level="tool_call", data={"tool": "Read", "input_preview": "x"}))
assert "opacity-60" in html
assert "pl-4" in html
def test_non_tool_no_indent(self) -> None:
html = render_log_html(self.make(level="info"))
assert "pl-4" not in html

View file

@ -0,0 +1,74 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from blackbox.dashboard.routes import build_session_info, mark_live
from blackbox.models import SessionInfo
# ---------------------------------------------------------------------------
# mark_live
# ---------------------------------------------------------------------------
class TestMarkLive:
def make_session(self, session_id: str = "sess-1") -> SessionInfo:
return SessionInfo(
session_id=session_id,
project_path="proj",
project_name="proj",
transcript_path="/tmp/proj/sess.jsonl",
started_at=1_000_000.0,
)
def test_no_live_ids_returns_same_list(self) -> None:
watcher = MagicMock()
watcher.live_session_ids.return_value = set()
sessions = [self.make_session()]
result = mark_live(sessions, watcher)
assert result is sessions
def test_marks_matching_session_as_live(self) -> None:
watcher = MagicMock()
watcher.live_session_ids.return_value = {"sess-1"}
sessions = [self.make_session("sess-1"), self.make_session("sess-2")]
result = mark_live(sessions, watcher)
assert result[0].is_live is True
assert result[1].is_live is False
def test_non_matching_sessions_unchanged(self) -> None:
watcher = MagicMock()
watcher.live_session_ids.return_value = {"other"}
sessions = [self.make_session("sess-1")]
result = mark_live(sessions, watcher)
assert result[0].is_live is False
# ---------------------------------------------------------------------------
# build_session_info
# ---------------------------------------------------------------------------
class TestBuildSessionInfo:
def write_transcript(self, path: Path, entries: list[dict[str, Any]]) -> None:
path.write_text("\n".join(json.dumps(e) for e in entries))
def test_returns_info_from_transcript(self, tmp_path: Path) -> None:
path = tmp_path / "abc.jsonl"
self.write_transcript(
path,
[{"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hello"}}],
)
info = build_session_info(path, "abc", "proj")
assert "abc" == info.session_id
assert "hello" == info.first_prompt
def test_fallback_for_empty_transcript(self, tmp_path: Path) -> None:
path = tmp_path / "empty.jsonl"
path.write_text("")
info = build_session_info(path, "empty", "proj")
assert "empty" == info.session_id
assert "proj" == info.project_name
assert "" == info.first_prompt

View file

@ -0,0 +1,552 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from blackbox.dashboard.transcript import (
decode_project_name,
extract_text_content,
extract_tool_results,
extract_tool_uses,
parse_assistant_entry,
parse_entry,
parse_transcript,
parse_transcript_tail,
parse_user_entry,
quick_session_info,
scan_sessions,
tool_input_preview,
ts_to_epoch,
)
# ---------------------------------------------------------------------------
# ts_to_epoch
# ---------------------------------------------------------------------------
class TestTsToEpoch:
def test_none_returns_zero(self) -> None:
assert 0.0 == ts_to_epoch(None)
def test_empty_string_returns_zero(self) -> None:
assert 0.0 == ts_to_epoch("")
def test_valid_iso_timestamp(self) -> None:
result = ts_to_epoch("2024-01-01T00:00:00Z")
assert result > 0
def test_naive_datetime_treated_as_utc(self) -> None:
result = ts_to_epoch("2024-01-01T00:00:00")
assert result > 0
def test_invalid_format_returns_zero(self) -> None:
assert 0.0 == ts_to_epoch("not-a-date")
# ---------------------------------------------------------------------------
# extract_text_content
# ---------------------------------------------------------------------------
class TestExtractTextContent:
def test_string_passthrough(self) -> None:
assert "hello" == extract_text_content("hello")
def test_list_of_text_blocks(self) -> None:
content = [
{"type": "text", "text": "hello"},
{"type": "text", "text": "world"},
]
assert "hello\nworld" == extract_text_content(content)
def test_non_text_blocks_skipped(self) -> None:
content = [
{"type": "tool_use", "name": "Bash"},
{"type": "text", "text": "only this"},
]
assert "only this" == extract_text_content(content)
def test_empty_list(self) -> None:
assert "" == extract_text_content([])
def test_non_string_non_list(self) -> None:
assert "" == extract_text_content(42)
def test_non_dict_items_skipped(self) -> None:
assert "" == extract_text_content(["not a dict"])
# ---------------------------------------------------------------------------
# extract_tool_uses / extract_tool_results
# ---------------------------------------------------------------------------
class TestExtractToolUses:
def test_extracts_tool_use_blocks(self) -> None:
content = [{"type": "tool_use", "name": "Read"}, {"type": "text", "text": "x"}]
assert [{"type": "tool_use", "name": "Read"}] == extract_tool_uses(content)
def test_non_list_returns_empty(self) -> None:
assert [] == extract_tool_uses("string")
def test_empty_list(self) -> None:
assert [] == extract_tool_uses([])
class TestExtractToolResults:
def test_extracts_tool_result_blocks(self) -> None:
content = [{"type": "tool_result", "content": "ok"}, {"type": "text", "text": "x"}]
assert [{"type": "tool_result", "content": "ok"}] == extract_tool_results(content)
def test_non_list_returns_empty(self) -> None:
assert [] == extract_tool_results(42)
# ---------------------------------------------------------------------------
# tool_input_preview
# ---------------------------------------------------------------------------
class TestToolInputPreview:
def test_bash_shows_command(self) -> None:
assert "ls -la" == tool_input_preview("Bash", {"command": "ls -la"})
def test_read_shows_path(self) -> None:
assert "/foo.py" == tool_input_preview("Read", {"file_path": "/foo.py"})
def test_write_shows_path(self) -> None:
assert "/bar.py" == tool_input_preview("Write", {"file_path": "/bar.py"})
def test_edit_shows_path_and_old_string(self) -> None:
result = tool_input_preview("Edit", {"file_path": "/f.py", "old_string": "x" * 100})
assert "/f.py" in result
assert result.endswith("...")
def test_agent_shows_description(self) -> None:
result = tool_input_preview("Agent", {"description": "find bugs", "prompt": "long prompt"})
assert "find bugs" == result
def test_agent_falls_back_to_prompt(self) -> None:
result = tool_input_preview("Agent", {"prompt": "do stuff"})
assert "do stuff" == result
def test_skill_shows_skill_name(self) -> None:
assert "commit" == tool_input_preview("Skill", {"skill": "commit"})
def test_unknown_tool_json_preview(self) -> None:
result = tool_input_preview("CustomTool", {"key": "value"})
assert "key" in result
assert "value" in result
def test_unknown_tool_truncated_at_200(self) -> None:
result = tool_input_preview("CustomTool", {"key": "x" * 300})
assert len(result) <= 200
# ---------------------------------------------------------------------------
# parse_entry / parse_user_entry / parse_assistant_entry
# ---------------------------------------------------------------------------
class TestParseEntry:
def test_user_entry(self) -> None:
raw = {"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hello"}}
entries = parse_entry(raw)
assert 1 == len(entries)
assert "user" == entries[0].source
assert "hello" == entries[0].message
def test_assistant_text_entry(self) -> None:
raw = {
"type": "assistant",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": [{"type": "text", "text": "hi"}]},
}
entries = parse_entry(raw)
assert 1 == len(entries)
assert "claude" == entries[0].source
assert "assistant" == entries[0].level
def test_assistant_tool_use(self) -> None:
raw = {
"type": "assistant",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": [{"type": "tool_use", "name": "Read", "input": {"file_path": "/x.py"}}]},
}
entries = parse_entry(raw)
assert 1 == len(entries)
assert "tool_call" == entries[0].level
assert "Read" in entries[0].message
def test_assistant_thinking_block(self) -> None:
raw = {
"type": "assistant",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": [{"type": "thinking"}]},
}
entries = parse_entry(raw)
assert 1 == len(entries)
assert "(thinking)" == entries[0].message
def test_system_entry(self) -> None:
raw = {"type": "system", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "init"}}
entries = parse_entry(raw)
assert 1 == len(entries)
assert "system" == entries[0].source
def test_unknown_type_returns_empty(self) -> None:
assert [] == parse_entry({"type": "unknown"})
class TestParseUserEntry:
def test_tool_result_with_error(self) -> None:
content = [{"type": "tool_result", "content": "fail", "is_error": True}]
raw: dict[str, Any] = {"toolUseResult": {"stderr": "bad command"}}
entries = parse_user_entry(0.0, {"content": content}, raw)
assert 1 == len(entries)
assert "error" == entries[0].level
assert "bad command" == entries[0].message
def test_tool_result_success(self) -> None:
content = [{"type": "tool_result", "content": "output text"}]
entries = parse_user_entry(0.0, {"content": content}, {})
assert 1 == len(entries)
assert "tool_result" == entries[0].level
def test_tool_result_with_stdout(self) -> None:
content = [{"type": "tool_result", "content": "ignored"}]
raw: dict[str, Any] = {"toolUseResult": {"stdout": "real output"}}
entries = parse_user_entry(0.0, {"content": content}, raw)
assert "real output" == entries[0].message
def test_tool_result_content_as_list(self) -> None:
content = [{"type": "tool_result", "content": [{"text": "a"}, {"text": "b"}]}]
entries = parse_user_entry(0.0, {"content": content}, {})
assert "a b" == entries[0].message
def test_non_dict_message_returns_empty(self) -> None:
assert [] == parse_user_entry(0.0, "not a dict", {})
def test_tool_result_message_truncated_at_2000(self) -> None:
content = [{"type": "tool_result", "content": "x" * 3000}]
entries = parse_user_entry(0.0, {"content": content}, {})
assert 2000 == len(entries[0].message)
def test_tool_use_result_non_dict_ignored(self) -> None:
content = [{"type": "tool_result", "content": "ok", "is_error": True}]
raw: dict[str, Any] = {"toolUseResult": "not a dict"}
entries = parse_user_entry(0.0, {"content": content}, raw)
assert 1 == len(entries)
class TestParseAssistantEntry:
def test_non_dict_message_returns_empty(self) -> None:
assert [] == parse_assistant_entry(0.0, "not a dict")
def test_string_content(self) -> None:
entries = parse_assistant_entry(0.0, {"content": "hello"})
assert 1 == len(entries)
assert "hello" == entries[0].message
def test_empty_content(self) -> None:
assert [] == parse_assistant_entry(0.0, {"content": ""})
assert [] == parse_assistant_entry(0.0, {"content": []})
def test_non_dict_blocks_skipped(self) -> None:
entries = parse_assistant_entry(0.0, {"content": ["not a dict"]})
assert [] == entries
def test_mixed_content(self) -> None:
content = [
{"type": "text", "text": "thinking about it"},
{"type": "tool_use", "name": "Bash", "input": {"command": "ls"}},
{"type": "thinking"},
]
entries = parse_assistant_entry(0.0, {"content": content})
assert 3 == len(entries)
assert "assistant" == entries[0].level
assert "tool_call" == entries[1].level
assert "(thinking)" == entries[2].message
# ---------------------------------------------------------------------------
# parse_transcript
# ---------------------------------------------------------------------------
class TestParseTranscript:
def test_parses_jsonl(self, tmp_path: Path) -> None:
lines = [
json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hi"}}),
json.dumps(
{
"type": "assistant",
"timestamp": "2024-01-01T00:00:01Z",
"message": {"content": [{"type": "text", "text": "hello"}]},
}
),
]
path = tmp_path / "session.jsonl"
path.write_text("\n".join(lines))
entries = parse_transcript(path)
assert 2 == len(entries)
assert "user" == entries[0].source
assert "claude" == entries[1].source
def test_skips_blank_lines(self, tmp_path: Path) -> None:
lines = [
json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hi"}}),
"",
" ",
json.dumps(
{
"type": "assistant",
"timestamp": "2024-01-01T00:00:01Z",
"message": {"content": [{"type": "text", "text": "ok"}]},
}
),
]
path = tmp_path / "session.jsonl"
path.write_text("\n".join(lines))
assert 2 == len(parse_transcript(path))
def test_skips_invalid_json(self, tmp_path: Path) -> None:
path = tmp_path / "session.jsonl"
path.write_text("not json\n{bad json}\n")
assert [] == parse_transcript(path)
def test_empty_file(self, tmp_path: Path) -> None:
path = tmp_path / "session.jsonl"
path.write_text("")
assert [] == parse_transcript(path)
# ---------------------------------------------------------------------------
# parse_transcript_tail
# ---------------------------------------------------------------------------
class TestParseTranscriptTail:
def test_reads_from_offset(self, tmp_path: Path) -> None:
line1 = json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "first"}})
line2 = json.dumps({"type": "user", "timestamp": "2024-01-01T00:01:00Z", "message": {"content": "second"}})
path = tmp_path / "session.jsonl"
path.write_text(line1 + "\n")
offset = path.stat().st_size
with path.open("a") as f:
f.write(line2 + "\n")
entries, new_offset = parse_transcript_tail(path, offset)
assert 1 == len(entries)
assert "second" == entries[0].message
assert new_offset > offset
def test_offset_zero_reads_full_file(self, tmp_path: Path) -> None:
line = json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hi"}})
path = tmp_path / "session.jsonl"
path.write_text(line + "\n")
entries, offset = parse_transcript_tail(path, 0)
assert 1 == len(entries)
assert offset == path.stat().st_size
def test_no_new_data_returns_empty(self, tmp_path: Path) -> None:
line = json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hi"}})
path = tmp_path / "session.jsonl"
path.write_text(line + "\n")
offset = path.stat().st_size
entries, new_offset = parse_transcript_tail(path, offset)
assert [] == entries
assert new_offset == offset
def test_skips_invalid_json_in_tail(self, tmp_path: Path) -> None:
path = tmp_path / "session.jsonl"
path.write_text("")
offset = 0
with path.open("a") as f:
f.write("bad json\n")
f.write(
json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "ok"}}) + "\n"
)
entries, _ = parse_transcript_tail(path, offset)
assert 1 == len(entries)
assert "ok" == entries[0].message
def test_multiple_appends(self, tmp_path: Path) -> None:
path = tmp_path / "session.jsonl"
path.write_text("")
offset = 0
for i in range(3):
with path.open("a") as f:
f.write(
json.dumps(
{"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": f"msg-{i}"}}
)
+ "\n"
)
entries, offset = parse_transcript_tail(path, offset)
assert 1 == len(entries)
assert f"msg-{i}" == entries[0].message
# ---------------------------------------------------------------------------
# decode_project_name
# ---------------------------------------------------------------------------
class TestDecodeProjectName:
def test_encoded_path(self) -> None:
assert "work/myproject" == decode_project_name("-Users-kevin-Desktop-work-myproject")
def test_filters_common_parts(self) -> None:
result = decode_project_name("-Users-private-tmp-")
assert result == "-Users-private-tmp-"
def test_simple_name_passthrough(self) -> None:
assert "myproject" == decode_project_name("myproject")
def test_short_meaningful_parts(self) -> None:
assert "kevin/myproject" == decode_project_name("-Users-kevin-myproject")
# ---------------------------------------------------------------------------
# quick_session_info
# ---------------------------------------------------------------------------
class TestQuickSessionInfo:
def write_transcript(self, path: Path, entries: list[dict[str, Any]]) -> None:
path.write_text("\n".join(json.dumps(e) for e in entries))
def test_basic_session(self, tmp_path: Path) -> None:
path = tmp_path / "abc123.jsonl"
self.write_transcript(
path,
[
{
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": "help me debug this"},
"cwd": "/home/user/projects/myapp",
},
{
"type": "assistant",
"timestamp": "2024-01-01T00:05:00Z",
"message": {"content": [{"type": "text", "text": "sure"}]},
},
],
)
info = quick_session_info(path, "abc123", "encoded-project", "myproject")
assert info is not None
assert "abc123" == info.session_id
assert "help me debug this" == info.first_prompt
assert 1 == info.message_count
assert "projects/myapp" == info.project_name
def test_skips_tool_results_for_first_prompt(self, tmp_path: Path) -> None:
path = tmp_path / "abc123.jsonl"
self.write_transcript(
path,
[
{
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": [{"type": "tool_result", "content": "output"}]},
},
{
"type": "user",
"timestamp": "2024-01-01T00:01:00Z",
"message": {"content": "real prompt"},
},
],
)
info = quick_session_info(path, "abc123", "enc", "proj")
assert info is not None
assert "real prompt" == info.first_prompt
def test_returns_none_for_empty_file(self, tmp_path: Path) -> None:
path = tmp_path / "empty.jsonl"
path.write_text("")
assert quick_session_info(path, "empty", "enc", "proj") is None
def test_returns_none_for_missing_file(self, tmp_path: Path) -> None:
path = tmp_path / "missing.jsonl"
assert quick_session_info(path, "missing", "enc", "proj") is None
def test_first_prompt_truncated_at_120(self, tmp_path: Path) -> None:
path = tmp_path / "abc.jsonl"
self.write_transcript(
path,
[{"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "x" * 200}}],
)
info = quick_session_info(path, "abc", "enc", "proj")
assert info is not None
assert 120 == len(info.first_prompt)
def test_uses_mtime_for_finished_at(self, tmp_path: Path) -> None:
path = tmp_path / "abc.jsonl"
self.write_transcript(
path,
[{"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hi"}}],
)
info = quick_session_info(path, "abc", "enc", "proj")
assert info is not None
assert info.finished_at is not None
assert info.finished_at >= info.started_at
def test_cwd_used_for_display_name(self, tmp_path: Path) -> None:
path = tmp_path / "abc.jsonl"
self.write_transcript(
path,
[
{
"type": "user",
"timestamp": "2024-01-01T00:00:00Z",
"message": {"content": "hi"},
"cwd": "/Users/kevin/Desktop/work/myapp",
},
],
)
info = quick_session_info(path, "abc", "enc", "proj")
assert info is not None
assert "work/myapp" == info.project_name
# ---------------------------------------------------------------------------
# scan_sessions
# ---------------------------------------------------------------------------
class TestScanSessions:
def test_empty_dir(self, tmp_path: Path) -> None:
assert [] == scan_sessions(tmp_path)
def test_nonexistent_dir(self, tmp_path: Path) -> None:
assert [] == scan_sessions(tmp_path / "nonexistent")
def test_finds_sessions(self, tmp_path: Path) -> None:
project_dir = tmp_path / "encoded-project"
project_dir.mkdir()
transcript = project_dir / "sess-123.jsonl"
transcript.write_text(
json.dumps({"type": "user", "timestamp": "2024-01-01T00:00:00Z", "message": {"content": "hi"}})
)
sessions = scan_sessions(tmp_path)
assert 1 == len(sessions)
assert "sess-123" == sessions[0].session_id
def test_sorted_by_started_at_descending(self, tmp_path: Path) -> None:
project_dir = tmp_path / "proj"
project_dir.mkdir()
for i, ts in enumerate(["2024-01-01T00:00:00Z", "2024-06-01T00:00:00Z", "2024-03-01T00:00:00Z"]):
path = project_dir / f"sess-{i}.jsonl"
path.write_text(json.dumps({"type": "user", "timestamp": ts, "message": {"content": "hi"}}))
sessions = scan_sessions(tmp_path)
assert 3 == len(sessions)
assert sessions[0].started_at >= sessions[1].started_at >= sessions[2].started_at
def test_skips_non_directory_entries(self, tmp_path: Path) -> None:
(tmp_path / "not_a_dir.txt").write_text("hello")
assert [] == scan_sessions(tmp_path)

View file

@ -0,0 +1,169 @@
from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import MagicMock
from blackbox.dashboard.watcher import LIVE_THRESHOLD_S, SessionWatcher
class TestSessionWatcherLiveIds:
def test_empty_initially(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
assert set() == w.live_session_ids()
def test_recent_modification_is_live(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "abc123.jsonl")
w.on_modified(event)
assert {"abc123"} == w.live_session_ids()
def test_old_modification_expires(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
with w._lock:
w._last_modified["old-session"] = time.time() - LIVE_THRESHOLD_S - 10
assert set() == w.live_session_ids()
def test_non_jsonl_ignored(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "notes.txt")
w.on_modified(event)
assert set() == w.live_session_ids()
def test_directory_events_ignored(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
event = MagicMock()
event.is_directory = True
event.src_path = str(tmp_path / "somedir")
w.on_modified(event)
assert set() == w.live_session_ids()
def test_on_created_delegates_to_on_modified(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "new-session.jsonl")
w.on_created(event)
assert {"new-session"} == w.live_session_ids()
def test_expired_entries_cleaned_up(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
with w._lock:
w._last_modified["expired"] = time.time() - LIVE_THRESHOLD_S - 100
w._last_modified["fresh"] = time.time()
live = w.live_session_ids()
assert {"fresh"} == live
with w._lock:
assert "expired" not in w._last_modified
class TestSessionWatcherStartStop:
def test_start_without_directory_is_noop(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path / "nonexistent")
w.start()
assert w._observer is None
def test_stop_without_start_is_safe(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.stop()
def test_start_and_stop(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.start()
assert w._observer is not None
w.stop()
assert w._observer is None
def test_multiple_stops_safe(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.start()
w.stop()
w.stop()
class TestSessionWatcherCache:
def test_get_sessions_calls_scan_on_first_call(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
sentinel: list[object] = []
result = w.get_sessions(lambda _: sentinel)
assert result is sentinel
def test_get_sessions_returns_cached_on_second_call(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
call_count = 0
def counting_scan(_: object) -> list[object]:
nonlocal call_count
call_count += 1
return []
w.get_sessions(counting_scan)
w.get_sessions(counting_scan)
assert 1 == call_count
def test_on_modified_invalidates_cache(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
call_count = 0
def counting_scan(_: object) -> list[object]:
nonlocal call_count
call_count += 1
return []
w.get_sessions(counting_scan)
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "sess.jsonl")
w.on_modified(event)
w.get_sessions(counting_scan)
assert 2 == call_count
def test_on_created_invalidates_cache(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.get_sessions(lambda _: [])
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "new.jsonl")
w.on_created(event)
assert w._cached_sessions is None
def test_on_deleted_invalidates_cache(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.get_sessions(lambda _: [])
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "removed.jsonl")
w.on_deleted(event)
assert w._cached_sessions is None
def test_on_deleted_ignores_non_jsonl(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.get_sessions(lambda _: [])
event = MagicMock()
event.is_directory = False
event.src_path = str(tmp_path / "notes.txt")
w.on_deleted(event)
assert w._cached_sessions is not None
def test_on_deleted_ignores_directories(self, tmp_path: Path) -> None:
w = SessionWatcher(tmp_path)
w.get_sessions(lambda _: [])
event = MagicMock()
event.is_directory = True
event.src_path = str(tmp_path / "somedir")
w.on_deleted(event)
assert w._cached_sessions is not None

View file

@ -48,6 +48,9 @@ codeflash-python = { workspace = true }
codeflash-service = { workspace = true } codeflash-service = { workspace = true }
attrs = { url = "https://github.com/KRRT7/attrs/releases/download/26.1.0.post1/attrs-26.1.0.post1-py3-none-any.whl" } attrs = { url = "https://github.com/KRRT7/attrs/releases/download/26.1.0.post1/attrs-26.1.0.post1-py3-none-any.whl" }
[tool.ty.environment]
python-version = "3.12"
[tool.ruff] [tool.ruff]
src = [ src = [
"packages/codeflash-api/src", "packages/codeflash-api/src",

569
uv.lock
View file

@ -18,6 +18,7 @@ resolution-markers = [
[manifest] [manifest]
members = [ members = [
"blackbox",
"codeflash-api", "codeflash-api",
"codeflash-ci-audit", "codeflash-ci-audit",
"codeflash-core", "codeflash-core",
@ -298,6 +299,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
] ]
[[package]]
name = "blackbox"
version = "0.1.0"
source = { editable = "packages/blackbox" }
dependencies = [
{ name = "attrs" },
{ name = "danom" },
{ name = "fastapi", extra = ["standard"] },
{ name = "jinja2" },
{ name = "sse-starlette" },
{ name = "uvicorn" },
{ name = "watchdog" },
]
[package.dev-dependencies]
dev = [
{ name = "interrogate" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-playwright" },
{ name = "ruff" },
{ name = "ty" },
]
typing = [
{ name = "mypy" },
]
[package.metadata]
requires-dist = [
{ name = "attrs", url = "https://github.com/KRRT7/attrs/releases/download/26.1.0.post1/attrs-26.1.0.post1-py3-none-any.whl" },
{ name = "danom", git = "https://github.com/KRRT7/danom.git?branch=feat%2Fadd-py-typed" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "sse-starlette", specifier = ">=2.0.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
{ name = "watchdog", specifier = ">=4.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "interrogate", specifier = ">=1.7.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
{ name = "pytest-playwright", specifier = ">=0.7.2" },
{ name = "ruff", specifier = ">=0.15.12" },
{ name = "ty", specifier = ">=0.0.33" },
]
typing = [{ name = "mypy", specifier = ">=1.20.2" }]
[[package]] [[package]]
name = "blinker" name = "blinker"
version = "1.9.0" version = "1.9.0"
@ -596,7 +648,7 @@ requires-dist = [{ name = "codeflash-core", editable = "packages/codeflash-core"
[[package]] [[package]]
name = "codeflash-python" name = "codeflash-python"
version = "0.1.0" version = "0.1.1.dev0"
source = { editable = "packages/codeflash-python" } source = { editable = "packages/codeflash-python" }
dependencies = [ dependencies = [
{ name = "codeflash-core" }, { name = "codeflash-core" },
@ -1008,6 +1060,14 @@ nvtx = [
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" },
] ]
[[package]]
name = "danom"
version = "0.13.1"
source = { git = "https://github.com/KRRT7/danom.git?branch=feat%2Fadd-py-typed#2525ecf139c65645c869f7f1d11a2c62b15b5459" }
dependencies = [
{ name = "attrs" },
]
[[package]] [[package]]
name = "dash" name = "dash"
version = "4.1.0" version = "4.1.0"
@ -1051,6 +1111,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
] ]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]] [[package]]
name = "docker" name = "docker"
version = "7.1.0" version = "7.1.0"
@ -1074,6 +1143,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
] ]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.0" version = "0.136.0"
@ -1090,6 +1172,130 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" },
] ]
[package.optional-dependencies]
standard = [
{ name = "email-validator" },
{ name = "fastapi-cli", extra = ["standard"] },
{ name = "fastar" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic-extra-types" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cli"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" },
]
[package.optional-dependencies]
standard = [
{ name = "fastapi-cloud-cli" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cloud-cli"
version = "0.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastar" },
{ name = "httpx" },
{ name = "pydantic", extra = ["email"] },
{ name = "rich-toolkit" },
{ name = "rignore" },
{ name = "sentry-sdk" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/57/cee8e91b83f39e75ae5562a2237261442a8179dcb3b631c7398113157398/fastapi_cloud_cli-0.17.1.tar.gz", hash = "sha256:0baece208fa88063bec46dccb5fb512f3199162092165e57654b44e64adbc44d", size = 47409, upload-time = "2026-04-27T13:38:07.094Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/a0/e252b68cf155409afabea037ab2971f41509481838847f6503fe890884ea/fastapi_cloud_cli-0.17.1-py3-none-any.whl", hash = "sha256:325e0199bdac7cb86f5df4f4a1d2070054095588088ef7b923a60cec458dcd63", size = 34046, upload-time = "2026-04-27T13:38:08.319Z" },
]
[[package]]
name = "fastar"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" },
{ url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" },
{ url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" },
{ url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" },
{ url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" },
{ url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" },
{ url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" },
{ url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" },
{ url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" },
{ url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" },
{ url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" },
{ url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" },
{ url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" },
{ url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" },
{ url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" },
{ url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" },
{ url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" },
{ url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" },
{ url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" },
{ url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" },
{ url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" },
{ url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" },
{ url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" },
{ url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" },
{ url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" },
{ url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" },
{ url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" },
{ url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" },
{ url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" },
{ url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" },
]
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.29.0" version = "3.29.0"
@ -1267,6 +1473,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471, upload-time = "2020-03-13T18:57:48.872Z" }, { url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471, upload-time = "2020-03-13T18:57:48.872Z" },
] ]
[[package]]
name = "greenlet"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]] [[package]]
name = "grpcio" name = "grpcio"
version = "1.80.0" version = "1.80.0"
@ -2701,6 +2954,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
] ]
[[package]]
name = "playwright"
version = "1.58.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
]
[[package]] [[package]]
name = "plotly" name = "plotly"
version = "6.7.0" version = "6.7.0"
@ -2930,6 +3202,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
] ]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.46.3" version = "2.46.3"
@ -3005,6 +3282,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" },
] ]
[[package]]
name = "pydantic-extra-types"
version = "2.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
]
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.14.0" version = "2.14.0"
@ -3019,6 +3309,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" },
] ]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]] [[package]]
name = "pygls" name = "pygls"
version = "2.1.1" version = "2.1.1"
@ -3100,6 +3402,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
] ]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
]
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "7.1.0" version = "7.1.0"
@ -3114,6 +3429,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
] ]
[[package]]
name = "pytest-playwright"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-base-url" },
{ name = "python-slugify" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -3135,6 +3465,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" version = "0.4.1"
@ -3300,28 +3651,110 @@ wheels = [
] ]
[[package]] [[package]]
name = "ruff" name = "rich-toolkit"
version = "0.15.11" version = "0.19.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, { url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" },
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, ]
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, name = "rignore"
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, version = "0.7.6"
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" },
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" },
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" },
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" },
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" },
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" },
{ url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" },
{ url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" },
{ url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" },
{ url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" },
{ url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" },
{ url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" },
{ url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" },
{ url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" },
{ url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" },
{ url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" },
{ url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" },
{ url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" },
{ url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" },
{ url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" },
{ url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" },
{ url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" },
{ url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" },
{ url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" },
{ url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" },
{ url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
{ url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
{ url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
{ url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
{ url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
{ url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
{ url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
{ url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
{ url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
{ url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
{ url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
{ url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
{ url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
{ url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
{ url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
]
[[package]]
name = "ruff"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
] ]
[[package]] [[package]]
@ -3412,6 +3845,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
] ]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@ -3439,6 +3881,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
] ]
[[package]]
name = "sse-starlette"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/9a/f35932a8c0eb6b2287b66fa65a0321df8c84e4e355a659c1841a37c39fdb/sse_starlette-3.4.1.tar.gz", hash = "sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555", size = 35127, upload-time = "2026-04-26T13:32:32.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/07/45c21ed03d708c477367305726b89919b020a3a2a01f72aaf5ad941caf35/sse_starlette-3.4.1-py3-none-any.whl", hash = "sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0", size = 16487, upload-time = "2026-04-26T13:32:30.819Z" },
]
[[package]] [[package]]
name = "stamina" name = "stamina"
version = "26.1.0" version = "26.1.0"
@ -3556,6 +4011,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" },
] ]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]] [[package]]
name = "textual" name = "textual"
version = "8.2.4" version = "8.2.4"
@ -3708,6 +4172,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
] ]
[[package]]
name = "ty"
version = "0.0.33"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" },
{ url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" },
{ url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" },
{ url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" },
{ url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" },
{ url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" },
{ url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" },
{ url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" },
{ url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" },
{ url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" },
{ url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" },
{ url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" },
{ url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" },
{ url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" },
]
[[package]]
name = "typer"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" },
]
[[package]] [[package]]
name = "types-requests" name = "types-requests"
version = "2.33.0.20260408" version = "2.33.0.20260408"
@ -3889,6 +4392,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" },
] ]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]] [[package]]
name = "watchfiles" name = "watchfiles"
version = "1.1.1" version = "1.1.1"