78일차 정답: LAB-A · VIBE-A-TCHECK · answers(정답 워크플로우 · 실행 기록) · 강사가 만든 tcheck

This commit is contained in:
mcchae committed 2026-11-02 20:54:06 +09:00
1 parent acf3a93041
commit c7df2e5344
85 files changed
+52637

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
+29
View File
@@ -0,0 +1,29 @@
# tcheck IMP-PLAN
## 엔진 1단계 · 읽기 · 스키마 · 코드 · n8n과 같은 결과 (R1 · R2 · R3 · R4 · R12 · R13)
- [x] load.py: `read_rows(path)` — `.csv`(첫 줄 열 이름) · JSON 목록 · `{"items"}` · 78일차 요청 파일(`{"schema", "batch", "csv" | "items"}`), `read_schema(name, url, file)` — 데이터 API `GET /schemas/<name>` 또는 `--schema-file`
- [x] checks.py: `check(rows, schema, batch)` — 스키마의 `fields`를 돌며 타입마다 (필드 이름을 코드에 쓰지 않음), 코드는 AGENTS.md 3절 그대로, 키 중복, `row`는 1부터
- [x] cli: `tcheck check FILE [--schema] [--url | $TCHECK_URL]`, 종료 코드 0 · 1(못 읽음) · 2(쓰는 법)
- [x] tests: 직원 9줄 중 4줄 · 접수 건 2줄 · FAQ B-3 5줄, n8n `my-check`가 낸 rows · codes와 **같음** (`tests/data/n8n-my-check.json`), 가짜 데이터 API
- 확인: `uv run pytest -q`, `uv run tcheck check ../requests/employees-new.json --url http://localhost:8800`
## 엔진 2단계 · JSON · 내보내기 · 게이트 (R5 · R6 · R7)
- [x] `--json`: my-check와 같은 모양
- [x] `--out` 깨끗한 줄 · `--rejects` 나쁜 줄 + `errors` 칸 (.csv · .json)
- [x] `tcheck gate --max-invalid 0.3`: 넘으면 종료 코드 4, 같으면 통과
- [x] tests: 파일 두 개 · 직원 0.5556 → 4 · 0.6 → 0 · 1/10 · 0.1 → 통과
- 확인: `uv run tcheck gate ../requests/employees-new.json --url http://localhost:8800; echo $?`
## TUI 1단계 · 탭 · 거르기 · 내보내기 (R11)
- [x] app.py `CheckApp`: 위 요약 한 줄, 탭(문제 줄 · 코드별 · 깨끗한 줄), `[/]` 거르기(키 · 코드), `[Esc]` 풀기, `[e]` 깨끗한 줄 내보내기
- [x] 엔진(`checks`)의 결과를 보여 주기만
- [x] tests: `run_test`로 5 · 5 · 4줄, `email`로 거르면 1줄, `e` → 파일
## 설치 (R14)
- [x] `[project.scripts] tcheck = "tcheck.cli:main"`, `uv tool install --editable .`, `cd /tmp && tcheck --help`
## 엔진 3단계 · 내용 규칙 · 고침 목록 · 품질 기록 (R8 · R9 · R10)
- [x] `--content answer`: Lab 01의 `pii`(하이픈 없는 휴대전화 · 메일) · `external_link`(호스트가 정확히 intra.example.com)
- [x] `--fix`: `fix_list`(줄 순서 · 코드마다 말) · `message`(`TK-1: 9줄 중 2줄 통과, 7줄 고칠 것`)
- [x] `--log`: 데이터 API `POST /quality-log`에 my-check(Lab 03 과제 2)와 같은 한 줄
- [x] tests: 내용 규칙 다섯 줄, 고침 말, 가짜 API에 기록 한 줄
+45
View File
@@ -0,0 +1,45 @@
# tcheck · 강사가 수업 준비 때 직접 만든 본 (2026-11-01)
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TCHECK.md](../VIBE-A-TCHECK.md)와 함께 올립니다.
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-Q의 요구 R1~R14와 [Doc/IMP-PLAN.md](Doc/IMP-PLAN.md)를 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구의 **확인 방법**이 같으면 됩니다.
## 설치 · 실행
```bash
cd ~/aio-01d4-stu/Last3Mon/78/tcheck
uv sync && uv run pytest -q # 테스트 22개 (n8n · 데이터 API 없이, 가짜 서버)
uv tool install --editable . # 어느 폴더에서나 tcheck (R14)
cd /tmp && tcheck --help
```
| 하고 싶은 것 | 명령 |
| --- | --- |
| 검사 (n8n `my-check`와 같은 판정) | `tcheck check ../requests/employees-new.json --url http://localhost:8800` (`--json`) |
| 스키마를 파일에서 | `tcheck check employees.csv --schema-file ../data-api/data/schemas/employee.json` |
| 깨끗한 줄 · 나쁜 줄 파일 | `tcheck check … --out clean.csv --rejects rejects.csv` |
| 배치 게이트 (넘으면 종료 코드 4) | `tcheck gate … --max-invalid 0.3` |
| FAQ 내용 규칙 (개인정보 · 바깥 링크) | `tcheck check ../requests/faq-batch-2.json --schema faq --content answer` |
| 보낸 사람에게 고침 목록 | `tcheck check … --fix` |
| 품질 기록 한 줄 | `tcheck check … --log` (데이터 API `/quality-log`) |
| 화면(TUI) | `tcheck ui …` (`[/]` 거르기, `[Esc]` 풀기, `[e]` 깨끗한 줄 내보내기, `[q]` 끝) |
- 종료 코드: 0 됨 · 1 파일이나 스키마를 못 읽음 · 2 쓰는 법이 틀림 · 4 게이트가 배치를 거절.
- 데이터 API 주소는 `--url` 또는 `$TCHECK_URL`. `--schema-file`을 주면 파일이 먼저입니다. 스키마 이름은 `--schema` 또는 요청 파일의 `"schema"`.
- 판정 규칙은 [AGENTS.md](../AGENTS.md) 3절 "스키마" 표와 같습니다 (공백만 있는 값은 빈 값, 선택 칸은 비면 통과, 실제 있는 날짜, 키 중복, `row`는 1부터, 소수 4자리 half up).
## 파일
| 파일 | 하는 일 |
| --- | --- |
| `src/tcheck/load.py` | 읽는 층 (R1 · R2 · R10): CSV · JSON · 요청 파일, 스키마(API · 파일), 품질 기록 보내기 |
| `src/tcheck/checks.py` | 규칙 한 곳 (R3 · R7 · R8 · R9 · R13): 타입마다 판정 · 내용 규칙 · 게이트 · 고침 목록 · 기록 한 줄 · 나누기 |
| `src/tcheck/cli.py` | `check` · `gate` · `ui`, 종료 코드 (R5 · R6 · R12) |
| `src/tcheck/app.py` | Textual 화면 (R11) |
| `tests/` | 직원 CSV · 접수 건 · FAQ B-3 · 스키마 셋 사본, n8n `my-check`가 낸 결과(`n8n-my-check.json`), 가짜 데이터 API (R12) |
## 수업 준비 때 확인한 것 (11/01)
- 같은 세 배치(직원 HR-1 · 접수 건 TK-1 · FAQ B-3)의 `rows` · `codes` · `valid`가 n8n `my-check`의 응답(`answers/18` · `answers/21`)과 **모두 같음** (`test_same_rows_and_codes_as_n8n`).
- FAQ B-1에 `--schema faq --content answer`: 13줄 중 8줄 나쁨 (0.6154) — Lab 01 예제 2의 내 검사와 같은 8줄 (코드는 스키마 꼴 `missing:title` · `bad_format:id` …).
- 반올림은 JavaScript `Math.round`처럼 half up (`js_round`). 77일차 `tlog`와 같은 까닭.
- 처음 판에서 TUI의 거르기 상자를 연 `/` 글자가 상자에 들어가고, 상자를 닫은 뒤에도 포커스가 남아 `e`(내보내기)가 글자로 들어갔음 → 글자 앞의 `/`를 지우고, 닫을 때 표로 포커스를 옮김.
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "tcheck"
version = "0.1.0"
description = "Check CSV · JSON rows against a data schema (the day 78 data API), with the same codes and results as n8n my-check, a batch gate, and a TUI"
requires-python = ">=3.11"
dependencies = ["textual>=0.80"]
[project.scripts]
tcheck = "tcheck.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tcheck"]
[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 @@
"""tcheck · day 78 data quality checks in the terminal."""
+96
View File
@@ -0,0 +1,96 @@
"""Textual screen (R11): the summary on top, three tabs (problem rows · by code · clean rows), [/] filter, [e] export.
The screen only shows what the engine (checks) already decided."""
from __future__ import annotations
from pathlib import Path
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Header, Input, Static, TabbedContent, TabPane
from . import checks
class CheckApp(App):
TITLE = "tcheck"
BINDINGS = [("slash", "filter", "Filter"), ("e", "export", "Export clean rows"), ("escape", "clear", "Clear filter"),
("q", "quit", "Quit")]
CSS = "#top { height: auto; margin: 0 1; }"
def __init__(self, args, rows: list, schema: dict, result: dict):
super().__init__()
self.args, self.rows, self.schema, self.result = args, rows, schema, result
self.word = ""
self.exported = ""
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="top", markup=False)
yield Input(placeholder="filter: key or code, Enter to apply, Esc to clear", id="filter")
with TabbedContent(id="tabs"):
with TabPane("Problem rows", id="problems"):
yield DataTable(id="problem-table", cursor_type="row")
with TabPane("By code", id="codes"):
yield DataTable(id="code-table", cursor_type="row")
with TabPane("Clean rows", id="clean"):
yield DataTable(id="clean-table", cursor_type="row")
yield Footer()
def on_mount(self):
self.query_one("#filter", Input).display = False
r = self.result
self.sub_title = f"{self.args.file} · schema {r['schema']}"
self.query_one("#top", Static).update(
f"{r['batch']} · {r['total']} rows · valid {r['valid']} · invalid {r['invalid']} · score {checks.score(r)}")
self.fill()
def keep(self, row: dict) -> bool:
return not self.word or self.word in row["key"] or any(self.word in e for e in row["errors"])
def fill(self):
problems = self.query_one("#problem-table", DataTable)
problems.clear(columns=True)
problems.add_columns("row", "key", "errors")
for row in self.result["rows"]:
if not row["ok"] and self.keep(row):
problems.add_row(str(row["row"]), row["key"] or "-", ", ".join(row["errors"]))
codes = self.query_one("#code-table", DataTable)
codes.clear(columns=True)
codes.add_columns("count", "code", "rows")
for code, count in sorted(self.result["codes"].items(), key=lambda kv: (-kv[1], kv[0])):
if not self.word or self.word in code:
where = [str(r["row"]) for r in self.result["rows"] if code in r["errors"]]
codes.add_row(str(count), code, " ".join(where))
clean = self.query_one("#clean-table", DataTable)
clean.clear(columns=True)
clean.add_columns("row", "key")
for row in self.result["rows"]:
if row["ok"] and self.keep(row):
clean.add_row(str(row["row"]), row["key"] or "-")
def action_filter(self):
box = self.query_one("#filter", Input)
box.value, box.display = "", True
self.call_after_refresh(box.focus)
def on_input_submitted(self, event: Input.Submitted):
self.word = event.value.strip().lstrip("/").strip() # the "/" that opened the box may land in it
event.input.display = False
self.fill()
self.query_one("#problem-table", DataTable).focus()
def action_clear(self):
self.word = ""
box = self.query_one("#filter", Input)
box.value, box.display = "", False
self.fill()
self.query_one("#problem-table", DataTable).focus()
def action_export(self):
from .cli import write_rows
source = Path(self.args.file)
target = self.args.out or str(source.with_suffix(".clean" + (".csv" if source.suffix.lower() == ".csv" else ".json")))
clean, _ = checks.split(self.rows, self.result)
write_rows(target, clean)
self.exported = target
self.notify(f"{len(clean)} clean rows → {target}")
+165
View File
@@ -0,0 +1,165 @@
"""The rules, in one place (R13): schema types (R2 · R3), content rules (R8), the gate (R7), the fix list (R9) and
the quality log line (R10). Same codes and the same results as the day 78 n8n my-check (AGENTS.md 3)."""
from __future__ import annotations
import datetime
import math
import re
EMAIL = re.compile(r"^[\w.+-]+@[\w-]+(\.[\w-]+)+$", re.ASCII)
PHONE = re.compile(r"^0\d{1,2}-\d{3,4}-\d{4}$", re.ASCII)
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$", re.ASCII)
PII = (re.compile(r"01[016789][-. ]?\d{3,4}[-. ]?\d{4}", re.ASCII), re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+", re.ASCII))
EXTERNAL = re.compile(r"https?://(?!intra\.example\.com(?![\w.-]))", re.ASCII | re.IGNORECASE)
def js_round(value: float, digits: int = 4) -> float:
"""Half up like JavaScript Math.round (n8n Code nodes), not Python's round-half-even."""
scale = 10 ** digits
out = math.floor(value * scale + 0.5) / scale
return int(out) if out == int(out) else out
def js_str(value) -> str:
if value is True or value is False:
return "true" if value else "false"
if isinstance(value, float) and value == int(value):
return str(int(value))
return str(value)
def is_empty(value) -> bool:
return value is None or (isinstance(value, str) and value.strip() == "") or (isinstance(value, list) and not value)
def is_date(text: str) -> bool:
if not DATE.match(text):
return False
try:
return datetime.date.fromisoformat(text).isoformat() == text
except ValueError:
return False
def field_errors(name: str, rule: dict, value) -> list[str]:
"""One field against one schema rule. The field name never appears in this code (R2)."""
if is_empty(value):
return [f"missing:{name}"] if rule.get("required") else []
kind, text, out = rule.get("type"), js_str(value), []
if kind == "string":
if rule.get("max") and len(text) > rule["max"]:
out.append(f"too_long:{name}")
if rule.get("min") and len(text) < rule["min"]:
out.append(f"too_short:{name}")
elif kind == "enum" and text not in [js_str(v) for v in rule.get("values", [])]:
out.append(f"not_allowed:{name}")
elif kind == "email" and not EMAIL.match(text):
out.append(f"bad_format:{name}")
elif kind == "phone" and not PHONE.match(text):
out.append(f"bad_format:{name}")
elif kind == "date" and not is_date(text):
out.append(f"bad_format:{name}")
elif kind == "pattern" and not re.search(rule.get("pattern", ""), text, re.ASCII):
out.append(f"bad_format:{name}")
elif kind == "list":
if not isinstance(value, list):
out.append(f"bad_format:{name}")
else:
if rule.get("min_items") and len(value) < rule["min_items"]:
out.append(f"too_few:{name}")
if rule.get("max_items") and len(value) > rule["max_items"]:
out.append(f"too_many:{name}")
return out
def content_errors(value) -> list[str]:
"""R8: Lab 01 content rules on a text field (personal phone · e-mail, links outside intra.example.com)."""
text = "" if value is None else js_str(value).strip()
out = []
if any(p.search(text) for p in PII):
out.append("pii")
if EXTERNAL.search(text):
out.append("external_link")
return out
def check(rows: list, schema: dict, batch: str = "", content: tuple[str, ...] = ()) -> dict:
"""{"schema", "batch", "total", "valid", "invalid", "rows": [{"row", "key", "ok", "errors"}], "codes"} like my-check."""
seen, out = set(), []
key_name = schema.get("key", "")
for index, row in enumerate(rows, 1):
row = row if isinstance(row, dict) else {}
errors: list[str] = []
for name, rule in schema["fields"].items():
errors += field_errors(name, rule, row.get(name))
for name in content:
errors += content_errors(row.get(name))
key = "" if row.get(key_name) is None else js_str(row.get(key_name))
if key and key in seen:
errors.append("duplicate_in_batch")
if key:
seen.add(key)
out.append({"row": index, "key": key, "ok": not errors, "errors": errors})
codes: dict[str, int] = {}
for r in out:
for e in r["errors"]:
codes[e] = codes.get(e, 0) + 1
valid = sum(r["ok"] for r in out)
return {"schema": schema.get("name", ""), "batch": batch, "total": len(out), "valid": valid,
"invalid": len(out) - valid, "rows": out, "codes": codes}
def score(result: dict) -> float:
return js_round(result["valid"] / result["total"]) if result["total"] else 0
def gate(result: dict, max_invalid: float = 0.3) -> dict:
"""R7: reject when invalid / total is above the limit (equal passes), like the Lab 02 batch gate."""
ratio = js_round(result["invalid"] / result["total"]) if result["total"] else 0
raw = result["invalid"] / result["total"] if result["total"] else 0
return {"accepted": raw <= max_invalid, "invalid_ratio": ratio, "max_invalid_ratio": max_invalid}
FIX = {
"missing": "{f} 칸을 채워 주세요",
"too_long": "{f}을(를) 더 짧게 ({max}자 이내)",
"too_short": "{f}을(를) 더 길게 ({min}자 이상)",
"not_allowed": "{f}은(는) {values} 중 하나로",
"bad_format": "{f}의 형식을 확인해 주세요",
"too_few": "{f}에 {min_items}개 이상",
"too_many": "{f}에 {max_items}개 이하",
"duplicate_in_batch": "같은 배치에 같은 {key}가 또 있습니다. 하나만 남겨 주세요",
"pii": "개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요",
"external_link": "바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요",
}
def fix_text(code: str, schema: dict) -> str:
kind, _, name = code.partition(":")
rule = schema["fields"].get(name, {})
values = " · ".join(map(str, rule.get("values", [])))
return FIX.get(kind, code).format(f=name, key=schema.get("key", "key"), values=values,
max=rule.get("max"), min=rule.get("min"),
min_items=rule.get("min_items"), max_items=rule.get("max_items"))
def fix_list(result: dict, schema: dict) -> dict:
"""R9: what the sender should fix, in row order, plus a one-line message (Lab 01 task 2)."""
items = [{"row": r["row"], "key": r["key"], "codes": r["errors"], "fix": [fix_text(c, schema) for c in r["errors"]]}
for r in result["rows"] if r["errors"]]
message = f"{result['batch']}: {result['total']}줄 중 {result['valid']}줄 통과, {result['invalid']}줄 고칠 것"
return {"fix_list": items, "message": message}
def quality_entry(result: dict) -> dict:
"""R10: the line my-check writes to /quality-log (Lab 03 task 2)."""
return {"batch": result["batch"], "dataset": result["schema"], "total": result["total"], "valid": result["valid"],
"quarantined": result["invalid"], "rejected": False, "score": score(result), "codes": result["codes"]}
def split(rows: list, result: dict) -> tuple[list[dict], list[dict]]:
"""R6: clean rows as they came, rejected rows with an "errors" column."""
clean = [rows[r["row"] - 1] for r in result["rows"] if r["ok"]]
rejects = [{**(rows[r["row"] - 1] if isinstance(rows[r["row"] - 1], dict) else {}), "errors": " ".join(r["errors"])}
for r in result["rows"] if not r["ok"]]
return clean, rejects
+143
View File
@@ -0,0 +1,143 @@
"""tcheck commands: check · gate · ui. Exit codes (R12): 0 ok · 1 cannot read · 2 usage · 4 batch rejected by the gate."""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from pathlib import Path
from . import checks, load
EXIT_OK, EXIT_READ, EXIT_USAGE, EXIT_REJECTED = 0, 1, 2, 4
class Parser(argparse.ArgumentParser):
def error(self, message): # usage mistakes are exit code 2, with the message on stderr
self.print_usage(sys.stderr)
print(f"tcheck: {message}", file=sys.stderr)
sys.exit(EXIT_USAGE)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument("file", help="rows: .csv, a JSON list, {\"items\": [...]} or a day 78 request {\"schema\", \"batch\", \"csv\" | \"items\"}")
p.add_argument("--schema", default="", help="schema name in the data API (faq · employee · ticket); default: the file's \"schema\"")
p.add_argument("--schema-file", help="read the schema from a JSON file instead of the data API")
p.add_argument("--url", default=os.environ.get("TCHECK_URL"), help="data API base, e.g. http://localhost:8800 ($TCHECK_URL)")
p.add_argument("--batch", default="", help="batch name (default: the file's \"batch\" or its file name)")
p.add_argument("--content", action="append", default=[], metavar="FIELD",
help="also apply the content rules (pii · external_link) to this text field (R8), e.g. --content answer")
p.add_argument("--json", action="store_true", help="print JSON in the shape of n8n my-check (R5)")
def load_all(args) -> tuple[list, dict, dict]:
rows, meta = load.read_rows(args.file)
name = args.schema or meta.get("schema", "")
if not args.schema_file and not name:
raise SystemExit(usage("which schema? give --schema NAME (or a request file with \"schema\") or --schema-file"))
schema = load.read_schema(name, args.url, args.schema_file)
result = checks.check(rows, schema, args.batch or meta.get("batch", ""), tuple(args.content))
return rows, schema, result
def usage(message: str) -> int:
print(f"tcheck: {message}", file=sys.stderr)
return EXIT_USAGE
def write_rows(path: str, rows: list[dict]) -> None:
"""R6: .csv keeps columns in first-seen order, anything else is a JSON list."""
target = Path(path)
if target.suffix.lower() == ".csv":
cols: list[str] = []
for row in rows:
cols += [c for c in row if c not in cols]
with target.open("w", encoding="utf-8", newline="") as out:
writer = csv.DictWriter(out, fieldnames=cols)
writer.writeheader()
writer.writerows([{c: (json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict)) else v)
for c, v in row.items()} for row in rows])
else:
target.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def print_result(r: dict, fixes: dict | None) -> None:
print(f"{r['schema']} · {r['batch']} · {r['total']} rows · valid {r['valid']} · invalid {r['invalid']} · score {checks.score(r)}")
for row in r["rows"]:
if not row["ok"]:
print(f" {row['row']:>3} {row['key'] or '-':<26} {', '.join(row['errors'])}")
if r["codes"]:
print("codes: " + " · ".join(f"{k} {v}" for k, v in sorted(r["codes"].items(), key=lambda kv: (-kv[1], kv[0]))))
if fixes:
print(fixes["message"])
for item in fixes["fix_list"]:
print(f" {item['row']:>3} " + " / ".join(item["fix"]))
def main(argv: list[str] | None = None) -> int:
parser = Parser(prog="tcheck", description="Check CSV · JSON rows against a day 78 data schema, like n8n my-check.")
commands = parser.add_subparsers(dest="command", required=True)
p = commands.add_parser("check", help="rows · codes · valid (R1 · R2 · R3 · R5), clean / rejected files (R6)")
common(p)
p.add_argument("--out", help="write the clean rows here (.csv or .json)")
p.add_argument("--rejects", help="write the rejected rows with an 'errors' column here")
p.add_argument("--fix", action="store_true", help="add the fix list and the one-line message (R9)")
p.add_argument("--log", action="store_true", help="write one line to the data API quality log (R10)")
p = commands.add_parser("gate", help="the batch gate: exit code 4 when invalid / total is above --max-invalid (R7)")
common(p)
p.add_argument("--max-invalid", type=float, default=0.3, help="limit for invalid / total, equal passes (default 0.3)")
p = commands.add_parser("ui", help="the TUI: problem rows · by code · clean rows (R11)")
common(p)
p.add_argument("--out", help="where [e] writes the clean rows (default: <file>.clean.csv or .json)")
try:
args = parser.parse_args(argv)
except SystemExit as stop: # --help is 0, usage mistakes are 2
return int(stop.code or 0)
try:
rows, schema, r = load_all(args)
if args.command == "ui":
from .app import CheckApp
CheckApp(args, rows, schema, r).run()
return EXIT_OK
if args.command == "gate":
g = checks.gate(r, args.max_invalid)
out = {**{k: r[k] for k in ("schema", "batch", "total", "valid", "invalid", "codes")}, **g}
if args.json:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
mark = "✔ accepted" if g["accepted"] else "✘ rejected"
print(f"{mark} · {r['batch']} · invalid {r['invalid']}/{r['total']} = {g['invalid_ratio']} (limit {args.max_invalid})")
return EXIT_OK if g["accepted"] else EXIT_REJECTED
fixes = checks.fix_list(r, schema) if args.fix else None
if args.out or args.rejects:
clean, rejects = checks.split(rows, r)
if args.out:
write_rows(args.out, clean)
if args.rejects:
write_rows(args.rejects, rejects)
if args.log:
if not args.url:
return usage("--log needs --url (or $TCHECK_URL)")
load.post_log(args.url, checks.quality_entry(r))
if args.json:
print(json.dumps({**r, **(fixes or {})}, ensure_ascii=False, indent=2))
else:
print_result(r, fixes)
if args.out:
print(f"clean rows → {args.out}")
if args.rejects:
print(f"rejected rows → {args.rejects}")
if args.log:
print(f"quality log ← {r['batch']} · {r['schema']} · score {checks.score(r)}")
return EXIT_OK
except load.LoadError as error:
print(f"tcheck: 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())
+85
View File
@@ -0,0 +1,85 @@
"""Read rows (R1) from CSV · JSON files, and the schema (R2) from the data API or a file."""
from __future__ import annotations
import csv
import io
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
class LoadError(Exception):
"""The rows or the schema could not be read (exit code 1)."""
def parse_csv(text: str) -> list[dict]:
"""First line = column names, comma separated (AGENTS.md 3: no quotes needed). Values are trimmed."""
reader = csv.reader(io.StringIO(text.strip()))
lines = [line for line in reader if line]
if not lines:
return []
cols = [c.strip() for c in lines[0]]
return [{cols[i]: v.strip() for i, v in enumerate(line) if i < len(cols)} for line in lines[1:]]
def read_rows(path: str) -> tuple[list[dict], dict]:
"""A .csv file, a JSON list, {"items": [...]}, or a day 78 request {"schema", "batch", "csv" | "items"}.
Returns (rows, meta) where meta may hold "schema" and "batch"."""
file = Path(path)
if not file.exists():
raise LoadError(f"no such file: {path}")
text = file.read_text(encoding="utf-8")
if file.suffix.lower() == ".csv":
return parse_csv(text), {"batch": file.stem}
try:
data = json.loads(text)
except json.JSONDecodeError as error:
raise LoadError(f"not CSV or JSON: {path} ({error.msg})") from error
if isinstance(data, list):
return data, {"batch": file.stem}
if isinstance(data, dict):
meta = {k: data[k] for k in ("schema", "batch") if k in data}
meta.setdefault("batch", file.stem)
if isinstance(data.get("csv"), str):
return parse_csv(data["csv"]), meta
if isinstance(data.get("items"), list):
return data["items"], meta
raise LoadError(f"expected a list of rows, {{\"items\": [...]}} or {{\"csv\": \"...\"}}: {path}")
def http_json(url: str, body: dict | None = None, timeout: float = 10) -> object:
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as answer:
return json.loads(answer.read())
def read_schema(name: str = "", url: str | None = None, file: str | None = None) -> dict:
"""--schema-file wins over the data API ($TCHECK_URL may be set for every call)."""
if file:
try:
schema = json.loads(Path(file).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise LoadError(f"cannot read schema file {file}: {error}") from error
elif url and name:
try:
schema = http_json(url.rstrip("/") + "/schemas/" + urllib.parse.quote(name))
except urllib.error.HTTPError as error:
raise LoadError(f"unknown schema '{name}' (data API {error.code})") from error
except (urllib.error.URLError, OSError, json.JSONDecodeError) as error:
raise LoadError(f"data API {url}: {error}") from error
else:
raise LoadError("no schema: give --schema NAME with --url (or $TCHECK_URL), or --schema-file")
if not isinstance(schema, dict) or not isinstance(schema.get("fields"), dict):
raise LoadError("the schema has no 'fields'")
return schema
def post_log(url: str, entry: dict) -> dict:
"""R10: one line in the data API quality log (POST /quality-log)."""
try:
return http_json(url.rstrip("/") + "/quality-log", entry)
except (urllib.error.URLError, OSError, json.JSONDecodeError) as error:
raise LoadError(f"data API {url}: {error}") from error
+55
View File
@@ -0,0 +1,55 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
@pytest.fixture(scope="session")
def n8n():
"""What n8n my-check answered on 2026-11-01 for the same three batches (Last3Mon/78/answers/18 · 21)."""
return json.loads((DATA / "n8n-my-check.json").read_text(encoding="utf-8"))
@pytest.fixture
def api():
"""A local stand-in for the day 78 data API: GET /schemas/<name> from tests/data/schemas, POST /quality-log kept in memory."""
log: list[dict] = []
class Handler(BaseHTTPRequestHandler):
def send(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path = DATA / "schemas" / (self.path.removeprefix("/schemas/") + ".json")
if self.path.startswith("/schemas/") and path.exists():
return self.send(200, json.loads(path.read_text(encoding="utf-8")))
return self.send(404, {"error": "schema not found"})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
if self.path == "/quality-log":
log.append(body)
return self.send(200, {"stored": 1, "count": len(log)})
return self.send(404, {})
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
server.log = log
yield server
server.shutdown()
@pytest.fixture
def url(api):
return f"http://127.0.0.1:{api.server_address[1]}"
@@ -0,0 +1,10 @@
name,dept,email,phone,joined
오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01
윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05
강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06
서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07
임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01
,IT팀,noname@example.com,,2026-10-09
배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12
배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12
정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14
1 name dept email phone joined
2 오세훈 영업팀 sehoon.oh@example.com 010-2222-3333 2026-10-01
3 윤하늘 재무팀 haneul.yoon@example.com 2026-10-05
4 강민수 개발팀 minsu.kang@example 010-4444-5555 2026-10-06
5 서지아 마케팅팀 jia.seo@example.com 010-6666-7777 2026-10-07
6 임도윤 인사팀 doyun.lim@example.com 010-8888-9999 2026-13-01
7 IT팀 noname@example.com 2026-10-09
8 배수아 총무팀 sua.bae@example.com 02-123-4567 2026-10-12
9 배수아 총무팀 sua.bae@example.com 02-123-4567 2026-10-12
10 정다은 IT팀 daeun.jung@example.com 010-1111-2222 2026-10-14
@@ -0,0 +1,113 @@
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
@@ -0,0 +1,54 @@
{
"batch": "B-3",
"items": [
{
"id": "FAQ-301",
"title": "택배 수령",
"keywords": [
"택배"
],
"answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다."
},
{
"id": "FAQ-302",
"title": "주차 등록",
"keywords": [
"주차"
],
"answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다."
},
{
"id": "FAQ-303",
"title": "명함 신청",
"keywords": [
"명함"
],
"answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다."
},
{
"id": "FAQ-304",
"title": "사내 식당 메뉴",
"keywords": [
"식당",
"메뉴"
],
"answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다."
},
{
"id": "FAQ-305",
"title": "교육 신청",
"keywords": [
"교육"
],
"answer": "짧"
},
{
"id": "FAQ-306",
"title": "건강검진",
"keywords": [
"건강검진"
],
"answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다."
}
]
}
@@ -0,0 +1,218 @@
{
"employee": {
"schema": "employee",
"batch": "HR-1",
"total": 9,
"valid": 4,
"invalid": 5,
"rows": [
{
"row": 1,
"key": "sehoon.oh@example.com",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "haneul.yoon@example.com",
"ok": true,
"errors": []
},
{
"row": 3,
"key": "minsu.kang@example",
"ok": false,
"errors": [
"bad_format:email"
]
},
{
"row": 4,
"key": "jia.seo@example.com",
"ok": false,
"errors": [
"not_allowed:dept"
]
},
{
"row": 5,
"key": "doyun.lim@example.com",
"ok": false,
"errors": [
"bad_format:joined"
]
},
{
"row": 6,
"key": "noname@example.com",
"ok": false,
"errors": [
"missing:name"
]
},
{
"row": 7,
"key": "sua.bae@example.com",
"ok": true,
"errors": []
},
{
"row": 8,
"key": "sua.bae@example.com",
"ok": false,
"errors": [
"duplicate_in_batch"
]
},
{
"row": 9,
"key": "daeun.jung@example.com",
"ok": true,
"errors": []
}
],
"codes": {
"bad_format:email": 1,
"not_allowed:dept": 1,
"bad_format:joined": 1,
"missing:name": 1,
"duplicate_in_batch": 1
}
},
"faq": {
"schema": "faq",
"batch": "B-3",
"total": 6,
"valid": 5,
"invalid": 1,
"rows": [
{
"row": 1,
"key": "FAQ-301",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "FAQ-302",
"ok": true,
"errors": []
},
{
"row": 3,
"key": "FAQ-303",
"ok": true,
"errors": []
},
{
"row": 4,
"key": "FAQ-304",
"ok": true,
"errors": []
},
{
"row": 5,
"key": "FAQ-305",
"ok": false,
"errors": [
"too_short:answer"
]
},
{
"row": 6,
"key": "FAQ-306",
"ok": true,
"errors": []
}
],
"codes": {
"too_short:answer": 1
}
},
"ticket": {
"schema": "ticket",
"batch": "TK-1",
"total": 9,
"valid": 2,
"invalid": 7,
"rows": [
{
"row": 1,
"key": "T-2001",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "T-12",
"ok": false,
"errors": [
"bad_format:ticket_id"
]
},
{
"row": 3,
"key": "T-2003",
"ok": false,
"errors": [
"not_allowed:priority"
]
},
{
"row": 4,
"key": "T-2004",
"ok": false,
"errors": [
"bad_format:opened"
]
},
{
"row": 5,
"key": "T-2005",
"ok": false,
"errors": [
"too_short:title"
]
},
{
"row": 6,
"key": "T-2006",
"ok": false,
"errors": [
"bad_format:contact"
]
},
{
"row": 7,
"key": "T-2001",
"ok": false,
"errors": [
"duplicate_in_batch"
]
},
{
"row": 8,
"key": "T-2008",
"ok": false,
"errors": [
"missing:requester"
]
},
{
"row": 9,
"key": "T-2009",
"ok": true,
"errors": []
}
],
"codes": {
"bad_format:ticket_id": 1,
"not_allowed:priority": 1,
"bad_format:opened": 1,
"too_short:title": 1,
"bad_format:contact": 1,
"duplicate_in_batch": 1,
"missing:requester": 1
}
}
}
@@ -0,0 +1,11 @@
{
"name": "employee",
"key": "email",
"fields": {
"name": {"type": "string", "required": true, "max": 20},
"dept": {"type": "enum", "required": true, "values": ["영업팀", "재무팀", "인사팀", "개발팀", "IT팀", "총무팀"]},
"email": {"type": "email", "required": true},
"phone": {"type": "phone", "required": false},
"joined": {"type": "date", "required": true}
}
}
@@ -0,0 +1,10 @@
{
"name": "faq",
"key": "id",
"fields": {
"id": {"type": "pattern", "required": true, "pattern": "^FAQ-\\d{3}$"},
"title": {"type": "string", "required": true, "max": 40},
"keywords": {"type": "list", "required": true, "min_items": 1, "max_items": 8},
"answer": {"type": "string", "required": true, "min": 10, "max": 300}
}
}
@@ -0,0 +1,12 @@
{
"name": "ticket",
"key": "ticket_id",
"fields": {
"ticket_id": {"type": "pattern", "required": true, "pattern": "^T-\\d{4}$"},
"title": {"type": "string", "required": true, "min": 4, "max": 40},
"requester": {"type": "string", "required": true, "max": 20},
"priority": {"type": "enum", "required": true, "values": ["low", "normal", "high"]},
"opened": {"type": "date", "required": true},
"contact": {"type": "phone", "required": false}
}
}
@@ -0,0 +1,71 @@
{
"schema": "ticket",
"batch": "TK-1",
"items": [
{
"ticket_id": "T-2001",
"title": "모니터가 켜지지 않음",
"requester": "박서연",
"priority": "normal",
"opened": "2026-10-28",
"contact": "010-2345-6789"
},
{
"ticket_id": "T-12",
"title": "메일 첨부가 안 열림",
"requester": "이준호",
"priority": "low",
"opened": "2026-10-28"
},
{
"ticket_id": "T-2003",
"title": "VPN이 계속 끊김",
"requester": "최유진",
"priority": "urgent",
"opened": "2026-10-29"
},
{
"ticket_id": "T-2004",
"title": "키보드 교체 요청",
"requester": "한지훈",
"priority": "low",
"opened": "2026-02-30"
},
{
"ticket_id": "T-2005",
"title": "프린터",
"requester": "김민지",
"priority": "normal",
"opened": "2026-10-29"
},
{
"ticket_id": "T-2006",
"title": "공유 드라이브 권한",
"requester": "박서연",
"priority": "normal",
"opened": "2026-10-30",
"contact": "010-12-3456"
},
{
"ticket_id": "T-2001",
"title": "모니터가 또 꺼짐",
"requester": "박서연",
"priority": "high",
"opened": "2026-10-30"
},
{
"ticket_id": "T-2008",
"title": "화상회의 카메라 대여",
"requester": "",
"priority": "low",
"opened": "2026-10-30"
},
{
"ticket_id": "T-2009",
"title": "새 직원 계정 만들기",
"requester": "이준호",
"priority": "high",
"opened": "2026-10-30"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
import json
from types import SimpleNamespace
from tcheck import checks, load
from tcheck.app import CheckApp
from conftest import DATA
def make(tmp_path):
rows, _ = load.read_rows(str(DATA / "employees.csv"))
schema = json.loads((DATA / "schemas" / "employee.json").read_text())
args = SimpleNamespace(file=str(DATA / "employees.csv"), out=str(tmp_path / "clean.csv"))
return CheckApp(args, rows, schema, checks.check(rows, schema, "HR-1"))
async def test_tabs_filter_and_export(tmp_path):
app = make(tmp_path)
async with app.run_test() as pilot:
assert "9 rows · valid 4" in str(app.query_one("#top").render())
assert app.query_one("#problem-table").row_count == 5
assert app.query_one("#code-table").row_count == 5
assert app.query_one("#clean-table").row_count == 4
await pilot.press("slash")
await pilot.pause()
for key in "email":
await pilot.press(key)
await pilot.press("enter")
assert app.query_one("#problem-table").row_count == 1
await pilot.press("escape")
assert app.query_one("#problem-table").row_count == 5
await pilot.press("e")
assert app.exported.endswith("clean.csv") and (tmp_path / "clean.csv").read_text().count("\n") == 5
+78
View File
@@ -0,0 +1,78 @@
import json
import pytest
from tcheck import checks, load
from conftest import DATA
FILES = {"employee": "employees.csv", "ticket": "tickets-new.json", "faq": "faq-batch-3.json"}
def schema(name):
return json.loads((DATA / "schemas" / f"{name}.json").read_text(encoding="utf-8"))
def run(name, batch="X", content=()):
rows, _ = load.read_rows(str(DATA / FILES[name]))
return checks.check(rows, schema(name), batch, content)
@pytest.mark.parametrize("name", ["employee", "ticket", "faq"])
def test_same_rows_and_codes_as_n8n(name, n8n):
"""R4: the same file gives the same rows · codes · valid as n8n my-check."""
r = run(name, n8n[name]["batch"])
for key in ("schema", "batch", "total", "valid", "invalid", "rows", "codes"):
assert r[key] == n8n[name][key], key
def test_employee_reasons():
r = run("employee")
assert [row["errors"] for row in r["rows"]] == [[], [], ["bad_format:email"], ["not_allowed:dept"], ["bad_format:joined"],
["missing:name"], [], ["duplicate_in_batch"], []]
def test_ticket_optional_field_and_impossible_date():
r = run("ticket")
assert r["rows"][3]["errors"] == ["bad_format:opened"] # 2026-02-30
assert r["rows"][5]["errors"] == ["bad_format:contact"] # present but wrong
assert r["rows"][8]["ok"] # contact missing is fine
def test_string_list_and_blank_edges():
s = {"name": "t", "key": "id", "fields": {"id": {"type": "string", "required": True, "min": 2, "max": 3},
"tags": {"type": "list", "required": False, "min_items": 1, "max_items": 2}}}
r = checks.check([{"id": "ab"}, {"id": "abcd"}, {"id": " "}, {"id": "abc", "tags": "x"}, {"id": "ab2", "tags": [1, 2, 3]}], s)
assert [row["errors"] for row in r["rows"]] == [[], ["too_long:id"], ["missing:id"], ["bad_format:tags"], ["too_many:tags"]]
def test_content_rules_on_a_field():
"""R8: Lab 01 rules — phone without dashes, exact intra host."""
s = {"name": "faq", "key": "id", "fields": {}}
rows = [{"id": "1", "answer": "당직 01012345678"}, {"id": "2", "answer": "02-123-4567"},
{"id": "3", "answer": "https://intra.example.com/help"}, {"id": "4", "answer": "http://intra.example.com.evil.io/x"},
{"id": "5", "answer": "a@b.com"}]
r = checks.check(rows, s, content=("answer",))
assert [row["errors"] for row in r["rows"]] == [["pii"], [], [], ["external_link"], ["pii"]]
def test_gate_equal_passes():
r = run("employee") # 5 / 9 = 0.5556
assert checks.gate(r, 0.3) == {"accepted": False, "invalid_ratio": 0.5556, "max_invalid_ratio": 0.3}
assert checks.gate({"invalid": 1, "total": 10}, 0.1)["accepted"] is True
assert checks.gate({"invalid": 0, "total": 0}, 0.3)["accepted"] is True
def test_fix_list_and_message():
r = run("ticket", "TK-1")
f = checks.fix_list(r, schema("ticket"))
assert f["message"] == "TK-1: 9줄 중 2줄 통과, 7줄 고칠 것"
assert [i["row"] for i in f["fix_list"]] == [2, 3, 4, 5, 6, 7, 8]
assert f["fix_list"][1]["fix"] == ["priority은(는) low · normal · high 중 하나로"]
assert "ticket_id" in f["fix_list"][5]["fix"][0]
def test_quality_entry_and_round():
e = checks.quality_entry(run("employee", "HR-1"))
assert e == {"batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": False,
"score": 0.4444, "codes": e["codes"]}
assert checks.js_round(0.00005) == 0.0001 and checks.js_round(2 / 3) == 0.6667
+49
View File
@@ -0,0 +1,49 @@
import csv
import json
from tcheck import cli
from conftest import DATA
EMP = str(DATA / "employees.csv")
EMP_SCHEMA = str(DATA / "schemas" / "employee.json")
def test_check_plain(capsys):
assert cli.main(["check", EMP, "--schema-file", EMP_SCHEMA]) == 0
out = capsys.readouterr().out
assert "9 rows · valid 4 · invalid 5 · score 0.4444" in out and "bad_format:email" in out
def test_check_json_like_my_check(capsys, url, n8n):
assert cli.main(["check", str(DATA / "tickets-new.json"), "--url", url, "--json"]) == 0 # schema from the file
out = json.loads(capsys.readouterr().out)
assert {k: out[k] for k in ("schema", "batch", "valid", "rows", "codes")} == {k: n8n["ticket"][k] for k in ("schema", "batch", "valid", "rows", "codes")}
def test_out_and_rejects_files(tmp_path):
clean, rejects = tmp_path / "clean.csv", tmp_path / "rejects.csv"
assert cli.main(["check", EMP, "--schema-file", EMP_SCHEMA, "--out", str(clean), "--rejects", str(rejects)]) == 0
assert len(list(csv.DictReader(clean.open()))) == 4
bad = list(csv.DictReader(rejects.open()))
assert len(bad) == 5 and bad[0]["errors"] == "bad_format:email"
def test_gate_exit_codes(capsys):
assert cli.main(["gate", EMP, "--schema-file", EMP_SCHEMA]) == 4
assert "rejected" in capsys.readouterr().out
assert cli.main(["gate", EMP, "--schema-file", EMP_SCHEMA, "--max-invalid", "0.6"]) == 0
def test_fix_and_log(capsys, url, api):
assert cli.main(["check", EMP, "--schema", "employee", "--url", url, "--batch", "HR-1", "--fix", "--log"]) == 0
out = capsys.readouterr().out
assert "HR-1: 9줄 중 4줄 통과, 5줄 고칠 것" in out and "email의 형식을 확인해 주세요" in out
assert api.log == [{"batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": False,
"score": 0.4444, "codes": api.log[0]["codes"]}]
def test_exit_codes_read_and_usage(tmp_path, url, capsys):
assert cli.main(["check", str(tmp_path / "none.csv"), "--schema-file", EMP_SCHEMA]) == 1
assert cli.main(["check", EMP, "--schema", "nothing", "--url", url]) == 1
assert cli.main(["check", EMP]) == 2 # which schema?
assert cli.main(["nonsense"]) == 2
+44
View File
@@ -0,0 +1,44 @@
import json
import pytest
from tcheck import load
from conftest import DATA
def test_csv_first_line_is_columns():
rows, meta = load.read_rows(str(DATA / "employees.csv"))
assert len(rows) == 9 and rows[2]["email"] == "minsu.kang@example" and rows[1]["phone"] == ""
assert meta["batch"] == "employees"
def test_request_file_with_csv_or_items(tmp_path):
rows, meta = load.read_rows(str(DATA / "tickets-new.json"))
assert len(rows) == 9 and meta == {"schema": "ticket", "batch": "TK-1"}
f = tmp_path / "hr.json"
f.write_text(json.dumps({"schema": "employee", "batch": "HR-1", "csv": (DATA / "employees.csv").read_text()}))
assert len(load.read_rows(str(f))[0]) == 9
def test_plain_list(tmp_path):
f = tmp_path / "rows.json"
f.write_text("[{\"a\": 1}]")
assert load.read_rows(str(f)) == ([{"a": 1}], {"batch": "rows"})
def test_missing_or_bad_files(tmp_path):
with pytest.raises(load.LoadError):
load.read_rows(str(tmp_path / "nope.csv"))
bad = tmp_path / "bad.json"
bad.write_text("{\"x\": 1}")
with pytest.raises(load.LoadError):
load.read_rows(str(bad))
def test_schema_from_api_or_file(url):
assert load.read_schema("employee", url)["key"] == "email"
assert load.read_schema("", None, str(DATA / "schemas" / "ticket.json"))["name"] == "ticket"
with pytest.raises(load.LoadError):
load.read_schema("nothing", url)
with pytest.raises(load.LoadError):
load.read_schema("employee", None)
+195
View File
@@ -0,0 +1,195 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "platformdirs"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/4e/2684728d9f2c8c4110738dcadca7e0bed4b60c8dc68c40c2a9e0cf017900/platformdirs-4.14.2.tar.gz", hash = "sha256:6d13b4c1d66b7548dcec9e3db13c1ba9d9adb65041b5d07869313afc07e6e41b", size = 70792, upload-time = "2026-10-31T15:10:34.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/2d/98af31fdcd7c95c7114e2eb7c2eb0fa55d2a012c37ba4eb3722b7272abcb/platformdirs-4.14.2-py3-none-any.whl", hash = "sha256:dc1f93390f2a9265e44e5afc8667ec7faf737739604e39bc538fec889720c36b", size = 35291, upload-time = "2026-10-31T15:10:32.615Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "tcheck"
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 = "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 = "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" },
]