From 9c3d3ef7ed0d48084c6e1dd5c9ab0340b3fd1cd5 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 21 May 2026 18:35:34 -0400 Subject: [PATCH] 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 --- cantaloupe/__init__.py | 3 + cantaloupe/__main__.py | 5 + cantaloupe/cli.py | 274 +++++++++++++++++++++ cantaloupe/config.py | 93 +++++++ src/auth.py | 27 +- tests/test_auth.py | 20 +- tests/test_cli.py | 548 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 960 insertions(+), 10 deletions(-) create mode 100644 cantaloupe/__init__.py create mode 100644 cantaloupe/__main__.py create mode 100644 cantaloupe/cli.py create mode 100644 cantaloupe/config.py create mode 100644 tests/test_cli.py diff --git a/cantaloupe/__init__.py b/cantaloupe/__init__.py new file mode 100644 index 0000000..703396d --- /dev/null +++ b/cantaloupe/__init__.py @@ -0,0 +1,3 @@ +"""Cantaloupe Downloader CLI - Export machine list from mycantaloupe.com.""" + +__version__ = "0.1.0" diff --git a/cantaloupe/__main__.py b/cantaloupe/__main__.py new file mode 100644 index 0000000..cfdd290 --- /dev/null +++ b/cantaloupe/__main__.py @@ -0,0 +1,5 @@ +"""Entry point for python -m cantaloupe.""" + +from cantaloupe.cli import cli + +cli() diff --git a/cantaloupe/cli.py b/cantaloupe/cli.py new file mode 100644 index 0000000..78e19c3 --- /dev/null +++ b/cantaloupe/cli.py @@ -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() diff --git a/cantaloupe/config.py b/cantaloupe/config.py new file mode 100644 index 0000000..68252d7 --- /dev/null +++ b/cantaloupe/config.py @@ -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 "", "" diff --git a/src/auth.py b/src/auth.py index 9a733cb..9d76765 100644 --- a/src/auth.py +++ b/src/auth.py @@ -57,13 +57,36 @@ class AuthSession: def _get_credentials() -> tuple[str, str]: - """Read credentials from environment variables.""" + """Read credentials from environment variables or ~/.cantaloupe.env.""" email = os.environ.get("CANTALOUPE_EMAIL") password = os.environ.get("CANTALOUPE_PASSWORD") + + # If env vars are set, use them + if email and password: + return email, password + + # Fall back to ~/.cantaloupe.env + env_file = os.path.expanduser("~/.cantaloupe.env") + if os.path.exists(env_file): + for line in open(env_file).read().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 == "CANTALOUPE_EMAIL" and not email: + email = value + elif key == "CANTALOUPE_PASSWORD" and not password: + password = value + if not email or not password: raise AuthenticationError( "Credentials not found. Set CANTALOUPE_EMAIL and CANTALOUPE_PASSWORD " - "environment variables." + "environment variables, or create ~/.cantaloupe.env with:\n" + " CANTALOUPE_EMAIL=you@example.com\n" + " CANTALOUPE_PASSWORD=your_password\n" + "Or run: python -m cantaloupe configure" ) return email, password diff --git a/tests/test_auth.py b/tests/test_auth.py index b1f98ef..5531e77 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -34,20 +34,23 @@ def test_get_credentials_from_env(): def test_get_credentials_missing_email(): with patch.dict(os.environ, {"CANTALOUPE_PASSWORD": "pw"}, clear=True): - with pytest.raises(AuthenticationError, match="Credentials not found"): - _get_credentials() + with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"): + with pytest.raises(AuthenticationError, match="Credentials not found"): + _get_credentials() def test_get_credentials_missing_password(): with patch.dict(os.environ, {"CANTALOUPE_EMAIL": "u@test.com"}, clear=True): - with pytest.raises(AuthenticationError, match="Credentials not found"): - _get_credentials() + with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"): + with pytest.raises(AuthenticationError, match="Credentials not found"): + _get_credentials() def test_get_credentials_none_set(): with patch.dict(os.environ, {}, clear=True): - with pytest.raises(AuthenticationError, match="Credentials not found"): - _get_credentials() + with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"): + with pytest.raises(AuthenticationError, match="Credentials not found"): + _get_credentials() # ───────────────────────────────────────────────── @@ -230,8 +233,9 @@ def test_login_with_custom_client(): def test_login_missing_credentials(): with patch.dict(os.environ, {}, clear=True): - with pytest.raises(AuthenticationError, match="Credentials not found"): - login() + with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"): + with pytest.raises(AuthenticationError, match="Credentials not found"): + login() # ───────────────────────────────────────────────── diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..297e288 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,548 @@ +""" +Tests for cantaloupe CLI and config modules. + +Covers: + - config module: load_credentials, save_credentials, prompt_credentials + - cli module: export command, configure command, --scheduled, --headless/--visible + - auth.py: _get_credentials env-file fallback (integration with config) +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from cantaloupe.cli import cli, _count_excel_rows, _format_size, _resolve_output +from cantaloupe.config import load_credentials, save_credentials + + +# ───────────────────────────────────────────────────────────────── +# config module +# ───────────────────────────────────────────────────────────────── + + +def test_load_credentials_from_env(): + """load_credentials prefers env vars over env file.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "env@test.com", "CANTALOUPE_PASSWORD": "envpass"}, + ): + email, password = load_credentials() + assert email == "env@test.com" + assert password == "envpass" + + +def test_load_credentials_from_file(tmp_path): + """load_credentials falls back to env file when env vars are absent.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "CANTALOUPE_EMAIL=file@test.com\nCANTALOUPE_PASSWORD=filepass\n" + ) + + with patch.dict(os.environ, {}, clear=True): + with patch("cantaloupe.config.ENV_FILE", env_file): + email, password = load_credentials() + assert email == "file@test.com" + assert password == "filepass" + + +def test_load_credentials_missing_both(): + """load_credentials returns empty strings when nothing is set.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object(Path, "exists", return_value=False): + email, password = load_credentials() + assert email == "" + assert password == "" + + +def test_load_credentials_env_overrides_file(tmp_path): + """env vars take priority over the env file.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "CANTALOUPE_EMAIL=file@test.com\nCANTALOUPE_PASSWORD=filepass\n" + ) + + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "env@test.com"}, + ): + with patch("cantaloupe.config.ENV_FILE", env_file): + email, password = load_credentials() + assert email == "env@test.com" # env overrides file + assert password == "filepass" # file fallback for password + + +def test_load_credentials_file_with_comments_and_quotes(tmp_path): + """load_credentials handles comments and quoted values.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "# This is a comment\n" + 'CANTALOUPE_EMAIL="quoted@test.com"\n' + "CANTALOUPE_PASSWORD='singlequoted'\n" + "# Another comment" + ) + + with patch.dict(os.environ, {}, clear=True): + with patch("cantaloupe.config.ENV_FILE", env_file): + email, password = load_credentials() + assert email == "quoted@test.com" + assert password == "singlequoted" + + +def test_save_credentials_creates_file(tmp_path): + """save_credentials writes the env file with correct permissions.""" + env_file = tmp_path / ".cantaloupe.env" + + with patch("cantaloupe.config.ENV_FILE", env_file): + result = save_credentials("new@test.com", "newpass") + assert result == env_file + assert env_file.exists() + + content = env_file.read_text() + assert "CANTALOUPE_EMAIL=new@test.com" in content + assert "CANTALOUPE_PASSWORD=newpass" in content + + +def test_save_credentials_preserves_other_lines(tmp_path): + """save_credentials updates email/password lines, preserves everything else.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "# Config file\n" + "SOME_OTHER_KEY=value\n" + "CANTALOUPE_EMAIL=old@test.com\n" + "CANTALOUPE_PASSWORD=oldpass\n" + ) + + with patch("cantaloupe.config.ENV_FILE", env_file): + save_credentials("updated@test.com", "updpw") + content = env_file.read_text() + assert "CANTALOUPE_EMAIL=updated@test.com" in content + assert "CANTALOUPE_PASSWORD=updpw" in content + assert "CANTALOUPE_EMAIL=old@test.com" not in content + assert "CANTALOUPE_PASSWORD=oldpass" not in content + assert "SOME_OTHER_KEY=value" in content # preserved + + +def test_save_credentials_empty_password(): + """save_credentials handles empty password (allowed).""" + env_file = Path(tempfile.mkdtemp()) / ".cantaloupe.env" + + with patch("cantaloupe.config.ENV_FILE", env_file): + save_credentials("e@test.com", "") + content = env_file.read_text() + assert "CANTALOUPE_EMAIL=e@test.com" in content + assert "CANTALOUPE_PASSWORD=" in content + + +# ───────────────────────────────────────────────────────────────── +# _format_size +# ───────────────────────────────────────────────────────────────── + + +def test_format_size_bytes(): + assert _format_size(0) == "0.0 B" + assert _format_size(500) == "500.0 B" + assert _format_size(1023) == "1023.0 B" + + +def test_format_size_kb(): + assert _format_size(1024) == "1.0 KB" + assert _format_size(1536) == "1.5 KB" + + +def test_format_size_mb(): + assert _format_size(1024 * 1024) == "1.0 MB" + assert _format_size(int(2.5 * 1024 * 1024)) == "2.5 MB" + + +def test_format_size_gb(): + assert _format_size(1024 * 1024 * 1024) == "1.0 GB" + + +def test_format_size_large(): + """1 TB = 1024 GB.""" + one_tb = 1024 * 1024 * 1024 * 1024 + assert _format_size(one_tb) == "1.0 TB" + + +# ───────────────────────────────────────────────────────────────── +# _count_excel_rows +# ───────────────────────────────────────────────────────────────── + + +def test_count_excel_rows_no_file(): + """Returns 0 when file doesn't exist.""" + with patch("openpyxl.load_workbook", side_effect=FileNotFoundError): + result = _count_excel_rows(Path("/nonexistent.xlsx")) + assert result == 0 + + +def test_count_excel_rows_empty_sheet(): + """Returns 0 when sheet has only header row.""" + mock_wb = MagicMock() + mock_ws = MagicMock() + mock_ws.max_row = 1 # just header + mock_wb.active = mock_ws + mock_wb.close = MagicMock() + + with patch("openpyxl.load_workbook", return_value=mock_wb): + result = _count_excel_rows(Path("/fake.xlsx")) + assert result == 0 + + +def test_count_excel_rows_with_data(): + """Subtracts header row from total rows.""" + mock_wb = MagicMock() + mock_ws = MagicMock() + mock_ws.max_row = 51 # 1 header + 50 data rows + mock_wb.active = mock_ws + mock_wb.close = MagicMock() + + with patch("openpyxl.load_workbook", return_value=mock_wb): + result = _count_excel_rows(Path("/fake.xlsx")) + assert result == 50 + + +def test_count_excel_rows_none_active(): + """Returns 0 when workbook.active is None.""" + mock_wb = MagicMock() + mock_wb.active = None + mock_wb.close = MagicMock() + + with patch("openpyxl.load_workbook", return_value=mock_wb): + result = _count_excel_rows(Path("/fake.xlsx")) + assert result == 0 + + +# ───────────────────────────────────────────────────────────────── +# _resolve_output +# ───────────────────────────────────────────────────────────────── + + +def test_resolve_output_interactive_default(tmp_path, monkeypatch): + """Interactive mode defaults to cwd.""" + monkeypatch.chdir(tmp_path) + result = _resolve_output(None, scheduled=False) + assert result == tmp_path + + +def test_resolve_output_scheduled_default(tmp_path): + """Scheduled mode defaults to ~/cantaloupe-exports.""" + home = tmp_path / "home" / "testuser" + home.mkdir(parents=True) + with patch("cantaloupe.cli.Path.home", return_value=home): + result = _resolve_output(None, scheduled=True) + assert result == home / "cantaloupe-exports" + + +def test_resolve_output_explicit_path(tmp_path): + """Explicit output path is used as-is.""" + out = tmp_path / "my-exports" + result = _resolve_output(str(out), scheduled=False) + assert result == out + assert out.exists() # created + + +def test_resolve_output_file_path(tmp_path): + """File path with extension — parent is created.""" + out = tmp_path / "deep" / "nested" / "file.xlsx" + result = _resolve_output(str(out), scheduled=True) + assert result == out + assert out.parent.exists() + + +# ───────────────────────────────────────────────────────────────── +# CLI via Click test runner +# ───────────────────────────────────────────────────────────────── + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_env_file(tmp_path): + """Create a temporary env file with test credentials.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "CANTALOUPE_EMAIL=cli@test.com\nCANTALOUPE_PASSWORD=clipw\n" + ) + return env_file + + +# ── configure command ── + + +def test_configure_saves_credentials(runner, tmp_path): + """configure command writes to ~/.cantaloupe.env.""" + env_file = tmp_path / ".cantaloupe.env" + with patch("cantaloupe.config.ENV_FILE", env_file): + result = runner.invoke( + cli, + ["configure"], + input="user@example.com\nsecret123\n", + ) + assert result.exit_code == 0 + assert "Credentials saved" in result.output + content = env_file.read_text() + assert "CANTALOUPE_EMAIL=user@example.com" in content + assert "CANTALOUPE_PASSWORD=secret123" in content + + +# ── export command ── + + +def test_export_no_credentials_interactive(runner, tmp_path): + """export prompts for credentials when none found.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object(Path, "exists", return_value=False): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "test.xlsx" + mock_path.write_bytes(b"fake xlsx data") + mock_download.return_value = mock_path + + result = runner.invoke( + cli, + ["export", "-o", str(tmp_path)], + input="user@example.com\nsecret123\ny\n", + ) + + assert result.exit_code == 0 + assert "Cantaloupe credentials not found" in result.output + + +def test_export_no_credentials_scheduled(runner): + """export in scheduled mode exits with error when no credentials.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object(Path, "exists", return_value=False): + result = runner.invoke(cli, ["export", "--scheduled"]) + assert result.exit_code == 1 + assert "ERROR" in result.output or "credentials" in result.output.lower() + + +def test_export_with_env_vars(runner, tmp_path): + """export picks up credentials from environment variables.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "env@test.com", "CANTALOUPE_PASSWORD": "envpw"}, + ): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "export.xlsx" + mock_path.write_bytes(b"xlsx content") + mock_download.return_value = mock_path + + result = runner.invoke(cli, ["export", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "Export complete" in result.output + + +def test_export_with_env_file(runner, mock_env_file, tmp_path): + """export picks up credentials from ~/.cantaloupe.env.""" + with patch.dict(os.environ, {}, clear=True): + with patch("cantaloupe.config.ENV_FILE", mock_env_file): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "export.xlsx" + mock_path.write_bytes(b"data") + mock_download.return_value = mock_path + + result = runner.invoke(cli, ["export", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "Export complete" in result.output + + +def test_export_with_cli_flags(runner, tmp_path): + """--email and --password CLI flags override everything else.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object(Path, "exists", return_value=False): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "export.xlsx" + mock_path.write_bytes(b"data") + mock_download.return_value = mock_path + + result = runner.invoke( + cli, + [ + "export", + "--email", "flag@test.com", + "--password", "flagpw", + "-o", str(tmp_path), + ], + ) + assert result.exit_code == 0 + assert "Export complete" in result.output + + +def test_export_scheduled_flag(runner, tmp_path): + """export --scheduled runs in non-interactive mode.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "cron@test.com", "CANTALOUPE_PASSWORD": "cronpw"}, + ): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "export.xlsx" + mock_path.write_bytes(b"data") + mock_download.return_value = mock_path + + result = runner.invoke(cli, ["export", "--scheduled", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "[scheduled]" in result.output + assert "Export complete" in result.output + + +def test_export_shows_file_stats(runner, tmp_path): + """export reports file size and row count on completion.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "u@t.com", "CANTALOUPE_PASSWORD": "p"}, + ): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "export.xlsx" + mock_path.write_bytes(b"x" * 2048) # exactly 2 KB + mock_download.return_value = mock_path + + with patch("cantaloupe.cli._count_excel_rows", return_value=42): + result = runner.invoke(cli, ["export", "-o", str(tmp_path)]) + assert result.exit_code == 0 + assert "2.0 KB" in result.output + assert "42" in result.output + assert "Export complete" in result.output + + +def test_export_download_error(runner): + """export handles ExportError gracefully.""" + from src.download import ExportError + + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "u@t.com", "CANTALOUPE_PASSWORD": "p"}, + ): + with patch("src.download.download", side_effect=ExportError("simulated failure")): + result = runner.invoke(cli, ["export", "-o", "/tmp"]) + assert result.exit_code == 1 + assert "ERROR" in result.output or "simulated failure" in result.output + + +def test_export_headless_flag(runner, tmp_path): + """--headless and --visible flags are accepted (for Playwright fallback).""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "u@t.com", "CANTALOUPE_PASSWORD": "p"}, + ): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "test.xlsx" + mock_path.write_bytes(b"fake") + mock_download.return_value = mock_path + result = runner.invoke(cli, ["export", "--headless", "-o", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_export_visible_flag(runner, tmp_path): + """--visible flag is accepted.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "u@t.com", "CANTALOUPE_PASSWORD": "p"}, + ): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "test.xlsx" + mock_path.write_bytes(b"fake") + mock_download.return_value = mock_path + result = runner.invoke(cli, ["export", "--visible", "-o", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_export_verbose_flag(runner, tmp_path): + """-v / --verbose enables debug logging.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "u@t.com", "CANTALOUPE_PASSWORD": "p"}, + ): + with patch("src.download.download") as mock_download: + mock_path = tmp_path / "export.xlsx" + mock_path.write_bytes(b"data") + mock_download.return_value = mock_path + + result = runner.invoke(cli, ["-v", "export", "-o", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_export_keyboard_interrupt(runner, tmp_path): + """export exits cleanly on Ctrl+C.""" + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "u@t.com", "CANTALOUPE_PASSWORD": "p"}, + ): + with patch("src.download.download", side_effect=KeyboardInterrupt): + result = runner.invoke(cli, ["export", "-o", str(tmp_path)]) + assert result.exit_code == 130 + assert "Cancelled" in result.output + + +# ───────────────────────────────────────────────────────────────── +# auth.py: _get_credentials env-file fallback +# ───────────────────────────────────────────────────────────────── + +from src.auth import _get_credentials, AuthenticationError + + +def test_get_credentials_from_env_file(tmp_path): + """_get_credentials reads from ~/.cantaloupe.env when env vars are absent.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "CANTALOUPE_EMAIL=file@auth.com\nCANTALOUPE_PASSWORD=filepw\n" + ) + + with patch.dict(os.environ, {}, clear=True): + with patch("src.auth.os.path.expanduser", return_value=str(env_file)): + email, password = _get_credentials() + assert email == "file@auth.com" + assert password == "filepw" + + +def test_get_credentials_env_overrides_file(tmp_path): + """env vars take priority over the env file in auth.py.""" + env_file = tmp_path / ".cantaloupe.env" + env_file.write_text( + "CANTALOUPE_EMAIL=file@auth.com\nCANTALOUPE_PASSWORD=filepw\n" + ) + + with patch.dict( + os.environ, + {"CANTALOUPE_EMAIL": "env@auth.com", "CANTALOUPE_PASSWORD": "envpw"}, + ): + with patch("src.auth.os.path.expanduser", return_value=str(env_file)): + email, password = _get_credentials() + assert email == "env@auth.com" + assert password == "envpw" + + +def test_get_credentials_missing_both_still_raises(): + """_get_credentials raises AuthenticationError when nothing is set.""" + with patch.dict(os.environ, {}, clear=True): + with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"): + with pytest.raises(AuthenticationError, match="Credentials not found"): + _get_credentials() + + +# ───────────────────────────────────────────────────────────────── +# CLI version +# ───────────────────────────────────────────────────────────────── + + +def test_cli_version(runner): + """--version prints the version string.""" + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output + + +def test_cli_help(runner): + """--help prints usage info.""" + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Cantaloupe Downloader" in result.output + assert "export" in result.output + assert "configure" in result.output