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:
@@ -0,0 +1,3 @@
|
||||
"""Cantaloupe Downloader CLI - Export machine list from mycantaloupe.com."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point for python -m cantaloupe."""
|
||||
|
||||
from cantaloupe.cli import cli
|
||||
|
||||
cli()
|
||||
@@ -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()
|
||||
@@ -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 "", ""
|
||||
+25
-2
@@ -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
|
||||
|
||||
|
||||
+12
-8
@@ -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()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user