mirror of
https://github.com/codeflash-ai/codeflash-agent.git
synced 2026-05-04 18:25:19 +00:00
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:
parent
2ff9431656
commit
0ad5e60523
38 changed files with 5855 additions and 23 deletions
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
|
|
@ -66,18 +66,49 @@ jobs:
|
|||
- name: Test changed packages
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
uv run pytest packages/ -v
|
||||
uv run pytest packages/ -v --ignore=packages/blackbox/tests/e2e
|
||||
else
|
||||
CHANGED='${{ needs.changes.outputs.packages }}'
|
||||
for pkg in $(echo "$CHANGED" | jq -r '.[]'); do
|
||||
echo "::group::Testing $pkg"
|
||||
uv run pytest "packages/$pkg" -v
|
||||
uv run pytest "packages/$pkg" -v --ignore="packages/$pkg/tests/e2e"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
fi
|
||||
env:
|
||||
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:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
61
packages/blackbox/README.md
Normal file
61
packages/blackbox/README.md
Normal 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/
|
||||
```
|
||||
95
packages/blackbox/pyproject.toml
Normal file
95
packages/blackbox/pyproject.toml
Normal 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",
|
||||
]
|
||||
23
packages/blackbox/src/blackbox/__init__.py
Normal file
23
packages/blackbox/src/blackbox/__init__.py
Normal 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",
|
||||
]
|
||||
393
packages/blackbox/src/blackbox/analytics.py
Normal file
393
packages/blackbox/src/blackbox/analytics.py
Normal 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
|
||||
58
packages/blackbox/src/blackbox/cli.py
Normal file
58
packages/blackbox/src/blackbox/cli.py
Normal 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)
|
||||
0
packages/blackbox/src/blackbox/dashboard/__init__.py
Normal file
0
packages/blackbox/src/blackbox/dashboard/__init__.py
Normal file
37
packages/blackbox/src/blackbox/dashboard/app.py
Normal file
37
packages/blackbox/src/blackbox/dashboard/app.py
Normal 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()
|
||||
180
packages/blackbox/src/blackbox/dashboard/rendering.py
Normal file
180
packages/blackbox/src/blackbox/dashboard/rendering.py
Normal 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("&", "&").replace("<", "<").replace(">", ">").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>"
|
||||
)
|
||||
170
packages/blackbox/src/blackbox/dashboard/routes.py
Normal file
170
packages/blackbox/src/blackbox/dashboard/routes.py
Normal 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))
|
||||
108
packages/blackbox/src/blackbox/dashboard/templates/base.html
Normal file
108
packages/blackbox/src/blackbox/dashboard/templates/base.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
· {{ 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 %}
|
||||
279
packages/blackbox/src/blackbox/dashboard/transcript.py
Normal file
279
packages/blackbox/src/blackbox/dashboard/transcript.py
Normal 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,
|
||||
)
|
||||
85
packages/blackbox/src/blackbox/dashboard/watcher.py
Normal file
85
packages/blackbox/src/blackbox/dashboard/watcher.py
Normal 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
|
||||
266
packages/blackbox/src/blackbox/formatting.py
Normal file
266
packages/blackbox/src/blackbox/formatting.py
Normal 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]
|
||||
261
packages/blackbox/src/blackbox/models.py
Normal file
261
packages/blackbox/src/blackbox/models.py
Normal 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
|
||||
0
packages/blackbox/src/blackbox/py.typed
Normal file
0
packages/blackbox/src/blackbox/py.typed
Normal file
0
packages/blackbox/tests/__init__.py
Normal file
0
packages/blackbox/tests/__init__.py
Normal file
38
packages/blackbox/tests/conftest.py
Normal file
38
packages/blackbox/tests/conftest.py
Normal 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 {}))
|
||||
0
packages/blackbox/tests/e2e/__init__.py
Normal file
0
packages/blackbox/tests/e2e/__init__.py
Normal file
188
packages/blackbox/tests/e2e/conftest.py
Normal file
188
packages/blackbox/tests/e2e/conftest.py
Normal 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
|
||||
35
packages/blackbox/tests/e2e/test_dashboard_loads.py
Normal file
35
packages/blackbox/tests/e2e/test_dashboard_loads.py
Normal 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
|
||||
77
packages/blackbox/tests/e2e/test_session_detail.py
Normal file
77
packages/blackbox/tests/e2e/test_session_detail.py
Normal 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)
|
||||
42
packages/blackbox/tests/e2e/test_session_list.py
Normal file
42
packages/blackbox/tests/e2e/test_session_list.py
Normal 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
|
||||
50
packages/blackbox/tests/e2e/test_sidebar.py
Normal file
50
packages/blackbox/tests/e2e/test_sidebar.py
Normal 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
|
||||
41
packages/blackbox/tests/e2e/test_sse_streaming.py
Normal file
41
packages/blackbox/tests/e2e/test_sse_streaming.py
Normal 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
|
||||
693
packages/blackbox/tests/test_analytics.py
Normal file
693
packages/blackbox/tests/test_analytics.py
Normal 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
|
||||
54
packages/blackbox/tests/test_cli.py
Normal file
54
packages/blackbox/tests/test_cli.py
Normal 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()
|
||||
333
packages/blackbox/tests/test_formatting.py
Normal file
333
packages/blackbox/tests/test_formatting.py
Normal 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"
|
||||
305
packages/blackbox/tests/test_models.py
Normal file
305
packages/blackbox/tests/test_models.py
Normal 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"
|
||||
268
packages/blackbox/tests/test_rendering.py
Normal file
268
packages/blackbox/tests/test_rendering.py
Normal 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 & b" == esc("a & b")
|
||||
|
||||
def test_angle_brackets(self) -> None:
|
||||
assert "<div>" == esc("<div>")
|
||||
|
||||
def test_newlines_become_br(self) -> None:
|
||||
assert "a<br>b" == esc("a\nb")
|
||||
|
||||
def test_combined(self) -> None:
|
||||
assert "<b>hi</b><br>&" == 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 "<script>" 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
|
||||
74
packages/blackbox/tests/test_routes.py
Normal file
74
packages/blackbox/tests/test_routes.py
Normal 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
|
||||
552
packages/blackbox/tests/test_transcript.py
Normal file
552
packages/blackbox/tests/test_transcript.py
Normal 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)
|
||||
169
packages/blackbox/tests/test_watcher.py
Normal file
169
packages/blackbox/tests/test_watcher.py
Normal 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
|
||||
|
|
@ -48,6 +48,9 @@ codeflash-python = { 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" }
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.12"
|
||||
|
||||
[tool.ruff]
|
||||
src = [
|
||||
"packages/codeflash-api/src",
|
||||
|
|
|
|||
569
uv.lock
569
uv.lock
|
|
@ -18,6 +18,7 @@ resolution-markers = [
|
|||
|
||||
[manifest]
|
||||
members = [
|
||||
"blackbox",
|
||||
"codeflash-api",
|
||||
"codeflash-ci-audit",
|
||||
"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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
|
|
@ -596,7 +648,7 @@ requires-dist = [{ name = "codeflash-core", editable = "packages/codeflash-core"
|
|||
|
||||
[[package]]
|
||||
name = "codeflash-python"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1.dev0"
|
||||
source = { editable = "packages/codeflash-python" }
|
||||
dependencies = [
|
||||
{ name = "codeflash-core" },
|
||||
|
|
@ -1008,6 +1060,14 @@ nvtx = [
|
|||
{ 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]]
|
||||
name = "dash"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "docker"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "fastapi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[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]]
|
||||
name = "filelock"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "grpcio"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "plotly"
|
||||
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" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pydantic-settings"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygls"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pytest-cov"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dateutil"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pytokens"
|
||||
version = "0.4.1"
|
||||
|
|
@ -3300,28 +3651,110 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.11"
|
||||
name = "rich-toolkit"
|
||||
version = "0.19.7"
|
||||
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 = [
|
||||
{ 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/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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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/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/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/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/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/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/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/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/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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rignore"
|
||||
version = "0.7.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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]]
|
||||
|
|
@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "six"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "stamina"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "textual"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "types-requests"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
|
|
|
|||
Loading…
Reference in a new issue