74일차 정답 공개 (LAB-A · VIBE-A · answers · tver), Lab 03 예제 3 이해하기 보강

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-28 20:43:38 +09:00
1 parent 2ae33432a9
commit d663d045e3
86 files changed
+16144

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# tver IMP-PLAN
## 엔진 1단계 · 정리하고 비교하기 (R1 · R2 · R3 · R4)
- [x] `workflow.clean`: id · position · webhookId · 내보내기 도장 빼기, 노드 이름순 · `dumps`는 키 정렬
- [x] `diff.compare(old, new)` → `Change{op, node, category, key, before, after, lines}`
- [x] `diff.category`: Chat model의 model → model, systemMessage · text · jsCode → prompt, 나머지 → flow
- [x] 80자 넘거나 여러 줄이면 `difflib` 줄 diff
- [x] tests: v1 → v2 7개 · 1/1/5, 위치 · id · 순서만 바꾼 파일 0개, systemMessage에 `+ … [FAQ-003]`
- [x] cli: `tver diff a.json b.json`
- 확인: `uv run pytest -q`, `uv run tver diff tests/data/helpdesk-v1.json tests/data/helpdesk-v2.json`
## 엔진 2단계 · git에 저장 · 목록 (R5 · R6)
- [x] `Store.save_workflow`: 정리한 글이 같으면 "no change", 다르면 쓰고 커밋 (메시지 = 바로 전과의 요약)
- [x] `Store.log` · `Store.show`, `tver diff <rev1> <rev2> <name>`
- [x] tests: 두 번 저장 → 버전 1개, v2로 바꿔 저장 → 메시지에 "model 1 · prompt 1 · flow 5"
- 확인: `uv run tver --repo /tmp/s save tests/data/helpdesk-v1.json` 두 번, `log`
## TUI 1단계 · 버전 목록 · 바뀐 것 트리 · 자세히 (R8)
- [x] 왼쪽 ListView(버전), 오른쪽 Tree(노드 → 바뀐 것), 아래 자세히 (줄 diff 또는 전 · 후)
- [x] 고른 버전 vs 바로 전 버전 비교는 worker
- [x] `[s]` 지금 파일을 버전으로 저장
- [x] tests: 저장 1개 → `[s]` → 2개, 트리 제목에 "model 1 · prompt 1 · flow 5"
## 설치 (R14)
- [x] `[project.scripts] tver = "tver.cli:main"`, 저장소 기본 `~/.tver/store` (`--repo` · `$TVER_REPO`)
- 확인: `uv tool install --editable .`, `cd /tmp && tver --help`
## 엔진 3단계 · 키 거부 · JSON · 폴더 (R7 · R11 · R13)
- [x] `find_secrets`: `sk-…` · AWS · GitHub · Slack 토큰 모양, 메시지에는 앞 5자 + 끝 2자만
- [x] `--json`, 종료 코드 0 / 1 / 2
- [x] `save <폴더>`: `nodes` · `connections`가 있는 것만, 나머지 skipped
## 엔진 4단계 · n8n에서 가져오기 · 꺼내기 · 이름 바꿈 (R9 · R10 · R12)
- [x] `pull`: `docker exec <컨테이너> n8n export:workflow --all` → 이름별 저장 (n8n에는 쓰지 않음)
- [x] `restore <rev> <name> -o <파일>`: `git show`로 꺼내 파일로, 저장소는 그대로
- [x] 짝 없는 − · + 노드 중 종류 · 설정이 같은 한 쌍 → `renamed`, 연결도 새 이름으로 맞춰 비교
+48
View File
@@ -0,0 +1,48 @@
# tver · 강사가 수업 준비 때 직접 만든 본 (2026-10-27)
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TVER.md](../VIBE-A-TVER.md)와 함께 올립니다.
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-A의 SPEC · IMP-PLAN을 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구 R1~R14의 **확인 방법**이 같으면 됩니다.
## 설치 · 실행
```bash
cd ~/aio-01d4-stu/Last3Mon/74/tver
uv sync && uv run pytest -q # 테스트 11개 (저장소는 tmp_path, 가짜 키는 테스트 안에서 만듦)
uv tool install --editable . # 어느 폴더에서나 tver (R14)
cd /tmp && tver --help
```
| 하고 싶은 것 | 명령 |
| --- | --- |
| 두 파일 비교 | `tver diff a.json b.json` · 줄 diff 없이 `--short` · 다른 프로그램용 `--json` |
| 버전으로 저장 | `tver save my-work/helpdesk.json` · 폴더 `tver save my-work` (워크플로우만) |
| 버전 목록 · 비교 | `tver log helpdesk.json` · `tver diff <rev1> <rev2> helpdesk.json` |
| 한 버전 보기 · 꺼내기 | `tver show <rev> helpdesk.json` · `tver restore <rev> helpdesk.json -o my-work/helpdesk.json` |
| n8n에서 모두 가져오기 | `tver pull [--container day65-n8n-n8n-1]` (읽기만) |
| 화면(TUI) | `tver my-work/helpdesk.json` (`[s]` 지금 파일을 저장, `[q]` 끝) |
- 저장소: `~/.tver/store` (또는 `--repo <폴더>`, `$TVER_REPO`). 내 프로젝트 폴더 밖에 따로 둡니다.
- 종료 코드: `diff` 0 같음 · 1 다름, 모든 명령 2 오류 (파일이 없음 · 워크플로우가 아님 · 키가 있어 저장 거부).
## 파일
| 파일 | 하는 일 | 요구 |
| --- | --- | --- |
| `src/tver/workflow.py` | 읽기, 뜻 없는 칸 빼기(`clean`), 키 정렬 저장 글(`dumps`), 키 모양 찾기(가려서) | R2 · R7 |
| `src/tver/diff.py` | 노드 이름으로 짝짓기, 설정 · 연결 비교, 줄 diff, 모델 · 지시문 · 흐름 분류, 이름 바꿈 | R1 · R3 · R4 · R12 |
| `src/tver/store.py` | git 저장소: 저장(같으면 안 함) · 목록 · 꺼내기, 폴더 저장, n8n에서 내보내기 | R5 · R6 · R9 · R10 · R13 |
| `src/tver/cli.py` | 명령 · `--json` · 종료 코드 | R11 |
| `src/tver/app.py` | Textual 화면 | R8 |
| `tests/` | v1 · v2(`tests/data`, 비밀 없음)로 엔진 · 저장 · 화면 | |
| `Doc/IMP-PLAN.md` | 만든 순서 (엔진 1 → 2 → TUI 1 → 설치 → 엔진 3 · 4) | |
## 2026-10-27에 확인한 것
| 확인 | 결과 |
| --- | --- |
| `uv run pytest -q` | 11 passed |
| `tver diff reference/helpdesk-v1.json reference/helpdesk-v2.json --short` | `flow.py diff`와 같은 7줄, `7 change(s) · model 1 · prompt 1 · flow 5`, 종료 코드 1 |
| 오늘 Lab의 helpdesk 정답 여섯 단계를 차례로 `save` | 버전 6개, 메시지 예 "15 change(s) · model 1 · prompt 1 · flow 13" (Lab 01 과제 2 → Lab 02 예제 1) |
| `tver save answers` | 워크플로우 5개 저장, 테스트 파일 `74-lab02-challenge-golden.json`은 skipped |
| `tver pull` (강사 n8n) | 192 new version(s) — 강사 n8n에 있던 워크플로우 전부, n8n은 그대로 |
| `uv tool install --editable .` → `/tmp`에서 `tver restore` → 원본과 `tver diff` | `0 change(s)`, 종료 코드 0 |
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "tver"
version = "0.1.0"
description = "Keep n8n workflow files as versions in git and compare two versions node by node"
requires-python = ">=3.11"
dependencies = ["textual>=0.80"]
[project.scripts]
tver = "tver.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tver"]
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
+1
View File
@@ -0,0 +1 @@
"""tver: n8n workflow versions in git, compared node by node."""
+101
View File
@@ -0,0 +1,101 @@
"""Textual screen (R8): versions on the left, changed nodes as a tree on the right, the chosen change below.
`[s]` saves the file again as a new version, `[q]` quits."""
from pathlib import Path
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Footer, Header, ListItem, ListView, Static, Tree
from . import diff
from .store import Store, safe_name
from .workflow import load
class VersionApp(App):
TITLE = "tver"
BINDINGS = [("s", "save", "Save file as a version"), ("q", "quit", "Quit")]
CSS = """
#summary { height: auto; margin: 0 1; }
#versions { width: 40; border: round $accent; }
#changes { border: round $accent; }
#detail { height: 10; border: round $accent; padding: 0 1; }
"""
def __init__(self, target, store: Store | None = None):
super().__init__()
self.file = Path(target) if Path(target).exists() else None
self.name_ = safe_name(Path(target).name)
self.store = store or Store()
self.versions = []
self.changes = []
def compose(self) -> ComposeResult:
yield Header()
yield Static("loading...", id="summary")
with Horizontal():
yield ListView(id="versions")
yield Tree("changes", id="changes")
yield Static("", id="detail")
yield Footer()
def on_mount(self):
self.sub_title = f"{self.name_} · {self.store.repo}"
self.reload()
def reload(self):
self.versions = self.store.log(self.name_)
view = self.query_one(ListView)
view.clear()
for v in self.versions:
view.append(ListItem(Static(f"{v.rev} {v.date[5:16]} {v.message.split(': ', 1)[-1]}")))
self.query_one("#summary", Static).update(
f"{len(self.versions)} version(s)" + ("" if self.versions else f" · none yet: press [s] or run tver save {self.file or self.name_}"))
if self.versions:
view.index = 0
self.compare(0)
def on_list_view_highlighted(self, event):
if event.list_view.index is not None and 0 <= event.list_view.index < len(self.versions):
self.compare(event.list_view.index)
@work(thread=True, exclusive=True)
def compare(self, index: int):
"""The chosen version against the one before it (the oldest against nothing)."""
new = self.store.show(self.versions[index].rev, self.name_)
old = self.store.show(self.versions[index + 1].rev, self.name_) if index + 1 < len(self.versions) \
else {"nodes": [], "connections": {}}
self.call_from_thread(self.show_changes, index, diff.compare(old, new))
def show_changes(self, index: int, changes):
self.changes = changes
tree = self.query_one(Tree)
tree.clear()
before = self.versions[index + 1].rev if index + 1 < len(self.versions) else "(empty)"
tree.root.set_label(f"{before} → {self.versions[index].rev} · {diff.summary_line(diff.summary(changes))}")
groups = {}
for i, change in enumerate(changes):
parent = groups.get(change.node)
if parent is None:
parent = groups[change.node] = tree.root.add(change.node, expand=True)
parent.add_leaf(f"[{change.category}] {change.title()}", data=i)
tree.root.expand()
self.query_one("#detail", Static).update("choose a change" if changes else "no change")
def on_tree_node_highlighted(self, event):
index = event.node.data
if index is None or not 0 <= index < len(self.changes):
return
change = self.changes[index]
rows = [change.title()]
rows += change.lines or [f"- {diff.short(change.before, 200)}", f"+ {diff.short(change.after, 200)}"] \
if change.op == "~set" else []
self.query_one("#detail", Static).update("\n".join(rows[:12]))
def action_save(self):
if not self.file:
self.query_one("#summary", Static).update("open tver with a file path to save it")
return
status, message = self.store.save_workflow(load(self.file), self.name_)
self.reload()
self.query_one("#summary", Static).update(f"{status} {message}".strip() + f" · {len(self.versions)} version(s)")
+121
View File
@@ -0,0 +1,121 @@
"""Command line. Exit codes: diff 0 same · 1 different, 2 error (any command)."""
import argparse
import json
import sys
from pathlib import Path
from . import diff
from .store import DEFAULT_REPO, Store, StoreError, export_from_n8n, safe_name
from .workflow import NotWorkflow, load
COMMANDS = ("diff", "save", "log", "show", "restore", "pull")
def load_side(store: Store, spec: str, name: str | None) -> dict:
"""A file path, or a revision of <name> in the store."""
if Path(spec).exists():
return load(spec)
if not name:
raise StoreError(f"{spec}: no such file (to compare versions give the workflow name: tver diff <rev1> <rev2> <name>)")
return store.show(spec, name)
def print_changes(changes, full=False):
for change in changes:
print(change.title())
if change.lines and full:
for row in change.lines:
print(f" {row}")
print(diff.summary_line(diff.summary(changes)))
def run_diff(args, store):
old = load_side(store, args.old, args.name)
new = load_side(store, args.new, args.name)
changes = diff.compare(old, new)
if args.json:
print(json.dumps({"changes": [c.to_dict() for c in changes], "summary": diff.summary(changes)},
ensure_ascii=False, indent=2))
else:
print(f"{old.get('name', args.old)} → {new.get('name', args.new)}")
print_changes(changes, full=not args.short)
return 1 if changes else 0
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
from .app import VersionApp # `tver <workflow file or name>` opens the TUI
repo = None
if "--repo" in argv:
repo = argv[argv.index("--repo") + 1]
VersionApp(argv[0], Store(repo)).run()
sys.exit(0)
p = argparse.ArgumentParser(prog="tver", description="n8n workflow versions in git, compared node by node. "
"`tver <file or name>` opens the TUI.")
p.add_argument("--repo", default=None, help=f"version store (default {DEFAULT_REPO}, or $TVER_REPO)")
sub = p.add_subparsers(dest="cmd", required=True)
d = sub.add_parser("diff", help="compare two files, or two versions: tver diff <rev1> <rev2> <name>")
d.add_argument("old")
d.add_argument("new")
d.add_argument("name", nargs="?", help="workflow name in the store when old/new are revisions")
d.add_argument("--plain", action="store_true", help="plain lines (default)")
d.add_argument("--short", action="store_true", help="no line diff for long text")
d.add_argument("--json", action="store_true", help="JSON for other programs")
s = sub.add_parser("save", help="save a workflow file (or every workflow file in a folder) as a new version")
s.add_argument("path")
s.add_argument("--allow-secrets", action="store_true", help="save even if it looks like it has a secret (not advised)")
lg = sub.add_parser("log", help="versions of one workflow")
lg.add_argument("name")
lg.add_argument("--json", action="store_true")
sh = sub.add_parser("show", help="print one version")
sh.add_argument("rev")
sh.add_argument("name")
r = sub.add_parser("restore", help="write an old version to a file (then push it yourself)")
r.add_argument("rev")
r.add_argument("name")
r.add_argument("-o", "--out", required=True, help="file to write, e.g. my-work/helpdesk.json")
pl = sub.add_parser("pull", help="export every workflow from the running n8n and save each as a version")
pl.add_argument("--container", default="day65-n8n-n8n-1")
args = p.parse_args(argv)
store = Store(args.repo)
try:
if args.cmd == "diff":
return run_diff(args, store)
if args.cmd == "save":
rows = store.save(args.path, args.allow_secrets)
for name, status, message in rows:
print(f"{status:9} {name}" + (f" {message}" if message and status == "saved" else ""))
return 0
if args.cmd == "log":
versions = store.log(args.name)
if args.json:
print(json.dumps([v.__dict__ for v in versions], ensure_ascii=False, indent=2))
for v in versions if not args.json else []:
print(f"{v.rev} {v.date} {v.message}")
if not versions:
print(f"no versions of {safe_name(Path(args.name).name)} in {store.repo}", file=sys.stderr)
return 0
if args.cmd == "show":
print(json.dumps(store.show(args.rev, args.name), ensure_ascii=False, indent=2))
return 0
if args.cmd == "restore":
out = store.restore(args.rev, args.name, args.out)
print(f"wrote {out} from {args.rev}. Push it yourself: uv run tools/flow.py push {out}")
return 0
if args.cmd == "pull":
saved = 0
for workflow in export_from_n8n(args.container):
status, message = store.save_workflow(workflow, safe_name(str(workflow.get("name", "workflow"))))
saved += status == "saved"
print(f"{status:9} {safe_name(str(workflow.get('name')))}" + (f" {message}" if status == "saved" else ""))
print(f"{saved} new version(s) in {store.repo}")
return 0
except (StoreError, NotWorkflow) as error:
print(f"tver: {error}", file=sys.stderr)
return 2
return 2
if __name__ == "__main__":
sys.exit(main())
+147
View File
@@ -0,0 +1,147 @@
"""Node-by-node difference of two workflows (R1), line diff for long text (R3),
model · prompt · flow summary (R4), renamed nodes (R12)."""
import difflib
import json
from dataclasses import asdict, dataclass, field
from .workflow import NOISE
LONG = 80
PROMPT_KEYS = ("systemMessage", "text", "jsCode", "pythonCode", "prompt")
@dataclass
class Change:
op: str # "+node" "-node" "~set" "renamed" "+link" "-link"
node: str
category: str # "model" | "prompt" | "flow"
key: str = ""
before: object = None
after: object = None
lines: list = field(default_factory=list) # unified diff lines for long text
def to_dict(self):
return asdict(self)
def title(self):
if self.op == "+node":
return f"+ node '{self.node}' ({self.after})"
if self.op == "-node":
return f"- node '{self.node}' ({self.before})"
if self.op == "renamed":
return f"~ renamed '{self.before}' → '{self.after}'"
if self.op in ("+link", "-link"):
return f"{self.op[0]} link {self.node} → {self.after if self.op == '+link' else self.before} ({self.key})"
if self.lines:
return f"~ '{self.node}' {self.key}: changed ({len(text(self.before))} → {len(text(self.after))} chars)"
return f"~ '{self.node}' {self.key}: {short(self.before)} → {short(self.after)}"
def text(value) -> str:
if value is None:
return "(none)"
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)
def short(value, width=60) -> str:
s = text(value)
return s if len(s) <= width else s[: width - 1] + "…"
def flat(value, prefix=""):
if isinstance(value, dict) and value:
out = {}
for key, item in value.items():
out.update(flat(item, f"{prefix}.{key}" if prefix else str(key)))
return out
return {prefix: value}
def kind(node) -> str:
return str(node.get("type", "?")).split(".")[-1]
def category(node, key: str) -> str:
"""Chat model's model → model; Agent / chain text · systemMessage and Code → prompt; the rest → flow."""
t = kind(node).lower()
last = key.split(".")[-1]
if t.startswith("lmchat") or t.startswith("lm") or "embeddings" in t:
if last in ("model", "value", "modelName") and ("model" in key):
return "model"
if last in PROMPT_KEYS:
return "prompt"
return "flow"
def node_settings(node) -> dict:
body = {k: v for k, v in node.items() if k not in NOISE and k != "name"}
return flat(body)
def links(workflow) -> set:
out = set()
for src, kinds in (workflow.get("connections") or {}).items():
for link_kind, outputs in (kinds or {}).items():
for index, output in enumerate(outputs or []):
for dst in output or []:
out.add((src, f"{link_kind}[{index}]", dst.get("node")))
return out
def line_diff(before, after) -> list[str]:
a, b = text(before).splitlines(), text(after).splitlines()
return [row for row in difflib.unified_diff(a, b, "before", "after", lineterm="", n=1)][2:]
def compare(old: dict, new: dict) -> list[Change]:
old_nodes = {n.get("name"): n for n in old.get("nodes", [])}
new_nodes = {n.get("name"): n for n in new.get("nodes", [])}
gone = sorted(set(old_nodes) - set(new_nodes))
added = sorted(set(new_nodes) - set(old_nodes))
renamed = {}
for name in list(gone):
twin = [a for a in added if kind(new_nodes[a]) == kind(old_nodes[name])
and node_settings(new_nodes[a]) == node_settings(old_nodes[name])]
if len(twin) == 1:
renamed[name] = twin[0]
gone.remove(name)
added.remove(twin[0])
changes = []
for name in added:
changes.append(Change("+node", name, "flow", after=kind(new_nodes[name])))
for name in gone:
changes.append(Change("-node", name, "flow", before=kind(old_nodes[name])))
for old_name, new_name in sorted(renamed.items()):
changes.append(Change("renamed", new_name, "flow", before=old_name, after=new_name))
pairs = [(n, n) for n in sorted(set(old_nodes) & set(new_nodes))] + sorted(renamed.items())
for old_name, new_name in pairs:
a, b = node_settings(old_nodes[old_name]), node_settings(new_nodes[new_name])
for key in sorted(set(a) | set(b)):
if a.get(key) == b.get(key):
continue
before, after = a.get(key), b.get(key)
long = max(len(text(before)), len(text(after))) > LONG or "\n" in text(before) + text(after)
changes.append(Change("~set", new_name, category(new_nodes[new_name], key), key, before, after,
line_diff(before, after) if long else []))
rename = {o: n for o, n in renamed.items()}
old_links = {(rename.get(s, s), k, rename.get(d, d)) for s, k, d in links(old)}
new_links = links(new)
for src, link_kind, dst in sorted(new_links - old_links):
changes.append(Change("+link", src, "flow", link_kind, after=dst))
for src, link_kind, dst in sorted(old_links - new_links):
changes.append(Change("-link", src, "flow", link_kind, before=dst))
return changes
def summary(changes: list[Change]) -> dict:
counts = {"model": 0, "prompt": 0, "flow": 0}
for change in changes:
counts[change.category] += 1
nodes = {"+": sum(c.op == "+node" for c in changes), "-": sum(c.op == "-node" for c in changes),
"~": len({c.node for c in changes if c.op == "~set"}), "renamed": sum(c.op == "renamed" for c in changes)}
return {"total": len(changes), **counts, "nodes": nodes,
"links": {"+": sum(c.op == "+link" for c in changes), "-": sum(c.op == "-link" for c in changes)}}
def summary_line(s: dict) -> str:
return f"{s['total']} change(s) · model {s['model']} · prompt {s['prompt']} · flow {s['flow']}"
+118
View File
@@ -0,0 +1,118 @@
"""Versions in a git repository (R5 save · R6 log/show · R10 restore · R13 folder · R9 pull from n8n)."""
import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from . import diff
from .workflow import NotWorkflow, dumps, find_secrets, is_workflow, load, parse
DEFAULT_REPO = Path(os.getenv("TVER_REPO", Path.home() / ".tver" / "store"))
class StoreError(RuntimeError):
pass
@dataclass
class Version:
rev: str
date: str
message: str
def safe_name(name: str) -> str:
keep = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in name.strip())
return (keep or "workflow").lower() + ("" if keep.endswith(".json") else ".json")
class Store:
def __init__(self, repo=None):
self.repo = Path(repo or DEFAULT_REPO).expanduser()
def git(self, *args, check=True) -> str:
result = subprocess.run(["git", "-C", str(self.repo), *args], capture_output=True, text=True)
if check and result.returncode != 0:
raise StoreError((result.stderr or result.stdout).strip() or f"git {args[0]} failed")
return result.stdout
def ensure(self):
if not (self.repo / ".git").exists():
self.repo.mkdir(parents=True, exist_ok=True)
self.git("init", "-q")
self.git("config", "user.name", "tver")
self.git("config", "user.email", "tver@localhost")
def save_workflow(self, workflow: dict, name: str, force_secrets=False) -> tuple[str, str]:
"""Write the cleaned JSON as <name> and commit when it changed. Returns (status, message)."""
text = dumps(workflow)
secrets = find_secrets(text)
if secrets and not force_secrets:
raise StoreError(f"{name}: looks like a secret ({', '.join(secrets)}). Not saved. "
"Remove it (use an n8n Credential) and save again.")
self.ensure()
target = self.repo / name
previous = target.read_text(encoding="utf-8") if target.exists() else None
if previous == text:
return "no change", ""
message = f"{name}: new" if previous is None else \
f"{name}: " + diff.summary_line(diff.summary(diff.compare(parse(previous, name), parse(text, name))))
target.write_text(text, encoding="utf-8")
self.git("add", name)
self.git("commit", "-q", "-m", message)
return "saved", message
def save(self, path, force_secrets=False) -> list[tuple[str, str, str]]:
"""A file, or every workflow file in a folder (others are skipped). Returns (name, status, message)."""
path = Path(path)
files = sorted(path.glob("*.json")) if path.is_dir() else [path]
out = []
for file in files:
try:
workflow = load(file)
except NotWorkflow:
if path.is_dir():
out.append((file.name, "skipped", "not a workflow"))
continue
raise
status, message = self.save_workflow(workflow, safe_name(file.name), force_secrets)
out.append((safe_name(file.name), status, message))
return out
def names(self) -> list[str]:
if not (self.repo / ".git").exists():
return []
return sorted(line for line in self.git("ls-files").splitlines() if line.endswith(".json"))
def log(self, name: str) -> list[Version]:
name = safe_name(Path(name).name)
if not (self.repo / ".git").exists():
return []
rows = self.git("log", "--format=%h%x09%ad%x09%s", "--date=format:%Y-%m-%d %H:%M:%S", "--", name, check=False)
return [Version(*row.split("\t", 2)) for row in rows.splitlines() if row.count("\t") >= 2]
def show(self, rev: str, name: str) -> dict:
name = safe_name(Path(name).name)
return parse(self.git("show", f"{rev}:{name}"), f"{rev}:{name}")
def restore(self, rev: str, name: str, out) -> Path:
"""Write that version to a file (the person pushes it to n8n). The store itself is not changed."""
workflow = self.show(rev, name)
out = Path(out)
out.write_text(json.dumps(workflow, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return out
def export_from_n8n(container: str) -> list[dict]:
"""R9: every workflow in the running n8n container (docker exec ... n8n export:workflow --all)."""
command = ["docker", "exec", container, "sh", "-c",
"n8n export:workflow --all --output=/tmp/tver-all.json >/dev/null 2>&1; cat /tmp/tver-all.json; rm -f /tmp/tver-all.json"]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
except (OSError, subprocess.TimeoutExpired) as error:
raise StoreError(f"docker exec failed: {error}") from error
if result.returncode != 0 or not result.stdout.strip():
raise StoreError((result.stderr or "no output").strip() + f"\n Is the n8n container '{container}' running?")
data = json.loads(result.stdout)
return [item for item in (data if isinstance(data, list) else [data]) if is_workflow(item)]
+55
View File
@@ -0,0 +1,55 @@
"""Read and clean n8n workflow JSON: drop values that change without meaning (R2), find secrets (R7)."""
import json
import re
from pathlib import Path
NOISE = {"id", "position", "webhookId", "versionId", "meta", "pinData", "updatedAt", "createdAt", "active", "shared", "tags"}
SECRET = re.compile(r"\b(sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|xox[abp]-[A-Za-z0-9-]{10,})")
class NotWorkflow(ValueError):
pass
def is_workflow(data) -> bool:
return isinstance(data, dict) and isinstance(data.get("nodes"), list) and isinstance(data.get("connections"), dict)
def load(path) -> dict:
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise NotWorkflow(f"{path}: {error}") from error
if not is_workflow(data):
raise NotWorkflow(f"{path}: not an n8n workflow (needs nodes and connections)")
return data
def parse(text: str, label: str = "workflow") -> dict:
try:
data = json.loads(text)
except json.JSONDecodeError as error:
raise NotWorkflow(f"{label}: {error}") from error
if not is_workflow(data):
raise NotWorkflow(f"{label}: not an n8n workflow")
return data
def clean(workflow: dict) -> dict:
"""Same meaning → same JSON: no ids, positions or export stamps; nodes sorted by name."""
nodes = []
for node in workflow.get("nodes", []):
nodes.append({k: v for k, v in node.items() if k not in NOISE})
nodes.sort(key=lambda n: str(n.get("name", "")))
out = {k: v for k, v in workflow.items() if k not in NOISE | {"nodes"}}
out["nodes"] = nodes
return out
def dumps(workflow: dict) -> str:
return json.dumps(clean(workflow), ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def find_secrets(text: str) -> list[str]:
"""Masked secret-looking values (sk-… keys etc.). Credential references (id · name) are fine."""
return [m.group(0)[:5] + "…" + m.group(0)[-2:] for m in SECRET.finditer(text)]
+33
View File
@@ -0,0 +1,33 @@
import json
import shutil
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
@pytest.fixture
def v1():
return json.loads((DATA / "helpdesk-v1.json").read_text(encoding="utf-8"))
@pytest.fixture
def v2():
return json.loads((DATA / "helpdesk-v2.json").read_text(encoding="utf-8"))
@pytest.fixture
def work(tmp_path):
"""A my-work folder with v1 as helpdesk.json, a note and a cases file (not workflows)."""
folder = tmp_path / "my-work"
folder.mkdir()
shutil.copy(DATA / "helpdesk-v1.json", folder / "helpdesk.json")
(folder / "PROMPTS.md").write_text("# prompts\n", encoding="utf-8")
(folder / "cases.json").write_text('{"webhook": "x", "cases": []}', encoding="utf-8")
return folder
@pytest.fixture
def store_dir(tmp_path):
return tmp_path / "store"
@@ -0,0 +1,286 @@
{
"name": "74 IT helpdesk v1 (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "rel-v1",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "0709e90d-e8ea-509c-8c91-1721c012d260",
"position": [
0,
300
],
"id": "d087d97a-46d1-544f-b742-78984643db81"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "0cafcebc-1ba4-546e-aa2e-22f24643ca5a"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "f60c3d44-3793-50e4-89c1-96c15c0075b0"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
440,
300
],
"id": "7bf6486f-83e4-56c6-9052-ee94305aa025"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
660,
300
],
"id": "9f1e31b4-c9b1-5848-8bc7-4f9f5c5b905b"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
"maxIterations": 1
}
},
"position": [
880,
300
],
"id": "28d25947-3225-58fb-adec-112be9f61a60"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
880,
520
],
"id": "008c5ad6-673c-5e09-ae15-bbcf04777148"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
},
"position": [
1100,
300
],
"id": "5eabccbd-9c75-59f6-ac72-9070ef5f3a89"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1320,
300
],
"id": "95e15847-a230-567c-a1f5-827a415a52a4"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,310 @@
{
"name": "74 IT helpdesk v2 (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "rel-v2",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "4dd9ca0d-6e35-58d9-afb9-36689beca2c7",
"position": [
0,
300
],
"id": "edf3da10-d647-5671-a906-292126b204df"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "cf8a9038-ff3e-56a3-a17f-9c062bdd0391"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "3e81f84e-0040-52a4-80a2-e093464dea75"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
440,
300
],
"id": "7e858b12-8c3b-5b72-aa02-09276f0d63e0"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
660,
300
],
"id": "9265daab-0313-5c12-aa77-7a596f23eae5"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.\nWhen the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. Keep answers short.",
"maxIterations": 1
}
},
"position": [
880,
300
],
"id": "027a9c55-9b22-5122-85e5-9f8e347fe189"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-5.4-nano"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
880,
520
],
"id": "bfe53f6e-ecec-5876-8943-03d0b6dad9a5"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($json.output ?? '').trim();\nif (answer.length > 400) answer = answer.slice(0, 400);\nreturn [{ json: { output: answer } }];"
},
"position": [
1100,
300
],
"id": "0d0c0486-e241-521a-83ca-66b725fd05d0"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
},
"position": [
1320,
300
],
"id": "27c64881-2145-56c1-ab88-8f0a7515dbd2"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
300
],
"id": "5a7d65a6-3b4e-562d-bf7e-1be3756a8cde"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+34
View File
@@ -0,0 +1,34 @@
"""R8: versions list, change tree, detail of the chosen change, [s] saves."""
import asyncio
import json
from textual.widgets import ListView, Tree
from tver.app import VersionApp
from tver.store import Store
async def wait_for(pilot, check):
for _ in range(60):
await asyncio.sleep(0.05)
await pilot.pause()
if check():
return True
return False
async def test_versions_tree_and_save(work, store_dir, v2):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
app = VersionApp(str(work / "helpdesk.json"), store)
async with app.run_test() as pilot:
assert len(app.query_one(ListView).children) == 1
await pilot.press("s")
assert await wait_for(pilot, lambda: len(app.query_one(ListView).children) == 2)
assert await wait_for(pilot, lambda: "model 1 · prompt 1 · flow 5" in str(app.query_one(Tree).root.label))
tree = app.query_one(Tree)
tree.focus()
await pilot.press("down", "down")
await pilot.pause()
assert str(app.query_one("#detail").render()).strip() != ""
+60
View File
@@ -0,0 +1,60 @@
"""R1 · R2 · R3 · R4 · R11 · R12 with the day 74 v1 → v2 files."""
import copy
import json
from tver import diff
from tver.cli import main
from tver.workflow import clean, dumps
def test_v1_to_v2_is_seven_changes_like_flow_py(v1, v2):
changes = diff.compare(v1, v2)
titles = [c.title() for c in changes]
assert "+ node 'Review check' (code)" in titles
assert any(t.startswith("~ 'Answer' parameters.options.systemMessage: changed") for t in titles)
assert "~ 'Answer model' parameters.model.value: gpt-4.1-mini → gpt-5.4-nano" in titles
assert "~ 'Question webhook' parameters.path: rel-v1 → rel-v2" in titles
s = diff.summary(changes)
assert (s["total"], s["model"], s["prompt"], s["flow"]) == (7, 1, 1, 5)
assert s["links"] == {"+": 2, "-": 1}
def test_noise_is_not_a_change(v1):
moved = copy.deepcopy(v1)
for node in moved["nodes"]:
node["position"] = [node["position"][0] + 100, 0]
node["id"] = "other-" + node["name"]
moved["nodes"].reverse()
assert diff.compare(v1, moved) == []
assert dumps(v1) == dumps(moved)
assert all("position" not in n and "id" not in n for n in clean(v1)["nodes"])
def test_long_prompt_gets_line_diff(v1, v2):
change = next(c for c in diff.compare(v1, v2) if c.key.endswith("systemMessage"))
assert change.category == "prompt"
assert any(row.startswith("+") and "[FAQ-003]" in row for row in change.lines)
def test_renamed_node_is_paired(v1):
renamed = copy.deepcopy(v1)
for node in renamed["nodes"]:
if node["name"] == "Search FAQ":
node["name"] = "Find FAQ"
conn = json.dumps(renamed["connections"]).replace('"Search FAQ"', '"Find FAQ"')
renamed["connections"] = json.loads(conn)
changes = diff.compare(v1, renamed)
assert [c.op for c in changes] == ["renamed"]
assert (changes[0].before, changes[0].after) == ("Search FAQ", "Find FAQ")
def test_cli_diff_exit_codes_and_json(tmp_path, v1, v2, capsys):
a, b = tmp_path / "a.json", tmp_path / "b.json"
a.write_text(json.dumps(v1), encoding="utf-8")
b.write_text(json.dumps(v2), encoding="utf-8")
assert main(["diff", str(a), str(a)]) == 0
capsys.readouterr()
assert main(["diff", str(a), str(b), "--json"]) == 1
out = json.loads(capsys.readouterr().out)
assert out["summary"]["total"] == 7
assert main(["diff", str(a), str(tmp_path / "missing.json")]) == 2
+57
View File
@@ -0,0 +1,57 @@
"""R5 save · R6 log/diff of versions · R7 no secrets · R10 restore · R13 folder."""
import json
import pytest
from tver.cli import main
from tver.store import Store, StoreError
FAKE_KEY = "sk-" + "test-" + "a" * 24 # made here, never stored in a file of this repository
def test_save_twice_makes_one_commit(work, store_dir):
store = Store(store_dir)
assert store.save(work / "helpdesk.json")[0][1] == "saved"
assert store.save(work / "helpdesk.json")[0][1] == "no change"
assert len(store.log("helpdesk.json")) == 1
def test_versions_and_diff_between_them(work, store_dir, v2, capsys):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
name, status, message = store.save(work / "helpdesk.json")[0]
assert status == "saved" and "model 1 · prompt 1 · flow 5" in message
versions = store.log("helpdesk.json")
assert [v.message.split(": ")[0] for v in versions] == ["helpdesk.json", "helpdesk.json"]
assert main(["--repo", str(store_dir), "diff", versions[1].rev, versions[0].rev, "helpdesk.json", "--short"]) == 1
assert "7 change(s) · model 1 · prompt 1 · flow 5" in capsys.readouterr().out
def test_secret_is_not_saved(work, store_dir):
data = json.loads((work / "helpdesk.json").read_text(encoding="utf-8"))
data["nodes"][0]["parameters"]["note"] = FAKE_KEY
(work / "helpdesk.json").write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(StoreError) as error:
Store(store_dir).save(work / "helpdesk.json")
assert FAKE_KEY not in str(error.value)
assert not (store_dir / "helpdesk.json").exists()
def test_restore_writes_a_file_and_keeps_the_store(work, store_dir, v2, tmp_path):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
store.save(work / "helpdesk.json")
oldest = store.log("helpdesk.json")[-1].rev
out = store.restore(oldest, "helpdesk.json", tmp_path / "back.json")
restored = json.loads(out.read_text(encoding="utf-8"))
model = next(n for n in restored["nodes"] if n["name"] == "Answer model")
assert model["parameters"]["model"]["value"] == "gpt-4.1-mini"
assert len(store.log("helpdesk.json")) == 2
def test_folder_save_skips_files_that_are_not_workflows(work, store_dir):
rows = Store(store_dir).save(work)
assert ("cases.json", "skipped", "not a workflow") in rows
assert [r[0] for r in rows if r[1] == "saved"] == ["helpdesk.json"]
+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.13.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/43/8c4b5fe23f0e119cf4e1b16c2d16240ab9057cdbb138568c91d5a48abf81/platformdirs-4.13.6.tar.gz", hash = "sha256:211d176e24674b5895dedf9021c262bcf9fad008a70610bfb147d4a8d6d8fe1b", size = 62750, upload-time = "2026-10-27T08:19:11.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/24/b312a9466839b1f708eed42790b47e927dc7f774a528bb49b1cdf8b734b9/platformdirs-4.13.6-py3-none-any.whl", hash = "sha256:21fc198fb09f3e78cadcdfd8a6ae4fd65cadc64a3cba78b694321c2996114a3e", size = 32767, upload-time = "2026-10-27T08:19:10.405Z" },
]
[[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.1.1"
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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[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 = "tver"
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" },
]