Add diff tracking — compare exports between runs
- 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
This commit is contained in:
@@ -3,11 +3,15 @@ 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
|
||||
"""
|
||||
|
||||
@@ -16,6 +20,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
@@ -83,6 +88,54 @@ def _resolve_output(output: str | None, scheduled: bool) -> Path:
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -137,6 +190,12 @@ def cli(ctx: click.Context, verbose: bool) -> None:
|
||||
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,
|
||||
@@ -155,6 +214,7 @@ def export(
|
||||
output: str | None,
|
||||
headless: bool,
|
||||
scheduled: bool,
|
||||
diff_flag: bool,
|
||||
email: str | None,
|
||||
password: str | None,
|
||||
) -> None:
|
||||
@@ -163,6 +223,8 @@ def export(
|
||||
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", "")
|
||||
@@ -238,6 +300,32 @@ def export(
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -268,6 +356,86 @@ def configure(email: str, password: str) -> None:
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user