e408867aac
- New cantaloupe/diff.py: diff engine comparing xlsx by Asset ID, manifest tracking at ~/.cantaloupe-exports/manifest.json - New subcommand: python -m cantaloupe diff [--from FILE] [--to FILE] - Export --diff flag: auto-compare with previous export after download - Every export auto-records to manifest for history tracking - 44 new tests, 130/130 pass total
443 lines
15 KiB
Python
443 lines
15 KiB
Python
"""
|
|
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()
|