""" Click CLI for cantaloupe export tool. Commands: export Download machine list Excel from mycantaloupe.com diff Compare two machine list exports and show what changed configure Save credentials to ~/.cantaloupe.env Usage: python -m cantaloupe export --output ./exports/ python -m cantaloupe export --scheduled --output /data/exports/ python -m cantaloupe export --diff # auto-compare with previous export python -m cantaloupe diff --from old.xlsx --to new.xlsx python -m cantaloupe diff # compare two most recent exports python -m cantaloupe configure """ import logging import os import sys import time from pathlib import Path from typing import Optional import click from cantaloupe.config import load_credentials, prompt_credentials, save_credentials logger = logging.getLogger(__name__) # ── helpers ──────────────────────────────────────────────────────────────── def _format_size(size_bytes: int) -> str: """Format a byte count into a human-readable string.""" size: float = size_bytes for unit in ("B", "KB", "MB", "GB", "TB"): if size < 1024: return f"{size:.1f} {unit}" size /= 1024 return f"{size:.1f} PB" def _count_excel_rows(path: Path) -> int: """Count data rows in an Excel file (excluding the header row). Uses openpyxl read-only mode for memory efficiency. Returns 0 if the row count can't be determined. """ try: from openpyxl import load_workbook wb = load_workbook(path, read_only=True) ws = wb.active if ws is None: wb.close() return 0 row_count = max(0, (ws.max_row or 0) - 1) # subtract header wb.close() return row_count except Exception: logger.warning("Could not count Excel rows", exc_info=True) return 0 def _resolve_output(output: str | None, scheduled: bool) -> Path: """Resolve and validate the output path. When output is not specified, defaults to the current working directory for interactive mode and ~/cantaloupe-exports/ for scheduled/cron mode. """ if output: out_path = Path(os.path.expanduser(output)) elif scheduled: out_path = Path.home() / "cantaloupe-exports" else: out_path = Path.cwd() out_path = out_path.resolve() # Create parent directory if it's a file path, or the directory itself if out_path.suffix: out_path.parent.mkdir(parents=True, exist_ok=True) else: out_path.mkdir(parents=True, exist_ok=True) return out_path def _print_diff_result(result: dict, from_label: str, to_label: str) -> None: """Pretty-print a diff result to the console.""" click.echo() click.echo("═" * 60) click.echo(" Diff Report") click.echo("═" * 60) click.echo(f" From : {from_label} ({result['from_count']} rows)") click.echo(f" To : {to_label} ({result['to_count']} rows)") click.echo(f" Key : {result['id_column']}") click.echo() added = result["added_count"] removed = result["removed_count"] changed = result["changed_count"] if added == 0 and removed == 0 and changed == 0: click.echo(" ✅ No changes — exports are identical.") else: if added: click.echo(f" 🟢 {added} machine(s) added") for row in result["added"][:10]: rid = row.get(result["id_column"], "?") click.echo(f" + {rid}") if added > 10: click.echo(f" ... and {added - 10} more") if removed: click.echo(f" 🔴 {removed} machine(s) removed") for row in result["removed"][:10]: rid = row.get(result["id_column"], "?") click.echo(f" - {rid}") if removed > 10: click.echo(f" ... and {removed - 10} more") if changed: click.echo(f" 🟡 {changed} machine(s) changed") for entry in result["changed"][:5]: rid = entry["id"] field_names = list(entry["changes"].keys()) click.echo(f" ~ {rid} ({', '.join(field_names[:5])})") if len(field_names) > 5: click.echo(f" +{len(field_names) - 5} more fields") if changed > 5: click.echo(f" ... and {changed - 5} more") click.echo("═" * 60) # ── CLI group ─────────────────────────────────────────────────────────────── @click.group() @click.version_option(version="0.1.0", prog_name="cantaloupe") @click.option( "--verbose", "-v", is_flag=True, help="Enable debug-level logging", ) @click.pass_context def cli(ctx: click.Context, verbose: bool) -> None: """Cantaloupe Downloader — export machine list from mycantaloupe.com. Credentials are loaded from (in priority order): \b 1. CANTALOUPE_EMAIL / CANTALOUPE_PASSWORD environment variables 2. ~/.cantaloupe.env file 3. Interactive prompt (unless --scheduled) """ ctx.ensure_object(dict) log_level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S", ) # Load from env / file early so all subcommands can access them email, password = load_credentials() ctx.obj["email"] = email ctx.obj["password"] = password # ── export command ────────────────────────────────────────────────────────── @cli.command() @click.option( "--output", "-o", default=None, help="Output directory or file path (default: current dir, or ~/cantaloupe-exports/ when --scheduled)", ) @click.option( "--headless/--visible", default=True, help="Browser headless mode for Playwright fallback (--visible shows the browser window).", ) @click.option( "--scheduled", is_flag=True, help="Non-interactive mode for cron usage — skips credential prompts, uses defaults.", ) @click.option( "--diff/--no-diff", "diff_flag", default=False, help="Compare with the previous export and show changes after download.", ) @click.option( "--email", default=None, envvar="CANTALOUPE_EMAIL", help="Cantaloupe account email (overrides env var and config file).", ) @click.option( "--password", default=None, envvar="CANTALOUPE_PASSWORD", help="Cantaloupe account password (overrides env var and config file).", ) @click.pass_context def export( ctx: click.Context, output: str | None, headless: bool, scheduled: bool, diff_flag: bool, email: str | None, password: str | None, ) -> None: """Download the machine list Excel export from mycantaloupe.com. Authenticates with your Cantaloupe account and downloads the VueMachineList ExcelExport file, reporting file size, row count, and elapsed time on completion. Use --diff to auto-compare with the previous export after download. """ # Resolve credentials (CLI flags > env vars > config file) resolved_email = email or ctx.obj.get("email", "") resolved_password = password or ctx.obj.get("password", "") # If still missing and not in scheduled mode, prompt interactively if (not resolved_email or not resolved_password) and not scheduled: click.echo("Cantaloupe credentials not found.") resolved_email, resolved_password = prompt_credentials() if resolved_email and resolved_password: # Offer to save for next time if click.confirm("Save credentials to ~/.cantaloupe.env?", default=True): save_credentials(resolved_email, resolved_password) click.echo(f"Credentials saved to ~/.cantaloupe.env") else: click.echo( "No credentials provided. Set CANTALOUPE_EMAIL / CANTALOUPE_PASSWORD\n" "environment variables, create ~/.cantaloupe.env, or run\n" " python -m cantaloupe configure", err=True, ) raise SystemExit(1) if not resolved_email or not resolved_password: click.echo( "ERROR: Cantaloupe credentials not found.\n" "Set CANTALOUPE_EMAIL / CANTALOUPE_PASSWORD env vars, or\n" "run interactively once to save to ~/.cantaloupe.env", err=True, ) raise SystemExit(1) # Push credentials into the environment so src.auth / src.download # can pick them up transparently. os.environ["CANTALOUPE_EMAIL"] = resolved_email os.environ["CANTALOUPE_PASSWORD"] = resolved_password # Resolve output path out_dir = _resolve_output(output, scheduled) if scheduled: click.echo("[scheduled] Running in non-interactive mode") click.echo(f"Output directory: {out_dir}") click.echo("Authenticating with mycantaloupe.com...") # Import download module (lazy, avoids import-time side effects) from src.download import ExportError, download start = time.monotonic() try: out_path = download(output=str(out_dir)) except ExportError as e: click.echo(f"ERROR: {e}", err=True) raise SystemExit(1) except KeyboardInterrupt: click.echo("\nCancelled by user") raise SystemExit(130) elapsed = time.monotonic() - start # ── report ────────────────────────────────────────────────────────── file_size = out_path.stat().st_size row_count = _count_excel_rows(out_path) click.echo() click.echo("Export complete! ✓") click.echo(f" File : {out_path}") click.echo(f" Size : {_format_size(file_size)}") if row_count: click.echo(f" Rows : {row_count:,}") click.echo(f" Time : {elapsed:.1f}s") # ── record to manifest ───────────────────────────────────────────── from cantaloupe.diff import record_export # Snapshot the previous export *before* recording this one, so # we diff against the run-before-last, not this run. previous_path: Path | None = None if diff_flag: from cantaloupe.diff import get_previous_export previous_path = get_previous_export() record_export(out_path, row_count, file_size) # ── diff against previous export ──────────────────────────────────── if diff_flag: if previous_path is None: click.echo("\n⚠ No previous export found — skipping diff.") elif previous_path.resolve() == out_path.resolve(): click.echo("\n⚠ Previous export is the same file — skipping diff.") else: try: from cantaloupe.diff import diff_excels result = diff_excels(previous_path, out_path) _print_diff_result(result, previous_path.name, out_path.name) except Exception as e: click.echo(f"\n⚠ Diff failed: {e}", err=True) # ── configure command ─────────────────────────────────────────────────────── @cli.command() @click.option( "--email", prompt="Cantaloupe email", help="Your mycantaloupe.com account email.", ) @click.option( "--password", prompt="Cantaloupe password", hide_input=True, confirmation_prompt=False, help="Your mycantaloupe.com account password.", ) def configure(email: str, password: str) -> None: """Save Cantaloupe credentials to ~/.cantaloupe.env. The file is stored with restrictive permissions (owner read/write only) so other users on the system cannot read your password. To update credentials, run this command again. """ save_credentials(email, password) click.echo(f"✓ Credentials saved to ~/.cantaloupe.env") click.echo(f" You can now run: python -m cantaloupe export") # ── diff command ───────────────────────────────────────────────────────────── @cli.command() @click.option( "--from", "from_path", default=None, help="Path to the older xlsx file (default: second-most-recent export).", ) @click.option( "--to", "to_path", default=None, help="Path to the newer xlsx file (default: most recent export).", ) @click.option( "--id-column", default=None, help="Column name to use as unique machine identifier (auto-detected if omitted).", ) @click.option( "--json", "json_output", is_flag=True, help="Output the diff result as JSON instead of pretty-printed text.", ) def diff( from_path: str | None, to_path: str | None, id_column: str | None, json_output: bool, ) -> None: """Compare two machine list Excel exports and show what changed. If --from and --to are omitted, the two most recent exports are used (tracked via ~/.cantaloupe-exports/manifest.json). Reports machines added, removed, and changed — keyed on the Asset ID column (auto-detected, or specified with --id-column). """ from cantaloupe.diff import diff_excels, get_latest_exports # Resolve file paths if from_path and to_path: f_path = Path(os.path.expanduser(from_path)).resolve() t_path = Path(os.path.expanduser(to_path)).resolve() elif from_path or to_path: raise click.UsageError( "Specify both --from and --to, or neither (to use latest exports)." ) else: latest = get_latest_exports(2) if len(latest) < 2: click.echo( "ERROR: Need at least two exports to diff. " "Run an export first, or specify --from and --to explicitly.", err=True, ) raise SystemExit(1) f_path = latest[1] # older t_path = latest[0] # newer if not f_path.exists(): click.echo(f"ERROR: File not found: {f_path}", err=True) raise SystemExit(1) if not t_path.exists(): click.echo(f"ERROR: File not found: {t_path}", err=True) raise SystemExit(1) try: result = diff_excels(f_path, t_path, id_column=id_column) except (ValueError, FileNotFoundError) as e: click.echo(f"ERROR: {e}", err=True) raise SystemExit(1) if json_output: import json click.echo(json.dumps(result, indent=2, default=str)) else: _print_diff_result(result, f_path.name, t_path.name) # ── direct invocation ─────────────────────────────────────────────────────── if __name__ == "__main__": cli()