feat: Click CLI wrapper for cantaloupe export

- cantaloupe/ package with click CLI (python -m cantaloupe export)
- export command: --output, --headless/--visible, --scheduled, --email, --password
- configure command: saves credentials to ~/.cantaloupe.env (600 perms)
- cantaloupe/config.py: credential loading from env vars + env file
- Updated src/auth.py _get_credentials() to also check ~/.cantaloupe.env
- File size, row count (openpyxl), and elapsed time reporting
- 39 new CLI/config tests + 4 updated auth tests; 86/86 pass
This commit is contained in:
2026-05-21 18:35:34 -04:00
parent b5ae89a490
commit 9c3d3ef7ed
7 changed files with 960 additions and 10 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Cantaloupe Downloader CLI - Export machine list from mycantaloupe.com."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
"""Entry point for python -m cantaloupe."""
from cantaloupe.cli import cli
cli()
+274
View File
@@ -0,0 +1,274 @@
"""
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()
+93
View File
@@ -0,0 +1,93 @@
"""
Credential management for cantaloupe CLI.
Reads credentials from:
1. CANTALOUPE_EMAIL / CANTALOUPE_PASSWORD environment variables
2. ~/.cantaloupe.env file (key=value format)
Saves credentials to ~/.cantaloupe.env with restrictive permissions (600).
"""
import os
from pathlib import Path
ENV_FILE = Path.home() / ".cantaloupe.env"
ENV_EMAIL_KEY = "CANTALOUPE_EMAIL"
ENV_PASSWORD_KEY = "CANTALOUPE_PASSWORD"
def load_credentials() -> tuple[str, str]:
"""Load credentials from env vars or ~/.cantaloupe.env.
Priority: environment variables override the env file.
Returns (email, password) — either or both may be empty strings.
"""
email = os.environ.get(ENV_EMAIL_KEY, "")
password = os.environ.get(ENV_PASSWORD_KEY, "")
# If both are set via env, use them
if email and password:
return email, password
# Check the env file
if ENV_FILE.exists():
for line in ENV_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key == ENV_EMAIL_KEY and not email:
email = value
elif key == ENV_PASSWORD_KEY and not password:
password = value
return email, password
def save_credentials(email: str, password: str) -> Path:
"""Save credentials to ~/.cantaloupe.env.
Updates the email/password lines in-place, preserving any other
configuration lines that may be present. Sets file permissions to 600.
Returns the path to the env file.
"""
ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
# Read existing lines, filtering out old credential entries
lines: list[str] = []
if ENV_FILE.exists():
for line in ENV_FILE.read_text().splitlines():
stripped = line.strip()
if stripped.startswith(f"{ENV_EMAIL_KEY}=") or stripped.startswith(
f"{ENV_PASSWORD_KEY}="
):
continue
lines.append(line)
# Append new credentials
lines.append(f"{ENV_EMAIL_KEY}={email}")
lines.append(f"{ENV_PASSWORD_KEY}={password}")
ENV_FILE.write_text("\n".join(lines) + "\n")
ENV_FILE.chmod(0o600)
return ENV_FILE
def prompt_credentials() -> tuple[str, str]:
"""Prompt interactively for credentials.
Uses input() for email and getpass() for password.
Returns (email, password) — empty strings if cancelled.
"""
try:
import getpass
email = input("Cantaloupe email: ").strip()
password = getpass.getpass("Cantaloupe password: ")
return email, password
except (EOFError, KeyboardInterrupt):
return "", ""