From 7f4a0ef88a9c80e7d8a1cc9a752338962ea9a15f Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Fri, 19 Jun 2026 22:45:02 +0800 Subject: [PATCH 1/4] feat(cli): add Phase 1 uniparser CLI with auth, parse, and fetch Introduce a Click-based CLI for document parsing, token-based result fetch, health/version checks, and persistent API key setup via ~/.uniparser/config.yaml. Co-authored-by: Cursor --- cli/__init__.py | 1 + cli/__main__.py | 5 + cli/commands/__init__.py | 1 + cli/commands/auth.py | 39 +++++++ cli/commands/fetch.py | 70 +++++++++++ cli/commands/health.py | 24 ++++ cli/commands/parse.py | 49 ++++++++ cli/commands/version.py | 37 ++++++ cli/core/__init__.py | 1 + cli/core/config.py | 42 +++++++ cli/core/credentials.py | 41 +++++++ cli/core/defaults.py | 8 ++ cli/core/errors.py | 58 ++++++++++ cli/core/input.py | 66 +++++++++++ cli/core/output.py | 104 +++++++++++++++++ cli/core/pipeline.py | 172 +++++++++++++++++++++++++++ cli/main.py | 33 ++++++ pyproject.toml | 3 + requirements.txt | 1 + tests/unit/test_cli.py | 244 +++++++++++++++++++++++++++++++++++++++ 20 files changed, 999 insertions(+) create mode 100644 cli/__init__.py create mode 100644 cli/__main__.py create mode 100644 cli/commands/__init__.py create mode 100644 cli/commands/auth.py create mode 100644 cli/commands/fetch.py create mode 100644 cli/commands/health.py create mode 100644 cli/commands/parse.py create mode 100644 cli/commands/version.py create mode 100644 cli/core/__init__.py create mode 100644 cli/core/config.py create mode 100644 cli/core/credentials.py create mode 100644 cli/core/defaults.py create mode 100644 cli/core/errors.py create mode 100644 cli/core/input.py create mode 100644 cli/core/output.py create mode 100644 cli/core/pipeline.py create mode 100644 cli/main.py create mode 100644 tests/unit/test_cli.py diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..4805eeb --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1 @@ +"""UniParser command-line interface.""" diff --git a/cli/__main__.py b/cli/__main__.py new file mode 100644 index 0000000..8a070d4 --- /dev/null +++ b/cli/__main__.py @@ -0,0 +1,5 @@ +from cli.main import cli + + +if __name__ == "__main__": + cli() diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py new file mode 100644 index 0000000..bb9ca15 --- /dev/null +++ b/cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI subcommands.""" diff --git a/cli/commands/auth.py b/cli/commands/auth.py new file mode 100644 index 0000000..5f0f4b8 --- /dev/null +++ b/cli/commands/auth.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import click + +from cli.core.credentials import CONFIG_PATH, mask_api_key, read_api_key_from_config, save_api_key + +API_KEY_URL = "https://uniparser.dp.tech/" + + +@click.command("auth") +@click.option("--show", is_flag=True, help="Show masked API key from config") +@click.option("--verify", is_flag=True, help="Verify that an API key is configured") +def auth_cmd(show: bool, verify: bool) -> None: + """Save, inspect, or verify API key configuration.""" + if show: + api_key = read_api_key_from_config() + if not api_key: + print("No API key configured.") + raise SystemExit(1) + print(f"API key: {mask_api_key(api_key)}") + print(f"Config: {CONFIG_PATH}") + raise SystemExit(0) + + if verify: + api_key = read_api_key_from_config() + if not api_key: + print("No API key configured.") + raise SystemExit(1) + print("API key is configured.") + raise SystemExit(0) + + print("UniParser API Key Setup") + print(f"Get your API key from: {API_KEY_URL}") + api_key = click.prompt("Enter your API key", type=str).strip() + if not api_key: + print("API key must not be empty.") + raise SystemExit(1) + save_api_key(api_key) + print("API key saved successfully") diff --git a/cli/commands/fetch.py b/cli/commands/fetch.py new file mode 100644 index 0000000..d08ca0d --- /dev/null +++ b/cli/commands/fetch.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path + +import click + +from cli.core.config import ctx_flag, make_client +from cli.core.errors import missing_token_error +from cli.core.output import ( + default_fetch_output_dir, + emit_success, + resolve_output_dir, + status_line, +) +from cli.core.pipeline import complete_fetch + + +@click.command("fetch") +@click.option("--token", required=True, help="Task token from a prior successful parse trigger response") +@click.option("--output-dir", "-o", help="Output directory (default: ~/Uni-Parser-Skill/token_/)") +@click.option("--overwrite", is_flag=True, help="Overwrite output directory if it already exists") +@click.pass_context +def fetch_cmd(ctx: click.Context, token: str, output_dir: str | None, overwrite: bool) -> None: + """Poll and download results for an existing job using its token.""" + resolved_token = (token or "").strip() + if not resolved_token: + raise SystemExit(missing_token_error()) + + client, err = make_client(ctx) + if err is not None: + raise SystemExit(err) + + source_stem = f"token_{resolved_token[:8]}" + if output_dir: + out_dir, dir_code = resolve_output_dir(source_stem, output_dir, overwrite=overwrite) + else: + out = default_fetch_output_dir(resolved_token) + if out.exists() and not overwrite: + from cli.core.errors import dir_exists_error + + raise SystemExit(dir_exists_error(out)) + if out.exists() and overwrite: + import shutil + + shutil.rmtree(out) + out_dir = out + dir_code = None + + if dir_code is not None: + raise SystemExit(dir_code) + + verbose = ctx_flag(ctx, "verbose") + + def on_poll(status: str | None, elapsed: float) -> None: + if verbose: + status_line(f"[uniparser] polling… status={status} elapsed={elapsed:.0f}s") + + summary = complete_fetch( + client, + resolved_token, + out_dir=Path(out_dir), + source_stem=source_stem, + on_poll=on_poll if verbose else None, + ) + if isinstance(summary, int): + raise SystemExit(summary) + + summary["fetched_by_token"] = True + emit_success(summary, json_output=ctx_flag(ctx, "json_output")) + raise SystemExit(0) diff --git a/cli/commands/health.py b/cli/commands/health.py new file mode 100644 index 0000000..8f7fd99 --- /dev/null +++ b/cli/commands/health.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json + +import click + +from cli.core.config import ctx_flag, make_client + + +@click.command("health") +@click.pass_context +def health_cmd(ctx: click.Context) -> None: + """Check UniParser service health.""" + client, err = make_client(ctx) + if err is not None: + raise SystemExit(err) + + result = client.health() + if ctx_flag(ctx, "json_output"): + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + status = result.get("status", result) + print(f"Health: {status}") + raise SystemExit(0 if result.get("status") != "error" else 1) diff --git a/cli/commands/parse.py b/cli/commands/parse.py new file mode 100644 index 0000000..7bfb4da --- /dev/null +++ b/cli/commands/parse.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import click + +from cli.core.config import ctx_flag, make_client +from cli.core.errors import config_error +from cli.core.input import resolve_input +from cli.core.output import emit_success, resolve_output_dir, status_line +from cli.core.pipeline import run_parse + + +@click.command("parse") +@click.argument("input", metavar="INPUT") +@click.option("--output-dir", "-o", help="Output directory (default: ~/Uni-Parser-Skill//)") +@click.option("--overwrite", is_flag=True, help="Overwrite output directory if it already exists") +@click.option("--async", "async_mode", is_flag=True, help="Submit with sync=false and poll until success") +@click.pass_context +def parse_cmd(ctx: click.Context, input: str, output_dir: str | None, overwrite: bool, async_mode: bool) -> None: + """Parse a local PDF/image or public PDF URL; save Markdown and pages_tree.""" + client, err = make_client(ctx) + if err is not None: + raise SystemExit(err) + + resolved = resolve_input(input) + if isinstance(resolved, str): + raise SystemExit(config_error(resolved)) + + out_dir, dir_code = resolve_output_dir(resolved.source_stem, output_dir, overwrite=overwrite) + if dir_code is not None: + raise SystemExit(dir_code) + + verbose = ctx_flag(ctx, "verbose") + + def on_poll(status: str | None, elapsed: float) -> None: + if verbose: + status_line(f"[uniparser] polling… status={status} elapsed={elapsed:.0f}s") + + code = run_parse( + client, + resolved, + out_dir=out_dir, + async_mode=async_mode, + on_poll=on_poll if verbose else None, + ) + if isinstance(code, int): + raise SystemExit(code) + + emit_success(code, json_output=ctx_flag(ctx, "json_output")) + raise SystemExit(0) diff --git a/cli/commands/version.py b/cli/commands/version.py new file mode 100644 index 0000000..5885063 --- /dev/null +++ b/cli/commands/version.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json + +import click + +from cli.core.config import ctx_flag, make_client + + +@click.command("version") +@click.pass_context +def version_cmd(ctx: click.Context) -> None: + """Print local package version and remote service version.""" + import uniparser_tools + + local_version = uniparser_tools.__version__ + + client, err = make_client(ctx) + if err is not None: + raise SystemExit(err) + + remote = client.version() + + if ctx_flag(ctx, "json_output"): + print( + json.dumps( + {"local": local_version, "remote": remote}, + ensure_ascii=False, + indent=2, + default=str, + ) + ) + else: + print(f"uniparser-tools: {local_version}") + remote_status = remote.get("status", remote) + print(f"remote: {remote_status}") + raise SystemExit(0) diff --git a/cli/core/__init__.py b/cli/core/__init__.py new file mode 100644 index 0000000..906c08e --- /dev/null +++ b/cli/core/__init__.py @@ -0,0 +1 @@ +"""Core logic for the UniParser CLI.""" diff --git a/cli/core/config.py b/cli/core/config.py new file mode 100644 index 0000000..df505f9 --- /dev/null +++ b/cli/core/config.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os +from typing import Any + +import click + +from cli.core.credentials import read_api_key_from_config +from cli.core.defaults import UNIPARSER_BASE_URL +from cli.core.errors import config_error + + +def resolve_host() -> str: + return UNIPARSER_BASE_URL.rstrip("/") + "/" + + +def resolve_api_key(ctx: click.Context) -> str | None: + key = (ctx.obj or {}).get("api_key") or os.getenv("UNIPARSER_API_KEY") or read_api_key_from_config() + return (key or "").strip() or None + + +def ctx_flag(ctx: click.Context, name: str) -> bool: + return bool((ctx.obj or {}).get(name)) + + +def make_client(ctx: click.Context): + from uniparser_tools.api.clients import UniParserClient + + api_key = resolve_api_key(ctx) + if not api_key: + return None, config_error("No API key found. Run 'uniparser auth' to configure your API key.") + try: + import uniparser_tools # noqa: F401 + except ImportError: + return None, config_error( + 'uniparser_tools is not installed. Run: pip install "git+https://github.com/dptech-corp/UniParser-Tools.git"' + ) + return UniParserClient(host=resolve_host(), api_key=api_key), None + + +def cli_options(ctx: click.Context) -> dict[str, Any]: + return ctx.obj or {} diff --git a/cli/core/credentials.py b/cli/core/credentials.py new file mode 100644 index 0000000..686a0a6 --- /dev/null +++ b/cli/core/credentials.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +CONFIG_DIR = Path.home() / ".uniparser" +CONFIG_PATH = CONFIG_DIR / "config.yaml" + + +def load_config() -> dict[str, Any]: + if not CONFIG_PATH.is_file(): + return {} + data = yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + + +def read_api_key_from_config() -> str | None: + key = load_config().get("api_key") + if key is None: + return None + text = str(key).strip() + return text or None + + +def save_api_key(api_key: str) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + config = load_config() + config["api_key"] = api_key.strip() + CONFIG_PATH.write_text( + yaml.safe_dump(config, default_flow_style=False, allow_unicode=True), + encoding="utf-8", + ) + + +def mask_api_key(api_key: str) -> str: + text = api_key.strip() + if len(text) <= 8: + return "*" * len(text) + return f"{text[:4]}...{text[-4:]}" diff --git a/cli/core/defaults.py b/cli/core/defaults.py new file mode 100644 index 0000000..986e8bd --- /dev/null +++ b/cli/core/defaults.py @@ -0,0 +1,8 @@ +UNIPARSER_BASE_URL = "https://uniparser.dp.tech/" + +POLL_INTERVAL_SEC = 3 +POLL_TIMEOUT_SEC = 1800 + +PENDING_STATUSES = frozenset({"undefined", "waiting", "processing"}) + +IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}) diff --git a/cli/core/errors.py b/cli/core/errors.py new file mode 100644 index 0000000..a1d70fa --- /dev/null +++ b/cli/core/errors.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def emit_json_stderr(payload: dict) -> None: + print(json.dumps(payload, ensure_ascii=False), file=sys.stderr) + + +def config_error(message: str) -> int: + emit_json_stderr({"ok": False, "error": {"code": "CONFIG_ERROR", "message": message}}) + return 1 + + +def missing_token_error() -> int: + emit_json_stderr( + { + "ok": False, + "error": { + "code": "MISSING_TOKEN", + "message": "--token is required. Use the token from a successful parse trigger response or trigger_meta.json.", + }, + } + ) + return 1 + + +def dir_exists_error(output_dir: Path) -> int: + emit_json_stderr( + { + "ok": False, + "error": { + "code": "DIR_EXISTS", + "message": ( + f"Output directory already exists: {output_dir}. Re-run with --overwrite if you want to replace it." + ), + "output_dir": str(output_dir), + }, + } + ) + return 1 + + +def parse_error(stage: str, result: dict) -> int: + emit_json_stderr( + { + "ok": False, + "error": { + "code": "PARSE_ERROR", + "message": result.get("description") or result.get("message") or str(result), + "stage": stage, + }, + "token": result.get("token"), + } + ) + return 1 diff --git a/cli/core/input.py b/cli/core/input.py new file mode 100644 index 0000000..96aa02a --- /dev/null +++ b/cli/core/input.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from urllib.parse import urlparse + +from cli.core.defaults import IMAGE_SUFFIXES + + +class InputKind(str, Enum): + FILE = "file" + IMAGE = "image" + URL = "url" + + +@dataclass(frozen=True) +class ResolvedInput: + kind: InputKind + source_stem: str + raw: str + path: Path | None = None + + +def source_stem_from_path(path: Path) -> str: + return path.stem or "document" + + +def source_stem_from_url(url: str) -> str: + segment = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] + if not segment: + return "url_document" + lower = segment.lower() + for ext in (".pdf", ".png", ".jpg", ".jpeg", ".webp"): + if lower.endswith(ext): + segment = segment[: -len(ext)] + break + return segment or "url_document" + + +def resolve_input(raw: str) -> ResolvedInput | str: + """Return ResolvedInput or an error message.""" + text = raw.strip() + if not text: + return "INPUT must not be empty." + + if text.startswith("http://") or text.startswith("https://"): + return ResolvedInput(kind=InputKind.URL, source_stem=source_stem_from_url(text), raw=text) + + path = Path(text).expanduser().resolve() + if not path.is_file(): + return f"File not found: {path}" + + if path.suffix.lower() in IMAGE_SUFFIXES: + return ResolvedInput( + kind=InputKind.IMAGE, + source_stem=source_stem_from_path(path), + raw=text, + path=path, + ) + return ResolvedInput( + kind=InputKind.FILE, + source_stem=source_stem_from_path(path), + raw=text, + path=path, + ) diff --git a/cli/core/output.py b/cli/core/output.py new file mode 100644 index 0000000..093d607 --- /dev/null +++ b/cli/core/output.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def default_output_dir(source_stem: str) -> Path: + return (Path.home() / "Uni-Parser-Skill" / source_stem).expanduser().resolve() + + +def default_fetch_output_dir(token: str) -> Path: + return (Path.home() / "Uni-Parser-Skill" / f"token_{token[:8]}").expanduser().resolve() + + +def resolve_output_dir( + source_stem: str, + output_dir: str | None, + *, + overwrite: bool, +) -> tuple[Path | None, int | None]: + from cli.core.errors import dir_exists_error + + out = Path(output_dir).expanduser().resolve() if output_dir else default_output_dir(source_stem) + if out.exists() and not overwrite: + return None, dir_exists_error(out) + if out.exists() and overwrite: + shutil.rmtree(out) + return out, None + + +def write_trigger_meta( + out_dir: Path, + *, + token: str, + input_type: str, + input_value: str, +) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + meta_path = out_dir / "trigger_meta.json" + payload = { + "token": token, + "input_type": input_type, + "input": input_value, + "submitted_at": datetime.now(timezone.utc).isoformat(), + } + meta_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + return meta_path + + +def save_parse_results( + *, + out_dir: Path, + source_stem: str, + pages_tree: dict, + formatted: dict, +) -> dict[str, Any]: + out_dir.mkdir(parents=True, exist_ok=True) + stem = source_stem or "document" + + pages_tree_path = out_dir / "pages_tree.json" + pages_tree_path.write_text( + json.dumps(pages_tree, indent=2, ensure_ascii=False, default=str), + encoding="utf-8", + ) + + md_path = out_dir / f"{stem}.md" + content = formatted.get("content", "") + md_path.write_text(content, encoding="utf-8") + + meta = {k: v for k, v in formatted.items() if k != "content"} + (out_dir / "formatted_meta.json").write_text( + json.dumps(meta, indent=2, ensure_ascii=False, default=str), + encoding="utf-8", + ) + + return { + "ok": True, + "output_dir": str(out_dir), + "pages_tree_path": str(pages_tree_path), + "markdown_path": str(md_path), + "content_chars": len(content), + } + + +def emit_success(summary: dict, *, json_output: bool) -> None: + if json_output: + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return + print(f"Token: {summary.get('token', '')}") + print(f"Markdown: {summary['markdown_path']}") + print(f"Pages tree: {summary['pages_tree_path']}") + print(f"Output directory: {summary['output_dir']}") + if summary.get("trigger_meta_path"): + print(f"Trigger meta: {summary['trigger_meta_path']}", file=sys.stderr) + print(f"Markdown saved to: {summary['markdown_path']}", file=sys.stderr) + print(f"Output directory: {summary['output_dir']}", file=sys.stderr) + + +def status_line(message: str) -> None: + print(message, file=sys.stderr) diff --git a/cli/core/pipeline.py b/cli/core/pipeline.py new file mode 100644 index 0000000..5e3fecf --- /dev/null +++ b/cli/core/pipeline.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from cli.core.defaults import PENDING_STATUSES, POLL_INTERVAL_SEC, POLL_TIMEOUT_SEC +from cli.core.errors import parse_error +from cli.core.input import InputKind, ResolvedInput +from cli.core.output import save_parse_results, write_trigger_meta + + +def scientific_paper_trigger_kwargs(*, sync: bool = True) -> dict: + from uniparser_tools.common.constant import ParseMode, ParseModeTextual + + return { + "sync": sync, + "textual": ParseModeTextual.OCRHighQuality, + "equation": ParseMode.OCRHighQuality, + "table": ParseMode.OCRHighQuality, + "chart": ParseMode.DumpBase64, + "figure": ParseMode.DumpBase64, + "expression": ParseMode.DumpBase64, + "molecule": ParseMode.OCRFast, + } + + +def trigger_input(client, resolved: ResolvedInput, *, sync: bool) -> tuple[dict, str]: + kwargs = scientific_paper_trigger_kwargs(sync=sync) + if resolved.kind is InputKind.FILE: + trigger = client.trigger_file(file_path=str(resolved.path), **kwargs) + return trigger, "trigger_file" + if resolved.kind is InputKind.IMAGE: + trigger = client.trigger_snip(snip_path=str(resolved.path), **kwargs) + return trigger, "trigger_snip" + trigger = client.trigger_url(pdf_url=resolved.raw, **kwargs) + return trigger, "trigger_url" + + +def poll_until_success( + client, + token: str, + *, + on_poll: Callable[[str | None, float], None] | None = None, +) -> dict | int: + deadline = time.time() + POLL_TIMEOUT_SEC + last: dict[str, Any] = {} + start = time.time() + + while time.time() < deadline: + last = client.get_result( + token, + content=False, + objects=False, + pages_dict=False, + pages_tree=False, + ) + status = last.get("status") + if on_poll is not None: + on_poll(status, time.time() - start) + if status == "success": + return last + if status == "error": + return parse_error("get_result_poll", last) + if status in PENDING_STATUSES or status is None: + time.sleep(POLL_INTERVAL_SEC) + continue + return parse_error("get_result_poll", last) + + return parse_error( + "get_result_poll", + { + "status": "error", + "description": f"Timed out after {POLL_TIMEOUT_SEC}s waiting for parsing to finish.", + "token": token, + "last_status": last.get("status"), + }, + ) + + +def fetch_pages_tree(client, token: str) -> dict: + return client.get_result(token, pages_tree=True, objects=False) + + +def fetch_markdown(client, token: str) -> dict: + from uniparser_tools.common.constant import FormatFlag + + return client.get_formatted( + token, + content=True, + textual=FormatFlag.Markdown, + table=FormatFlag.Markdown, + equation=FormatFlag.Latex, + ) + + +def save_stage_error(out_dir: Path, filename: str, payload: dict) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / filename).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + +def complete_fetch( + client, + token: str, + *, + out_dir: Path, + source_stem: str, + on_poll: Callable[[str | None, float], None] | None = None, +) -> dict[str, Any] | int: + poll_result = poll_until_success(client, token, on_poll=on_poll) + if isinstance(poll_result, int): + return poll_result + + pages_tree = fetch_pages_tree(client, token) + if pages_tree.get("status") != "success": + save_stage_error(out_dir, "pages_tree_error.json", pages_tree) + return parse_error("get_result_pages_tree", pages_tree) + + formatted = fetch_markdown(client, token) + if formatted.get("status") != "success": + save_stage_error(out_dir, "formatted_error.json", formatted) + return parse_error("get_formatted", formatted) + + summary = save_parse_results( + out_dir=out_dir, + source_stem=source_stem, + pages_tree=pages_tree, + formatted=formatted, + ) + summary["token"] = token + return summary + + +def run_parse( + client, + resolved: ResolvedInput, + *, + out_dir: Path, + async_mode: bool, + on_poll: Callable[[str | None, float], None] | None = None, +) -> dict[str, Any] | int: + trigger, stage = trigger_input(client, resolved, sync=not async_mode) + if trigger.get("status") != "success": + save_stage_error(out_dir, "trigger_error.json", trigger) + return parse_error(stage, trigger) + + token = trigger.get("token") + if not token: + return parse_error(stage, {"status": "error", "message": "trigger response missing token"}) + + meta_path = write_trigger_meta( + out_dir, + token=token, + input_type=resolved.kind.value, + input_value=resolved.raw, + ) + + summary = complete_fetch( + client, + token, + out_dir=out_dir, + source_stem=resolved.source_stem, + on_poll=on_poll, + ) + if isinstance(summary, int): + return summary + + summary["input_type"] = resolved.kind.value + summary["trigger_meta_path"] = str(meta_path) + return summary diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..0516643 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import click + +from cli.commands.auth import auth_cmd +from cli.commands.fetch import fetch_cmd +from cli.commands.health import health_cmd +from cli.commands.parse import parse_cmd +from cli.commands.version import version_cmd + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "--api-key", + envvar="UNIPARSER_API_KEY", + help="API key (X-API-Key); overrides env and ~/.uniparser/config.yaml", +) +@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON on stdout") +@click.option("-v", "--verbose", is_flag=True, help="Print poll progress to stderr") +@click.pass_context +def cli(ctx: click.Context, api_key: str | None, json_output: bool, verbose: bool) -> None: + """UniParser CLI — parse documents via https://uniparser.dp.tech/""" + ctx.ensure_object(dict) + ctx.obj["api_key"] = api_key + ctx.obj["json_output"] = json_output + ctx.obj["verbose"] = verbose + + +cli.add_command(auth_cmd) +cli.add_command(parse_cmd) +cli.add_command(fetch_cmd) +cli.add_command(health_cmd) +cli.add_command(version_cmd) diff --git a/pyproject.toml b/pyproject.toml index d8e43bd..5bb1d3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ readme = "README.md" requires-python = ">=3.8" keywords = ["ocr", "document-parser", "uniparser"] +[project.scripts] +uniparser = "cli.main:cli" + [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt index af277f2..b7b53b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +click>=8.1.0,<9 PyMuPDF==1.25.4 pytz==2024.2 html5lib==1.1 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..9e3a00c --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,244 @@ +"""Unit tests for the ``uniparser`` CLI (no network).""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from click.testing import CliRunner + +from cli.core.input import InputKind, resolve_input +from cli.main import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def env_without_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) + + +class TestCliConfig: + def test_parse_without_api_key(self, runner: CliRunner, env_without_api_key: None) -> None: + result = runner.invoke(cli, ["parse", "/tmp/paper.pdf"]) + assert result.exit_code == 1 + payload = json.loads(result.stderr.strip()) + assert payload["ok"] is False + assert payload["error"]["code"] == "CONFIG_ERROR" + assert "uniparser auth" in payload["error"]["message"] + + def test_parse_with_config_file_api_key( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) + config_dir = tmp_path / ".uniparser" + config_dir.mkdir() + config_path = config_dir / "config.yaml" + config_path.write_text("api_key: file-key\n", encoding="utf-8") + monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_path) + + mock_client = MagicMock() + mock_client.trigger_file.return_value = {"status": "error", "description": "stop early"} + monkeypatch.setattr("cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + result = runner.invoke(cli, ["parse", str(pdf)]) + assert result.exit_code == 1 + mock_client.trigger_file.assert_called_once() + + def test_fetch_without_api_key(self, runner: CliRunner, env_without_api_key: None) -> None: + result = runner.invoke(cli, ["fetch", "--token", "abc123"]) + assert result.exit_code == 1 + payload = json.loads(result.stderr.strip()) + assert payload["error"]["code"] == "CONFIG_ERROR" + + def test_fetch_requires_token_flag(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + result = runner.invoke(cli, ["fetch"]) + assert result.exit_code != 0 + + +class TestInputResolve: + def test_url_input(self) -> None: + resolved = resolve_input("https://example.com/paper.pdf") + assert not isinstance(resolved, str) + assert resolved.kind is InputKind.URL + assert resolved.source_stem == "paper" + + def test_missing_file(self) -> None: + assert isinstance(resolve_input("/nonexistent/file.pdf"), str) + + +class TestParseCommand: + def test_parse_success_writes_trigger_meta_and_json_token( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + + mock_client = MagicMock() + mock_client.trigger_file.return_value = {"status": "success", "token": "tok-parse-1"} + mock_client.get_result.side_effect = [ + {"status": "processing"}, + {"status": "success", "pages_tree": {}}, + {"status": "success", "pages_tree": {"tree": []}}, + ] + mock_client.get_formatted.return_value = {"status": "success", "content": "# Hi"} + + monkeypatch.setattr("cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + out = tmp_path / "out" + result = runner.invoke( + cli, + ["--json", "parse", str(pdf), "-o", str(out)], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["token"] == "tok-parse-1" + assert (out / "trigger_meta.json").is_file() + meta = json.loads((out / "trigger_meta.json").read_text(encoding="utf-8")) + assert meta["token"] == "tok-parse-1" + assert (out / "paper.md").is_file() + + +class TestFetchCommand: + def test_fetch_success( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + + mock_client = MagicMock() + mock_client.get_result.side_effect = [ + {"status": "success"}, + {"status": "success", "pages_tree": {"tree": []}}, + ] + mock_client.get_formatted.return_value = {"status": "success", "content": "body"} + + monkeypatch.setattr("cli.commands.fetch.make_client", lambda ctx: (mock_client, None)) + + out = tmp_path / "fetch-out" + result = runner.invoke( + cli, + ["--json", "fetch", "--token", "abcdef123456", "-o", str(out)], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["token"] == "abcdef123456" + assert payload["fetched_by_token"] is True + assert (out / "token_abcdef12.md").is_file() + + +class TestHealthVersion: + def test_health_json(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + mock_client = MagicMock() + mock_client.health.return_value = {"status": "success"} + monkeypatch.setattr("cli.commands.health.make_client", lambda ctx: (mock_client, None)) + + result = runner.invoke(cli, ["--json", "health"], env={**os.environ, "UNIPARSER_API_KEY": "test-key"}) + assert result.exit_code == 0 + assert json.loads(result.stdout)["status"] == "success" + + def test_version_human(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + mock_client = MagicMock() + mock_client.version.return_value = {"status": "success", "version": "1.0"} + monkeypatch.setattr("cli.commands.version.make_client", lambda ctx: (mock_client, None)) + + result = runner.invoke(cli, ["version"], env={**os.environ, "UNIPARSER_API_KEY": "test-key"}) + assert result.exit_code == 0 + assert "uniparser-tools:" in result.stdout + + +class TestHelp: + def test_parse_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["parse", "--help"]) + assert result.exit_code == 0 + assert "--output-dir" in result.stdout + assert "--async" in result.stdout + + def test_fetch_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["fetch", "--help"]) + assert result.exit_code == 0 + assert "--token" in result.stdout + + +class TestAuthCommand: + def test_auth_saves_api_key( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / ".uniparser" + config_path = config_dir / "config.yaml" + monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_path) + + result = runner.invoke(cli, ["auth"], input="my-secret-key\n") + assert result.exit_code == 0, result.stderr + assert "API key saved successfully" in result.stdout + assert config_path.is_file() + assert "my-secret-key" in config_path.read_text(encoding="utf-8") + + def test_auth_show_masked( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / ".uniparser" + config_dir.mkdir() + config_path = config_dir / "config.yaml" + config_path.write_text("api_key: abcdefghijklmnop\n", encoding="utf-8") + monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_path) + + result = runner.invoke(cli, ["auth", "--show"]) + assert result.exit_code == 0 + assert "abcd...mnop" in result.stdout + + def test_auth_show_without_config(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + config_dir = Path("/nonexistent/uniparser-config-test") + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_dir / "config.yaml") + + result = runner.invoke(cli, ["auth", "--show"]) + assert result.exit_code == 1 + assert "No API key configured" in result.stdout + + def test_auth_verify_success( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / ".uniparser" + config_dir.mkdir() + config_path = config_dir / "config.yaml" + config_path.write_text("api_key: configured\n", encoding="utf-8") + monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_path) + + result = runner.invoke(cli, ["auth", "--verify"]) + assert result.exit_code == 0 + assert "API key is configured" in result.stdout From be36d69e61cda6546fc21fa79c0ac0ac423ec58e Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Tue, 23 Jun 2026 03:13:00 +0800 Subject: [PATCH 2/4] feat: add uniparser CLI with auth, parse, fetch, health, version, and user docs --- README.md | 46 +++++++- cli/README.md | 242 ++++++++++++++++++++++++++++++++++++++++ cli/commands/auth.py | 53 ++++++--- cli/commands/fetch.py | 44 +++----- cli/commands/parse.py | 29 +++-- cli/commands/version.py | 28 ++++- cli/core/config.py | 21 +--- cli/core/credentials.py | 46 ++++++++ cli/core/errors.py | 7 +- cli/core/input.py | 7 ++ cli/core/output.py | 41 +++++-- cli/core/pipeline.py | 23 ++-- cli/main.py | 11 +- tests/unit/test_cli.py | 150 +++++++++++++++++++++++-- 14 files changed, 624 insertions(+), 124 deletions(-) create mode 100644 cli/README.md diff --git a/README.md b/README.md index a61d0c6..afe3612 100644 --- a/README.md +++ b/README.md @@ -44,22 +44,59 @@ UniParser Tools 是一个强大的文档解析工具包,支持对 PDF 文件 ## 安装 +安装 Python 依赖: + ```bash pip install -r requirements.txt ``` -或者安装为可编辑包: +使用 **`uniparser` 命令行工具**时,还需将本仓库安装为可编辑包(入口在 `pyproject.toml` 中注册): ```bash pip install -e . ``` +> **说明:** 仅执行 `pip install -r requirements.txt` **不会**注册 `uniparser` 命令;SDK 开发用前者即可,CLI 必须执行 `pip install -e .`。 + +安装后验证: + +```bash +uniparser --help +``` + +CLI 完整说明见 [`cli/README.md`](./cli/README.md)。 + +## CLI 命令行 + +`uniparser` 提供 `auth`、`parse`、`fetch`、`health`、`version` 等子命令。 + +| 命令 | 说明 | +|------|------| +| `uniparser auth` | 交互式配置 API Key(写入 `~/.uniparser/config.yaml`) | +| `uniparser parse INPUT` | 解析本地 PDF/图片或公网 PDF URL | +| `uniparser fetch --token TOKEN` | 用已有 token 轮询并下载结果 | +| `uniparser health` | 检查服务健康状态(需要 API Key) | +| `uniparser version` | 查看本地包版本(无 API Key 时跳过远端查询) | + +**API Key 优先级:** `--api-key` > `UNIPARSER_API_KEY` > `~/.uniparser/config.yaml` + +**`--json` 须写在子命令之前:** `uniparser --json parse paper.pdf` + +参数、输出文件、错误码等详见 [`cli/README.md`](./cli/README.md)。 + +首次使用: + +```bash +uniparser auth +uniparser parse report.pdf +``` + ## API-Key 配置 所有请求都通过 `X-API-Key` 请求头认证,`UniParserClient` 会自动注入。 - **获取方式**:在 UniParser 服务首页(如 `https://uniparser.dp.tech/`)注册访客账号,或向运维/业务方申请长期 API-Key。 -- **推荐存储**:使用环境变量 `UNIPARSER_API_KEY`,避免在代码中硬编码。 +- **推荐存储**:运行 `uniparser auth`,或设置环境变量 `UNIPARSER_API_KEY`,避免在代码中硬编码。 - **错误处理**:Key 缺失/过期、限流等情况都会被客户端统一包装成 `{"status": "error", ...}` 返回,详见下方 [错误处理](#错误处理)。 ```python @@ -452,6 +489,10 @@ uv run python -m uniparser_mcp # 启动 MCP 服务(stdio 模式) ## 项目结构 ``` +cli/ # uniparser 命令行工具(见 cli/README.md) +├── commands/ # auth, parse, fetch, health, version +└── core/ # 配置、凭证、pipeline、输出 + uniparser_tools/ ├── api/ # API 客户端 ├── common/ # 通用常量和数据类 @@ -477,6 +518,7 @@ playground/ 项目提供了丰富的示例和教程,位于 `playground/` 目录下: +- **CLI 命令行**:[`cli/README.md`](./cli/README.md) - `uniparser` 安装、子命令与参数说明 - **快速开始**:`playground/01.quick_start.ipynb` - 基础用法教程,包括 PDF 和图片解析、多种格式输出 - **高级用法**:`playground/02.advance.ipynb` - 高级功能教程,包括图片+图题+图注、表格+表题+表注、分子+分子索引、公式+公式索引的提取 - **异步回调**:`playground/04.use_callbacks.py` - 异步回调演示,用于在异步解析任务完成后自动接收通知和结果 diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..2e3d9c2 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,242 @@ +# UniParser CLI 使用说明 + +`uniparser` 是 UniParser 的命令行工具,用于把 PDF、图片或公网 PDF 链接解析成 Markdown,并保存到本地目录。 + +官网与 API Key 申请:[https://uniparser.dp.tech/](https://uniparser.dp.tech/) + +--- + +## 安装 + +在仓库根目录执行(**必须**用这一条,才会安装 `uniparser` 命令): + +```bash +cd /path/to/UniParser-Tools +pip install -e . +``` + +> 只运行 `pip install -r requirements.txt` **不会**出现 `uniparser` 命令。 + +检查是否安装成功: + +```bash +uniparser --help +``` + +--- + +## 快速开始 + +**第一次使用:配置 API Key** + +```bash +uniparser auth +``` + +按提示在 [https://uniparser.dp.tech/](https://uniparser.dp.tech/) 获取 Key 并粘贴保存。配置会写入 `~/.uniparser/config.yaml`,下次无需重复输入。 + +**解析一个 PDF** + +```bash +uniparser parse /path/to/report.pdf +``` + +默认把结果保存到: + +```text +~/Uni-Parser-Skill/report/ +├── report.md # 解析后的 Markdown +├── pages_tree.json # 文档版面结构(可选查阅) +└── trigger_meta.json # 任务信息(含 token,便于中断后恢复) +``` + +终端会显示 `Parsing... report.pdf`,完成后打印 Markdown 与输出目录路径。 + +--- + +## 命令一览 + +| 命令 | 作用 | +|------|------| +| `uniparser auth` | 配置 / 查看 API Key | +| `uniparser parse INPUT` | 解析文件或 PDF 链接 | +| `uniparser fetch --token TOKEN` | 用已有任务 token 重新下载结果 | +| `uniparser health` | 检查服务是否可用 | +| `uniparser version` | 查看工具版本 | + +--- + +## 配置 API Key + +### 交互式配置(推荐) + +```bash +uniparser auth +``` + +已有 Key 时直接回车可保留当前配置。 + +### 其他方式 + +也可任选其一(优先级从高到低): + +1. 命令行临时指定:`uniparser --api-key YOUR_KEY parse file.pdf` +2. 环境变量:`export UNIPARSER_API_KEY="YOUR_KEY"` +3. 配置文件:`~/.uniparser/config.yaml`(由 `uniparser auth` 写入) + +### 查看与检查 + +```bash +uniparser auth --show # 查看 Key 来源与脱敏后的 Key +uniparser auth --verify # 检查是否已配置(不联网校验) +``` + +--- + +## 解析文档:`parse` + +### 基本用法 + +```bash +# 本地 PDF +uniparser parse paper.pdf + +# 本地图片(png、jpg、webp 等) +uniparser parse figure.png + +# 公网 PDF 链接 +uniparser parse https://example.com/paper.pdf +``` + +### 常用选项 + +| 选项 | 说明 | +|------|------| +| `-o` / `--output-dir DIR` | 指定输出目录(默认 `~/Uni-Parser-Skill/<文件名>/`) | +| `--overwrite` | 输出目录已存在时,先清空再写入 | +| `--async` | 异步提交任务(适合较大文档) | + +示例: + +```bash +uniparser parse paper.pdf -o ./results/ +uniparser parse paper.pdf -o ./results/ --overwrite +``` + +### 输出说明 + +解析成功后,输出目录中通常包含: + +| 文件 | 用途 | +|------|------| +| `<文件名>.md` | 完整 Markdown 正文(主要结果) | +| `pages_tree.json` | 版面结构树,便于按章节/块定位 | +| `trigger_meta.json` | 任务 token 与输入信息,供 `fetch` 恢复使用 | + +解析过程中终端会显示一行 `Parsing... <文件名>`。大文档可能需要等待数分钟。 + +--- + +## 恢复下载:`fetch` + +若解析中断,或你已有任务 token,可用 `fetch` 继续拉取结果(不会重新上传文件): + +```bash +uniparser fetch --token YOUR_TOKEN +``` + +token 可在上次 `parse` 输出目录的 `trigger_meta.json` 中找到。 + +| 选项 | 说明 | +|------|------| +| `-o` / `--output-dir DIR` | 指定输出目录(默认 `~/Uni-Parser-Skill/token_<前8位>/`) | +| `--overwrite` | 输出目录已存在时,先清空再写入 | + +```bash +uniparser fetch --token abcdef1234567890 -o ./out/ +``` + +--- + +## 其他命令 + +### 检查服务 + +```bash +uniparser health +``` + +需要已配置 API Key。 + +### 查看版本 + +```bash +uniparser version +``` + +未配置 API Key 时仍会显示本地工具版本;配置后可同时查看远端服务版本。 + +--- + +## 脚本与自动化:`--json` + +在命令**最前面**加上 `--json`,成功时会在标准输出打印 JSON,便于脚本解析: + +```bash +uniparser --json parse paper.pdf +uniparser --json fetch --token YOUR_TOKEN +``` + +注意:`--json` 必须写在子命令**之前**: + +```bash +uniparser --json parse paper.pdf # 正确 +uniparser parse paper.pdf --json # 错误 +``` + +--- + +## 常见问题 + +**提示 `No API key found`** + +运行 `uniparser auth`,或设置环境变量 `UNIPARSER_API_KEY`。 + +**提示 `File not found`** + +检查 `parse` 后的文件路径是否正确(建议使用绝对路径)。 + +**提示输出目录已存在** + +加上 `--overwrite`,或换用 `-o` 指定新目录。 + +**命令 `uniparser` 找不到** + +确认已执行 `pip install -e .`,且与当前终端使用的是同一 Python 环境。 + +**解析时间较长** + +属正常现象;请等待 `Parsing...` 出现后保持终端不要关闭。若中断,用 `trigger_meta.json` 中的 token 执行 `uniparser fetch`。 + +--- + +## 推荐使用流程 + +```bash +# 1. 安装并配置 +pip install -e . +uniparser auth + +# 2. 解析 +uniparser parse ~/Documents/paper.pdf + +# 3. 打开结果 +open ~/Uni-Parser-Skill/paper/paper.md +``` + +若需中断后恢复: + +```bash +# 从 trigger_meta.json 复制 token +uniparser fetch --token +``` diff --git a/cli/commands/auth.py b/cli/commands/auth.py index 5f0f4b8..cd6cc2d 100644 --- a/cli/commands/auth.py +++ b/cli/commands/auth.py @@ -2,38 +2,59 @@ import click -from cli.core.credentials import CONFIG_PATH, mask_api_key, read_api_key_from_config, save_api_key - -API_KEY_URL = "https://uniparser.dp.tech/" +from cli.core.credentials import ( + API_KEY_URL, + mask_api_key, + resolve_api_key_source, + save_api_key, +) @click.command("auth") -@click.option("--show", is_flag=True, help="Show masked API key from config") -@click.option("--verify", is_flag=True, help="Verify that an API key is configured") -def auth_cmd(show: bool, verify: bool) -> None: +@click.option("--show", is_flag=True, help="Show API key source and masked key") +@click.option("--verify", is_flag=True, help="Verify that an API key is configured (does not call the API)") +@click.pass_context +def auth_cmd(ctx: click.Context, show: bool, verify: bool) -> None: """Save, inspect, or verify API key configuration.""" + resolved = resolve_api_key_source(ctx) + if show: - api_key = read_api_key_from_config() - if not api_key: + if not resolved.api_key: print("No API key configured.") + print("Run 'uniparser auth' to set up your API key.") raise SystemExit(1) - print(f"API key: {mask_api_key(api_key)}") - print(f"Config: {CONFIG_PATH}") + print(f"API key source: {resolved.source}") + print(f"API key: {mask_api_key(resolved.api_key)}") raise SystemExit(0) if verify: - api_key = read_api_key_from_config() - if not api_key: + if not resolved.api_key: print("No API key configured.") raise SystemExit(1) print("API key is configured.") + print(f" Source: {resolved.source}") raise SystemExit(0) print("UniParser API Key Setup") print(f"Get your API key from: {API_KEY_URL}") - api_key = click.prompt("Enter your API key", type=str).strip() - if not api_key: - print("API key must not be empty.") + print() + + if resolved.api_key: + print(f"Current API key source: {resolved.source}") + entered = click.prompt( + "Enter new API key (or press Enter to keep current)", + default="", + show_default=False, + ).strip() + else: + entered = click.prompt("Enter your API key", type=str).strip() + + if not entered: + if resolved.api_key: + print("Keeping existing API key.") + raise SystemExit(0) + print("API key is required.") raise SystemExit(1) - save_api_key(api_key) + + save_api_key(entered) print("API key saved successfully") diff --git a/cli/commands/fetch.py b/cli/commands/fetch.py index d08ca0d..4c3ca31 100644 --- a/cli/commands/fetch.py +++ b/cli/commands/fetch.py @@ -6,12 +6,7 @@ from cli.core.config import ctx_flag, make_client from cli.core.errors import missing_token_error -from cli.core.output import ( - default_fetch_output_dir, - emit_success, - resolve_output_dir, - status_line, -) +from cli.core.output import emit_success, fetch_source_stem, resolve_fetch_output_dir from cli.core.pipeline import complete_fetch @@ -20,7 +15,12 @@ @click.option("--output-dir", "-o", help="Output directory (default: ~/Uni-Parser-Skill/token_/)") @click.option("--overwrite", is_flag=True, help="Overwrite output directory if it already exists") @click.pass_context -def fetch_cmd(ctx: click.Context, token: str, output_dir: str | None, overwrite: bool) -> None: +def fetch_cmd( + ctx: click.Context, + token: str, + output_dir: str | None, + overwrite: bool, +) -> None: """Poll and download results for an existing job using its token.""" resolved_token = (token or "").strip() if not resolved_token: @@ -30,37 +30,21 @@ def fetch_cmd(ctx: click.Context, token: str, output_dir: str | None, overwrite: if err is not None: raise SystemExit(err) - source_stem = f"token_{resolved_token[:8]}" - if output_dir: - out_dir, dir_code = resolve_output_dir(source_stem, output_dir, overwrite=overwrite) - else: - out = default_fetch_output_dir(resolved_token) - if out.exists() and not overwrite: - from cli.core.errors import dir_exists_error - - raise SystemExit(dir_exists_error(out)) - if out.exists() and overwrite: - import shutil - - shutil.rmtree(out) - out_dir = out - dir_code = None - + source_stem = fetch_source_stem(resolved_token) + out_dir, dir_code = resolve_fetch_output_dir( + resolved_token, + output_dir, + overwrite=overwrite, + ) if dir_code is not None: raise SystemExit(dir_code) - verbose = ctx_flag(ctx, "verbose") - - def on_poll(status: str | None, elapsed: float) -> None: - if verbose: - status_line(f"[uniparser] polling… status={status} elapsed={elapsed:.0f}s") - summary = complete_fetch( client, resolved_token, out_dir=Path(out_dir), source_stem=source_stem, - on_poll=on_poll if verbose else None, + parsing_label=source_stem, ) if isinstance(summary, int): raise SystemExit(summary) diff --git a/cli/commands/parse.py b/cli/commands/parse.py index 7bfb4da..7a1db58 100644 --- a/cli/commands/parse.py +++ b/cli/commands/parse.py @@ -3,44 +3,43 @@ import click from cli.core.config import ctx_flag, make_client -from cli.core.errors import config_error +from cli.core.errors import input_error from cli.core.input import resolve_input -from cli.core.output import emit_success, resolve_output_dir, status_line +from cli.core.output import emit_success, resolve_output_dir from cli.core.pipeline import run_parse @click.command("parse") -@click.argument("input", metavar="INPUT") +@click.argument("source", metavar="INPUT") @click.option("--output-dir", "-o", help="Output directory (default: ~/Uni-Parser-Skill//)") @click.option("--overwrite", is_flag=True, help="Overwrite output directory if it already exists") @click.option("--async", "async_mode", is_flag=True, help="Submit with sync=false and poll until success") @click.pass_context -def parse_cmd(ctx: click.Context, input: str, output_dir: str | None, overwrite: bool, async_mode: bool) -> None: +def parse_cmd( + ctx: click.Context, + source: str, + output_dir: str | None, + overwrite: bool, + async_mode: bool, +) -> None: """Parse a local PDF/image or public PDF URL; save Markdown and pages_tree.""" + resolved = resolve_input(source) + if isinstance(resolved, str): + raise SystemExit(input_error(resolved)) + client, err = make_client(ctx) if err is not None: raise SystemExit(err) - resolved = resolve_input(input) - if isinstance(resolved, str): - raise SystemExit(config_error(resolved)) - out_dir, dir_code = resolve_output_dir(resolved.source_stem, output_dir, overwrite=overwrite) if dir_code is not None: raise SystemExit(dir_code) - verbose = ctx_flag(ctx, "verbose") - - def on_poll(status: str | None, elapsed: float) -> None: - if verbose: - status_line(f"[uniparser] polling… status={status} elapsed={elapsed:.0f}s") - code = run_parse( client, resolved, out_dir=out_dir, async_mode=async_mode, - on_poll=on_poll if verbose else None, ) if isinstance(code, int): raise SystemExit(code) diff --git a/cli/commands/version.py b/cli/commands/version.py index 5885063..a235038 100644 --- a/cli/commands/version.py +++ b/cli/commands/version.py @@ -5,6 +5,16 @@ import click from cli.core.config import ctx_flag, make_client +from cli.core.credentials import resolve_api_key_source + + +def format_remote_version(remote: object) -> str: + if isinstance(remote, dict): + if remote.get("status") is not None: + return str(remote["status"]) + if remote.get("version") is not None: + return str(remote["version"]) + return str(remote) @click.command("version") @@ -14,6 +24,21 @@ def version_cmd(ctx: click.Context) -> None: import uniparser_tools local_version = uniparser_tools.__version__ + resolved = resolve_api_key_source(ctx) + + if not resolved.api_key: + if ctx_flag(ctx, "json_output"): + print( + json.dumps( + {"local": local_version, "remote": None, "remote_skipped": True}, + ensure_ascii=False, + indent=2, + ) + ) + else: + print(f"uniparser-tools: {local_version}") + print("remote: skipped (no API key)") + raise SystemExit(0) client, err = make_client(ctx) if err is not None: @@ -32,6 +57,5 @@ def version_cmd(ctx: click.Context) -> None: ) else: print(f"uniparser-tools: {local_version}") - remote_status = remote.get("status", remote) - print(f"remote: {remote_status}") + print(f"remote: {format_remote_version(remote)}") raise SystemExit(0) diff --git a/cli/core/config.py b/cli/core/config.py index df505f9..da45c9e 100644 --- a/cli/core/config.py +++ b/cli/core/config.py @@ -1,11 +1,8 @@ from __future__ import annotations -import os -from typing import Any - import click -from cli.core.credentials import read_api_key_from_config +from cli.core.credentials import resolve_api_key_source from cli.core.defaults import UNIPARSER_BASE_URL from cli.core.errors import config_error @@ -14,29 +11,19 @@ def resolve_host() -> str: return UNIPARSER_BASE_URL.rstrip("/") + "/" -def resolve_api_key(ctx: click.Context) -> str | None: - key = (ctx.obj or {}).get("api_key") or os.getenv("UNIPARSER_API_KEY") or read_api_key_from_config() - return (key or "").strip() or None - - def ctx_flag(ctx: click.Context, name: str) -> bool: return bool((ctx.obj or {}).get(name)) def make_client(ctx: click.Context): - from uniparser_tools.api.clients import UniParserClient - - api_key = resolve_api_key(ctx) + api_key = resolve_api_key_source(ctx).api_key if not api_key: return None, config_error("No API key found. Run 'uniparser auth' to configure your API key.") try: - import uniparser_tools # noqa: F401 + import uniparser_tools + from uniparser_tools.api.clients import UniParserClient except ImportError: return None, config_error( 'uniparser_tools is not installed. Run: pip install "git+https://github.com/dptech-corp/UniParser-Tools.git"' ) return UniParserClient(host=resolve_host(), api_key=api_key), None - - -def cli_options(ctx: click.Context) -> dict[str, Any]: - return ctx.obj or {} diff --git a/cli/core/credentials.py b/cli/core/credentials.py index 686a0a6..d85b86b 100644 --- a/cli/core/credentials.py +++ b/cli/core/credentials.py @@ -1,12 +1,22 @@ from __future__ import annotations +import os +from dataclasses import dataclass from pathlib import Path from typing import Any import yaml +from click.core import ParameterSource CONFIG_DIR = Path.home() / ".uniparser" CONFIG_PATH = CONFIG_DIR / "config.yaml" +API_KEY_URL = "https://uniparser.dp.tech/" + + +@dataclass(frozen=True) +class ApiKeySource: + api_key: str | None + source: str # "flag", "env", "config", "" def load_config() -> dict[str, Any]: @@ -24,6 +34,42 @@ def read_api_key_from_config() -> str | None: return text or None +def _root_context(ctx: click.Context) -> click.Context: + while ctx.parent is not None: + ctx = ctx.parent + return ctx + + +def resolve_api_key_source(ctx: click.Context | None = None) -> ApiKeySource: + if ctx is not None: + root = _root_context(ctx) + api_key = ((root.obj or {}).get("api_key") or "").strip() + if api_key: + try: + param_source = root.get_parameter_source("api_key") + except LookupError: + param_source = None + if param_source == ParameterSource.COMMANDLINE: + return ApiKeySource(api_key=api_key, source="flag") + if param_source == ParameterSource.ENVIRONMENT: + return ApiKeySource(api_key=api_key, source="env") + config_key = read_api_key_from_config() + if config_key and api_key == config_key: + return ApiKeySource(api_key=api_key, source="config") + env_key = (os.getenv("UNIPARSER_API_KEY") or "").strip() + if env_key and api_key == env_key: + return ApiKeySource(api_key=api_key, source="env") + return ApiKeySource(api_key=api_key, source="flag") + + env_key = (os.getenv("UNIPARSER_API_KEY") or "").strip() + if env_key: + return ApiKeySource(api_key=env_key, source="env") + config_key = read_api_key_from_config() + if config_key: + return ApiKeySource(api_key=config_key, source="config") + return ApiKeySource(api_key=None, source="") + + def save_api_key(api_key: str) -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) config = load_config() diff --git a/cli/core/errors.py b/cli/core/errors.py index a1d70fa..d15cd1a 100644 --- a/cli/core/errors.py +++ b/cli/core/errors.py @@ -14,13 +14,18 @@ def config_error(message: str) -> int: return 1 +def input_error(message: str) -> int: + emit_json_stderr({"ok": False, "error": {"code": "INPUT_ERROR", "message": message}}) + return 1 + + def missing_token_error() -> int: emit_json_stderr( { "ok": False, "error": { "code": "MISSING_TOKEN", - "message": "--token is required. Use the token from a successful parse trigger response or trigger_meta.json.", + "message": "--token is required. Use the token from a prior successful parse trigger response.", }, } ) diff --git a/cli/core/input.py b/cli/core/input.py index 96aa02a..aa301ee 100644 --- a/cli/core/input.py +++ b/cli/core/input.py @@ -26,6 +26,13 @@ def source_stem_from_path(path: Path) -> str: return path.stem or "document" +def display_label_for_input(resolved: ResolvedInput) -> str: + if resolved.path is not None: + return resolved.path.name + segment = urlparse(resolved.raw).path.rstrip("/").rsplit("/", 1)[-1] + return segment or "url_document" + + def source_stem_from_url(url: str) -> str: segment = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] if not segment: diff --git a/cli/core/output.py b/cli/core/output.py index 093d607..30f833f 100644 --- a/cli/core/output.py +++ b/cli/core/output.py @@ -7,13 +7,27 @@ from pathlib import Path from typing import Any +from cli.core.errors import dir_exists_error + def default_output_dir(source_stem: str) -> Path: return (Path.home() / "Uni-Parser-Skill" / source_stem).expanduser().resolve() +def fetch_source_stem(token: str) -> str: + return f"token_{token[:8]}" + + def default_fetch_output_dir(token: str) -> Path: - return (Path.home() / "Uni-Parser-Skill" / f"token_{token[:8]}").expanduser().resolve() + return (Path.home() / "Uni-Parser-Skill" / fetch_source_stem(token)).expanduser().resolve() + + +def _ensure_output_dir(out: Path, *, overwrite: bool) -> tuple[Path | None, int | None]: + if out.exists() and not overwrite: + return None, dir_exists_error(out) + if out.exists() and overwrite: + shutil.rmtree(out) + return out, None def resolve_output_dir( @@ -22,14 +36,19 @@ def resolve_output_dir( *, overwrite: bool, ) -> tuple[Path | None, int | None]: - from cli.core.errors import dir_exists_error - out = Path(output_dir).expanduser().resolve() if output_dir else default_output_dir(source_stem) - if out.exists() and not overwrite: - return None, dir_exists_error(out) - if out.exists() and overwrite: - shutil.rmtree(out) - return out, None + return _ensure_output_dir(out, overwrite=overwrite) + + +def resolve_fetch_output_dir( + token: str, + output_dir: str | None, + *, + overwrite: bool, +) -> tuple[Path | None, int | None]: + if output_dir: + return resolve_output_dir(fetch_source_stem(token), output_dir, overwrite=overwrite) + return _ensure_output_dir(default_fetch_output_dir(token), overwrite=overwrite) def write_trigger_meta( @@ -96,9 +115,7 @@ def emit_success(summary: dict, *, json_output: bool) -> None: print(f"Output directory: {summary['output_dir']}") if summary.get("trigger_meta_path"): print(f"Trigger meta: {summary['trigger_meta_path']}", file=sys.stderr) - print(f"Markdown saved to: {summary['markdown_path']}", file=sys.stderr) - print(f"Output directory: {summary['output_dir']}", file=sys.stderr) -def status_line(message: str) -> None: - print(message, file=sys.stderr) +def print_parsing_status(label: str) -> None: + print(f"Parsing... {label}", file=sys.stderr) diff --git a/cli/core/pipeline.py b/cli/core/pipeline.py index 5e3fecf..09e84d9 100644 --- a/cli/core/pipeline.py +++ b/cli/core/pipeline.py @@ -2,14 +2,13 @@ import json import time -from collections.abc import Callable from pathlib import Path from typing import Any from cli.core.defaults import PENDING_STATUSES, POLL_INTERVAL_SEC, POLL_TIMEOUT_SEC from cli.core.errors import parse_error -from cli.core.input import InputKind, ResolvedInput -from cli.core.output import save_parse_results, write_trigger_meta +from cli.core.input import InputKind, ResolvedInput, display_label_for_input +from cli.core.output import print_parsing_status, save_parse_results, write_trigger_meta def scientific_paper_trigger_kwargs(*, sync: bool = True) -> dict: @@ -39,15 +38,9 @@ def trigger_input(client, resolved: ResolvedInput, *, sync: bool) -> tuple[dict, return trigger, "trigger_url" -def poll_until_success( - client, - token: str, - *, - on_poll: Callable[[str | None, float], None] | None = None, -) -> dict | int: +def poll_until_success(client, token: str) -> dict | int: deadline = time.time() + POLL_TIMEOUT_SEC last: dict[str, Any] = {} - start = time.time() while time.time() < deadline: last = client.get_result( @@ -58,8 +51,6 @@ def poll_until_success( pages_tree=False, ) status = last.get("status") - if on_poll is not None: - on_poll(status, time.time() - start) if status == "success": return last if status == "error": @@ -107,9 +98,10 @@ def complete_fetch( *, out_dir: Path, source_stem: str, - on_poll: Callable[[str | None, float], None] | None = None, + parsing_label: str, ) -> dict[str, Any] | int: - poll_result = poll_until_success(client, token, on_poll=on_poll) + print_parsing_status(parsing_label) + poll_result = poll_until_success(client, token) if isinstance(poll_result, int): return poll_result @@ -139,7 +131,6 @@ def run_parse( *, out_dir: Path, async_mode: bool, - on_poll: Callable[[str | None, float], None] | None = None, ) -> dict[str, Any] | int: trigger, stage = trigger_input(client, resolved, sync=not async_mode) if trigger.get("status") != "success": @@ -162,7 +153,7 @@ def run_parse( token, out_dir=out_dir, source_stem=resolved.source_stem, - on_poll=on_poll, + parsing_label=display_label_for_input(resolved), ) if isinstance(summary, int): return summary diff --git a/cli/main.py b/cli/main.py index 0516643..e85c633 100644 --- a/cli/main.py +++ b/cli/main.py @@ -15,15 +15,18 @@ envvar="UNIPARSER_API_KEY", help="API key (X-API-Key); overrides env and ~/.uniparser/config.yaml", ) -@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON on stdout") -@click.option("-v", "--verbose", is_flag=True, help="Print poll progress to stderr") +@click.option( + "--json", + "json_output", + is_flag=True, + help="Machine-readable JSON on stdout (must appear before the subcommand)", +) @click.pass_context -def cli(ctx: click.Context, api_key: str | None, json_output: bool, verbose: bool) -> None: +def cli(ctx: click.Context, api_key: str | None, json_output: bool) -> None: """UniParser CLI — parse documents via https://uniparser.dp.tech/""" ctx.ensure_object(dict) ctx.obj["api_key"] = api_key ctx.obj["json_output"] = json_output - ctx.obj["verbose"] = verbose cli.add_command(auth_cmd) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9e3a00c..a060ba1 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -10,6 +10,7 @@ import pytest from click.testing import CliRunner +from cli.core.credentials import ApiKeySource from cli.core.input import InputKind, resolve_input from cli.main import cli @@ -24,9 +25,23 @@ def env_without_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) +@pytest.fixture +def no_config_file(monkeypatch: pytest.MonkeyPatch) -> None: + config_path = Path("/nonexistent/uniparser-cli-test-config/config.yaml") + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_path) + + class TestCliConfig: - def test_parse_without_api_key(self, runner: CliRunner, env_without_api_key: None) -> None: - result = runner.invoke(cli, ["parse", "/tmp/paper.pdf"]) + def test_parse_without_api_key( + self, + runner: CliRunner, + env_without_api_key: None, + no_config_file: None, + tmp_path: Path, + ) -> None: + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + result = runner.invoke(cli, ["parse", str(pdf)]) assert result.exit_code == 1 payload = json.loads(result.stderr.strip()) assert payload["ok"] is False @@ -53,11 +68,14 @@ def test_parse_with_config_file_api_key( pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") - result = runner.invoke(cli, ["parse", str(pdf)]) + out = tmp_path / "out" + result = runner.invoke(cli, ["parse", str(pdf), "-o", str(out)]) assert result.exit_code == 1 mock_client.trigger_file.assert_called_once() - def test_fetch_without_api_key(self, runner: CliRunner, env_without_api_key: None) -> None: + def test_fetch_without_api_key( + self, runner: CliRunner, env_without_api_key: None, no_config_file: None + ) -> None: result = runner.invoke(cli, ["fetch", "--token", "abc123"]) assert result.exit_code == 1 payload = json.loads(result.stderr.strip()) @@ -80,6 +98,36 @@ def test_missing_file(self) -> None: assert isinstance(resolve_input("/nonexistent/file.pdf"), str) +class TestParseInputErrors: + def test_parse_missing_file_returns_input_error_without_api_key( + self, + runner: CliRunner, + no_config_file: None, + ) -> None: + env = {k: v for k, v in os.environ.items() if k != "UNIPARSER_API_KEY"} + result = runner.invoke(cli, ["parse", "/nonexistent/file.pdf"], env=env) + assert result.exit_code == 1 + payload = json.loads(result.stderr.strip()) + assert payload["error"]["code"] == "INPUT_ERROR" + + def test_parse_missing_file_returns_input_error( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + no_config_file: None, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + result = runner.invoke( + cli, + ["parse", "/nonexistent/file.pdf"], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + assert result.exit_code == 1 + payload = json.loads(result.stderr.strip()) + assert payload["error"]["code"] == "INPUT_ERROR" + assert "File not found" in payload["error"]["message"] + + class TestParseCommand: def test_parse_success_writes_trigger_meta_and_json_token( self, @@ -109,6 +157,7 @@ def test_parse_success_writes_trigger_meta_and_json_token( env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, ) assert result.exit_code == 0, result.stderr + assert "Parsing... paper.pdf" in result.stderr payload = json.loads(result.stdout) assert payload["token"] == "tok-parse-1" assert (out / "trigger_meta.json").is_file() @@ -142,6 +191,7 @@ def test_fetch_success( env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, ) assert result.exit_code == 0, result.stderr + assert "Parsing... token_abcdef12" in result.stderr payload = json.loads(result.stdout) assert payload["token"] == "abcdef123456" assert payload["fetched_by_token"] is True @@ -169,6 +219,34 @@ def test_version_human(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) assert result.exit_code == 0 assert "uniparser-tools:" in result.stdout + def test_version_without_api_key( + self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, no_config_file: None + ) -> None: + monkeypatch.setattr( + "cli.commands.version.resolve_api_key_source", + lambda ctx: ApiKeySource(api_key=None, source=""), + ) + env = {k: v for k, v in os.environ.items() if k != "UNIPARSER_API_KEY"} + result = runner.invoke(cli, ["version"], env=env) + assert result.exit_code == 0 + assert "uniparser-tools:" in result.stdout + assert "remote: skipped (no API key)" in result.stdout + + def test_version_json_without_api_key( + self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, no_config_file: None + ) -> None: + monkeypatch.setattr( + "cli.commands.version.resolve_api_key_source", + lambda ctx: ApiKeySource(api_key=None, source=""), + ) + env = {k: v for k, v in os.environ.items() if k != "UNIPARSER_API_KEY"} + result = runner.invoke(cli, ["--json", "version"], env=env) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert "local" in payload + assert payload["remote"] is None + assert payload["remote_skipped"] is True + class TestHelp: def test_parse_help(self, runner: CliRunner) -> None: @@ -176,6 +254,7 @@ def test_parse_help(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert "--output-dir" in result.stdout assert "--async" in result.stdout + assert "--verbose" not in result.stdout def test_fetch_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["fetch", "--help"]) @@ -190,6 +269,7 @@ def test_auth_saves_api_key( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) config_dir = tmp_path / ".uniparser" config_path = config_dir / "config.yaml" monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) @@ -197,16 +277,55 @@ def test_auth_saves_api_key( result = runner.invoke(cli, ["auth"], input="my-secret-key\n") assert result.exit_code == 0, result.stderr + assert "Enter your API key" in result.stdout assert "API key saved successfully" in result.stdout assert config_path.is_file() assert "my-secret-key" in config_path.read_text(encoding="utf-8") + def test_auth_keeps_existing_on_enter( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) + config_dir = tmp_path / ".uniparser" + config_dir.mkdir() + config_path = config_dir / "config.yaml" + config_path.write_text("api_key: existing-key\n", encoding="utf-8") + monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_path) + + result = runner.invoke(cli, ["auth"], input="\n") + assert result.exit_code == 0, result.stderr + assert "Current API key source: config" in result.stdout + assert "Keeping existing API key." in result.stdout + assert config_path.read_text(encoding="utf-8") == "api_key: existing-key\n" + + def test_auth_interactive_shows_env_source( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / ".uniparser" + monkeypatch.setattr("cli.core.credentials.CONFIG_DIR", config_dir) + monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_dir / "config.yaml") + env = {k: v for k, v in os.environ.items() if k != "UNIPARSER_API_KEY"} + env["UNIPARSER_API_KEY"] = "env-key" + + result = runner.invoke(cli, ["auth"], input="\n", env=env) + assert result.exit_code == 0, result.stderr + assert "Current API key source: env" in result.stdout + assert "Keeping existing API key." in result.stdout + def test_auth_show_masked( self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) config_dir = tmp_path / ".uniparser" config_dir.mkdir() config_path = config_dir / "config.yaml" @@ -216,15 +335,26 @@ def test_auth_show_masked( result = runner.invoke(cli, ["auth", "--show"]) assert result.exit_code == 0 + assert "API key source: config" in result.stdout assert "abcd...mnop" in result.stdout + def test_auth_show_env_source(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + env = {k: v for k, v in os.environ.items() if k != "UNIPARSER_API_KEY"} + env["UNIPARSER_API_KEY"] = "env-secret-key" + result = runner.invoke(cli, ["auth", "--show"], env=env) + assert result.exit_code == 0 + assert "API key source: env" in result.stdout + assert "env-...-key" in result.stdout + def test_auth_show_without_config(self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: - config_dir = Path("/nonexistent/uniparser-config-test") - monkeypatch.setattr("cli.core.credentials.CONFIG_PATH", config_dir / "config.yaml") + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) + monkeypatch.setattr("cli.core.credentials.read_api_key_from_config", lambda: None) + env = {k: v for k, v in os.environ.items() if k != "UNIPARSER_API_KEY"} - result = runner.invoke(cli, ["auth", "--show"]) + result = runner.invoke(cli, ["auth", "--show"], env=env) assert result.exit_code == 1 - assert "No API key configured" in result.stdout + assert "No API key configured." in result.stdout + assert "uniparser auth" in result.stdout def test_auth_verify_success( self, @@ -232,6 +362,7 @@ def test_auth_verify_success( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + monkeypatch.delenv("UNIPARSER_API_KEY", raising=False) config_dir = tmp_path / ".uniparser" config_dir.mkdir() config_path = config_dir / "config.yaml" @@ -241,4 +372,5 @@ def test_auth_verify_success( result = runner.invoke(cli, ["auth", "--verify"]) assert result.exit_code == 0 - assert "API key is configured" in result.stdout + assert "API key is configured." in result.stdout + assert "Source: config" in result.stdout From c4a24869206c7ce8e40ee329800f5ef79a7ed0ff Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Tue, 23 Jun 2026 03:21:38 +0800 Subject: [PATCH 3/4] fix: resolve Ruff lint and format issues for CI --- cli/core/config.py | 1 - cli/core/credentials.py | 2 ++ skills/UniParser-Tools/scripts/lib_common.py | 5 +---- tests/unit/test_cli.py | 4 +--- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/cli/core/config.py b/cli/core/config.py index da45c9e..d6a820b 100644 --- a/cli/core/config.py +++ b/cli/core/config.py @@ -20,7 +20,6 @@ def make_client(ctx: click.Context): if not api_key: return None, config_error("No API key found. Run 'uniparser auth' to configure your API key.") try: - import uniparser_tools from uniparser_tools.api.clients import UniParserClient except ImportError: return None, config_error( diff --git a/cli/core/credentials.py b/cli/core/credentials.py index d85b86b..6765ac6 100644 --- a/cli/core/credentials.py +++ b/cli/core/credentials.py @@ -5,9 +5,11 @@ from pathlib import Path from typing import Any +import click import yaml from click.core import ParameterSource + CONFIG_DIR = Path.home() / ".uniparser" CONFIG_PATH = CONFIG_DIR / "config.yaml" API_KEY_URL = "https://uniparser.dp.tech/" diff --git a/skills/UniParser-Tools/scripts/lib_common.py b/skills/UniParser-Tools/scripts/lib_common.py index e7d1c3a..0924d02 100644 --- a/skills/UniParser-Tools/scripts/lib_common.py +++ b/skills/UniParser-Tools/scripts/lib_common.py @@ -82,10 +82,7 @@ def check_uniparser_installed() -> int | None: try: import uniparser_tools # noqa: F401 except ImportError: - return config_error( - "uniparser_tools is not installed. Run once: " - f'pip install "{INSTALL_CMD}"' - ) + return config_error(f'uniparser_tools is not installed. Run once: pip install "{INSTALL_CMD}"') return None diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index a060ba1..f1aacd9 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -73,9 +73,7 @@ def test_parse_with_config_file_api_key( assert result.exit_code == 1 mock_client.trigger_file.assert_called_once() - def test_fetch_without_api_key( - self, runner: CliRunner, env_without_api_key: None, no_config_file: None - ) -> None: + def test_fetch_without_api_key(self, runner: CliRunner, env_without_api_key: None, no_config_file: None) -> None: result = runner.invoke(cli, ["fetch", "--token", "abc123"]) assert result.exit_code == 1 payload = json.loads(result.stderr.strip()) From cdc3328934d93999ae32c5f4568c8d835f3b205d Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Mon, 29 Jun 2026 09:49:36 +0800 Subject: [PATCH 4/4] feat: add per-field parse trigger options for 7 semantic types. --- cli/README.md | 37 ++++++++++++++- cli/commands/parse.py | 44 +++++++++++++++++- cli/core/output.py | 5 +- cli/core/parse_options.py | 67 ++++++++++++++++++++++++++ cli/core/pipeline.py | 27 ++++------- tests/unit/test_cli.py | 80 ++++++++++++++++++++++++++++++++ tests/unit/test_parse_options.py | 56 ++++++++++++++++++++++ 7 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 cli/core/parse_options.py create mode 100644 tests/unit/test_parse_options.py diff --git a/cli/README.md b/cli/README.md index 2e3d9c2..99fd3a9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -116,8 +116,43 @@ uniparser parse https://example.com/paper.pdf | `--overwrite` | 输出目录已存在时,先清空再写入 | | `--async` | 异步提交任务(适合较大文档) | +### 解析配置(7 类语义) + +未指定的字段使用 **scientific-paper** 默认值(与网站推荐科学文献配置一致)。只写你关心的 `--xxx` 即可覆盖对应项。 + +| 选项 | 含义 | 可选值 | 默认值 | +|------|------|--------|--------| +| `--textual` | 普通文本 | `disable` / `ocr-fast` / `ocr-hq` / `digital` / `base64` | `ocr-hq` | +| `--equation` | 数学公式 | `disable` / `ocr-fast` / `ocr-hq` / `base64` | `ocr-hq` | +| `--table` | 表格 | 同上 | `ocr-hq` | +| `--chart` | 图表 | 同上 | `base64` | +| `--figure` | 插图 | 同上 | `base64` | +| `--expression` | 化学反应式 | 同上 | `base64` | +| `--molecule` | 化学分子 | 同上 | `ocr-fast` | + 示例: +```bash +# 全部默认(scientific-paper) +uniparser parse paper.pdf + +# 数字 PDF 直接抽文本,其余保持默认 +uniparser parse paper.pdf --textual digital + +# 关闭分子解析 +uniparser parse paper.pdf --molecule disable + +# 多项覆盖 +uniparser parse paper.pdf --textual digital --table ocr-fast --molecule disable + +# 与输出目录、异步组合 +uniparser parse paper.pdf -o ./results/ --overwrite --async +``` + +`trigger_meta.json` 会记录本次实际提交的 `trigger_kwargs`,便于复现配置。 + +### 输出目录示例 + ```bash uniparser parse paper.pdf -o ./results/ uniparser parse paper.pdf -o ./results/ --overwrite @@ -131,7 +166,7 @@ uniparser parse paper.pdf -o ./results/ --overwrite |------|------| | `<文件名>.md` | 完整 Markdown 正文(主要结果) | | `pages_tree.json` | 版面结构树,便于按章节/块定位 | -| `trigger_meta.json` | 任务 token 与输入信息,供 `fetch` 恢复使用 | +| `trigger_meta.json` | 任务 token、输入信息及 `trigger_kwargs`,供 `fetch` 恢复使用 | 解析过程中终端会显示一行 `Parsing... <文件名>`。大文档可能需要等待数分钟。 diff --git a/cli/commands/parse.py b/cli/commands/parse.py index 7a1db58..48ffd3c 100644 --- a/cli/commands/parse.py +++ b/cli/commands/parse.py @@ -6,14 +6,36 @@ from cli.core.errors import input_error from cli.core.input import resolve_input from cli.core.output import emit_success, resolve_output_dir +from cli.core.parse_options import PARSE_MODE_CHOICES, TEXTUAL_CHOICES, resolve_trigger_kwargs from cli.core.pipeline import run_parse +def _parse_mode_option(name: str, help_text: str): + return click.option( + f"--{name}", + type=click.Choice(PARSE_MODE_CHOICES, case_sensitive=False), + default=None, + help=help_text, + ) + + @click.command("parse") @click.argument("source", metavar="INPUT") @click.option("--output-dir", "-o", help="Output directory (default: ~/Uni-Parser-Skill//)") @click.option("--overwrite", is_flag=True, help="Overwrite output directory if it already exists") @click.option("--async", "async_mode", is_flag=True, help="Submit with sync=false and poll until success") +@click.option( + "--textual", + type=click.Choice(TEXTUAL_CHOICES, case_sensitive=False), + default=None, + help="Text parsing mode (default: ocr-hq).", +) +@_parse_mode_option("equation", "Equation parsing mode (default: ocr-hq).") +@_parse_mode_option("table", "Table parsing mode (default: ocr-hq).") +@_parse_mode_option("chart", "Chart parsing mode (default: base64).") +@_parse_mode_option("figure", "Figure parsing mode (default: base64).") +@_parse_mode_option("expression", "Chemical expression parsing mode (default: base64).") +@_parse_mode_option("molecule", "Molecule parsing mode (default: ocr-fast).") @click.pass_context def parse_cmd( ctx: click.Context, @@ -21,6 +43,13 @@ def parse_cmd( output_dir: str | None, overwrite: bool, async_mode: bool, + textual: str | None, + equation: str | None, + table: str | None, + chart: str | None, + figure: str | None, + expression: str | None, + molecule: str | None, ) -> None: """Parse a local PDF/image or public PDF URL; save Markdown and pages_tree.""" resolved = resolve_input(source) @@ -35,11 +64,24 @@ def parse_cmd( if dir_code is not None: raise SystemExit(dir_code) + trigger_kwargs = resolve_trigger_kwargs( + sync=not async_mode, + overrides={ + "textual": textual, + "equation": equation, + "table": table, + "chart": chart, + "figure": figure, + "expression": expression, + "molecule": molecule, + }, + ) + code = run_parse( client, resolved, out_dir=out_dir, - async_mode=async_mode, + trigger_kwargs=trigger_kwargs, ) if isinstance(code, int): raise SystemExit(code) diff --git a/cli/core/output.py b/cli/core/output.py index 30f833f..91c21f4 100644 --- a/cli/core/output.py +++ b/cli/core/output.py @@ -57,15 +57,18 @@ def write_trigger_meta( token: str, input_type: str, input_value: str, + trigger_kwargs: dict | None = None, ) -> Path: out_dir.mkdir(parents=True, exist_ok=True) meta_path = out_dir / "trigger_meta.json" - payload = { + payload: dict[str, Any] = { "token": token, "input_type": input_type, "input": input_value, "submitted_at": datetime.now(timezone.utc).isoformat(), } + if trigger_kwargs is not None: + payload["trigger_kwargs"] = trigger_kwargs meta_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") return meta_path diff --git a/cli/core/parse_options.py b/cli/core/parse_options.py new file mode 100644 index 0000000..b93f269 --- /dev/null +++ b/cli/core/parse_options.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from uniparser_tools.common.constant import ParseMode, ParseModeTextual + + +SEMANTIC_FIELDS = ("textual", "equation", "table", "chart", "figure", "expression", "molecule") + +TEXTUAL_ALIASES: dict[str, ParseModeTextual] = { + "disable": ParseModeTextual.Disable, + "ocr-fast": ParseModeTextual.OCRFast, + "ocr-hq": ParseModeTextual.OCRHighQuality, + "digital": ParseModeTextual.DigitalExported, + "base64": ParseModeTextual.DumpBase64, +} + +PARSE_MODE_ALIASES: dict[str, ParseMode] = { + "disable": ParseMode.Disable, + "ocr-fast": ParseMode.OCRFast, + "ocr-hq": ParseMode.OCRHighQuality, + "base64": ParseMode.DumpBase64, +} + +TEXTUAL_CHOICES = list(TEXTUAL_ALIASES.keys()) +PARSE_MODE_CHOICES = list(PARSE_MODE_ALIASES.keys()) + +SCIENTIFIC_PAPER_DEFAULTS: dict[str, ParseMode | ParseModeTextual] = { + "textual": ParseModeTextual.OCRHighQuality, + "equation": ParseMode.OCRHighQuality, + "table": ParseMode.OCRHighQuality, + "chart": ParseMode.DumpBase64, + "figure": ParseMode.DumpBase64, + "expression": ParseMode.DumpBase64, + "molecule": ParseMode.OCRFast, +} + +_TEXTUAL_ALIAS_BY_MODE = {mode: alias for alias, mode in TEXTUAL_ALIASES.items()} +_PARSE_MODE_ALIAS_BY_MODE = {mode: alias for alias, mode in PARSE_MODE_ALIASES.items()} + + +def resolve_trigger_kwargs(*, sync: bool, overrides: dict[str, str | None]) -> dict[str, Any]: + kwargs: dict[str, Any] = dict(SCIENTIFIC_PAPER_DEFAULTS) + for field, value in overrides.items(): + if value is None: + continue + if field == "textual": + kwargs[field] = TEXTUAL_ALIASES[value] + else: + kwargs[field] = PARSE_MODE_ALIASES[value] + kwargs["sync"] = sync + return kwargs + + +def serialize_trigger_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + serialized: dict[str, Any] = {} + for field in SEMANTIC_FIELDS: + if field not in kwargs: + continue + value = kwargs[field] + if field == "textual": + serialized[field] = _TEXTUAL_ALIAS_BY_MODE[value] + else: + serialized[field] = _PARSE_MODE_ALIAS_BY_MODE[value] + if "sync" in kwargs: + serialized["sync"] = kwargs["sync"] + return serialized diff --git a/cli/core/pipeline.py b/cli/core/pipeline.py index 09e84d9..094c2b1 100644 --- a/cli/core/pipeline.py +++ b/cli/core/pipeline.py @@ -9,25 +9,15 @@ from cli.core.errors import parse_error from cli.core.input import InputKind, ResolvedInput, display_label_for_input from cli.core.output import print_parsing_status, save_parse_results, write_trigger_meta +from cli.core.parse_options import resolve_trigger_kwargs, serialize_trigger_kwargs def scientific_paper_trigger_kwargs(*, sync: bool = True) -> dict: - from uniparser_tools.common.constant import ParseMode, ParseModeTextual - - return { - "sync": sync, - "textual": ParseModeTextual.OCRHighQuality, - "equation": ParseMode.OCRHighQuality, - "table": ParseMode.OCRHighQuality, - "chart": ParseMode.DumpBase64, - "figure": ParseMode.DumpBase64, - "expression": ParseMode.DumpBase64, - "molecule": ParseMode.OCRFast, - } - - -def trigger_input(client, resolved: ResolvedInput, *, sync: bool) -> tuple[dict, str]: - kwargs = scientific_paper_trigger_kwargs(sync=sync) + return resolve_trigger_kwargs(sync=sync, overrides={}) + + +def trigger_input(client, resolved: ResolvedInput, *, trigger_kwargs: dict) -> tuple[dict, str]: + kwargs = trigger_kwargs if resolved.kind is InputKind.FILE: trigger = client.trigger_file(file_path=str(resolved.path), **kwargs) return trigger, "trigger_file" @@ -130,9 +120,9 @@ def run_parse( resolved: ResolvedInput, *, out_dir: Path, - async_mode: bool, + trigger_kwargs: dict, ) -> dict[str, Any] | int: - trigger, stage = trigger_input(client, resolved, sync=not async_mode) + trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) if trigger.get("status") != "success": save_stage_error(out_dir, "trigger_error.json", trigger) return parse_error(stage, trigger) @@ -146,6 +136,7 @@ def run_parse( token=token, input_type=resolved.kind.value, input_value=resolved.raw, + trigger_kwargs=serialize_trigger_kwargs(trigger_kwargs), ) summary = complete_fetch( diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f1aacd9..27045bc 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -13,6 +13,7 @@ from cli.core.credentials import ApiKeySource from cli.core.input import InputKind, resolve_input from cli.main import cli +from uniparser_tools.common.constant import ParseMode, ParseModeTextual @pytest.fixture @@ -161,8 +162,85 @@ def test_parse_success_writes_trigger_meta_and_json_token( assert (out / "trigger_meta.json").is_file() meta = json.loads((out / "trigger_meta.json").read_text(encoding="utf-8")) assert meta["token"] == "tok-parse-1" + assert meta["trigger_kwargs"]["textual"] == "ocr-hq" + assert meta["trigger_kwargs"]["sync"] is True + assert "preset" not in meta assert (out / "paper.md").is_file() + def test_parse_default_trigger_kwargs( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + + mock_client = MagicMock() + mock_client.trigger_file.return_value = {"status": "error", "description": "stop"} + monkeypatch.setattr("cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + out = tmp_path / "out" + runner.invoke( + cli, + ["parse", str(pdf), "-o", str(out)], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + + _, call_kwargs = mock_client.trigger_file.call_args + assert call_kwargs["sync"] is True + assert call_kwargs["textual"] is ParseModeTextual.OCRHighQuality + assert call_kwargs["equation"] is ParseMode.OCRHighQuality + assert call_kwargs["table"] is ParseMode.OCRHighQuality + assert call_kwargs["chart"] is ParseMode.DumpBase64 + assert call_kwargs["figure"] is ParseMode.DumpBase64 + assert call_kwargs["expression"] is ParseMode.DumpBase64 + assert call_kwargs["molecule"] is ParseMode.OCRFast + + def test_parse_molecule_disable_override( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + + mock_client = MagicMock() + mock_client.trigger_file.return_value = {"status": "error", "description": "stop"} + monkeypatch.setattr("cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + out = tmp_path / "out" + runner.invoke( + cli, + ["parse", str(pdf), "-o", str(out), "--molecule", "disable"], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + + _, call_kwargs = mock_client.trigger_file.call_args + assert call_kwargs["molecule"] is ParseMode.Disable + assert call_kwargs["table"] is ParseMode.OCRHighQuality + + def test_parse_invalid_equation_mode_rejected( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + + result = runner.invoke( + cli, + ["parse", str(pdf), "--equation", "digital"], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + assert result.exit_code != 0 + assert "digital" in result.stderr.lower() or "invalid" in result.stderr.lower() + class TestFetchCommand: def test_fetch_success( @@ -252,6 +330,8 @@ def test_parse_help(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert "--output-dir" in result.stdout assert "--async" in result.stdout + assert "--textual" in result.stdout + assert "--molecule" in result.stdout assert "--verbose" not in result.stdout def test_fetch_help(self, runner: CliRunner) -> None: diff --git a/tests/unit/test_parse_options.py b/tests/unit/test_parse_options.py new file mode 100644 index 0000000..5b308a5 --- /dev/null +++ b/tests/unit/test_parse_options.py @@ -0,0 +1,56 @@ +"""Unit tests for parse trigger option resolution.""" + +from __future__ import annotations + +from cli.core.parse_options import ( + PARSE_MODE_ALIASES, + SCIENTIFIC_PAPER_DEFAULTS, + TEXTUAL_ALIASES, + resolve_trigger_kwargs, + serialize_trigger_kwargs, +) +from uniparser_tools.common.constant import ParseMode, ParseModeTextual + + +class TestResolveTriggerKwargs: + def test_defaults_match_scientific_paper(self) -> None: + kwargs = resolve_trigger_kwargs(sync=True, overrides={}) + assert kwargs["sync"] is True + assert kwargs["textual"] is ParseModeTextual.OCRHighQuality + assert kwargs["equation"] is ParseMode.OCRHighQuality + assert kwargs["table"] is ParseMode.OCRHighQuality + assert kwargs["chart"] is ParseMode.DumpBase64 + assert kwargs["figure"] is ParseMode.DumpBase64 + assert kwargs["expression"] is ParseMode.DumpBase64 + assert kwargs["molecule"] is ParseMode.OCRFast + + def test_override_single_field(self) -> None: + kwargs = resolve_trigger_kwargs(sync=False, overrides={"molecule": "disable"}) + assert kwargs["sync"] is False + assert kwargs["molecule"] is ParseMode.Disable + assert kwargs["table"] is SCIENTIFIC_PAPER_DEFAULTS["table"] + + def test_serialize_round_trip(self) -> None: + kwargs = resolve_trigger_kwargs( + sync=True, + overrides={"textual": "digital", "molecule": "disable"}, + ) + serialized = serialize_trigger_kwargs(kwargs) + assert serialized == { + "textual": "digital", + "equation": "ocr-hq", + "table": "ocr-hq", + "chart": "base64", + "figure": "base64", + "expression": "base64", + "molecule": "disable", + "sync": True, + } + + def test_all_aliases_map_to_enums(self) -> None: + for alias, mode in TEXTUAL_ALIASES.items(): + kwargs = resolve_trigger_kwargs(sync=True, overrides={"textual": alias}) + assert kwargs["textual"] is mode + for alias, mode in PARSE_MODE_ALIASES.items(): + kwargs = resolve_trigger_kwargs(sync=True, overrides={"table": alias}) + assert kwargs["table"] is mode