""" Click CLI for cantaloupe export tool. Commands: export Download machine list Excel from mycantaloupe.com configure Save credentials to ~/.cantaloupe.env Usage: python -m cantaloupe export --output ./exports/ python -m cantaloupe export --scheduled --output /data/exports/ python -m cantaloupe configure """ import logging import os import sys import time from pathlib import Path 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 # ── 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( "--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, 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. """ # 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") # ── 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") # ── direct invocation ─────────────────────────────────────────────────────── if __name__ == "__main__": cli()