79일차 운영 데이터 기반 Ops Assistant: 실습 15개 · 강의(개요 30장 · 단계 문서 · 4단계 정리 · 05 바이브), 정답본 LAB-A · answers · VIBE-A · tops 포함
This commit is contained in:
1 parent
9ce9aceba6
commit
6f0ecd59a9
156 files changed
+31640
No files matched your search
@@ -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 결과 한 줄
|
||||
@@ -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건"으로 고침 (읽기만의 뜻은 '읽는 주소만'이 아니라 '바꾸는 주소 없음').
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""tops · the day 79 operations assistant in the terminal (reads n8n my-ops-*, never changes anything)."""
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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", {})
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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일차)"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)"
|
||||
Generated
+195
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user