76일차 자료 추가 (답 포함): 실습 15개 · 의도 · 생활 속 예 · 실행 결과, LAB-A · 정답 워크플로우와 25단계 실행 기록, 교안 개요 30장 · 4단계 정리, 바이브 코딩 tretry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-29 23:18:55 +09:00
1 parent 3f36605043
commit bbf17e4696
141 files changed
+34072

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
+33
View File
@@ -0,0 +1,33 @@
# tretry IMP-PLAN
## 엔진 1단계 · 다시 하기 · 대체 · 종료 코드 (R1 · R2 · R3 · R4 · R12 · R13)
- [x] retry.py: `Policy(tries, backoff, max_wait, jitter, deadline)`, `wait(i) = min(backoff × 2^(i-1), max_wait) × (1 - jitter × 무작위)`
- [x] `run_with_retry(attempt, policy, fallback, per_try_timeout, sleep, clock, rand)` → `Result(ok, output, attempts, stopped, used_fallback, seconds)`
- [x] `Outcome(ok, retryable, error, output, status, retry_after)`: 다시 할 수 있나는 실행기가 정함
- [x] runners.command(argv): 종료 코드 ≠ 0 · 시간 초과 → 다시, 명령 없음 → 다시 안 함
- [x] cli: `tretry [옵션] -- <명령>`, 종료 코드 0 · 1 · 2 · 3, `--json`
- [x] tests: 가짜 시계 [0.5, 1.0], 상한 1 · 2 · 4 · 4, jitter, 대체, 다시 못 함은 1번 · 대체 안 함
- 확인: `uv run pytest -q`, `uv run tretry --tries 3 --backoff 0.2 -- false; echo $?`
## 엔진 2단계 · HTTP · 멱등 키 · 기록 (R5 · R6 · R8)
- [x] runners.http(method, url, body, headers, idem_key, body_key): 2xx 성공 · 5xx · 408 · 425 · 429 · 시간 초과 · 연결 실패 다시 · 그 밖 4xx 다시 안 함, `Retry-After`
- [x] `--idem-key auto|값`: 실행에 한 번 만들어 헤더 `Idempotency-Key`, `--body-key`면 본문에도
- [x] log.py: JSON Lines `~/.tretry/log.jsonl` (`--log`, `$TRETRY_LOG`), 시도마다 한 줄, 키 · 토큰은 `***`
- [x] tests: 가짜 서버 500 → 201 · 400 · 느림, 같은 키 3번, 기록에 `secret123` · `sk-` 없음
- 확인: 데이터 API `flaky`에서 `tretry http POST .../tickets --idem-key x --body-key key`
## TUI 1단계 · 기록 보기 (R11)
- [x] app.py `LogApp`: 왼쪽 대상 순위(실패한 시도 많은 순), 고르면 오른쪽에 그 대상의 시도, `[f]` 실패만, `[r]` 다시 읽기
- [x] 엔진(`log.stats` · `log.runs`)을 불러 쓰기만
- [x] tests: `run_test`로 두 표 · `down` · `f`
- 확인: `uv run tretry log`
## 설치 (R14)
- [x] `[project.scripts] tretry = "tretry.cli:main"`
- [x] `uv tool install --editable .`, `/tmp`에서 `tretry --help`
## 엔진 3단계 · 마감 · 순위 · 회로 차단 (R7 · R9 · R10)
- [x] `--deadline`: 시도 전 남은 시간, 마감을 넘는 기다림은 하지 않음, 마지막 시도의 시간 제한을 남은 시간으로
- [x] `tretry stats [--json]`: 대상마다 runs · 성공률 · 실행당 시도 · 실패한 시도 · 대체 · p95
- [x] `--breaker N`(기본 5, 0 끔): 최근 60초 그 대상의 시도(대체 제외) N개가 모두 실패면 바로 대체, 대체가 없으면 멈춤
- [x] tests: 3초 시도 · 마감 7 → 2번, 마감이 시간 제한을 줄임, 실패 5번 뒤 바로 대체, stats 숫자
+37
View File
@@ -0,0 +1,37 @@
# tretry · 강사가 수업 준비 때 직접 만든 본 (2026-10-29)
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TRETRY.md](../VIBE-A-TRETRY.md)와 함께 올립니다.
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-Q의 요구 R1~R14와 [Doc/IMP-PLAN.md](Doc/IMP-PLAN.md)를 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구의 **확인 방법**이 같으면 됩니다.
## 설치 · 실행
```bash
cd ~/aio-01d4-stu/Last3Mon/76/tretry
uv sync && uv run pytest -q # 테스트 17개 (기다리지 않음, 가짜 서버)
uv tool install --editable . # 어느 폴더에서나 tretry (R14)
cd /tmp && tretry --help
```
| 하고 싶은 것 | 명령 |
| --- | --- |
| 명령을 다시 해 보기 | `tretry --tries 4 --backoff 0.5 --max-wait 8 --jitter 0.2 --timeout 10 -- <명령 ...>` |
| 다 실패하면 대신 | `--fallback "cat ~/cache/faq.json"` |
| 전체 마감 | `--deadline 5` |
| HTTP 호출 | `tretry http POST http://localhost:8800/tickets --body '{"title": "t"}' --idem-key auto --body-key key` |
| 순위 | `tretry stats` (`--json`) |
| 화면(TUI) | `tretry log` (`[f]` 실패만, `[r]` 다시 읽기, `[q]` 끝) |
| 다른 프로그램이 읽게 | `--json` |
- 종료 코드: 0 됨(대체 포함) · 1 다 실패 · 2 쓰는 법이 틀림 · 3 고칠 수 없는 실패 (HTTP 4xx, 408 · 425 · 429 빼고).
- 기록: `~/.tretry/log.jsonl` (`--log`, `$TRETRY_LOG`). 헤더 값은 남기지 않고 URL · 오류의 `sk-…` · `Bearer …` · `api_key=…`는 `***`.
- 회로 차단: 같은 대상의 최근 60초 시도(대체 제외) 5개가 모두 실패면 시도 없이 바로 대체 (`--breaker 0`은 끔). 대상은 `METHOD URL` 또는 명령 줄.
## 파일
| 파일 | 하는 일 | 요구 |
| --- | --- | --- |
| `src/tretry/retry.py` | 다시 하기 엔진 (`Policy` · `run_with_retry`), sleep · clock · 무작위를 바꿔 끼움 | R1 · R2 · R4 · R7 · R13 |
| `src/tretry/runners.py` | 한 번 해 보기: 명령(종료 코드 · 시간 초과) · HTTP(상태 코드 · `Retry-After` · 멱등 키) | R1 · R3 · R5 · R6 |
| `src/tretry/log.py` | JSON Lines 기록 · 키 지우기 · 순위 · 회로 차단 | R8 · R9 · R10 |
| `src/tretry/cli.py` | 명령 · `--json` · 종료 코드 | R12 |
| `src/tretry/app.py` | Textual 화면 | R11 |
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "tretry"
version = "0.1.0"
description = "Run a command or an HTTP call with timeout, retry (backoff), a total deadline and a fallback; keep a log and show it"
requires-python = ">=3.11"
dependencies = ["textual>=0.80"]
[project.scripts]
tretry = "tretry.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tretry"]
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1 @@
"""tretry: retry, backoff, deadline, fallback and a log for any command or HTTP call."""
+72
View File
@@ -0,0 +1,72 @@
"""Textual screen (R11): targets ranked by failed tries on the left, the runs of the chosen target on the right."""
from __future__ import annotations
from pathlib import Path
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import DataTable, Footer, Header, Static
from . import log
class LogApp(App):
TITLE = "tretry"
BINDINGS = [("r", "reload", "Reload"), ("f", "failed_only", "Failed only"), ("q", "quit", "Quit")]
CSS = "#targets { width: 55%; } #tries { width: 45%; } #note { height: auto; margin: 0 1; }"
def __init__(self, path: Path):
super().__init__()
self.path = Path(path)
self.rows: list[dict] = []
self.failed_only = False
self.chosen: str | None = None
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
yield DataTable(id="targets", cursor_type="row")
yield DataTable(id="tries", cursor_type="row")
yield Static("", id="note", markup=False)
yield Footer()
def on_mount(self):
self.sub_title = str(self.path)
self.action_reload()
def action_reload(self):
self.rows = log.read(self.path)
table = self.query_one("#targets", DataTable)
table.clear(columns=True)
table.add_columns("target", "runs", "ok%", "tries/run", "failed", "fallback", "p95 s")
for r in log.stats(self.rows):
table.add_row(r["target"][:60], r["runs"], f"{r['success_rate'] * 100:.0f}%", r["attempts_per_run"],
r["failed_attempts"], r["fallbacks"], f"{r['p95_seconds']:.2f}", key=r["target"])
total = len(log.runs(self.rows))
self.query_one("#note", Static).update(f"{total} run(s) · {len(self.rows)} tr(ies) in {self.path} · "
"[enter] show tries · [f] failed only · [r] reload")
if self.chosen is None and table.row_count:
self.chosen = str(table.coordinate_to_cell_key((0, 0)).row_key.value)
self.show_tries()
def action_failed_only(self):
self.failed_only = not self.failed_only
self.show_tries()
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted):
if event.data_table.id == "targets" and event.row_key is not None:
self.chosen = str(event.row_key.value)
self.show_tries()
def show_tries(self):
table = self.query_one("#tries", DataTable)
table.clear(columns=True)
table.add_columns("run", "try", "at +s", "result", "error", "s")
for number, group in enumerate(log.runs(self.rows), 1):
if group[-1]["target"] != self.chosen:
continue
for r in group:
if self.failed_only and r["ok"]:
continue
table.add_row(number, "fallback" if r.get("fallback") else r["attempt"], f"{r['started']:.2f}",
"✔" if r["ok"] else "✘", (r.get("error") or "")[:40], f"{r['seconds']:.2f}")
+166
View File
@@ -0,0 +1,166 @@
"""Command line. Exit codes: 0 worked (maybe by the fallback) · 1 all tries and the fallback failed ·
2 usage error · 3 not retryable (e.g. HTTP 400: the request itself is wrong)."""
from __future__ import annotations
import argparse
import json
import shlex
import sys
import uuid
from pathlib import Path
from . import log, runners
from .retry import Policy, Result, run_with_retry
SUBCOMMANDS = ("http", "stats", "log")
def options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--tries", type=int, default=3, help="how many tries (default 3)")
parser.add_argument("--backoff", type=float, default=0.5, help="wait before try 2, doubled each time (default 0.5s)")
parser.add_argument("--max-wait", type=float, default=8.0, help="longest single wait (default 8s)")
parser.add_argument("--jitter", type=float, default=0.0, help="make each wait up to this share shorter, e.g. 0.2")
parser.add_argument("--timeout", type=float, help="seconds for one try")
parser.add_argument("--deadline", type=float, help="seconds for all tries together")
parser.add_argument("--fallback", help='shell command to run once when every try failed, e.g. "cat cached.json"')
parser.add_argument("--breaker", type=int, default=5,
help="skip straight to the fallback when the last N tries at this target in 60s all failed (0 = off)")
parser.add_argument("--log", type=Path, help="log file (default $TRETRY_LOG or ~/.tretry/log.jsonl)")
parser.add_argument("--json", action="store_true", help="print the result as JSON for other programs")
parser.add_argument("-q", "--quiet", action="store_true", help="print only the output of the command that worked")
def policy(args) -> Policy:
if args.tries < 1 or args.backoff < 0 or args.max_wait < 0 or not 0 <= args.jitter < 1:
raise SystemExit(usage_error("--tries must be ≥ 1, --backoff/--max-wait ≥ 0, --jitter in [0, 1)"))
return Policy(args.tries, args.backoff, args.max_wait, args.jitter, args.deadline)
def usage_error(message: str) -> int:
print(f"tretry: {message}", file=sys.stderr)
return 2
def report(args, kind: str, target: str, result: Result, path: Path) -> int:
log.write(path, kind, target, result)
code = 0 if result.ok else (3 if result.stopped == "not-retryable" else 1)
if args.json:
print(json.dumps({"ok": result.ok, "exit_code": code, "stopped": result.stopped, "used_fallback": result.used_fallback,
"seconds": result.seconds, "attempts": [a.__dict__ for a in result.attempts], "output": result.output},
ensure_ascii=False, indent=2))
return code
if not args.quiet:
for a in result.attempts:
mark = "✔" if a.ok else "✘"
label = "fallback" if a.fallback else f"#{a.attempt}"
wait = f" (waited {a.waited:.2f}s)" if a.waited else ""
print(f"{label:<9}{a.started:>7.2f}s {mark} {a.error or 'ok'} · {a.seconds:.2f}s{wait}", file=sys.stderr)
how = "by the fallback" if result.ok and result.used_fallback else ("" if result.ok else f"({result.stopped})")
tries = sum(1 for a in result.attempts if not a.fallback)
state = " ".join(part for part in ("ok" if result.ok else "FAILED", how) if part)
print(f"result: {state} · {tries} tr{'y' if tries == 1 else 'ies'} · {result.seconds:.2f}s"
+ (" · breaker open: tries skipped" if result.stopped == "breaker" else ""), file=sys.stderr)
if result.output:
sys.stdout.write(result.output if result.output.endswith("\n") else result.output + "\n")
return code
def run(args, kind: str, target: str, attempt) -> int:
path = args.log or log.default_path()
skip = args.breaker > 0 and log.breaker_open(log.read(path), target, failures=args.breaker)
if skip and not args.fallback:
print(f"tretry: breaker open for {log.clean(target)} (last {args.breaker} tries failed); no --fallback, so stopping",
file=sys.stderr)
fallback = runners.command(args.fallback, shell=True) if args.fallback else None
result = run_with_retry(attempt, policy(args), fallback=fallback, per_try_timeout=args.timeout, skip_tries=skip)
return report(args, kind, target, result, path)
def main_command(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="tretry", usage="tretry [options] -- <command ...>",
description="Run a command with timeout, retry (backoff), deadline and fallback.",
epilog="also: tretry http METHOD URL · tretry stats · tretry log (screen)")
options(parser)
if "--" not in argv:
if "-h" in argv or "--help" in argv:
parser.print_help()
return 0
return usage_error("put the command after --, e.g. tretry --tries 3 -- curl -sf http://localhost:8800/health")
split = argv.index("--")
args = parser.parse_args(argv[:split])
command = argv[split + 1:]
if not command:
return usage_error("no command after --")
return run(args, "command", shlex.join(command), runners.command(command))
def main_http(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="tretry http", description="HTTP call: 5xx, 408/425/429, timeout and connection errors are "
"tried again; other 4xx are not.")
parser.add_argument("method")
parser.add_argument("url")
parser.add_argument("--body", help="JSON body, or @file.json")
parser.add_argument("--header", action="append", default=[], help="K: V (values are never logged)")
parser.add_argument("--idem-key", help="idempotency key sent with every try (header Idempotency-Key); 'auto' makes one")
parser.add_argument("--body-key", help="also put the idempotency key in this JSON body field if it is missing, e.g. key")
options(parser)
args = parser.parse_args(argv)
body = args.body
if body and body.startswith("@"):
body = Path(body[1:]).read_text(encoding="utf-8")
if body:
try:
json.loads(body)
except json.JSONDecodeError as error:
return usage_error(f"--body is not JSON: {error}")
headers = {}
for item in args.header:
name, _, value = item.partition(":")
headers[name.strip()] = value.strip()
key = uuid.uuid4().hex if args.idem_key == "auto" else args.idem_key
if args.body_key and not key:
return usage_error("--body-key needs --idem-key")
target = f"{args.method.upper()} {args.url}"
return run(args, "http", target, runners.http(args.method, args.url, body, headers, key, args.body_key))
def main_stats(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="tretry stats", description="Per target: runs, success rate, tries per run, p95 time.")
parser.add_argument("--log", type=Path)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
rows = log.stats(log.read(args.log or log.default_path()))
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2))
return 0
if not rows:
print("no log yet")
return 0
print(f"{'target':<48} {'runs':>4} {'ok%':>5} {'tries/run':>9} {'failed':>6} {'fallback':>8} {'p95 s':>7}")
for r in rows:
print(f"{r['target'][:48]:<48} {r['runs']:>4} {r['success_rate'] * 100:>4.0f}% {r['attempts_per_run']:>9} "
f"{r['failed_attempts']:>6} {r['fallbacks']:>8} {r['p95_seconds']:>7.2f}")
return 0
def main_log(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="tretry log", description="Screen: targets by failures, and the tries of one target.")
parser.add_argument("--log", type=Path)
args = parser.parse_args(argv)
from .app import LogApp
LogApp(args.log or log.default_path()).run()
return 0
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
try:
if argv and argv[0] in SUBCOMMANDS:
return {"http": main_http, "stats": main_stats, "log": main_log}[argv[0]](argv[1:])
return main_command(argv)
except SystemExit as stop:
return stop.code if isinstance(stop.code, int) else 2
if __name__ == "__main__":
sys.exit(main())
+95
View File
@@ -0,0 +1,95 @@
"""The attempt log as JSON Lines (R7), stats from it (R9) and the circuit breaker that reads it (R10)."""
from __future__ import annotations
import json
import os
import re
import time
from dataclasses import asdict
from pathlib import Path
from .retry import Result
SECRET = re.compile(r"(sk-[A-Za-z0-9_-]{8,}|Bearer\s+\S+|(api[_-]?key|token|password)=[^&\s]+)", re.I)
def default_path() -> Path:
return Path(os.environ.get("TRETRY_LOG") or Path.home() / ".tretry" / "log.jsonl")
def clean(text: str) -> str:
"""Never keep a key or a token in the log, even when it is part of a URL or an error message."""
return SECRET.sub("***", text)
def write(path: Path, kind: str, target: str, result: Result, now: float | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
at = now if now is not None else time.time()
with path.open("a", encoding="utf-8") as out:
first = at - result.seconds # wall clock time of the first try
for item in result.attempts:
row = {"at": round(first + item.started, 3), "kind": kind, "target": clean(target), **asdict(item)}
row["error"] = clean(row["error"])
row["final"] = item is result.attempts[-1]
row["result"] = "ok" if result.ok else "failed"
row["stopped"] = result.stopped
out.write(json.dumps(row, ensure_ascii=False) + "\n")
def read(path: Path) -> list[dict]:
if not path.exists():
return []
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue # a half-written line from a killed run
return rows
def runs(rows: list[dict]) -> list[list[dict]]:
"""Group attempt rows into runs (one tretry call = the rows up to and including its final row)."""
groups, current = [], []
for row in rows:
current.append(row)
if row.get("final"):
groups.append(current)
current = []
return groups
def p95(values: list[float]) -> float:
if not values:
return 0.0
ordered = sorted(values)
return ordered[min(len(ordered) - 1, round(0.95 * (len(ordered) - 1)))]
def stats(rows: list[dict]) -> list[dict]:
"""Per target: runs, success rate, attempts per run, failed attempts, fallbacks, p95 run time. Most failures first."""
table: dict[str, dict] = {}
for group in runs(rows):
target = group[-1]["target"]
row = table.setdefault(target, {"target": target, "kind": group[-1]["kind"], "runs": 0, "ok": 0, "attempts": 0,
"failed_attempts": 0, "fallbacks": 0, "times": []})
row["runs"] += 1
row["ok"] += group[-1]["result"] == "ok"
row["attempts"] += len(group)
row["failed_attempts"] += sum(1 for r in group if not r["ok"])
row["fallbacks"] += any(r.get("fallback") for r in group)
row["times"].append(group[-1]["started"] + group[-1]["seconds"])
out = []
for row in table.values():
times = row.pop("times")
out.append({**row, "success_rate": round(row["ok"] / row["runs"], 3), "attempts_per_run": round(row["attempts"] / row["runs"], 2),
"p95_seconds": round(p95(times), 3)})
return sorted(out, key=lambda r: (-r["failed_attempts"], r["target"]))
def breaker_open(rows: list[dict], target: str, failures: int = 5, window: float = 60.0, now: float | None = None) -> bool:
"""Open when the last `failures` attempts at this target (not fallbacks) all failed within `window` seconds."""
now = now if now is not None else time.time()
recent = [r for r in rows if r.get("target") == clean(target) and not r.get("fallback") and now - r["at"] <= window]
last = recent[-failures:]
return len(last) >= failures and not any(r["ok"] for r in last)
+114
View File
@@ -0,0 +1,114 @@
"""The retry engine (R1 · R2 · R6 · R13). Knows nothing about commands or HTTP: it calls attempt() and reads its Outcome.
sleep, clock and rand are arguments so tests never wait and always get the same numbers."""
from __future__ import annotations
import random
import time
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class Outcome:
"""What one attempt gave. retryable=False means trying again cannot help (e.g. HTTP 400)."""
ok: bool
retryable: bool = True
error: str = ""
output: str = ""
status: int | None = None
retry_after: float | None = None # seconds the server asked us to wait (HTTP 429 / 503 Retry-After)
@dataclass
class Policy:
tries: int = 3
backoff: float = 0.5 # wait before try 2; doubles each time
max_wait: float = 8.0 # one wait is never longer than this
jitter: float = 0.0 # 0.2 = each wait is randomly 0..20% shorter, so many clients do not retry at the same moment
deadline: float | None = None # seconds for all tries together; no new try that could not finish in time
def wait(self, attempt: int, rand: Callable[[], float] = random.random) -> float:
"""Wait after attempt number `attempt` (1-based) and before the next one."""
base = min(self.backoff * 2 ** (attempt - 1), self.max_wait)
return round(base * (1 - self.jitter * rand()), 3)
@dataclass
class Attempt:
attempt: int
started: float # seconds since the first try started
seconds: float
ok: bool
error: str = ""
status: int | None = None
waited: float = 0.0 # wait before this attempt
fallback: bool = False
@dataclass
class Result:
ok: bool
output: str = ""
attempts: list[Attempt] = field(default_factory=list)
stopped: str = "" # why it stopped: success · tries · not-retryable · deadline · breaker
used_fallback: bool = False
seconds: float = 0.0
def run_with_retry(attempt: Callable[[float | None], Outcome], policy: Policy, *,
fallback: Callable[[float | None], Outcome] | None = None,
per_try_timeout: float | None = None,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
rand: Callable[[], float] = random.random,
skip_tries: bool = False) -> Result:
"""Call attempt(timeout) until it works, up to policy.tries. Then fallback once, if given.
skip_tries=True goes straight to the fallback (the circuit breaker is open)."""
start = clock()
result = Result(ok=False)
waited = 0.0
number = 0
while not skip_tries and number < policy.tries:
number += 1
timeout = per_try_timeout
if policy.deadline is not None:
left = policy.deadline - (clock() - start)
if left <= 0:
result.stopped = "deadline"
break
timeout = min(timeout, left) if timeout else left
began = clock()
outcome = attempt(timeout)
result.attempts.append(Attempt(number, round(began - start, 3), round(clock() - began, 3), outcome.ok,
outcome.error, outcome.status, waited))
if outcome.ok:
result.ok, result.output, result.stopped = True, outcome.output, "success"
break
if not outcome.retryable:
result.output, result.stopped = outcome.output, "not-retryable"
break
if number == policy.tries:
result.stopped = "tries"
break
waited = policy.wait(number, rand)
if outcome.retry_after is not None:
waited = min(max(waited, outcome.retry_after), policy.max_wait)
if policy.deadline is not None and clock() - start + waited >= policy.deadline:
result.stopped = "deadline"
break
sleep(waited)
if skip_tries:
result.stopped = "breaker"
# a request that cannot be right (4xx) gets no fallback: the fallback would hide the caller's mistake
if not result.ok and fallback is not None and result.stopped != "not-retryable":
began = clock()
outcome = fallback(per_try_timeout)
result.attempts.append(Attempt(len(result.attempts) + 1, round(began - start, 3), round(clock() - began, 3),
outcome.ok, outcome.error, outcome.status, 0.0, fallback=True))
result.used_fallback = True
if outcome.ok:
result.ok, result.output = True, outcome.output
result.seconds = round(clock() - start, 3)
return result
+71
View File
@@ -0,0 +1,71 @@
"""One attempt of a command or of an HTTP call, as an Outcome (R1 · R3 · R4 · R5)."""
from __future__ import annotations
import json
import socket
import subprocess
import urllib.error
import urllib.request
from .retry import Outcome
# 408 timeout · 425 too early · 429 too many requests: waiting can help. Other 4xx: the request itself is wrong.
RETRY_4XX = {408, 425, 429}
def command(argv: list[str] | str, shell: bool = False):
"""attempt(timeout) for a command: exit code 0 is success; a timeout is retryable."""
def attempt(timeout: float | None) -> Outcome:
try:
done = subprocess.run(argv, shell=shell, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return Outcome(False, True, f"timeout {timeout:.1f}s")
except FileNotFoundError as error:
return Outcome(False, False, f"not found: {error.filename}")
if done.returncode == 0:
return Outcome(True, output=done.stdout)
tail = (done.stderr or done.stdout).strip().splitlines()[-1:] or [""]
return Outcome(False, True, f"exit {done.returncode} {tail[0][:120]}".strip(), output=done.stdout)
return attempt
def retry_after(value: str | None) -> float | None:
try:
return max(0.0, float(value)) if value else None
except ValueError:
return None # an HTTP date: ignore, use our own backoff
def http(method: str, url: str, body: str | None = None, headers: dict[str, str] | None = None,
idem_key: str | None = None, body_key: str | None = None):
"""attempt(timeout) for an HTTP call: 2xx success · 5xx, 408/425/429, timeout, connection error retryable ·
other 4xx not retryable. The same idempotency key goes with every try (header, and the body field if asked)."""
data = None
send = dict(headers or {})
if body is not None:
if body_key and idem_key:
parsed = json.loads(body)
parsed.setdefault(body_key, idem_key)
body = json.dumps(parsed, ensure_ascii=False)
data = body.encode("utf-8")
send.setdefault("Content-Type", "application/json")
if idem_key:
send["Idempotency-Key"] = idem_key
def attempt(timeout: float | None) -> Outcome:
request = urllib.request.Request(url, data=data, method=method.upper(), headers=send)
try:
with urllib.request.urlopen(request, timeout=timeout or 30) as response:
return Outcome(True, output=response.read().decode("utf-8", "replace"), status=response.status)
except urllib.error.HTTPError as error:
text = error.read().decode("utf-8", "replace")
retryable = error.code >= 500 or error.code in RETRY_4XX
return Outcome(False, retryable, f"HTTP {error.code} {text[:120]}".strip(), output=text, status=error.code,
retry_after=retry_after(error.headers.get("Retry-After")))
except (TimeoutError, socket.timeout):
return Outcome(False, True, f"timeout {timeout or 30:.1f}s")
except urllib.error.URLError as error:
if isinstance(error.reason, (TimeoutError, socket.timeout)):
return Outcome(False, True, f"timeout {timeout or 30:.1f}s")
return Outcome(False, True, f"connection: {error.reason}")
return attempt
+60
View File
@@ -0,0 +1,60 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
class FakeClock:
"""sleep() only moves the clock, so a test never waits and the waits can be checked."""
def __init__(self):
self.now = 0.0
self.slept: list[float] = []
def sleep(self, seconds: float):
self.slept.append(seconds)
self.now += seconds
def __call__(self):
return self.now
@pytest.fixture
def clock():
return FakeClock()
@pytest.fixture
def server():
"""A local server whose answers come from a script: path -> list of (status, body, headers), one per call."""
script: dict[str, list] = {}
seen: list[dict] = []
class Handler(BaseHTTPRequestHandler):
def answer(self):
length = int(self.headers.get("Content-Length") or 0)
seen.append({"path": self.path, "method": self.command, "headers": dict(self.headers),
"body": self.rfile.read(length).decode() if length else ""})
queue = script.get(self.path) or [(200, {"ok": True}, {})]
status, body, headers = queue.pop(0) if len(queue) > 1 else queue[0]
if status == "sleep":
import time
time.sleep(body)
status, body, headers = 200, {"late": True}, {}
data = json.dumps(body).encode()
self.send_response(status)
for key, value in headers.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = do_POST = answer
def log_message(self, *args):
pass
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{httpd.server_port}", script, seen
httpd.shutdown()
+29
View File
@@ -0,0 +1,29 @@
"""R11: the screen ranks targets by failed tries and lists the tries of the chosen one."""
from textual.widgets import DataTable
from tretry import log
from tretry.app import LogApp
from tretry.retry import Attempt, Result
def make_log(tmp_path):
path = tmp_path / "log.jsonl"
log.write(path, "http", "POST http://x/tickets", Result(False, attempts=[Attempt(1, 0, 3, False, "timeout"), Attempt(2, 3.5, 3, False, "timeout")],
stopped="tries", seconds=6.5), now=100)
log.write(path, "command", "curl ok", Result(True, attempts=[Attempt(1, 0, 0.1, True)], stopped="success", seconds=0.1), now=101)
return path
async def test_tables(tmp_path):
app = LogApp(make_log(tmp_path))
async with app.run_test() as pilot:
await pilot.pause()
targets = app.query_one("#targets", DataTable)
assert targets.row_count == 2 and targets.get_row_at(0)[0] == "POST http://x/tickets" # most failures first
assert app.query_one("#tries", DataTable).row_count == 2
await pilot.press("down")
await pilot.pause()
assert app.query_one("#tries", DataTable).row_count == 1
await pilot.press("f")
await pilot.pause()
assert app.query_one("#tries", DataTable).row_count == 0
+91
View File
@@ -0,0 +1,91 @@
"""R1 · R3 · R4 · R5 · R7 · R8 · R9 · R10 through the command line (real commands, local fake server, temp log)."""
import json
import sys
from tretry import cli, log
PY = sys.executable
def run(capsys, *argv):
code = cli.main(list(argv))
out = capsys.readouterr()
return code, out.out, out.err
def test_command_fails_then_fallback(tmp_path, capsys):
logfile = tmp_path / "log.jsonl"
code, out, err = run(capsys, "--tries", "2", "--backoff", "0", "--log", str(logfile), "--fallback", "echo cached",
"--", PY, "-c", "import sys; sys.exit(3)")
assert code == 0 and out.strip() == "cached" and "by the fallback" in err
rows = log.read(logfile)
assert [r["fallback"] for r in rows] == [False, False, True] and rows[0]["error"].startswith("exit 3")
def test_command_timeout(tmp_path, capsys):
code, _, err = run(capsys, "--tries", "1", "--timeout", "0.3", "--log", str(tmp_path / "l"), "--",
PY, "-c", "import time; time.sleep(5)")
assert code == 1 and "timeout 0.3s" in err
def test_http_5xx_retried_4xx_not(server, tmp_path, capsys):
base, script, seen = server
script["/flaky"] = [(500, {"error": "x"}, {}), (201, {"ticket_id": "T-1"}, {})]
script["/bad"] = [(400, {"error": "title is required"}, {})]
logfile = str(tmp_path / "log.jsonl")
code, out, _ = run(capsys, "http", "POST", base + "/flaky", "--body", "{}", "--backoff", "0", "--log", logfile)
assert code == 0 and "T-1" in out and len(seen) == 2
code, _, err = run(capsys, "http", "POST", base + "/bad", "--body", "{}", "--backoff", "0", "--log", logfile,
"--fallback", "echo cached")
assert code == 3 and len(seen) == 3 and "not-retryable" in err # one try, no fallback
def test_http_same_idempotency_key_every_try(server, tmp_path, capsys):
base, script, seen = server
script["/tickets"] = [(500, {}, {}), (500, {}, {}), (201, {"ticket_id": "T-1"}, {})]
code, _, _ = run(capsys, "http", "POST", base + "/tickets", "--body", '{"title": "t"}', "--idem-key", "auto",
"--body-key", "key", "--backoff", "0", "--log", str(tmp_path / "l"))
keys = {s["headers"]["Idempotency-Key"] for s in seen}
bodies = {json.loads(s["body"])["key"] for s in seen}
assert code == 0 and len(seen) == 3 and len(keys) == 1 and keys == bodies # R5
def test_http_timeout_is_retried(server, tmp_path, capsys):
base, script, seen = server
script["/slow"] = [("sleep", 1.0, {}), (200, {"ok": True}, {})]
code, _, err = run(capsys, "http", "GET", base + "/slow", "--timeout", "0.3", "--backoff", "0", "--log", str(tmp_path / "l"))
assert code == 0 and "timeout" in err and len(seen) == 2
def test_log_never_keeps_a_key(server, tmp_path, capsys):
base, script, _ = server
script["/x?api_key=secret123"] = [(503, {}, {})]
logfile = tmp_path / "log.jsonl"
run(capsys, "http", "GET", base + "/x?api_key=secret123", "--tries", "1", "--header", "Authorization: Bearer sk-abcdefghijklmnop",
"--log", str(logfile))
text = logfile.read_text()
assert "secret123" not in text and "sk-abcdefghijklmnop" not in text and "***" in text # R7
def test_json_output_and_exit_code(tmp_path, capsys):
code, out, _ = run(capsys, "--tries", "2", "--backoff", "0", "--json", "--log", str(tmp_path / "l"), "--", PY, "-c", "raise SystemExit(1)")
data = json.loads(out)
assert code == 1 and data["exit_code"] == 1 and data["stopped"] == "tries" and len(data["attempts"]) == 2 # R8
def test_stats_and_breaker(tmp_path, capsys):
logfile = str(tmp_path / "log.jsonl")
fail = ["--backoff", "0", "--log", logfile, "--", PY, "-c", "raise SystemExit(1)"]
run(capsys, "--tries", "3", *fail)
run(capsys, "--tries", "2", *fail) # 5 failed tries in a row → breaker opens
code, out, err = run(capsys, "--tries", "3", "--fallback", "echo cached", *fail)
assert code == 0 and out.strip() == "cached" and "breaker open" in err # R10: no try, straight to fallback
code, out, _ = run(capsys, "stats", "--log", logfile, "--json")
row = json.loads(out)[0]
assert row["runs"] == 3 and row["failed_attempts"] == 5 and row["fallbacks"] == 1 and row["success_rate"] == 0.333 # R9
def test_usage_errors(capsys):
assert run(capsys, "echo", "hi")[0] == 2 # no --
assert run(capsys, "http", "POST", "http://x", "--body", "{bad")[0] == 2
assert run(capsys, "--tries", "0", "--", "true")[0] == 2
+67
View File
@@ -0,0 +1,67 @@
"""R1 · R2 · R3 · R6: the engine with fake attempts and a fake clock (no waiting)."""
from tretry.retry import Outcome, Policy, run_with_retry
def failing(times, retryable=True):
calls = []
def attempt(timeout):
calls.append(timeout)
if len(calls) <= times:
return Outcome(False, retryable, f"boom {len(calls)}")
return Outcome(True, output="done")
return attempt, calls
def test_success_after_two_failures_waits_with_backoff(clock):
attempt, calls = failing(2)
result = run_with_retry(attempt, Policy(tries=4, backoff=0.5), sleep=clock.sleep, clock=clock)
assert result.ok and result.output == "done" and len(calls) == 3
assert clock.slept == [0.5, 1.0] # R1: 0.5 → 1.0 (doubles)
def test_wait_has_a_cap_and_jitter_only_shortens():
policy = Policy(tries=10, backoff=1, max_wait=4, jitter=0.5)
assert [policy.wait(n, rand=lambda: 0.0) for n in range(1, 6)] == [1, 2, 4, 4, 4] # R1: cap
assert policy.wait(3, rand=lambda: 1.0) == 2.0 # R2: up to 50% shorter
def test_all_fail_then_fallback(clock):
attempt, _ = failing(9)
result = run_with_retry(attempt, Policy(tries=3), fallback=lambda t: Outcome(True, output="cached"),
sleep=clock.sleep, clock=clock)
assert result.ok and result.used_fallback and result.output == "cached" # R3
assert [a.fallback for a in result.attempts] == [False, False, False, True]
def test_not_retryable_stops_at_once_and_skips_fallback(clock):
attempt, calls = failing(9, retryable=False)
result = run_with_retry(attempt, Policy(tries=5), fallback=lambda t: Outcome(True, output="cached"),
sleep=clock.sleep, clock=clock)
assert not result.ok and len(calls) == 1 and result.stopped == "not-retryable" and not result.used_fallback
def test_deadline_stops_before_a_wait_that_cannot_fit(clock):
def slow(timeout):
clock.now += 3 # every try takes 3 s
return Outcome(False, True, "timeout")
result = run_with_retry(slow, Policy(tries=5, backoff=1, deadline=7), sleep=clock.sleep, clock=clock)
assert result.stopped == "deadline" and len(result.attempts) == 2 # R6: 3 + 1 + 3 = 7 → no third try
assert result.seconds <= 7
def test_deadline_shortens_the_last_timeout(clock):
seen = []
def attempt(timeout):
seen.append(timeout)
clock.now += timeout
return Outcome(False, True, "timeout")
run_with_retry(attempt, Policy(tries=3, backoff=0.5, deadline=5), per_try_timeout=3, sleep=clock.sleep, clock=clock)
assert seen == [3, 1.5] # 3 + 0.5 wait → 1.5 s left for try 2
def test_retry_after_is_respected_up_to_the_cap(clock):
answers = [Outcome(False, True, "HTTP 429", status=429, retry_after=3), Outcome(True, output="ok")]
result = run_with_retry(lambda t: answers.pop(0), Policy(tries=3, backoff=0.5, max_wait=8), sleep=clock.sleep, clock=clock)
assert result.ok and clock.slept == [3] # R4: the server's 3 s, not our 0.5 s
+195
View File
@@ -0,0 +1,195 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "platformdirs"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b", size = 70387, upload-time = "2026-10-28T18:27:30.973Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91", size = 35259, upload-time = "2026-10-28T18:27:29.659Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "textual"
version = "8.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
]
[[package]]
name = "tretry"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "textual" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [{ name = "textual", specifier = ">=0.80" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]