"""DocuElevate command-line interface. Provides a pipe-friendly CLI for scripting and automation against the DocuElevate REST API. Authentication is via personal API tokens (the same tokens managed at ``/api-tokens`` in the web UI). Usage:: docuelevate --url http://my-instance --token de_xxx list DOCUELEVATE_URL=http://my-instance DOCUELEVATE_API_TOKEN=de_xxx docuelevate list Commands -------- upload Upload one or more local files for processing. download Download a processed (or original) file by ID. search Full-text search across all documents. list List documents with optional filtering. token Sub-commands: create / list / revoke API tokens. """ import json import sys from pathlib import Path from typing import Any from urllib.parse import unquote import click import requests # --------------------------------------------------------------------------- # Environment-variable defaults # --------------------------------------------------------------------------- ENV_URL = "DOCUELEVATE_URL" ENV_TOKEN = "DOCUELEVATE_API_TOKEN" _DEFAULT_URL = "http://localhost:8000" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _build_headers(token: str) -> dict[str, str]: """Return Authorization headers for the given API token.""" return {"Authorization": f"Bearer {token}"} def _api( method: str, base_url: str, path: str, token: str, timeout: int = 60, **kwargs: Any, ) -> requests.Response: """Make an authenticated API request and return the response. Args: method: HTTP method (GET, POST, DELETE, …). base_url: The base URL of the DocuElevate instance. path: API path starting with ``/``. token: Plaintext API token. timeout: Request timeout in seconds (default: 60). **kwargs: Extra keyword arguments forwarded to :func:`requests.request`. Returns: The :class:`requests.Response` object. Raises: click.ClickException: On network errors. """ url = base_url.rstrip("/") + path headers = _build_headers(token) try: resp = requests.request(method, url, headers=headers, timeout=timeout, **kwargs) except requests.ConnectionError as exc: raise click.ClickException(f"Could not connect to {base_url}: {exc}") from exc except requests.Timeout as exc: raise click.ClickException(f"Request timed out: {exc}") from exc return resp def _require_ok(resp: requests.Response) -> dict[str, Any] | list[Any]: """Assert a successful HTTP response and return parsed JSON. Args: resp: The response to check. Returns: Parsed JSON payload. Raises: click.ClickException: If the response status indicates an error. """ if resp.status_code >= 400: try: detail = resp.json().get("detail", resp.text) except Exception: detail = resp.text raise click.ClickException(f"API error {resp.status_code}: {detail}") try: return resp.json() except Exception: return {} def _output(data: Any, fmt: str) -> None: """Write *data* to stdout in the requested format. Args: data: The value to serialise (dict, list, or primitive). fmt: Either ``"json"`` (machine-readable) or ``"table"`` (human-readable). """ if fmt == "json": click.echo(json.dumps(data, indent=2, default=str)) else: _print_table(data) def _print_table(data: Any) -> None: """Pretty-print a list of dicts as a fixed-width table. Falls back to JSON if the data is not a homogeneous list of dicts. Args: data: Data to render. """ if isinstance(data, dict): # Single-object output — print as key: value pairs for key, value in data.items(): click.echo(f" {key}: {value}") return if not isinstance(data, list) or not data: click.echo(json.dumps(data, indent=2, default=str)) return if not isinstance(data[0], dict): for item in data: click.echo(str(item)) return # Determine column widths keys = list(data[0].keys()) widths: dict[str, int] = {k: len(k) for k in keys} for row in data: for k in keys: widths[k] = max(widths[k], len(str(row.get(k, "")))) header = " ".join(k.upper().ljust(widths[k]) for k in keys) separator = " ".join("-" * widths[k] for k in keys) click.echo(header) click.echo(separator) for row in data: click.echo(" ".join(str(row.get(k, "")).ljust(widths[k]) for k in keys)) # --------------------------------------------------------------------------- # Root command group # --------------------------------------------------------------------------- @click.group(context_settings={"help_option_names": ["-h", "--help"]}) @click.option( "--url", envvar=ENV_URL, default=_DEFAULT_URL, show_default=True, show_envvar=True, help="Base URL of the DocuElevate instance.", metavar="URL", ) @click.option( "--token", envvar=ENV_TOKEN, default=None, show_envvar=True, help="API token (de_…). Required for all commands except help.", metavar="TOKEN", ) @click.option( "--format", "fmt", type=click.Choice(["table", "json"], case_sensitive=False), default="table", show_default=True, help="Output format. Use 'json' for machine-readable / pipe-friendly output.", ) @click.option( "--timeout", default=60, show_default=True, envvar="DOCUELEVATE_TIMEOUT", show_envvar=True, type=int, help="HTTP request timeout in seconds.", ) @click.version_option(package_name="docuelevate", prog_name="docuelevate") @click.pass_context def cli(ctx: click.Context, url: str, token: str | None, fmt: str, timeout: int) -> None: """DocuElevate CLI — interact with DocuElevate from the command line. Configure the target instance and credentials via options or environment variables: \b DOCUELEVATE_URL Base URL of the instance (default: http://localhost:8000) DOCUELEVATE_API_TOKEN Personal API token (de_…) DOCUELEVATE_TIMEOUT HTTP request timeout in seconds (default: 60) Examples: \b # Upload a file docuelevate --token de_xxx upload report.pdf \b # List files as JSON for further processing docuelevate --token de_xxx --format json list | jq '.[].original_filename' \b # Search for invoices docuelevate --token de_xxx search "invoice amazon" """ ctx.ensure_object(dict) ctx.obj["url"] = url ctx.obj["token"] = token ctx.obj["fmt"] = fmt ctx.obj["timeout"] = timeout def _get_token(ctx: click.Context) -> str: """Return the token from context, raising ClickException if absent. Args: ctx: The current Click context. Returns: The API token string. Raises: click.ClickException: If no token has been provided. """ token = ctx.obj.get("token") if not token: raise click.ClickException(f"No API token provided. Use --token or set the {ENV_TOKEN} environment variable.") return token # --------------------------------------------------------------------------- # list command # --------------------------------------------------------------------------- @cli.command("list") @click.option("--page", default=1, show_default=True, help="Page number.") @click.option("--per-page", default=25, show_default=True, help="Items per page (max 200).") @click.option("--search", default=None, help="Filter by filename substring.") @click.option("--mime-type", default=None, help="Filter by MIME type (e.g. application/pdf).") @click.option("--status", "file_status", default=None, help="Filter by status: pending, processing, completed, failed.") @click.option("--sort-by", default="created_at", show_default=True, help="Sort field.") @click.option("--sort-order", type=click.Choice(["asc", "desc"]), default="desc", show_default=True) @click.pass_context def list_files( ctx: click.Context, page: int, per_page: int, search: str | None, mime_type: str | None, file_status: str | None, sort_by: str, sort_order: str, ) -> None: """List documents stored in DocuElevate. Examples: \b docuelevate list docuelevate list --status completed --per-page 10 docuelevate --format json list | jq '.[].original_filename' """ token = _get_token(ctx) url: str = ctx.obj["url"] fmt: str = ctx.obj["fmt"] timeout: int = ctx.obj["timeout"] params: dict[str, Any] = { "page": page, "per_page": per_page, "sort_by": sort_by, "sort_order": sort_order, } if search: params["search"] = search if mime_type: params["mime_type"] = mime_type if file_status: params["status"] = file_status resp = _api("GET", url, "/api/files", token, timeout=timeout, params=params) payload = _require_ok(resp) # Extract the list from the paginated response files: list[dict[str, Any]] = payload.get("files", payload) if isinstance(payload, dict) else payload # type: ignore[assignment] pagination: dict[str, Any] = payload.get("pagination", {}) if isinstance(payload, dict) else {} if fmt == "json": _output(files, fmt) else: # Trim fields for readable table rows = [ { "id": f.get("id"), "filename": f.get("original_filename"), "size": f.get("file_size"), "status": f.get("status"), "created_at": str(f.get("created_at", ""))[:19], } for f in files ] _output(rows, fmt) if pagination: click.echo(f"\nPage {pagination.get('page')}/{pagination.get('pages')} ({pagination.get('total')} total)") # --------------------------------------------------------------------------- # upload command # --------------------------------------------------------------------------- @cli.command("upload") @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, readable=True)) @click.option( "--batch-size", default=5, show_default=True, help="Maximum number of concurrent uploads (sequential when 1).", ) @click.pass_context def upload_files(ctx: click.Context, files: tuple[str, ...], batch_size: int) -> None: """Upload one or more local files for processing. Supports glob patterns and multiple arguments for batch uploads. Examples: \b docuelevate upload report.pdf docuelevate upload *.pdf invoice_*.png docuelevate upload --batch-size 3 /scans/*.pdf """ token = _get_token(ctx) url: str = ctx.obj["url"] fmt: str = ctx.obj["fmt"] timeout: int = ctx.obj["timeout"] results: list[dict[str, Any]] = [] failed = 0 for i, file_path in enumerate(files, 1): path = Path(file_path) click.echo(f"[{i}/{len(files)}] Uploading {path.name}…", err=True) try: with path.open("rb") as fh: resp = _api( "POST", url, "/api/ui-upload", token, timeout=timeout, files={"file": (path.name, fh)}, ) if resp.status_code >= 400: try: detail = resp.json().get("detail", resp.text) except Exception: detail = resp.text click.echo(f" ERROR {resp.status_code}: {detail}", err=True) results.append({"file": path.name, "status": "error", "detail": detail}) failed += 1 else: data = resp.json() results.append({"file": path.name, "status": "queued", **data}) click.echo(f" OK task_id={data.get('task_id', '?')}", err=True) except click.ClickException: raise except Exception as exc: click.echo(f" ERROR: {exc}", err=True) results.append({"file": path.name, "status": "error", "detail": str(exc)}) failed += 1 _output(results, fmt) if failed: click.echo(f"\n{failed}/{len(files)} upload(s) failed.", err=True) sys.exit(1) # --------------------------------------------------------------------------- # download command # --------------------------------------------------------------------------- @cli.command("download") @click.argument("file_id", type=int) @click.option( "--output", "-o", default=None, help="Destination file path. Defaults to the server-provided filename in the current directory.", type=click.Path(), ) @click.option( "--version", type=click.Choice(["processed", "original"]), default="processed", show_default=True, help="Which version to download.", ) @click.pass_context def download_file(ctx: click.Context, file_id: int, output: str | None, version: str) -> None: """Download a file by its numeric ID. Examples: \b docuelevate download 42 docuelevate download 42 --version original -o /tmp/orig.pdf """ token = _get_token(ctx) url: str = ctx.obj["url"] timeout: int = ctx.obj["timeout"] resp = _api( "GET", url, f"/api/files/{file_id}/download", token, timeout=timeout, params={"version": version}, stream=True, ) _require_ok(resp) # Determine output filename if output: dest = Path(output) else: content_disp = resp.headers.get("content-disposition", "") filename = f"file_{file_id}" for raw_part in content_disp.split(";"): clean = raw_part.strip() if clean.startswith("filename="): filename = clean[len("filename=") :].strip('"').strip("'") break if clean.startswith("filename*="): raw = clean[len("filename*=") :] if raw.upper().startswith("UTF-8''"): filename = unquote(raw[7:]) break dest = Path(filename) with dest.open("wb") as fh: for chunk in resp.iter_content(chunk_size=65536): fh.write(chunk) click.echo(f"Downloaded {dest} ({dest.stat().st_size} bytes)") # --------------------------------------------------------------------------- # search command # --------------------------------------------------------------------------- @cli.command("search") @click.argument("query") @click.option("--mime-type", default=None, help="Filter by MIME type.") @click.option("--document-type", default=None, help="Filter by document type (e.g. Invoice).") @click.option("--tags", default=None, help="Filter by tag.") @click.option("--language", default=None, help="Filter by language code (e.g. en, de).") @click.option("--page", default=1, show_default=True) @click.option("--per-page", default=20, show_default=True, help="Results per page (max 100).") @click.pass_context def search( ctx: click.Context, query: str, mime_type: str | None, document_type: str | None, tags: str | None, language: str | None, page: int, per_page: int, ) -> None: """Full-text search across all documents. Examples: \b docuelevate search "invoice amazon" docuelevate search "contract" --document-type Contract --language en docuelevate --format json search "receipt" | jq '.[].file_id' """ token = _get_token(ctx) url: str = ctx.obj["url"] fmt: str = ctx.obj["fmt"] timeout: int = ctx.obj["timeout"] params: dict[str, Any] = {"q": query, "page": page, "per_page": per_page} if mime_type: params["mime_type"] = mime_type if document_type: params["document_type"] = document_type if tags: params["tags"] = tags if language: params["language"] = language resp = _api("GET", url, "/api/search", token, timeout=timeout, params=params) payload = _require_ok(resp) results: list[dict[str, Any]] = ( payload.get("results", payload) if isinstance(payload, dict) else payload # type: ignore[assignment] ) total: int = payload.get("total", len(results)) if isinstance(payload, dict) else len(results) pages: int = payload.get("pages", 1) if isinstance(payload, dict) else 1 if fmt == "json": _output(results, fmt) else: rows = [ { "file_id": r.get("file_id"), "filename": r.get("original_filename"), "type": r.get("document_type"), "tags": ",".join(r.get("tags") or []), } for r in results ] _output(rows, fmt) click.echo(f"\nPage {page}/{pages} ({total} total results)") # --------------------------------------------------------------------------- # token sub-group # --------------------------------------------------------------------------- @cli.group("token") @click.pass_context def token_group(ctx: click.Context) -> None: """Manage personal API tokens. Tokens can be created, listed, and revoked. Token rotation is achieved by creating a new token before revoking the old one. Examples: \b docuelevate token create "CI Pipeline" docuelevate token list docuelevate token revoke 3 """ @token_group.command("create") @click.argument("name") @click.pass_context def token_create(ctx: click.Context, name: str) -> None: """Create a new personal API token. The full token value is printed exactly once. Store it securely. Examples: \b docuelevate token create "My script" docuelevate --format json token create "CI" | jq -r '.token' """ token = _get_token(ctx) url: str = ctx.obj["url"] fmt: str = ctx.obj["fmt"] timeout: int = ctx.obj["timeout"] resp = _api("POST", url, "/api/api-tokens/", token, timeout=timeout, json={"name": name}) payload = _require_ok(resp) if fmt == "json": _output(payload, fmt) else: if not isinstance(payload, dict): raise click.ClickException("Unexpected API response format.") click.echo("Token created successfully:") click.echo(f" ID: {payload.get('id')}") click.echo(f" Name: {payload.get('name')}") click.echo(f" Prefix: {payload.get('token_prefix')}") click.echo(f" Token: {payload.get('token')}") click.echo() click.echo("Store this token securely — it will not be shown again.", err=True) @token_group.command("list") @click.pass_context def token_list(ctx: click.Context) -> None: """List all your API tokens (active and revoked). Examples: \b docuelevate token list docuelevate --format json token list | jq '.[] | select(.is_active)' """ token = _get_token(ctx) url: str = ctx.obj["url"] fmt: str = ctx.obj["fmt"] timeout: int = ctx.obj["timeout"] resp = _api("GET", url, "/api/api-tokens/", token, timeout=timeout) payload = _require_ok(resp) if fmt == "json": _output(payload, fmt) else: if not isinstance(payload, list): raise click.ClickException("Unexpected API response format.") rows = [ { "id": t.get("id"), "name": t.get("name"), "prefix": t.get("token_prefix"), "active": t.get("is_active"), "last_used": str(t.get("last_used_at") or "never")[:19], "created": str(t.get("created_at") or "")[:19], } for t in payload ] _output(rows, fmt) @token_group.command("revoke") @click.argument("token_id", type=int) @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") @click.pass_context def token_revoke(ctx: click.Context, token_id: int, yes: bool) -> None: """Revoke an API token by its numeric ID. The token is soft-deleted (kept for audit) but immediately invalidated. Examples: \b docuelevate token revoke 3 docuelevate token revoke 3 --yes """ token = _get_token(ctx) url: str = ctx.obj["url"] timeout: int = ctx.obj["timeout"] if not yes: click.confirm(f"Revoke token {token_id}?", abort=True) resp = _api("DELETE", url, f"/api/api-tokens/{token_id}", token, timeout=timeout) _require_ok(resp) click.echo(f"Token {token_id} revoked.") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: """Entry point for the ``docuelevate`` console script.""" cli(auto_envvar_prefix="DOCUELEVATE") # type: ignore[call-arg] if __name__ == "__main__": main()