79일차 운영 데이터 기반 Ops Assistant: 실습 15개 · 강의(개요 30장 · 단계 문서 · 4단계 정리 · 05 바이브), 정답본 LAB-A · answers · VIBE-A · tops 포함

This commit is contained in:
mcchae committed 2026-11-03 20:54:35 +09:00
1 parent 9ce9aceba6
commit 6f0ecd59a9
156 files changed
+31640

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# tops IMP-PLAN
## 엔진 1단계 · 읽기 · 상태 · 읽기만 · 종료 코드 (R1 · R2 · R3 · R5 · R7 · R13 · R14)
- [x] client.py: `post(base, path, body)` — 부를 수 있는 주소 목록(`READ_ONLY`) 밖이면 보내기 전에 오류, HTTP 오류의 JSON 몸은 돌려줌, 못 읽으면 `ReadError`
- [x] client.py: `facts(n8n, file, from, to)` — `--file`이 먼저, `health`가 없는 사실(과제 1 전)은 거절
- [x] view.py: `status_lines(facts)` — 색은 `health` 그대로, 숫자는 AGENTS.md 3절 브리핑 표의 꼴, `null`은 "source not answering" · ⚪
- [x] cli: `tops status [--n8n | $TOPS_N8N] [--file] [--from --to] [--json]`, 종료 코드 0 · 1 · 2 · 4(전체 빨강)
- [x] tests: 사실 사본 → 일곱 줄, `cost: null` → ⚪, `health`를 바꾸면 그 색, 쓰는 주소는 막힘(요청 0건), 꺼진 n8n → 1, 가짜 서버
- 확인: `uv run pytest -q`, `uv run tops status --n8n http://localhost:5678; echo $?`
## 엔진 2단계 · 질문 · 브리핑 (R4 · R6)
- [x] `tops ask "질문"`: `[영역 · 라우터]` + `refused (이유)` 또는 `numbers ✔/✘` + 답
- [x] `tops brief`: `markdown` 그대로
- [x] tests: 작업 요청 거절 · LLM 라우터 답(`[cost · llm]`) · 브리핑 표 줄
## TUI 1단계 · 탭 · 새로고침 · 질문하기 · 못 읽는 탭 (R11 · R12)
- [x] app.py `OpsApp`: 위 한 줄, 탭(Areas · Brief · Ask · Audit · Tools), `[r]` 새로고침, `[?]` 질문하기, `[q]` 끝
- [x] 탭마다 따로 읽고 못 읽으면 그 탭에 "not available", Enter 뒤 포커스를 표로
- [x] tests: `run_test`로 영역 6줄 · 거절 답 · 감사 3줄, 파일만 주면 영역 6줄 + 질문하기 · 감사 "not available"
## 설치
- [x] `[project.scripts] tops = "tops.cli:main"`, `uv tool install --editable .`, `cd /tmp && tops --help`
## 엔진 3단계 · 평가 관문 · 감사 · 지난 도구 (R8 · R9 · R10)
- [x] `tops eval FILE [--min-score]`: `8/8 · score 1 · gate pass (min 0.9)`, 틀린 질문 줄, gate fail → 4, 빈 목록(400) → 2
- [x] `tops audit [--api | $TOPS_API]`: `/audit/list`의 질문 · 영역 · 라우터 · 거절
- [x] `tops tools`: `tlog slo --url … --from 2026-10-26 --to 2026-10-30 --json`의 breaches, `tcheck` 설치 여부, 없으면 "not installed"
- [x] tests: 골든 8 → 0 · 틀린 기대 → 4 · 빈 목록 → 2, 감사 3줄, 없는 도구, tlog 결과 한 줄
+47
View File
@@ -0,0 +1,47 @@
# tops · 강사가 수업 준비 때 직접 만든 본 (2026-11-01)
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TOPS.md](../VIBE-A-TOPS.md)와 함께 올립니다.
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-Q의 요구 R1~R14와 [Doc/IMP-PLAN.md](Doc/IMP-PLAN.md)를 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구의 **확인 방법**이 같으면 됩니다.
## 설치 · 실행
```bash
cd ~/aio-01d4-stu/Last3Mon/79/tops
uv sync && uv run pytest -q # 테스트 27개 (n8n · 데이터 API 없이, 가짜 서버)
uv tool install --editable . # 어느 폴더에서나 tops
cd /tmp && tops --help
export TOPS_N8N=http://localhost:5678
```
| 하고 싶은 것 | 명령 |
| --- | --- |
| 영역 여섯 · 색 · 숫자 (빨강이면 종료 코드 4) | `tops status` (`--from 2026-10-19 --to 2026-10-23`, `--json`, `--file 사실.json`) |
| 질문 하나 | `tops ask "카나리 버전 문제 있어?"` |
| 아침 브리핑 | `tops brief` |
| 골든 질문 평가 · 관문 (실패면 4) | `tops eval ../requests/ops-golden.json --min-score 0.9` |
| 감사 기록 | `tops audit` (데이터 API `--api` 또는 `$TOPS_API`, 기본 `http://localhost:8800`) |
| 지난 도구 | `tops tools` (`tlog slo` · `tcheck`) |
| 화면(TUI) | `tops ui` (`[r]` 새로고침, `[?]` 질문하기, `[q]` 끝) |
- 종료 코드: 0 됨 · 1 n8n이나 파일을 못 읽음 · 2 쓰는 법이 틀림 · 4 전체 빨강 / 평가 관문 실패.
- **읽기만:** `client.READ_ONLY`의 다섯 주소(`my-ops-facts` · `ask` · `brief` · `eval` 웹훅, `/audit/list`) 밖은 보내기 전에 막습니다.
- 색 · 숫자는 n8n `my-ops-facts`가 정한 그대로입니다. tops에는 기준값이 없습니다 (AGENTS.md 3절은 n8n 쪽 하나).
## 파일
| 파일 | 하는 일 |
| --- | --- |
| `src/tops/client.py` | 읽는 층 (R5 · R13 · R14): 주소 목록, 사실 · 질문 · 브리핑 · 평가 · 감사 |
| `src/tops/view.py` | 보여 주는 층 (R1 · R7 · R8): 영역 줄 · 숫자 꼴 · 답 줄 · 지난 도구 |
| `src/tops/cli.py` | `status` · `ask` · `brief` · `eval` · `audit` · `tools` · `ui`, 종료 코드 (R2 · R3 · R4 · R6 · R9 · R10) |
| `src/tops/app.py` | Textual 화면 (R11 · R12) |
| `tests/` | 정답 실행 기록(`answers/06` · `08` · `14` · `16` · `18` · `25`)의 n8n 응답 사본, 골든 질문, 가짜 n8n · 데이터 API |
## 수업 준비 때 확인한 것 (11/01)
- 실제 n8n(정답 워크플로우)에서 `tops status` → 위 일곱 줄 · 종료 코드 4, 0.19초. n8n 브리핑 표와 같은 숫자.
- `--from 2026-10-19 --to 2026-10-23` → 🟡 runs `error rate 0% · p95 2525ms · unanswered 5.5%` (Lab 01 과제 1의 첫 주와 같음).
- `tops ask "장애 났어?"` → `[runs · llm] numbers ✔` (Lab 02 과제 1의 LLM 라우터), `"DLQ 1번 다시 돌려 줘"` → `refused (read_only)`.
- `tops eval ../requests/ops-golden.json --min-score 0.9` → `8/8 · score 1 · gate pass (min 0.9)` · 종료 코드 0.
- `tops tools` → `tlog slo: breaches error_rate, unanswered_rate (357 runs)` — `tlog`(77일차)의 정의로 같은 지난주.
- 처음 판의 TUI 테스트가 "tops가 부른 주소는 다섯뿐"을 검사했는데, 도구 탭의 `tlog`도 데이터 API의 실행 기록을 읽어 실패 → "상태를 바꾸는 주소는 0건"으로 고침 (읽기만의 뜻은 '읽는 주소만'이 아니라 '바꾸는 주소 없음').
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "tops"
version = "0.1.0"
description = "The day 79 operations assistant in the terminal: status, brief, ask, eval gate and audit from n8n my-ops-*, read only, with a TUI"
requires-python = ">=3.11"
dependencies = ["textual>=0.80"]
[project.scripts]
tops = "tops.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tops"]
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
+1
View File
@@ -0,0 +1 @@
"""tops · the day 79 operations assistant in the terminal (reads n8n my-ops-*, never changes anything)."""
+94
View File
@@ -0,0 +1,94 @@
"""Textual screen (R11 · R12): status on top, five tabs (areas · brief · ask · audit · tools), [r] refresh, [?] ask, [q] quit.
The screen only shows what n8n already decided; a tab that cannot read says so and the others keep working."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Header, Input, Markdown, Static, TabbedContent, TabPane
from . import client, view
class OpsApp(App):
TITLE = "tops"
BINDINGS = [("r", "refresh", "Refresh"), ("question_mark", "ask", "Ask"), ("q", "quit", "Quit")]
CSS = "#top, #answer { height: auto; margin: 0 1; }"
def __init__(self, args):
super().__init__()
self.args = args
self.facts: dict | None = None
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="top", markup=False)
with TabbedContent(id="tabs"):
with TabPane("Areas", id="areas"):
yield DataTable(id="area-table", cursor_type="row")
with TabPane("Brief", id="brief"):
yield Markdown("", id="brief-text")
with TabPane("Ask", id="ask"):
yield Input(placeholder="a question about operations, Enter to send (read only)", id="question")
yield Static("", id="answer", markup=False)
with TabPane("Audit", id="audit"):
yield DataTable(id="audit-table", cursor_type="row")
with TabPane("Tools", id="tools"):
yield Static("", id="tool-text", markup=False)
yield Footer()
def on_mount(self):
self.query_one("#area-table", DataTable).add_columns("", "area", "color", "numbers")
self.query_one("#audit-table", DataTable).add_columns("area", "router", "result", "question")
self.action_refresh()
def read(self, what):
try:
return what(), ""
except client.ReadError as error:
return None, f"not available: {error}"
def action_refresh(self):
a = self.args
self.facts, why = self.read(lambda: client.facts(a.n8n, a.file))
top = self.query_one("#top", Static)
table = self.query_one("#area-table", DataTable)
table.clear()
if self.facts is None:
top.update(why)
else:
lines = view.status_lines(self.facts)
top.update(lines[0] + (f" · {lines[-1].strip()}" if self.facts.get("sources_down") else ""))
for area in view.AREAS:
color = self.facts["health"].get(area, "unknown")
table.add_row(view.MARK.get(color, "?"), view.LABEL[area], color, view.numbers(self.facts, area), key=area)
brief_text = self.query_one("#brief-text", Markdown)
if not a.n8n:
brief_text.update("not available: no --n8n (reading a file)")
else:
b, why = self.read(lambda: client.brief(a.n8n))
brief_text.update(b.get("markdown", "") if b else why)
audit = self.query_one("#audit-table", DataTable)
audit.clear()
events, why = self.read(lambda: client.audit(getattr(a, "api", None) or "http://localhost:8800").get("events", []))
for e in events or []:
audit.add_row(e.get("area", "-"), e.get("router", "-"), f"refused {e.get('reason')}" if e.get("refused") else "answered", e.get("question", ""))
if events is None:
audit.add_row("-", "-", why, "")
api = getattr(a, "api", None) or "http://localhost:8800"
parts = [view.tool("tlog", ["slo", "--url", api, "--from", "2026-10-26", "--to", "2026-10-30", "--json"]), view.tool("tcheck", ["--help"])]
self.query_one("#tool-text", Static).update("\n".join(view.tool_line(part) for part in parts))
def action_ask(self):
self.query_one("#tabs", TabbedContent).active = "ask"
self.query_one("#question", Input).focus()
def on_input_submitted(self, event: Input.Submitted):
question = event.value.strip()
answer = self.query_one("#answer", Static)
if not question:
return
if not self.args.n8n:
answer.update("not available: no --n8n (reading a file)")
return
a, why = self.read(lambda: client.ask(self.args.n8n, question))
answer.update("\n".join(view.answer_lines(a)) if a else why)
self.query_one("#area-table", DataTable).focus()
+129
View File
@@ -0,0 +1,129 @@
"""tops commands (R3): status · ask · brief · eval · audit · tools · ui. Exit codes: 0 ok · 1 cannot read · 2 usage · 4 red / eval gate fail."""
from __future__ import annotations
import argparse
import json
import os
import sys
from . import client, view
EXIT_OK, EXIT_READ, EXIT_USAGE, EXIT_ALARM = 0, 1, 2, 4
class Parser(argparse.ArgumentParser):
def error(self, message):
self.print_usage(sys.stderr)
print(f"tops: {message}", file=sys.stderr)
sys.exit(EXIT_USAGE)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument("--n8n", default=os.environ.get("TOPS_N8N"), help="n8n base, e.g. http://localhost:5678 ($TOPS_N8N)")
p.add_argument("--json", action="store_true", help="print the n8n answer as JSON, unchanged")
def need_n8n(args) -> str:
if not args.n8n:
raise SystemExit(usage("give --n8n (or set $TOPS_N8N)"))
return args.n8n
def usage(message: str) -> int:
print(f"tops: {message}", file=sys.stderr)
return EXIT_USAGE
def show(args, data, lines) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2) if args.json else "\n".join(lines))
def main(argv: list[str] | None = None) -> int:
parser = Parser(prog="tops", description="The day 79 operations assistant in the terminal. It reads n8n my-ops-* and never changes anything.")
commands = parser.add_subparsers(dest="command", required=True)
p = commands.add_parser("status", help="areas · colors · numbers from my-ops-facts (R1 · R2 · R7), exit code 4 when overall is red")
common(p)
p.add_argument("--file", help="read the facts from a file instead of n8n")
p.add_argument("--from", dest="date_from", default="")
p.add_argument("--to", dest="date_to", default="")
p = commands.add_parser("ask", help="one question to my-ops-ask (R4)")
common(p)
p.add_argument("question")
p = commands.add_parser("brief", help="the markdown of my-ops-brief (R6)")
common(p)
p.add_argument("--from", dest="date_from", default="")
p.add_argument("--to", dest="date_to", default="")
p = commands.add_parser("eval", help="golden questions to my-ops-eval, exit code 4 when the gate fails (R9)")
common(p)
p.add_argument("file", help='{"cases": [...]} like requests/ops-golden.json')
p.add_argument("--min-score", type=float)
p = commands.add_parser("audit", help="the questions the assistant was asked (data API /audit) (R10)")
p.add_argument("--api", default=os.environ.get("TOPS_API", "http://localhost:8800"))
p.add_argument("--json", action="store_true")
p = commands.add_parser("tools", help="earlier course tools as parts: tlog · tcheck (R8)")
p.add_argument("--api", default=os.environ.get("TOPS_API", "http://localhost:8800"))
p.add_argument("--from", dest="date_from", default="2026-10-26")
p.add_argument("--to", dest="date_to", default="2026-10-30")
p.add_argument("--json", action="store_true")
p = commands.add_parser("ui", help="the TUI (R11)")
common(p)
p.add_argument("--file")
p.add_argument("--api", default=os.environ.get("TOPS_API", "http://localhost:8800"))
try:
args = parser.parse_args(argv)
except SystemExit as stop:
return int(stop.code or 0)
try:
if args.command == "status":
if not args.file and not args.n8n:
return usage("give --n8n (or set $TOPS_N8N) or --file")
f = client.facts(args.n8n, args.file, args.date_from, args.date_to)
show(args, f, view.status_lines(f))
return EXIT_ALARM if f.get("overall") == "red" else EXIT_OK
if args.command == "ask":
a = client.ask(need_n8n(args), args.question)
show(args, a, view.answer_lines(a))
return EXIT_OK
if args.command == "brief":
b = client.brief(need_n8n(args), args.date_from, args.date_to)
show(args, b, [b.get("markdown", "")])
return EXIT_OK
if args.command == "eval":
cases = client.read_json(args.file)
cases = cases.get("cases", []) if isinstance(cases, dict) else cases
e = client.evaluate(need_n8n(args), cases, args.min_score)
if "error" in e:
print(f"tops: {e['error']}", file=sys.stderr)
return EXIT_USAGE
lines = [f"{e['passed']}/{e['total']} · score {e['score']}" + (f" · gate {e['gate']} (min {e['min_score']})" if "gate" in e else "")]
lines += [f" ✘ {x['question']} · area {x.get('area')} · " + " ".join(k for k in ("area_ok", "refused_ok", "number_ok", "grounded") if not x.get(k))
for x in e.get("failed", [])]
show(args, e, lines)
return EXIT_ALARM if e.get("gate") == "fail" else EXIT_OK
if args.command == "audit":
events = client.audit(args.api).get("events", [])
lines = [f"{len(events)} question(s)"] + [
f" {e.get('area', '-'):<8} {e.get('router', '-'):<8} {'refused ' + str(e.get('reason')) if e.get('refused') else 'answered'} {e.get('question')}"
for e in events]
show(args, {"events": events}, lines)
return EXIT_OK
if args.command == "tools":
parts = [view.tool("tlog", ["slo", "--url", args.api, "--from", args.date_from, "--to", args.date_to, "--json"]),
view.tool("tcheck", ["--help"])]
show(args, parts, [view.tool_line(p) for p in parts])
return EXIT_OK
from .app import OpsApp
if not args.file and not args.n8n:
return usage("give --n8n (or set $TOPS_N8N) or --file")
OpsApp(args).run()
return EXIT_OK
except client.ReadError as error:
print(f"tops: cannot read: {error}", file=sys.stderr)
return EXIT_READ
except SystemExit as stop:
return int(stop.code or 0)
if __name__ == "__main__":
sys.exit(main())
+67
View File
@@ -0,0 +1,67 @@
"""The reading layer (R1 · R4 · R6 · R9 · R10 · R13): the n8n webhooks my-ops-facts · ask · brief · eval and the data API audit.
tops only ever reads: these are the only addresses it calls (R5)."""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from pathlib import Path
READ_ONLY = ("/webhook/my-ops-facts", "/webhook/my-ops-ask", "/webhook/my-ops-brief", "/webhook/my-ops-eval", "/audit/list")
class ReadError(Exception):
"""n8n or the data API did not answer, or a file could not be read (exit code 1)."""
def post(base: str, path: str, body: dict, timeout: float = 60) -> object:
if path not in READ_ONLY:
raise ValueError(f"tops does not call {path} (read only)")
request = urllib.request.Request(base.rstrip("/") + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=timeout) as answer:
return json.loads(answer.read())
except urllib.error.HTTPError as error:
try:
return json.loads(error.read())
except json.JSONDecodeError:
raise ReadError(f"{base}{path}: HTTP {error.code}") from error
except (urllib.error.URLError, OSError, json.JSONDecodeError) as error:
raise ReadError(f"{base}{path}: {error}") from error
def read_json(path: str) -> object:
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ReadError(f"cannot read {path}: {error}") from error
def facts(n8n: str | None = None, file: str | None = None, date_from: str = "", date_to: str = "") -> dict:
"""--file wins over --n8n ($TOPS_N8N may be set for every call)."""
data = read_json(file) if file else post(n8n or "", "/webhook/my-ops-facts", {"from": date_from, "to": date_to}) if n8n else None
if data is None:
raise ReadError("give --n8n (or $TOPS_N8N) or --file")
if not isinstance(data, dict) or "health" not in data:
raise ReadError("not the facts of my-ops-facts with health (Lab 01 task 1)")
return data
def ask(n8n: str, question: str) -> dict:
return post(n8n, "/webhook/my-ops-ask", {"question": question})
def brief(n8n: str, date_from: str = "", date_to: str = "") -> dict:
return post(n8n, "/webhook/my-ops-brief", {"from": date_from, "to": date_to})
def evaluate(n8n: str, cases: list, min_score: float | None = None) -> dict:
body: dict = {"cases": cases}
if min_score is not None:
body["min_score"] = min_score
return post(n8n, "/webhook/my-ops-eval", body, timeout=300)
def audit(api: str) -> dict:
return post(api, "/audit/list", {})
+81
View File
@@ -0,0 +1,81 @@
"""What to show (R1 · R7 · R8): the colors and numbers come from the facts as they are; nothing is recounted here."""
from __future__ import annotations
import json
import shutil
import subprocess
AREAS = ("runs", "alerts", "dlq", "quality", "cost", "release")
LABEL = {"runs": "runs", "alerts": "alerts", "dlq": "DLQ", "quality": "quality", "cost": "cost", "release": "release"}
MARK = {"red": "🔴", "yellow": "🟡", "green": "🟢", "unknown": "⚪"}
def pct(value) -> str:
return f"{round(value * 100, 2)}%"
def numbers(f: dict, area: str) -> str:
"""The same short form as the day 79 brief table (AGENTS.md 3)."""
x = f.get(area)
if x is None:
return "source not answering"
if area == "runs":
return f"error rate {pct(x['error_rate'])} · p95 {x['p95_ms']}ms · unanswered {pct(x['unanswered_rate'])}"
if area == "alerts":
return f"{x['count']} (high {x['high']})"
if area == "dlq":
return f"{x['size']} (parked {x['parked']})"
if area == "quality":
return f"avg {x['avg_score']} · last {x['last_score']} · quarantine {x['quarantine']}"
if area == "cost":
return f"${x['cost_usd']} / ${x['daily_usd']} ({pct(x['used_ratio'])})"
text = str(x["active"])
if x.get("canary_release"):
rate = "-" if x.get("canary_repeat_rate") is None else pct(x["canary_repeat_rate"])
text += f" · canary {x['canary_release']} {x['canary_percent']}% (repeat {rate})"
return text
def status_lines(f: dict) -> list[str]:
period = f.get("period", {})
lines = [f"overall {MARK.get(f['overall'], '?')} {f['overall']} · runs {period.get('from', '…')} ~ {period.get('to', '…')} + state now"]
for area in AREAS:
color = f["health"].get(area, "unknown")
lines.append(f" {MARK.get(color, '?')} {LABEL[area]:<8} {numbers(f, area)}")
if f.get("sources_down"):
lines.append(" sources down: " + ", ".join(f["sources_down"]))
return lines
def answer_lines(a: dict) -> list[str]:
head = f"[{a.get('area')} · {a.get('router')}]"
if a.get("refused"):
return [f"{head} refused ({a.get('reason')})", f" {a.get('answer')}"]
check = "numbers ✔" if a.get("numbers_ok") else f"numbers ✘ {a.get('unknown_numbers')}"
return [f"{head} {check}", f" {a.get('answer')}"]
def tool(name: str, args: list[str], timeout: float = 30) -> dict:
"""R8: an earlier course tool as a part (its --json), or why it is not available."""
if not shutil.which(name):
return {"tool": name, "ok": False, "why": "not installed (uv tool install)"}
try:
out = subprocess.run([name, *args], capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return {"tool": name, "ok": False, "why": "timed out"}
try:
return {"tool": name, "ok": True, "code": out.returncode, "result": json.loads(out.stdout)}
except json.JSONDecodeError:
if out.returncode == 0:
return {"tool": name, "ok": True, "code": 0, "result": out.stdout.strip().splitlines()[-1:]}
return {"tool": name, "ok": False, "why": (out.stderr or out.stdout).strip().splitlines()[-1][:120] if (out.stderr or out.stdout).strip() else f"exit {out.returncode}"}
def tool_line(part: dict) -> str:
"""tlog slo → its breaches (the day 77 SLO, its own definition); any other tool → installed."""
if not part["ok"]:
return f" · {part['tool']}: {part['why']}"
r = part["result"]
if part["tool"] == "tlog" and isinstance(r, dict):
return f" ✔ tlog slo: {'ok' if r.get('ok') else 'breaches ' + ', '.join(r.get('breaches', []))} ({r.get('total')} runs)"
return f" ✔ {part['tool']}: installed"
+74
View File
@@ -0,0 +1,74 @@
"""A fake n8n + data API on a local port, answering with responses recorded from the day 79 answers (answers/06 · 08 · 14 · 16 · 18 · 25)."""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
def load(name):
return json.loads((DATA / name).read_text(encoding="utf-8"))
class Fake:
def __init__(self):
self.calls: list[tuple[str, dict]] = []
self.cost_down = False
self.brief_down = False
def handler(fake: Fake):
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def send(self, code, data):
body = json.dumps(data, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
fake.calls.append((self.path, body))
if self.path == "/webhook/my-ops-facts":
return self.send(200, load("facts-cost-down.json" if fake.cost_down else "facts.json"))
if self.path == "/webhook/my-ops-ask":
asks = load("asks.json")
return self.send(200, asks.get(body.get("question"), asks["점심 메뉴 추천해 줘"]))
if self.path == "/webhook/my-ops-brief":
return self.send(500, {"message": "Error in workflow"}) if fake.brief_down else self.send(200, load("brief.json"))
if self.path == "/webhook/my-ops-eval":
cases = body.get("cases") or []
if not cases:
return self.send(400, {"error": "cases must be a non-empty list"})
e = load("eval.json")
return self.send(200, e["pass"] if len(cases) == 8 else e["fail"])
if self.path == "/audit/list":
return self.send(200, load("audit.json"))
self.send(404, {"message": "not registered"})
return H
@pytest.fixture
def fake():
f = Fake()
server = ThreadingHTTPServer(("127.0.0.1", 0), handler(f))
threading.Thread(target=server.serve_forever, daemon=True).start()
f.base = f"http://127.0.0.1:{server.server_address[1]}"
yield f
server.shutdown()
@pytest.fixture
def down():
"""An address nothing listens on (n8n turned off)."""
return "http://127.0.0.1:9"
+58
View File
@@ -0,0 +1,58 @@
{
"DLQ 1번 다시 돌려 줘": {
"question": "DLQ 1번 다시 돌려 줘",
"area": "action",
"router": "keyword",
"refused": true,
"reason": "read_only",
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
},
"점심 메뉴 추천해 줘": {
"question": "점심 메뉴 추천해 줘",
"area": "unknown",
"router": "llm",
"refused": true,
"reason": "out_of_scope",
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
},
"지난주 오류율이 얼마야?": {
"question": "지난주 오류율이 얼마야?",
"area": "runs",
"router": "keyword",
"refused": false,
"reason": null,
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
"numbers_ok": true,
"unknown_numbers": []
},
"이번 주에 AI 쓰는 데 얼마나 나갔어?": {
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?",
"area": "cost",
"router": "llm",
"refused": false,
"reason": null,
"answer": "이번 주 AI 사용 비용은 총 0.073604달러입니다. 일일 평균 비용은 약 0.08달러이며, 사용 비율은 92.01%입니다.",
"numbers_ok": true,
"unknown_numbers": []
},
"어젯밤에 접수 못 하고 남은 건 몇 건이야?": {
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?",
"area": "dlq",
"router": "llm",
"refused": false,
"reason": null,
"answer": "어젯밤에 접수 못 하고 남은 건은 총 3건입니다. 이 중 2건은 대기 중이고 1건은 주차 상태입니다.",
"numbers_ok": true,
"unknown_numbers": []
},
"새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?": {
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?",
"area": "release",
"router": "llm",
"refused": false,
"reason": null,
"answer": "새 판 1.2.0은 일부 사용자 20%에게 먼저 내보내졌으며, 이 버전의 재사용률은 62.5%로 나타났습니다. 현재 전체 시스템 상태는 빨간색(문제 있음)입니다.",
"numbers_ok": true,
"unknown_numbers": []
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"events": [
{
"t": 1793526843.503,
"kind": "ops-ask",
"question": "DLQ 1번 다시 돌려 줘",
"area": "action",
"router": "keyword",
"refused": true,
"reason": "read_only",
"numbers_ok": null
},
{
"t": 1793526845.149,
"kind": "ops-ask",
"question": "점심 메뉴 추천해 줘",
"area": "unknown",
"router": "llm",
"refused": true,
"reason": "out_of_scope",
"numbers_ok": null
},
{
"t": 1793526846.297,
"kind": "ops-ask",
"question": "지난주 오류율이 얼마야?",
"area": "runs",
"router": "keyword",
"refused": false,
"reason": null,
"numbers_ok": true
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"overall": "red",
"red": [
"runs",
"dlq",
"release"
],
"yellow": [
"alerts",
"quality",
"cost"
],
"actions": [
{
"area": "runs",
"status": "red",
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
},
{
"area": "dlq",
"status": "red",
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
},
{
"area": "release",
"status": "red",
"action": "카나리 끄기 · 되돌리기 검토 (74일차)"
}
],
"markdown": "# 운영 브리핑 (2026-10-26 ~ 2026-10-30 실행 기록 + 지금 상태)\n\n전체: 🔴 red\n\n| 영역 | 상태 | 숫자 |\n| --- | --- | --- |\n| 실행 | 🔴 | 오류율 5.88% · p95 2598ms · 답 못 함 4.76% |\n| 알림 | 🟡 | 6건 (높음 3) |\n| DLQ | 🔴 | 3건 (parked 2) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟡 | $0.073604 / $0.08 (92.01%) |\n| 릴리스 | 🔴 | 1.1.0 · 카나리 1.2.0 20% (다시 묻기 62.5%) |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🔴 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n3. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)"
}
+37
View File
@@ -0,0 +1,37 @@
{
"pass": {
"total": 8,
"passed": 8,
"score": 1,
"area_ok": 8,
"refused_ok": 8,
"number_ok": 8,
"grounded": 8,
"failed": [],
"min_score": 0.8,
"gate": "pass"
},
"fail": {
"total": 1,
"passed": 0,
"score": 0,
"area_ok": 0,
"refused_ok": 1,
"number_ok": 1,
"grounded": 1,
"failed": [
{
"question": "알림이 몇 건 와 있어?",
"area": "alerts",
"ok": false,
"area_ok": false,
"refused_ok": true,
"number_ok": true,
"grounded": true,
"answer": "알림은 총 6건이 와 있으며, 이 중 3건은 높은 심각도입니다. 알림 사유로는 타임아웃 1건, 속도 제한 1건, 배치 거부 1건, 저품질 2건, API 오류 1건이 있습니다."
}
],
"min_score": 0.9,
"gate": "fail"
}
}
@@ -0,0 +1,73 @@
{
"period": {
"from": "2026-10-26",
"to": "2026-10-30"
},
"runs": {
"total": 357,
"errors": 21,
"error_rate": 0.0588,
"p95_ms": 2598,
"unanswered_rate": 0.0476,
"top_failure": {
"node": "Search FAQ",
"message": "timeout of 1000ms exceeded",
"count": 16
}
},
"alerts": {
"count": 6,
"high": 3,
"by_reason": {
"timeout": 1,
"rate_limit": 1,
"batch_rejected": 1,
"low_quality": 2,
"api_error": 1
}
},
"dlq": {
"size": 3,
"parked": 2,
"waiting": 1
},
"quality": {
"batches": 4,
"avg_score": 0.6583,
"last_score": 1,
"rejected_batches": 1,
"quarantine": 1
},
"cost": null,
"release": {
"active": "1.1.0",
"canary_release": "1.2.0",
"canary_percent": 20,
"active_repeat_rate": 0.0773,
"canary_repeat_rate": 0.625
},
"sources_down": [
"cost"
],
"health": {
"runs": "red",
"alerts": "yellow",
"dlq": "red",
"quality": "yellow",
"cost": "unknown",
"release": "red"
},
"red": [
"runs",
"dlq",
"release"
],
"yellow": [
"alerts",
"quality"
],
"unknown": [
"cost"
],
"overall": "red"
}
+79
View File
@@ -0,0 +1,79 @@
{
"period": {
"from": "2026-10-26",
"to": "2026-10-30"
},
"runs": {
"total": 357,
"errors": 21,
"error_rate": 0.0588,
"p95_ms": 2598,
"unanswered_rate": 0.0476,
"top_failure": {
"node": "Search FAQ",
"message": "timeout of 1000ms exceeded",
"count": 16
}
},
"alerts": {
"count": 6,
"high": 3,
"by_reason": {
"timeout": 1,
"rate_limit": 1,
"batch_rejected": 1,
"low_quality": 2,
"api_error": 1
}
},
"dlq": {
"size": 3,
"parked": 2,
"waiting": 1
},
"quality": {
"batches": 4,
"avg_score": 0.6583,
"last_score": 1,
"rejected_batches": 1,
"quarantine": 1
},
"cost": {
"requests": 156,
"calls": 192,
"cost_usd": 0.073604,
"daily_usd": 0.08,
"used_ratio": 0.9201,
"top_user": {
"user": "test-bot",
"cost_usd": 0.027228,
"share": 0.3699
}
},
"release": {
"active": "1.1.0",
"canary_release": "1.2.0",
"canary_percent": 20,
"active_repeat_rate": 0.0773,
"canary_repeat_rate": 0.625
},
"health": {
"runs": "red",
"alerts": "yellow",
"dlq": "red",
"quality": "yellow",
"cost": "yellow",
"release": "red"
},
"red": [
"runs",
"dlq",
"release"
],
"yellow": [
"alerts",
"quality",
"cost"
],
"overall": "red"
}
@@ -0,0 +1,58 @@
{
"cases": [
{
"question": "지난주 오류율이 얼마야?",
"area": "runs",
"numbers": [
"5.88"
]
},
{
"question": "알림이 몇 건 와 있어?",
"area": "alerts",
"numbers": [
"6"
]
},
{
"question": "DLQ에 parked 된 건 몇 건이야?",
"area": "dlq",
"numbers": [
"2"
]
},
{
"question": "FAQ 배치 품질 점수 평균은?",
"area": "quality",
"numbers": [
"0.6583",
"65.83",
"0.66"
]
},
{
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
"area": "cost",
"numbers": [
"92"
]
},
{
"question": "카나리 버전 문제 있어?",
"area": "release",
"numbers": [
"62.5"
]
},
{
"question": "DLQ 1번 다시 돌려 줘",
"area": "action",
"refused": true
},
{
"question": "점심 메뉴 추천해 줘",
"area": "unknown",
"refused": true
}
]
}
+34
View File
@@ -0,0 +1,34 @@
"""R11 · R12: the TUI shows the areas, a tab that cannot read says so, and asking stays read only."""
from argparse import Namespace
from textual.widgets import DataTable, Input, Static
from conftest import DATA
from tops.app import OpsApp
async def test_areas_and_ask(fake):
app = OpsApp(Namespace(n8n=fake.base, file=None, api=fake.base))
async with app.run_test() as pilot:
assert app.query_one("#area-table", DataTable).row_count == 6
assert "overall 🔴 red" in str(app.query_one("#top", Static).render())
await pilot.press("question_mark")
app.query_one("#question", Input).value = "DLQ 1번 다시 돌려 줘"
await pilot.press("enter")
assert "refused (read_only)" in str(app.query_one("#answer", Static).render())
assert app.query_one("#audit-table", DataTable).row_count == 3
# tlog on the Tools tab reads the run logs too; nothing that changes state is ever called
assert not {path for path, _ in fake.calls} & {"/dlq/update", "/dlq-clear", "/ops-reset", "/budget", "/admin/fault", "/release"}
async def test_file_mode_other_tabs_say_not_available(down):
app = OpsApp(Namespace(n8n=None, file=str(DATA / "facts-cost-down.json"), api=down))
async with app.run_test() as pilot:
assert "sources down: cost" in str(app.query_one("#top", Static).render())
assert app.query_one("#area-table", DataTable).row_count == 6
await pilot.press("question_mark")
app.query_one("#question", Input).value = "지난주 오류율이 얼마야?"
await pilot.press("enter")
assert "not available" in str(app.query_one("#answer", Static).render())
assert "not available" in str(app.query_one("#audit-table", DataTable).get_row_at(0)[2])
+79
View File
@@ -0,0 +1,79 @@
"""R2 · R3 · R4 · R6 · R9 · R10 · R14: commands, --json unchanged, exit codes 0 · 1 · 2 · 4."""
import json
from conftest import DATA, load
from tops.cli import main
def test_status_red_exits_4(fake, capsys):
assert main(["status", "--n8n", fake.base]) == 4
assert "🔴 DLQ 3 (parked 2)" in capsys.readouterr().out
def test_status_json_is_the_n8n_answer(fake, capsys):
main(["status", "--n8n", fake.base, "--json"])
assert json.loads(capsys.readouterr().out) == load("facts.json")
def test_status_from_env(fake, monkeypatch, capsys):
monkeypatch.setenv("TOPS_N8N", fake.base)
assert main(["status", "--from", "2026-10-19", "--to", "2026-10-23"]) == 4
assert fake.calls[0][1] == {"from": "2026-10-19", "to": "2026-10-23"}
def test_status_without_source_is_usage(monkeypatch, capsys):
monkeypatch.delenv("TOPS_N8N", raising=False)
assert main(["status"]) == 2
assert "--n8n" in capsys.readouterr().err
def test_n8n_off_exits_1(down, capsys):
assert main(["status", "--n8n", down]) == 1
assert "cannot read" in capsys.readouterr().err
def test_bad_option_exits_2(capsys):
assert main(["status", "--nope"]) == 2
def test_ask_refused_action(fake, capsys):
assert main(["ask", "--n8n", fake.base, "DLQ 1번 다시 돌려 줘"]) == 0
out = capsys.readouterr().out
assert "refused (read_only)" in out and "읽기만" in out
def test_ask_llm_router_answer(fake, capsys):
main(["ask", "--n8n", fake.base, "이번 주에 AI 쓰는 데 얼마나 나갔어?"])
assert capsys.readouterr().out.startswith("[cost · llm] numbers ✔")
def test_brief_prints_markdown(fake, capsys):
main(["brief", "--n8n", fake.base])
assert "| DLQ | 🔴 | 3건 (parked 2) |" in capsys.readouterr().out
def test_eval_gate_pass_and_fail(fake, capsys, tmp_path):
assert main(["eval", "--n8n", fake.base, str(DATA / "ops-golden.json"), "--min-score", "0.8"]) == 0
assert "8/8 · score 1 · gate pass (min 0.8)" in capsys.readouterr().out
one = tmp_path / "one.json"
one.write_text(json.dumps({"cases": [{"question": "알림이 몇 건 와 있어?", "area": "cost"}]}, ensure_ascii=False))
assert main(["eval", "--n8n", fake.base, str(one)]) == 4
assert "✘ 알림이 몇 건 와 있어? · area alerts · area_ok" in capsys.readouterr().out
assert fake.calls[-1][1] == {"cases": [{"question": "알림이 몇 건 와 있어?", "area": "cost"}]}
def test_eval_empty_is_usage(fake, tmp_path, capsys):
empty = tmp_path / "empty.json"
empty.write_text('{"cases": []}')
assert main(["eval", "--n8n", fake.base, str(empty)]) == 2
def test_audit_lists_questions(fake, capsys):
main(["audit", "--api", fake.base])
out = capsys.readouterr().out
assert out.startswith("3 question(s)") and "refused read_only" in out and "answered" in out
def test_tools_never_fail(capsys):
assert main(["tools"]) == 0
+38
View File
@@ -0,0 +1,38 @@
"""R5 · R13: tops calls only the read addresses; n8n off is a read error, not a crash."""
import pytest
from tops import client
def test_only_read_addresses(fake):
with pytest.raises(ValueError):
client.post(fake.base, "/dlq/update", {"id": 1})
with pytest.raises(ValueError):
client.post(fake.base, "/ops-reset", {})
assert fake.calls == []
def test_facts_sends_the_period(fake):
client.facts(fake.base, date_from="2026-10-19", date_to="2026-10-23")
assert fake.calls == [("/webhook/my-ops-facts", {"from": "2026-10-19", "to": "2026-10-23"})]
def test_file_wins_over_n8n(fake, tmp_path):
f = client.facts(fake.base, file=str(__import__("pathlib").Path(__file__).parent / "data" / "facts.json"))
assert f["overall"] == "red" and fake.calls == []
def test_facts_without_health_is_refused(tmp_path):
p = tmp_path / "old.json"
p.write_text('{"runs": {}}')
with pytest.raises(client.ReadError, match="health"):
client.facts(file=str(p))
def test_n8n_off_is_read_error(down):
with pytest.raises(client.ReadError):
client.ask(down, "지난주 오류율이 얼마야?")
def test_http_error_body_is_returned(fake):
assert client.evaluate(fake.base, []) == {"error": "cases must be a non-empty list"}
+41
View File
@@ -0,0 +1,41 @@
"""R1 · R7: the colors and numbers come from the facts unchanged."""
from conftest import load
from tops import view
def test_status_shows_every_area_with_the_facts_colors():
lines = view.status_lines(load("facts.json"))
assert lines[0].startswith("overall 🔴 red · runs 2026-10-26 ~ 2026-10-30")
assert " 🔴 runs error rate 5.88% · p95 2598ms · unanswered 4.76%" in lines
assert " 🔴 DLQ 3 (parked 2)" in lines
assert " 🟡 cost $0.073604 / $0.08 (92.01%)" in lines
assert " 🔴 release 1.1.0 · canary 1.2.0 20% (repeat 62.5%)" in lines
def test_source_down_is_unknown_not_zero():
lines = view.status_lines(load("facts-cost-down.json"))
assert " ⚪ cost source not answering" in lines
assert lines[-1] == " sources down: cost"
def test_colors_are_not_recounted():
f = load("facts.json")
f["health"]["cost"] = "green" # n8n says green: tops shows green even at 92%
assert any(line.startswith(" 🟢 cost") for line in view.status_lines(f))
def test_answer_lines_refused_and_checked():
asks = load("asks.json")
assert view.answer_lines(asks["DLQ 1번 다시 돌려 줘"])[0] == "[action · keyword] refused (read_only)"
assert view.answer_lines(asks["지난주 오류율이 얼마야?"])[0] == "[runs · keyword] numbers ✔"
def test_missing_tool_is_reported_not_raised():
part = view.tool("no-such-course-tool", ["--json"])
assert part == {"tool": "no-such-course-tool", "ok": False, "why": "not installed (uv tool install)"}
def test_tool_line_reads_tlog_slo():
part = {"tool": "tlog", "ok": True, "code": 4, "result": {"ok": False, "breaches": ["error_rate", "unanswered_rate"], "total": 357}}
assert view.tool_line(part) == " ✔ tlog slo: breaches error_rate, unanswered_rate (357 runs)"
+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.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/4e/2684728d9f2c8c4110738dcadca7e0bed4b60c8dc68c40c2a9e0cf017900/platformdirs-4.14.2.tar.gz", hash = "sha256:6d13b4c1d66b7548dcec9e3db13c1ba9d9adb65041b5d07869313afc07e6e41b", size = 70792, upload-time = "2026-10-31T15:10:34.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/2d/98af31fdcd7c95c7114e2eb7c2eb0fa55d2a012c37ba4eb3722b7272abcb/platformdirs-4.14.2-py3-none-any.whl", hash = "sha256:dc1f93390f2a9265e44e5afc8667ec7faf737739604e39bc538fec889720c36b", size = 35291, upload-time = "2026-10-31T15:10:32.615Z" },
]
[[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 = "tops"
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" },
]