From 3c6422d59cd380dfee8948ae92b5f21ce803421d Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 23 Apr 2026 12:49:40 -0500 Subject: [PATCH 01/11] initial commit --- cwmscli/__main__.py | 2 + cwmscli/commands/env.py | 457 +++++++++++++++++++++++++++++++++++++ docs/ENV_MANAGER.md | 80 +++++++ tests/commands/test_env.py | 86 +++++++ 4 files changed, 625 insertions(+) create mode 100644 cwmscli/commands/env.py create mode 100644 docs/ENV_MANAGER.md create mode 100644 tests/commands/test_env.py diff --git a/cwmscli/__main__.py b/cwmscli/__main__.py index e97e6b9..99811ad 100644 --- a/cwmscli/__main__.py +++ b/cwmscli/__main__.py @@ -7,6 +7,7 @@ from click.core import ParameterSource from cwmscli.commands import commands_cwms +from cwmscli.commands.env import env_group from cwmscli.load import __main__ as load from cwmscli.usgs import usgs_group from cwmscli.utils.click_help import add_version_to_help_tree @@ -88,6 +89,7 @@ def cli( cli.add_command(commands_cwms.blob_group) cli.add_command(commands_cwms.clob_group) cli.add_command(commands_cwms.users_group) +cli.add_command(env_group) cli.add_command(load.load_group) add_version_to_help_tree(cli) diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py new file mode 100644 index 0000000..0c86916 --- /dev/null +++ b/cwmscli/commands/env.py @@ -0,0 +1,457 @@ +import logging +import os +import sys +from pathlib import Path +from typing import Dict, Optional, Tuple + +import click + + +def get_envs_dir() -> Path: + """Get the directory where environment files are stored.""" + if sys.platform == "win32": + base_dir = Path(os.environ.get("APPDATA", "~/.config")).expanduser() + else: + base_dir = Path("~/.config").expanduser() + + envs_dir = base_dir / "cwms-cli" / "envs" + return envs_dir + + +def read_env_file(env_file: Path) -> Dict[str, str]: + """Read a .env file and return a dictionary of key-value pairs.""" + env_vars = {} + if not env_file.exists(): + return env_vars + + with open(env_file, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + env_vars[key.strip()] = value.strip() + + return env_vars + + +def write_env_file(env_file: Path, env_vars: Dict[str, str]) -> None: + """Write environment variables to a .env file.""" + env_file.parent.mkdir(parents=True, exist_ok=True) + + with open(env_file, "w") as f: + for key, value in env_vars.items(): + f.write(f"{key}={value}\n") + + if sys.platform != "win32": + os.chmod(env_file, 0o600) + + +ENV_DEFAULTS = { + "cwbi-prod": "https://cwms-data.usace.army.mil/cwms-data", + "localhost": "http://localhost:8082/cwms-data", +} + + +def detect_shell() -> Tuple[str, Optional[Path]]: + """Detect user's shell and return (shell_name, rc_file_path).""" + shell = os.environ.get("SHELL", "") + home = Path.home() + + if "zsh" in shell: + return ("zsh", home / ".zshrc") + elif "bash" in shell: + return ("bash", home / ".bashrc") + + return ("unknown", None) + + +def is_function_installed(rc_file: Path) -> bool: + """Check if cwms-env function is already installed in rc file.""" + if not rc_file.exists(): + return False + + content = rc_file.read_text() + return "# cwms-cli environment switcher" in content + + +def install_posix_function(rc_file: Path) -> None: + """Append cwms-env function to shell rc file.""" + function_block = """ +# cwms-cli environment switcher (added by cwms-cli) +cwms-env() { eval $(cwms-cli --quiet env activate "$@"); } +""" + + with open(rc_file, "a") as f: + f.write(function_block) + + +def get_windows_batch_dir() -> Path: + """Get directory for Windows batch files.""" + # Try %USERPROFILE%\bin first, fallback to config dir + user_bin = Path.home() / "bin" + if user_bin.exists(): + return user_bin + + # Use cwms-cli config directory + appdata = Path(os.environ.get("APPDATA", "~/.config")).expanduser() + return appdata / "cwms-cli" / "bin" + + +def install_windows_batch(batch_dir: Path) -> Path: + """Create cwms-env.bat batch file.""" + batch_dir.mkdir(parents=True, exist_ok=True) + batch_file = batch_dir / "cwms-env.bat" + + batch_content = """@echo off +for /f "delims=" %%i in ('cwms-cli --quiet env activate %*') do %%i +""" + + batch_file.write_text(batch_content) + return batch_file + + +def is_in_path(directory: Path) -> bool: + """Check if directory is in PATH.""" + path_env = os.environ.get("PATH", "") + path_dirs = path_env.split(os.pathsep) + dir_str = str(directory.resolve()) + + return any(str(Path(p).resolve()) == dir_str for p in path_dirs) + + +def add_to_user_path_windows(directory: Path) -> bool: + """Add directory to Windows user PATH via registry.""" + try: + import winreg + + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Environment", + 0, + winreg.KEY_READ | winreg.KEY_WRITE, + ) + + try: + current_path, _ = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + current_path = "" + + dir_str = str(directory) + if dir_str not in current_path.split(os.pathsep): + new_path = ( + f"{current_path}{os.pathsep}{dir_str}" if current_path else dir_str + ) + winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_path) + + winreg.CloseKey(key) + return True + + except Exception: + return False + + +@click.group("env", help="Manage CDA environments and API keys") +def env_group(): + """Environment management commands for cwms-cli.""" + pass + + +@env_group.command("setup", help="Create or update an environment configuration") +@click.argument("env_name") +@click.option( + "--api-root", + help="CDA API root URL (e.g., https://example.mil/cwms-data)", +) +@click.option( + "--api-key", + help="API key for authentication", +) +@click.option( + "--office", + help="Default office code (e.g., SWT)", +) +def setup_cmd( + env_name: str, + api_root: Optional[str], + api_key: Optional[str], + office: Optional[str], +): + """ + Create or update environment configuration. + + ENV_NAME can be: cwbi-dev, cwbi-test, cwbi-prod, onsite, localhost, or custom + """ + envs_dir = get_envs_dir() + env_file = envs_dir / f"{env_name}.env" + + existing_vars = read_env_file(env_file) if env_file.exists() else {} + + env_vars = existing_vars.copy() + env_vars["ENVIRONMENT"] = env_name + + if api_root: + env_vars["CDA_API_ROOT"] = api_root + elif "CDA_API_ROOT" not in env_vars and env_name in ENV_DEFAULTS: + env_vars["CDA_API_ROOT"] = ENV_DEFAULTS[env_name] + + if api_key: + env_vars["CDA_API_KEY"] = api_key + + if office: + env_vars["OFFICE"] = office.upper() + + if "CDA_API_ROOT" not in env_vars: + click.echo( + f"Error: --api-root is required for '{env_name}' (not a default environment)", + err=True, + ) + click.echo(f"Available defaults: {', '.join(ENV_DEFAULTS.keys())}", err=True) + sys.exit(1) + + write_env_file(env_file, env_vars) + + logging.info(f"Environment '{env_name}' configured at: {env_file}") + if api_key: + logging.info("API key stored securely (file permissions set to 600)") + + +@env_group.command("show", help="Show current environment and available configurations") +def show_cmd(): + """Display current environment and list all configured environments.""" + current_env = os.environ.get("ENVIRONMENT") + + if current_env: + click.echo(f"Current environment: {click.style(current_env, fg='green')}") + else: + click.echo("No environment currently active") + + envs_dir = get_envs_dir() + if not envs_dir.exists(): + click.echo(f"\nNo environments configured yet. Directory: {envs_dir}") + click.echo("Run 'cwms-cli env setup ' to create one.") + return + + env_files = sorted(envs_dir.glob("*.env")) + + if not env_files: + click.echo(f"\nNo environments found in: {envs_dir}") + click.echo("Run 'cwms-cli env setup ' to create one.") + return + + click.echo(f"\nAvailable environments in {envs_dir}:") + for env_file in env_files: + env_name = env_file.stem + is_current = env_name == current_env + marker = " (active)" if is_current else "" + click.echo(f" - {env_name}{marker}") + + if current_env: + current_file = envs_dir / f"{current_env}.env" + if current_file.exists(): + click.echo(f"\nCurrent environment values:") + env_vars = read_env_file(current_file) + for key, value in sorted(env_vars.items()): + if "KEY" in key.upper() and value: + display_value = value[:8] + "..." if len(value) > 8 else "***" + else: + display_value = value + click.echo(f" {key}={display_value}") + + +@env_group.command("install", help="Install cwms-env shell helper") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +def install_cmd(yes: bool): + """ + Install shell helper for activating environments. + + On Linux/Mac: Adds cwms-env() function to shell rc file + On Windows: Creates cwms-env.bat and adds to PATH + """ + + if sys.platform == "win32": + # Windows: Install batch file + batch_dir = get_windows_batch_dir() + batch_file = batch_dir / "cwms-env.bat" + + if batch_file.exists(): + click.echo(f"✓ Already installed: {batch_file}") + return + + click.echo(f"Will create: {batch_file}") + if not is_in_path(batch_dir): + click.echo(f"Will add to PATH: {batch_dir}") + + if not yes and not click.confirm("Continue?"): + click.echo("Installation cancelled.") + return + + # Install + installed_path = install_windows_batch(batch_dir) + click.echo(f"✓ Created: {installed_path}") + + # Update PATH + if not is_in_path(batch_dir): + if add_to_user_path_windows(batch_dir): + click.echo("✓ Added to PATH") + click.echo("\n⚠ Restart your terminal for PATH changes to take effect") + else: + click.echo("✗ Could not update PATH automatically") + click.echo(f"\nManually add to your PATH: {batch_dir}") + + click.echo("\nYou can now use: cwms-env ") + + else: + # Linux/Mac: Install shell function + shell_name, rc_file = detect_shell() + + if not rc_file: + click.echo("✗ Could not detect shell type") + click.echo("\nManually add to your shell rc file:") + click.echo(' cwms-env() { eval $(cwms-cli --quiet env activate "$@"); }') + return + + if is_function_installed(rc_file): + click.echo(f"✓ Already installed in: {rc_file}") + return + + click.echo(f"Will add cwms-env() function to: {rc_file}") + + if not yes and not click.confirm("Continue?"): + click.echo("Installation cancelled.") + return + + # Install + install_posix_function(rc_file) + click.echo(f"✓ Added cwms-env() function to: {rc_file}") + click.echo(f"\nReload your shell: source {rc_file}") + click.echo("Then use: cwms-env ") + + +@env_group.command( + "activate", help="Generate shell export commands to activate an environment" +) +@click.argument("env_name") +@click.option( + "--force", + is_flag=True, + hidden=True, + help="Skip safety warnings (use with caution)", +) +@click.pass_context +def activate_cmd(ctx: click.Context, env_name: str, force: bool): + """ + Output shell commands to activate an environment. + + RECOMMENDED WORKFLOW: + 1. First install the shell helper: + cwms-cli env install + + 2. Then switch environments using: + cwms-env + + ADVANCED USAGE: + Direct eval (not recommended for environments with API keys): + eval $(cwms-cli env activate cwbi-dev) + """ + envs_dir = get_envs_dir() + env_file = envs_dir / f"{env_name}.env" + + if not env_file.exists(): + click.echo( + f"# Error: Environment '{env_name}' not found at: {env_file}", + err=True, + ) + click.echo( + "# Run 'cwms-cli env show' to see available environments", + err=True, + ) + click.echo( + f"# Or run 'cwms-cli env setup {env_name}' to create it", + err=True, + ) + sys.exit(1) + + env_vars = read_env_file(env_file) + + # Security warning: Check if environment contains sensitive data and stdout is a TTY + has_api_key = any("KEY" in key.upper() for key in env_vars.keys()) + if has_api_key and sys.stdout.isatty() and not force: + click.echo( + click.style("\n⚠️ SECURITY WARNING", fg="red", bold=True), + err=True, + ) + click.echo( + click.style( + "This environment contains API keys that will be printed to your terminal.", + fg="yellow", + ), + err=True, + ) + click.echo( + "Running this command without 'eval' or the shell wrapper can expose secrets in:", + err=True, + ) + click.echo(" • Terminal history", err=True) + click.echo(" • Script logs", err=True) + click.echo(" • Screen recordings or screenshots", err=True) + click.echo("", err=True) + click.echo( + click.style("Recommended usage:", fg="green", bold=True), + err=True, + ) + click.echo( + f" {click.style(f'eval $(cwms-cli --quiet env activate {env_name})', bold=True)}", + err=True, + ) + click.echo("", err=True) + click.echo( + f"Or install the shell wrapper: {click.style('cwms-cli env install', bold=True)}", + err=True, + ) + click.echo( + f"and run: {click.style(f'cwms-env {env_name}', bold=True)}", + err=True, + ) + + click.echo("", err=True) + if not click.confirm( + click.style("Continue anyway?", fg="red"), + default=False, + err=True, + ): + click.echo("Activation cancelled.", err=True) + sys.exit(1) + + # Show helpful message to stderr (won't interfere with eval) unless logging is disabled + # We check if INFO level logging is active - quiet mode sets it to WARNING + if logging.getLogger().isEnabledFor(logging.INFO): + if sys.platform == "win32": + click.echo( + "# To activate: FOR /F %i IN ('cwms-cli --quiet env activate " + + env_name + + "') DO %i", + err=True, + ) + click.echo( + "# Or create cwms-env.bat with: @FOR /F %i IN ('cwms-cli --quiet env activate %*') DO %i", + err=True, + ) + else: + click.echo( + f"# To activate: eval $(cwms-cli --quiet env activate {env_name})", + err=True, + ) + click.echo( + '# Or add to ~/.bashrc: cwms-env() { eval $(cwms-cli --quiet env activate "$@"); }', + err=True, + ) + + # Output export commands to stdout (will be eval'd by shell) + if sys.platform == "win32": + for key, value in env_vars.items(): + click.echo(f"set {key}={value}") + else: + for key, value in env_vars.items(): + click.echo(f"export {key}={value}") diff --git a/docs/ENV_MANAGER.md b/docs/ENV_MANAGER.md new file mode 100644 index 0000000..6230555 --- /dev/null +++ b/docs/ENV_MANAGER.md @@ -0,0 +1,80 @@ +# Environment Manager + +Simple environment management for CDA environments. + +## Suggested Environments + +**Pre-configured (have default URLs):** +- `cwbi-prod` - Production CWBI (https://cwms-data.usace.army.mil/cwms-data) +- `localhost` - Local development server (http://localhost:8082/cwms-data) + +**Need --api-root:** +- `cwbi-dev` - Development CWBI +- `cwbi-test` - Test CWBI +- `onsite` - Local non-cloud server + +## Quick Start + +**1. Install shell helper (recommended):** +```bash +cwms-cli env install +``` + +**2. Setup environments:** +```bash +# Pre-configured environments (just add key/office) +cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY +cwms-cli env setup localhost --office SWT --api-key YOUR_KEY + +# Custom environments (need --api-root) +cwms-cli env setup cwbi-dev --api-root https://dev.example.mil/cwms-data --office SWT --api-key YOUR_KEY +``` + +**3. Switch environments:** +```bash +cwms-env cwbi-prod +cwms-env localhost +``` + +**4. View all environments:** +```bash +cwms-cli env show +``` + +## Shell Integration Details + +**Run once to install the `cwms-env` helper:** + +```bash +cwms-cli env install +``` + +**Restart your terminal** (or run `source ~/.bashrc` / `source ~/.zshrc`) for the `cwms-env` command to work. + +**What it does:** +- **Linux/Mac**: Adds function to `~/.bashrc` or `~/.zshrc` +- **Windows**: Creates `cwms-env.bat` in your PATH + +**Manual setup (if needed):** +- **Linux/Mac**: Add to shell config: `cwms-env() { eval $(cwms-cli --quiet env activate "$@"); }` +- **Windows**: Create `cwms-env.bat`: `@echo off` / `for /f "delims=" %%i in ('cwms-cli --quiet env activate %*') do %%i` + +## How It Works + +**Config files:** `~/.config/cwms-cli/envs/.env` (Linux/Mac) or `%APPDATA%\cwms-cli\envs\.env` (Windows) + +**Environment variables set:** +- `ENVIRONMENT` - Environment name +- `CDA_API_ROOT` - API root URL +- `CDA_API_KEY` - API key (if provided) +- `OFFICE` - Default office (if provided) + +**Usage with other commands:** +```bash +cwms-env cwbi-prod # Activate environment +cwms-cli blob list # Uses environment variables +cwms-cli users list # Uses environment variables + +# Command flags override environment variables +cwms-cli blob list --api-root https://other.mil/cwms-data +``` diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py new file mode 100644 index 0000000..b76dfe2 --- /dev/null +++ b/tests/commands/test_env.py @@ -0,0 +1,86 @@ +import os +from pathlib import Path + +import pytest + +from cwmscli.commands.env import get_envs_dir, read_env_file, write_env_file + + +def test_get_envs_dir_returns_path(): + envs_dir = get_envs_dir() + assert isinstance(envs_dir, Path) + assert "cwms-cli" in str(envs_dir) + assert "envs" in str(envs_dir) + + +def test_write_and_read_env_file(tmp_path): + env_file = tmp_path / "test.env" + env_vars = { + "ENVIRONMENT": "test", + "CDA_API_ROOT": "https://example.com/cwms-data", + "CDA_API_KEY": "secret123", + "OFFICE": "SWT", + } + + write_env_file(env_file, env_vars) + + assert env_file.exists() + read_vars = read_env_file(env_file) + + assert read_vars == env_vars + + +def test_read_env_file_skips_comments(tmp_path): + env_file = tmp_path / "test.env" + env_file.write_text( + "# This is a comment\n" + "ENVIRONMENT=test\n" + "\n" + "# Another comment\n" + "CDA_API_ROOT=https://example.com\n" + ) + + env_vars = read_env_file(env_file) + + assert env_vars == { + "ENVIRONMENT": "test", + "CDA_API_ROOT": "https://example.com", + } + + +def test_read_env_file_handles_equals_in_value(tmp_path): + env_file = tmp_path / "test.env" + env_file.write_text("CDA_API_ROOT=https://example.com?param=value\n") + + env_vars = read_env_file(env_file) + + assert env_vars["CDA_API_ROOT"] == "https://example.com?param=value" + + +def test_read_env_file_nonexistent_returns_empty_dict(tmp_path): + env_file = tmp_path / "nonexistent.env" + env_vars = read_env_file(env_file) + + assert env_vars == {} + + +def test_write_env_file_creates_parent_dirs(tmp_path): + env_file = tmp_path / "nested" / "dir" / "test.env" + env_vars = {"ENVIRONMENT": "test"} + + write_env_file(env_file, env_vars) + + assert env_file.exists() + assert env_file.parent.exists() + + +def test_write_env_file_sets_permissions(tmp_path): + env_file = tmp_path / "test.env" + env_vars = {"CDA_API_KEY": "secret"} + + write_env_file(env_file, env_vars) + + if os.name != "nt": + stat_info = env_file.stat() + mode = stat_info.st_mode & 0o777 + assert mode == 0o600 From b47074b3eec688b2292ab8aebf943d73015a054e Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 23 Apr 2026 13:41:12 -0500 Subject: [PATCH 02/11] add advanced warning --- cwmscli/commands/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 0c86916..3761311 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -330,7 +330,7 @@ def install_cmd(yes: bool): @env_group.command( - "activate", help="Generate shell export commands to activate an environment" + "activate", help="[Advanced] Generate shell export commands to activate an environment" ) @click.argument("env_name") @click.option( From 0bfa5411089981c17696f86facf847a6a9c854f6 Mon Sep 17 00:00:00 2001 From: msweier Date: Mon, 27 Apr 2026 11:20:09 -0500 Subject: [PATCH 03/11] switch to keyring, address comments --- cwmscli/commands/env.py | 523 ++++++++++++----------------------- cwmscli/utils/credentials.py | 285 +++++++++++++++++++ docs/ENV_MANAGER.md | 80 ------ docs/cli/env.rst | 222 +++++++++++++++ docs/index.rst | 1 + pyproject.toml | 1 + 6 files changed, 690 insertions(+), 422 deletions(-) create mode 100644 cwmscli/utils/credentials.py delete mode 100644 docs/ENV_MANAGER.md create mode 100644 docs/cli/env.rst diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 3761311..149378f 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -1,11 +1,22 @@ -import logging import os import sys from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Dict, Optional import click +from cwmscli.utils.credentials import ( + CredentialStorageError, + add_to_environment_index, + delete_environment, + get_environment, + get_environment_from_os_environ, + get_environment_index, + is_keyring_available, + remove_from_environment_index, + store_environment, +) + def get_envs_dir() -> Path: """Get the directory where environment files are stored.""" @@ -18,140 +29,11 @@ def get_envs_dir() -> Path: return envs_dir -def read_env_file(env_file: Path) -> Dict[str, str]: - """Read a .env file and return a dictionary of key-value pairs.""" - env_vars = {} - if not env_file.exists(): - return env_vars - - with open(env_file, "r") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, value = line.split("=", 1) - env_vars[key.strip()] = value.strip() - - return env_vars - - -def write_env_file(env_file: Path, env_vars: Dict[str, str]) -> None: - """Write environment variables to a .env file.""" - env_file.parent.mkdir(parents=True, exist_ok=True) - - with open(env_file, "w") as f: - for key, value in env_vars.items(): - f.write(f"{key}={value}\n") - - if sys.platform != "win32": - os.chmod(env_file, 0o600) - - ENV_DEFAULTS = { "cwbi-prod": "https://cwms-data.usace.army.mil/cwms-data", - "localhost": "http://localhost:8082/cwms-data", } -def detect_shell() -> Tuple[str, Optional[Path]]: - """Detect user's shell and return (shell_name, rc_file_path).""" - shell = os.environ.get("SHELL", "") - home = Path.home() - - if "zsh" in shell: - return ("zsh", home / ".zshrc") - elif "bash" in shell: - return ("bash", home / ".bashrc") - - return ("unknown", None) - - -def is_function_installed(rc_file: Path) -> bool: - """Check if cwms-env function is already installed in rc file.""" - if not rc_file.exists(): - return False - - content = rc_file.read_text() - return "# cwms-cli environment switcher" in content - - -def install_posix_function(rc_file: Path) -> None: - """Append cwms-env function to shell rc file.""" - function_block = """ -# cwms-cli environment switcher (added by cwms-cli) -cwms-env() { eval $(cwms-cli --quiet env activate "$@"); } -""" - - with open(rc_file, "a") as f: - f.write(function_block) - - -def get_windows_batch_dir() -> Path: - """Get directory for Windows batch files.""" - # Try %USERPROFILE%\bin first, fallback to config dir - user_bin = Path.home() / "bin" - if user_bin.exists(): - return user_bin - - # Use cwms-cli config directory - appdata = Path(os.environ.get("APPDATA", "~/.config")).expanduser() - return appdata / "cwms-cli" / "bin" - - -def install_windows_batch(batch_dir: Path) -> Path: - """Create cwms-env.bat batch file.""" - batch_dir.mkdir(parents=True, exist_ok=True) - batch_file = batch_dir / "cwms-env.bat" - - batch_content = """@echo off -for /f "delims=" %%i in ('cwms-cli --quiet env activate %*') do %%i -""" - - batch_file.write_text(batch_content) - return batch_file - - -def is_in_path(directory: Path) -> bool: - """Check if directory is in PATH.""" - path_env = os.environ.get("PATH", "") - path_dirs = path_env.split(os.pathsep) - dir_str = str(directory.resolve()) - - return any(str(Path(p).resolve()) == dir_str for p in path_dirs) - - -def add_to_user_path_windows(directory: Path) -> bool: - """Add directory to Windows user PATH via registry.""" - try: - import winreg - - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Environment", - 0, - winreg.KEY_READ | winreg.KEY_WRITE, - ) - - try: - current_path, _ = winreg.QueryValueEx(key, "Path") - except FileNotFoundError: - current_path = "" - - dir_str = str(directory) - if dir_str not in current_path.split(os.pathsep): - new_path = ( - f"{current_path}{os.pathsep}{dir_str}" if current_path else dir_str - ) - winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_path) - - winreg.CloseKey(key) - return True - - except Exception: - return False - - @click.group("env", help="Manage CDA environments and API keys") def env_group(): """Environment management commands for cwms-cli.""" @@ -183,10 +65,8 @@ def setup_cmd( ENV_NAME can be: cwbi-dev, cwbi-test, cwbi-prod, onsite, localhost, or custom """ - envs_dir = get_envs_dir() - env_file = envs_dir / f"{env_name}.env" - - existing_vars = read_env_file(env_file) if env_file.exists() else {} + # Get existing config from keyring, if any + existing_vars = get_environment(env_name) or {} env_vars = existing_vars.copy() env_vars["ENVIRONMENT"] = env_name @@ -210,248 +90,207 @@ def setup_cmd( click.echo(f"Available defaults: {', '.join(ENV_DEFAULTS.keys())}", err=True) sys.exit(1) - write_env_file(env_file, env_vars) - - logging.info(f"Environment '{env_name}' configured at: {env_file}") - if api_key: - logging.info("API key stored securely (file permissions set to 600)") + # Store in keyring + try: + store_environment(env_name, env_vars) + add_to_environment_index(env_name) + click.echo(f"Environment '{env_name}' configured securely in system keyring") + except CredentialStorageError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) @env_group.command("show", help="Show current environment and available configurations") -def show_cmd(): - """Display current environment and list all configured environments.""" +@click.option( + "--name", + "-n", + help="Show details for a specific environment", +) +def show_cmd(name: Optional[str]): + """ + Display current environment and list all configured environments. + + Without --name: Lists all environments with API root and key status + With --name: Shows detailed configuration for a specific environment + """ current_env = os.environ.get("ENVIRONMENT") - if current_env: - click.echo(f"Current environment: {click.style(current_env, fg='green')}") - else: - click.echo("No environment currently active") - - envs_dir = get_envs_dir() - if not envs_dir.exists(): - click.echo(f"\nNo environments configured yet. Directory: {envs_dir}") - click.echo("Run 'cwms-cli env setup ' to create one.") - return - - env_files = sorted(envs_dir.glob("*.env")) - - if not env_files: - click.echo(f"\nNo environments found in: {envs_dir}") - click.echo("Run 'cwms-cli env setup ' to create one.") - return - - click.echo(f"\nAvailable environments in {envs_dir}:") - for env_file in env_files: - env_name = env_file.stem - is_current = env_name == current_env - marker = " (active)" if is_current else "" - click.echo(f" - {env_name}{marker}") - - if current_env: - current_file = envs_dir / f"{current_env}.env" - if current_file.exists(): - click.echo(f"\nCurrent environment values:") - env_vars = read_env_file(current_file) - for key, value in sorted(env_vars.items()): + # Show details for a specific environment if requested + if name: + env_config = get_environment(name) + if env_config: + click.echo(f"Environment '{name}' configuration:") + for key, value in sorted(env_config.items()): if "KEY" in key.upper() and value: - display_value = value[:8] + "..." if len(value) > 8 else "***" + display_value = "***REDACTED***" else: display_value = value click.echo(f" {key}={display_value}") + else: + click.echo( + f"Environment '{name}' not found in keyring", + err=True, + ) + click.echo("Run 'cwms-cli env setup ' to create it.", err=True) + else: + # List all environments + if current_env: + click.echo( + f"Current environment: {click.style(current_env, fg='green', bold=True)}\n" + ) + else: + click.echo("No environment currently active\n") + + environments = get_environment_index() + + if environments: + click.echo("Available environments:") + for env_name in environments: + env_config = get_environment(env_name) + if env_config: + is_active = env_name == current_env + marker = "* " if is_active else " " + + api_root = env_config.get("CDA_API_ROOT", "not set") + office = env_config.get("OFFICE", "not set") + has_key = ( + "has API key" if env_config.get("CDA_API_KEY") else "no API key" + ) + + click.echo(f"{marker}{env_name}") + click.echo(f" API Root: {api_root}") + click.echo(f" Office: {office}") + click.echo(f" Status: {has_key}") + else: + click.echo("No environments configured") + click.echo("Run 'cwms-cli env setup ' to create one") + # Check for old .env files (for migration purposes) + envs_dir = get_envs_dir() + env_files = [] + if envs_dir.exists(): + env_files = sorted(envs_dir.glob("*.env")) -@env_group.command("install", help="Install cwms-env shell helper") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -def install_cmd(yes: bool): - """ - Install shell helper for activating environments. - - On Linux/Mac: Adds cwms-env() function to shell rc file - On Windows: Creates cwms-env.bat and adds to PATH - """ + if env_files: + click.echo("\nOld .env files found (not migrated to keyring):") + for env_file in env_files: + env_name = env_file.stem + click.echo(f" - {env_name}") - if sys.platform == "win32": - # Windows: Install batch file - batch_dir = get_windows_batch_dir() - batch_file = batch_dir / "cwms-env.bat" + click.echo( + "\nUse 'cwms-cli env show --name ' to view detailed configuration" + ) - if batch_file.exists(): - click.echo(f"✓ Already installed: {batch_file}") - return - click.echo(f"Will create: {batch_file}") - if not is_in_path(batch_dir): - click.echo(f"Will add to PATH: {batch_dir}") +@env_group.command("delete", help="Delete an environment configuration") +@click.argument("env_name") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +def delete_cmd(env_name: str, yes: bool): + """ + Delete an environment configuration from keyring. - if not yes and not click.confirm("Continue?"): - click.echo("Installation cancelled.") + Examples: + cwms-cli env delete myenv + cwms-cli env delete myenv --yes + """ + if not yes: + if not click.confirm(f"Delete environment '{env_name}'?"): + click.echo("Cancelled") return - # Install - installed_path = install_windows_batch(batch_dir) - click.echo(f"✓ Created: {installed_path}") - - # Update PATH - if not is_in_path(batch_dir): - if add_to_user_path_windows(batch_dir): - click.echo("✓ Added to PATH") - click.echo("\n⚠ Restart your terminal for PATH changes to take effect") - else: - click.echo("✗ Could not update PATH automatically") - click.echo(f"\nManually add to your PATH: {batch_dir}") - - click.echo("\nYou can now use: cwms-env ") + try: + delete_environment(env_name) + remove_from_environment_index(env_name) + click.echo(f"Environment '{env_name}' deleted") + except CredentialStorageError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) - else: - # Linux/Mac: Install shell function - shell_name, rc_file = detect_shell() - if not rc_file: - click.echo("✗ Could not detect shell type") - click.echo("\nManually add to your shell rc file:") - click.echo(' cwms-env() { eval $(cwms-cli --quiet env activate "$@"); }') - return +def spawn_shell_with_env(env_vars: Dict[str, str], env_name: str): + """Spawn a new shell with environment variables set.""" + import subprocess - if is_function_installed(rc_file): - click.echo(f"✓ Already installed in: {rc_file}") - return + # Detect user's shell + user_shell = os.environ.get("SHELL") + if not user_shell: + if sys.platform == "win32": + user_shell = os.environ.get("COMSPEC", "cmd.exe") + else: + user_shell = "/bin/bash" - click.echo(f"Will add cwms-env() function to: {rc_file}") + # Create environment dict + new_env = os.environ.copy() + new_env.update(env_vars) - if not yes and not click.confirm("Continue?"): - click.echo("Installation cancelled.") - return + # Show activation message + click.echo( + f"Activating environment: {click.style(env_name, fg='green', bold=True)}" + ) + click.echo(f"Shell: {user_shell}") + click.echo("Type 'exit' or press Ctrl+D to return to your original environment\n") - # Install - install_posix_function(rc_file) - click.echo(f"✓ Added cwms-env() function to: {rc_file}") - click.echo(f"\nReload your shell: source {rc_file}") - click.echo("Then use: cwms-env ") + # Spawn shell with modified environment + try: + result = subprocess.run([user_shell], env=new_env) + sys.exit(result.returncode) + except Exception as e: + click.echo(f"Error spawning shell: {e}", err=True) + sys.exit(1) -@env_group.command( - "activate", help="[Advanced] Generate shell export commands to activate an environment" -) +@env_group.command("activate", help="Activate an environment in a new shell") @click.argument("env_name") -@click.option( - "--force", - is_flag=True, - hidden=True, - help="Skip safety warnings (use with caution)", -) -@click.pass_context -def activate_cmd(ctx: click.Context, env_name: str, force: bool): +def activate_cmd(env_name: str): """ - Output shell commands to activate an environment. - - RECOMMENDED WORKFLOW: - 1. First install the shell helper: - cwms-cli env install + Activate an environment in a new shell session. - 2. Then switch environments using: - cwms-env + The environment variables will be set in the new shell and persist + until you exit the shell. Type 'exit' to return to your original environment. - ADVANCED USAGE: - Direct eval (not recommended for environments with API keys): - eval $(cwms-cli env activate cwbi-dev) + Examples: + cwms-cli env activate cwbi-prod + cwms-cli env activate localhost """ - envs_dir = get_envs_dir() - env_file = envs_dir / f"{env_name}.env" - - if not env_file.exists(): - click.echo( - f"# Error: Environment '{env_name}' not found at: {env_file}", - err=True, - ) - click.echo( - "# Run 'cwms-cli env show' to see available environments", - err=True, - ) - click.echo( - f"# Or run 'cwms-cli env setup {env_name}' to create it", - err=True, - ) - sys.exit(1) - - env_vars = read_env_file(env_file) - - # Security warning: Check if environment contains sensitive data and stdout is a TTY - has_api_key = any("KEY" in key.upper() for key in env_vars.keys()) - if has_api_key and sys.stdout.isatty() and not force: - click.echo( - click.style("\n⚠️ SECURITY WARNING", fg="red", bold=True), - err=True, - ) - click.echo( - click.style( - "This environment contains API keys that will be printed to your terminal.", - fg="yellow", - ), - err=True, - ) - click.echo( - "Running this command without 'eval' or the shell wrapper can expose secrets in:", - err=True, - ) - click.echo(" • Terminal history", err=True) - click.echo(" • Script logs", err=True) - click.echo(" • Screen recordings or screenshots", err=True) - click.echo("", err=True) - click.echo( - click.style("Recommended usage:", fg="green", bold=True), - err=True, - ) - click.echo( - f" {click.style(f'eval $(cwms-cli --quiet env activate {env_name})', bold=True)}", - err=True, - ) - click.echo("", err=True) - click.echo( - f"Or install the shell wrapper: {click.style('cwms-cli env install', bold=True)}", - err=True, - ) - click.echo( - f"and run: {click.style(f'cwms-env {env_name}', bold=True)}", - err=True, - ) - - click.echo("", err=True) - if not click.confirm( - click.style("Continue anyway?", fg="red"), - default=False, - err=True, - ): - click.echo("Activation cancelled.", err=True) - sys.exit(1) - - # Show helpful message to stderr (won't interfere with eval) unless logging is disabled - # We check if INFO level logging is active - quiet mode sets it to WARNING - if logging.getLogger().isEnabledFor(logging.INFO): - if sys.platform == "win32": - click.echo( - "# To activate: FOR /F %i IN ('cwms-cli --quiet env activate " - + env_name - + "') DO %i", - err=True, - ) - click.echo( - "# Or create cwms-env.bat with: @FOR /F %i IN ('cwms-cli --quiet env activate %*') DO %i", - err=True, - ) + # Try to get environment from keyring + env_vars = get_environment(env_name) + + if not env_vars: + # If not in keyring and keyring not available, try OS environment as fallback + if not is_keyring_available(): + fallback_vars = get_environment_from_os_environ() + if fallback_vars: + click.echo( + f"Using environment variables from current shell (keyring not available)", + err=True, + ) + env_vars = fallback_vars + env_vars["ENVIRONMENT"] = env_name + else: + click.echo( + "Error: Keyring not available and no environment variables found.", + err=True, + ) + click.echo( + "Set CDA_API_ROOT, CDA_API_KEY, and OFFICE in your environment,", + err=True, + ) + click.echo( + "or run 'cwms-cli env setup ' on a system with keyring support.", + err=True, + ) + sys.exit(1) else: click.echo( - f"# To activate: eval $(cwms-cli --quiet env activate {env_name})", + f"Error: Environment '{env_name}' not found in keyring", err=True, ) click.echo( - '# Or add to ~/.bashrc: cwms-env() { eval $(cwms-cli --quiet env activate "$@"); }', + "Run 'cwms-cli env show --name ' to see if it exists", err=True, ) + click.echo(f"Or run 'cwms-cli env setup {env_name}' to create it", err=True) + sys.exit(1) - # Output export commands to stdout (will be eval'd by shell) - if sys.platform == "win32": - for key, value in env_vars.items(): - click.echo(f"set {key}={value}") - else: - for key, value in env_vars.items(): - click.echo(f"export {key}={value}") + # Always spawn a new shell + spawn_shell_with_env(env_vars, env_name) diff --git a/cwmscli/utils/credentials.py b/cwmscli/utils/credentials.py new file mode 100644 index 0000000..9205beb --- /dev/null +++ b/cwmscli/utils/credentials.py @@ -0,0 +1,285 @@ +"""Credential storage using keyring for secure cross-platform credential management.""" + +import json +import os +from typing import Dict, List, Optional + +import keyring +from keyring.errors import KeyringError + + +class CredentialStorageError(Exception): + """Raised when credential storage operations fail.""" + + pass + + +def is_keyring_available() -> bool: + """ + Check if keyring backend is available and functional. + + Returns: + True if keyring can be used, False otherwise + """ + try: + backend = keyring.get_keyring() + # Test write/read/delete to ensure it actually works + test_service = "cwms-cli-test" + test_key = "__availability_test__" + test_value = "test" + + keyring.set_password(test_service, test_key, test_value) + result = keyring.get_password(test_service, test_key) + keyring.delete_password(test_service, test_key) + + return result == test_value + except (KeyringError, Exception): + return False + + +def store_credential(service: str, key: str, value: str) -> None: + """ + Store a credential in the system keyring. + + Args: + service: Service name (e.g., "cwms-cli-env") + key: Key/username for the credential + value: Value/password to store + + Raises: + CredentialStorageError: If keyring is not available + """ + if not is_keyring_available(): + raise CredentialStorageError( + "Secure credential storage (keyring) is not available.\n\n" + "For headless/CI environments, set these environment variables instead:\n" + ' export CDA_API_ROOT="https://..."\n' + ' export CDA_API_KEY="your_key"\n' + ' export OFFICE="SWT"\n\n' + "For interactive systems, install a keyring backend:\n" + " Linux: Install gnome-keyring, kwallet, or python3-secretstorage\n" + " macOS: Uses Keychain (built-in)\n" + " Windows: Uses Credential Manager (built-in)" + ) + + try: + keyring.set_password(service, key, value) + except KeyringError as e: + raise CredentialStorageError(f"Failed to store credential: {e}") from e + + +def get_credential(service: str, key: str) -> Optional[str]: + """ + Retrieve a credential from the system keyring. + + Args: + service: Service name (e.g., "cwms-cli-env") + key: Key/username for the credential + + Returns: + The credential value, or None if not found + """ + if not is_keyring_available(): + return None + + try: + return keyring.get_password(service, key) + except KeyringError: + return None + + +def delete_credential(service: str, key: str) -> None: + """ + Delete a credential from the system keyring. + + Args: + service: Service name (e.g., "cwms-cli-env") + key: Key/username for the credential + + Raises: + CredentialStorageError: If deletion fails + """ + if not is_keyring_available(): + raise CredentialStorageError("Keyring is not available") + + try: + keyring.delete_password(service, key) + except KeyringError as e: + raise CredentialStorageError(f"Failed to delete credential: {e}") from e + + +def list_stored_credentials(service_prefix: str) -> List[str]: + """ + List credential keys for a given service prefix. + + Note: This functionality is limited by keyring backend capabilities. + Some backends don't support enumeration, so this may return an empty list + even if credentials exist. + + Args: + service_prefix: Service name prefix (e.g., "cwms-cli-env") + + Returns: + List of credential keys matching the service prefix + """ + # Most keyring backends don't support enumeration + # This is a limitation we document and work around + # by having users explicitly name environments + return [] + + +# Environment-specific functions + + +def store_environment(env_name: str, config: Dict[str, str]) -> None: + """ + Store environment configuration in keyring. + + Args: + env_name: Name of the environment + config: Dictionary of environment variables to store + + Raises: + CredentialStorageError: If keyring is not available + """ + service = f"cwms-cli-env:{env_name}" + # Store the entire config as a JSON string + config_json = json.dumps(config, sort_keys=True) + store_credential(service, "config", config_json) + + +def get_environment(env_name: str) -> Optional[Dict[str, str]]: + """ + Retrieve environment configuration from keyring. + + Args: + env_name: Name of the environment + + Returns: + Dictionary of environment variables, or None if not found + """ + service = f"cwms-cli-env:{env_name}" + config_json = get_credential(service, "config") + + if config_json: + try: + return json.loads(config_json) + except json.JSONDecodeError: + return None + + return None + + +def delete_environment(env_name: str) -> None: + """ + Delete environment configuration from keyring. + + Args: + env_name: Name of the environment + + Raises: + CredentialStorageError: If deletion fails + """ + service = f"cwms-cli-env:{env_name}" + delete_credential(service, "config") + + +def get_environment_from_os_environ() -> Dict[str, str]: + """ + Build environment config from OS environment variables. + + This is used as a fallback for headless/CI environments where keyring + is not available. Only returns variables that are actually set. + + Returns: + Dictionary of environment variables found in os.environ + """ + env_vars = {} + + # Check for known environment variables + if "CDA_API_ROOT" in os.environ: + env_vars["CDA_API_ROOT"] = os.environ["CDA_API_ROOT"] + + if "CDA_API_KEY" in os.environ: + env_vars["CDA_API_KEY"] = os.environ["CDA_API_KEY"] + + if "OFFICE" in os.environ: + env_vars["OFFICE"] = os.environ["OFFICE"] + + if "ENVIRONMENT" in os.environ: + env_vars["ENVIRONMENT"] = os.environ["ENVIRONMENT"] + + return env_vars + + +# Environment index management + + +def get_environment_index() -> List[str]: + """ + Get list of all environment names from the index. + + Returns: + List of environment names, or empty list if index doesn't exist + """ + if not is_keyring_available(): + return [] + + service = "cwms-cli-meta" + index_json = get_credential(service, "environments") + + if index_json: + try: + return json.loads(index_json) + except json.JSONDecodeError: + return [] + + return [] + + +def add_to_environment_index(env_name: str) -> None: + """ + Add an environment name to the index. + + Args: + env_name: Name of the environment to add + + Raises: + CredentialStorageError: If keyring is not available + """ + if not is_keyring_available(): + raise CredentialStorageError("Keyring is not available") + + index = get_environment_index() + if env_name not in index: + index.append(env_name) + index.sort() + service = "cwms-cli-meta" + store_credential(service, "environments", json.dumps(index)) + + +def remove_from_environment_index(env_name: str) -> None: + """ + Remove an environment name from the index. + + Args: + env_name: Name of the environment to remove + + Raises: + CredentialStorageError: If keyring is not available + """ + if not is_keyring_available(): + raise CredentialStorageError("Keyring is not available") + + index = get_environment_index() + if env_name in index: + index.remove(env_name) + service = "cwms-cli-meta" + if index: + store_credential(service, "environments", json.dumps(index)) + else: + # If index is empty, delete it + try: + delete_credential(service, "environments") + except CredentialStorageError: + pass # It's okay if it doesn't exist diff --git a/docs/ENV_MANAGER.md b/docs/ENV_MANAGER.md deleted file mode 100644 index 6230555..0000000 --- a/docs/ENV_MANAGER.md +++ /dev/null @@ -1,80 +0,0 @@ -# Environment Manager - -Simple environment management for CDA environments. - -## Suggested Environments - -**Pre-configured (have default URLs):** -- `cwbi-prod` - Production CWBI (https://cwms-data.usace.army.mil/cwms-data) -- `localhost` - Local development server (http://localhost:8082/cwms-data) - -**Need --api-root:** -- `cwbi-dev` - Development CWBI -- `cwbi-test` - Test CWBI -- `onsite` - Local non-cloud server - -## Quick Start - -**1. Install shell helper (recommended):** -```bash -cwms-cli env install -``` - -**2. Setup environments:** -```bash -# Pre-configured environments (just add key/office) -cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY -cwms-cli env setup localhost --office SWT --api-key YOUR_KEY - -# Custom environments (need --api-root) -cwms-cli env setup cwbi-dev --api-root https://dev.example.mil/cwms-data --office SWT --api-key YOUR_KEY -``` - -**3. Switch environments:** -```bash -cwms-env cwbi-prod -cwms-env localhost -``` - -**4. View all environments:** -```bash -cwms-cli env show -``` - -## Shell Integration Details - -**Run once to install the `cwms-env` helper:** - -```bash -cwms-cli env install -``` - -**Restart your terminal** (or run `source ~/.bashrc` / `source ~/.zshrc`) for the `cwms-env` command to work. - -**What it does:** -- **Linux/Mac**: Adds function to `~/.bashrc` or `~/.zshrc` -- **Windows**: Creates `cwms-env.bat` in your PATH - -**Manual setup (if needed):** -- **Linux/Mac**: Add to shell config: `cwms-env() { eval $(cwms-cli --quiet env activate "$@"); }` -- **Windows**: Create `cwms-env.bat`: `@echo off` / `for /f "delims=" %%i in ('cwms-cli --quiet env activate %*') do %%i` - -## How It Works - -**Config files:** `~/.config/cwms-cli/envs/.env` (Linux/Mac) or `%APPDATA%\cwms-cli\envs\.env` (Windows) - -**Environment variables set:** -- `ENVIRONMENT` - Environment name -- `CDA_API_ROOT` - API root URL -- `CDA_API_KEY` - API key (if provided) -- `OFFICE` - Default office (if provided) - -**Usage with other commands:** -```bash -cwms-env cwbi-prod # Activate environment -cwms-cli blob list # Uses environment variables -cwms-cli users list # Uses environment variables - -# Command flags override environment variables -cwms-cli blob list --api-root https://other.mil/cwms-data -``` diff --git a/docs/cli/env.rst b/docs/cli/env.rst new file mode 100644 index 0000000..f8f4fed --- /dev/null +++ b/docs/cli/env.rst @@ -0,0 +1,222 @@ +Environment Manager +=================== + +Secure environment management for CDA environments using system keyring storage. + +Overview +-------- + +The environment manager stores your CDA API credentials and configuration securely using your system's keyring: + +- **Linux**: gnome-keyring, kwallet, or secretstorage +- **macOS**: Keychain (built-in) +- **Windows**: Credential Manager (built-in) +- **Solaris**: Keyring not available - use environment variables (see Headless/CI Usage below) + +This keeps API keys out of plaintext files and provides a consistent, secure experience across platforms. + +Suggested Environments +---------------------- + +**Pre-configured (have default URLs):** + +- ``cwbi-prod`` - Production CWBI (https://cwms-data.usace.army.mil/cwms-data) + +**Need --api-root:** + +- ``cwbi-dev`` - Development CWBI +- ``cwbi-test`` - Test CWBI +- ``localhost`` - Local development server (port varies: 8081, 8082, etc.) +- ``onsite`` - Local non-cloud server +- Custom environment names + +Quick Start +----------- + +**1. Setup environments:** + +.. code-block:: bash + + # Pre-configured environment (just add key/office) + cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY + + # Custom environments (need --api-root) + cwms-cli env setup cwbi-dev --api-root https://cwms-data-dev.example.mil/cwms-data --office SWT --api-key YOUR_KEY + cwms-cli env setup localhost --api-root http://localhost:8082/cwms-data --office DEV + +**2. Activate an environment:** + +.. code-block:: bash + + cwms-cli env activate cwbi-dev + +This spawns a new shell with the environment variables set. When you're done, type ``exit`` to return to your original shell. + +**3. View environments:** + +.. code-block:: bash + + # List all environments with their API roots + cwms-cli env show + + # View detailed config for a specific environment + cwms-cli env show --name cwbi-dev + +Commands +-------- + +cwms-cli env setup +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create or update an environment configuration. + +.. code-block:: bash + + # Setup with all options + cwms-cli env setup myenv --api-root https://example.mil/cwms-data --api-key YOUR_KEY --office SWT + + # Update just the API key + cwms-cli env setup myenv --api-key NEW_KEY + + # Update just the office + cwms-cli env setup myenv --office LRD + +cwms-cli env show +~~~~~~~~~~~~~~~~~~ + +List all configured environments or show details for a specific one. + +.. code-block:: bash + + # List all environments with API roots and key status + cwms-cli env show + + # Show detailed configuration for a specific environment + cwms-cli env show --name cwbi-prod + +**Without --name flag:** + +.. code-block:: text + + Current environment: cwbi-prod + + Available environments: + * cwbi-prod + API Root: https://cwms-data.usace.army.mil/cwms-data + Office: SWT + Status: has API key + cwbi-dev + API Root: https://cwms-data-dev.example.mil/cwms-data + Office: SWT + Status: no API key + +The ``*`` marks the currently active environment. + +**With --name flag:** + +Shows detailed configuration including: + +- ``CDA_API_ROOT`` - Full URL +- ``CDA_API_KEY`` - Redacted (``***REDACTED***``) +- ``OFFICE`` - Office code +- ``ENVIRONMENT`` - Environment name + +cwms-cli env activate +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Activate an environment in a new shell session. + +.. code-block:: bash + + cwms-cli env activate cwbi-prod + +The environment variables will be set in the new shell and persist until you exit: + +.. code-block:: bash + + # Now in the activated environment + echo $CDA_API_ROOT # Shows the API root + cwms-cli blob list # Uses environment config + + # Exit to return to original shell + exit + +**Benefits:** + +- Clean separation between environments +- Original shell remains unchanged +- Type ``exit`` to immediately return to original state + +cwms-cli env delete +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Delete an environment configuration from keyring. + +.. code-block:: bash + + # Delete with confirmation prompt + cwms-cli env delete myenv + + # Delete without confirmation + cwms-cli env delete myenv --yes + +How It Works +------------ + +**Secure storage:** Configuration is stored in your system's keyring: + +- ``~/.local/share/keyrings/`` (Linux with GNOME Keyring) +- ``~/Library/Keychains/`` (macOS Keychain) +- Windows Credential Manager (Windows) + +**Environment variables set when activated:** + +- ``ENVIRONMENT`` - Environment name +- ``CDA_API_ROOT`` - API root URL +- ``CDA_API_KEY`` - API key (if provided) +- ``OFFICE`` - Default office (if provided) + +**Usage with other commands:** + +.. code-block:: bash + + # Activate environment (spawns new shell) + cwms-cli env activate cwbi-prod + + # Now run commands (uses environment variables automatically) + cwms-cli blob list + cwms-cli users list + + # Command flags override environment variables + cwms-cli blob list --api-root https://cwms-data.usace.army.mil/cwms-data + + # Exit the environment shell + exit + +**Variable persistence:** + +- Variables persist until you ``exit`` the spawned shell +- Variables do NOT affect your original shell +- Variables do NOT persist across terminal restarts (activate again when needed) + +Headless/CI Usage (and Solaris) +-------------------------------- + +For headless, CI, or Solaris environments where keyring is not available, set environment variables directly: + +.. code-block:: bash + + export CDA_API_ROOT="https://cwms-data.usace.army.mil/cwms-data" + export CDA_API_KEY="your_key" + export OFFICE="SWT" + + # Commands will use these variables + cwms-cli blob list + +The CLI will automatically fall back to reading from ``os.environ`` when keyring is unavailable. + +**Note for Solaris users:** Since system keyring backends are not available on Solaris, you must use this environment variable approach. The ``cwms-cli env setup`` and ``cwms-cli env activate`` commands will not work without a keyring backend. Instead, set the variables directly in your shell profile (e.g., ``~/.bashrc`` or ``~/.profile``). + +.. click:: cwmscli.commands.env:env_group + :prog: cwms-cli env + :nested: full diff --git a/docs/index.rst b/docs/index.rst index 200cc81..5b811bf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,7 @@ Contents cli/csv2cwms cli/blob + cli/env cli/login cli/clob cli/users diff --git a/pyproject.toml b/pyproject.toml index 8bbca93..57daac0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ requests = "^2.30.0" hecdss = { version = ">=0.1.24", optional = true } # Via https://github.com/HydrologicEngineeringCenter/hec-python-library/blob/main/hec/shared.py#L9-10 cwms-python = { version = ">=1.0.7", optional = true} colorama = "^0.4.6" +keyring = "^25.0.0" [tool.poetry.group.dev.dependencies] black = "^24.2.0" From 1f61f69c9ceb6ebda7bf82a52d72f01f69968895 Mon Sep 17 00:00:00 2001 From: msweier Date: Mon, 27 Apr 2026 11:25:27 -0500 Subject: [PATCH 04/11] update poetry lock --- poetry.lock | 548 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 544 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index e0e4377..a832286 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,21 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version <= \"3.11\"" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] [[package]] name = "black" @@ -59,6 +76,104 @@ files = [ {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "cfgv" version = "3.4.0" @@ -236,7 +351,124 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} + +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version == \"3.9\" and sys_platform == \"linux\"" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "cryptography" +version = "47.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and sys_platform == \"linux\"" +files = [ + {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"}, + {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"}, + {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"}, + {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"}, + {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"}, + {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"}, + {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"}, + {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"}, + {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"}, + {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] [[package]] name = "cwms-python" @@ -370,6 +602,56 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.9\"" +files = [ + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and python_version < \"3.12\"" +files = [ + {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, + {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "iniconfig" version = "2.1.0" @@ -397,6 +679,143 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-context" +version = "6.1.1" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.9\"" +files = [ + {file = "jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808"}, + {file = "jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, + {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176"}, + {file = "jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "sys_platform == \"linux\"" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + [[package]] name = "librt" version = "0.8.1" @@ -515,6 +934,32 @@ click = ">=8.0.1,<9.0.0" pydantic = ">=1.10.13,<2.0.0" toml = ">=0.10.2,<0.11.0" +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.9\"" +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + +[[package]] +name = "more-itertools" +version = "11.0.2" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4"}, + {file = "more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804"}, +] + [[package]] name = "mypy" version = "1.19.1" @@ -922,6 +1367,32 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\" and python_version == \"3.9\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and sys_platform == \"linux\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "1.10.26" @@ -1062,6 +1533,19 @@ files = [ {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1201,6 +1685,40 @@ setuptools = ">=39.0" [package.extras] docs = ["Sphinx"] +[[package]] +name = "secretstorage" +version = "3.3.3" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "python_version == \"3.9\" and sys_platform == \"linux\"" +files = [ + {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, + {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and sys_platform == \"linux\"" +files = [ + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + [[package]] name = "setuptools" version = "82.0.1" @@ -1310,11 +1828,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {main = "sys_platform == \"linux\" and python_version == \"3.10\""} [[package]] name = "tzdata" @@ -1385,7 +1904,28 @@ click = ">=8.1.3" maison = ">=1.4.0,<1.4.3" ruyaml = ">=0.91.0" +[[package]] +name = "zipp" +version = "3.23.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version <= \"3.11\"" +files = [ + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "056bcf91885aac3e6fe947610dbf8925f3ab359f94382b8dcb12035db4610472" +content-hash = "3cb5794dfc58e421338f2f7f2387afec10ee79890ea19f25efdb1f9ef974a950" From 5aecf8e4704dcabea949a185fb5a58911ee9e309 Mon Sep 17 00:00:00 2001 From: msweier Date: Mon, 27 Apr 2026 11:57:41 -0500 Subject: [PATCH 05/11] update tests --- tests/commands/test_env.py | 228 ++++++++++++++++++++++++++++++------- 1 file changed, 185 insertions(+), 43 deletions(-) diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py index b76dfe2..cb86582 100644 --- a/tests/commands/test_env.py +++ b/tests/commands/test_env.py @@ -1,9 +1,10 @@ import os from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -from cwmscli.commands.env import get_envs_dir, read_env_file, write_env_file +from cwmscli.commands.env import get_envs_dir def test_get_envs_dir_returns_path(): @@ -13,8 +14,24 @@ def test_get_envs_dir_returns_path(): assert "envs" in str(envs_dir) -def test_write_and_read_env_file(tmp_path): - env_file = tmp_path / "test.env" +# Test keyring-based environment management +@pytest.fixture +def mock_keyring(): + """Mock keyring module for testing.""" + with patch("cwmscli.utils.credentials.keyring") as mock: + # Mock successful keyring operations + mock.get_keyring.return_value = MagicMock() + mock.get_password.return_value = None + mock.set_password.return_value = None + mock.delete_password.return_value = None + yield mock + + +def test_store_and_get_environment(mock_keyring): + """Test storing and retrieving environment configuration.""" + from cwmscli.utils.credentials import get_environment, store_environment + + env_name = "test-env" env_vars = { "ENVIRONMENT": "test", "CDA_API_ROOT": "https://example.com/cwms-data", @@ -22,65 +39,190 @@ def test_write_and_read_env_file(tmp_path): "OFFICE": "SWT", } - write_env_file(env_file, env_vars) + # Mock storage + stored_data = {} + + def set_password(service, key, value): + stored_data[f"{service}:{key}"] = value - assert env_file.exists() - read_vars = read_env_file(env_file) + def get_password(service, key): + return stored_data.get(f"{service}:{key}") - assert read_vars == env_vars + mock_keyring.set_password.side_effect = set_password + mock_keyring.get_password.side_effect = get_password + # Store environment + store_environment(env_name, env_vars) -def test_read_env_file_skips_comments(tmp_path): - env_file = tmp_path / "test.env" - env_file.write_text( - "# This is a comment\n" - "ENVIRONMENT=test\n" - "\n" - "# Another comment\n" - "CDA_API_ROOT=https://example.com\n" + # Verify it was stored + assert f"cwms-cli-env:{env_name}:config" in stored_data + + # Retrieve environment + retrieved_vars = get_environment(env_name) + assert retrieved_vars == env_vars + + +def test_delete_environment(mock_keyring): + """Test deleting environment configuration.""" + from cwmscli.utils.credentials import ( + delete_environment, + get_environment, + store_environment, ) - env_vars = read_env_file(env_file) + env_name = "test-env" + env_vars = {"ENVIRONMENT": "test", "CDA_API_ROOT": "https://example.com"} - assert env_vars == { - "ENVIRONMENT": "test", - "CDA_API_ROOT": "https://example.com", - } + # Mock storage + stored_data = {} + + def set_password(service, key, value): + stored_data[f"{service}:{key}"] = value + + def get_password(service, key): + return stored_data.get(f"{service}:{key}") + + def delete_password(service, key): + stored_data.pop(f"{service}:{key}", None) + + mock_keyring.set_password.side_effect = set_password + mock_keyring.get_password.side_effect = get_password + mock_keyring.delete_password.side_effect = delete_password + + # Store and then delete + store_environment(env_name, env_vars) + delete_environment(env_name) + + # Verify it was deleted + assert get_environment(env_name) is None + + +def test_get_environment_nonexistent_returns_none(mock_keyring): + """Test retrieving non-existent environment returns None.""" + from cwmscli.utils.credentials import get_environment + + mock_keyring.get_password.return_value = None + result = get_environment("nonexistent") + assert result is None + + +def test_environment_index_management(mock_keyring): + """Test adding and removing environments from the index.""" + from cwmscli.utils.credentials import ( + add_to_environment_index, + get_environment_index, + remove_from_environment_index, + ) + + # Mock storage + stored_data = {} + + def set_password(service, key, value): + stored_data[f"{service}:{key}"] = value + + def get_password(service, key): + return stored_data.get(f"{service}:{key}") + + def delete_password(service, key): + stored_data.pop(f"{service}:{key}", None) + + mock_keyring.set_password.side_effect = set_password + mock_keyring.get_password.side_effect = get_password + mock_keyring.delete_password.side_effect = delete_password + + # Initially empty + assert get_environment_index() == [] + + # Add environments + add_to_environment_index("env1") + add_to_environment_index("env2") + assert sorted(get_environment_index()) == ["env1", "env2"] + + # Remove environment + remove_from_environment_index("env1") + assert get_environment_index() == ["env2"] + + +def test_get_environment_from_os_environ(): + """Test building environment config from OS environment variables.""" + from cwmscli.utils.credentials import get_environment_from_os_environ + with patch.dict( + os.environ, + { + "CDA_API_ROOT": "https://example.com/cwms-data", + "CDA_API_KEY": "test-key", + "OFFICE": "SWT", + "ENVIRONMENT": "test", + }, + clear=False, + ): + result = get_environment_from_os_environ() + assert result == { + "CDA_API_ROOT": "https://example.com/cwms-data", + "CDA_API_KEY": "test-key", + "OFFICE": "SWT", + "ENVIRONMENT": "test", + } -def test_read_env_file_handles_equals_in_value(tmp_path): - env_file = tmp_path / "test.env" - env_file.write_text("CDA_API_ROOT=https://example.com?param=value\n") - env_vars = read_env_file(env_file) +def test_get_environment_from_os_environ_partial(): + """Test building config with only some variables set.""" + from cwmscli.utils.credentials import get_environment_from_os_environ - assert env_vars["CDA_API_ROOT"] == "https://example.com?param=value" + with patch.dict( + os.environ, + { + "CDA_API_ROOT": "https://example.com/cwms-data", + "OFFICE": "SWT", + }, + clear=True, + ): + result = get_environment_from_os_environ() + assert result == { + "CDA_API_ROOT": "https://example.com/cwms-data", + "OFFICE": "SWT", + } + assert "CDA_API_KEY" not in result + assert "ENVIRONMENT" not in result -def test_read_env_file_nonexistent_returns_empty_dict(tmp_path): - env_file = tmp_path / "nonexistent.env" - env_vars = read_env_file(env_file) +def test_is_keyring_available_success(mock_keyring): + """Test keyring availability check when keyring works.""" + from cwmscli.utils.credentials import is_keyring_available - assert env_vars == {} + # Mock successful keyring operations + stored_value = None + def set_password(service, key, value): + nonlocal stored_value + stored_value = value -def test_write_env_file_creates_parent_dirs(tmp_path): - env_file = tmp_path / "nested" / "dir" / "test.env" - env_vars = {"ENVIRONMENT": "test"} + def get_password(service, key): + return stored_value - write_env_file(env_file, env_vars) + mock_keyring.set_password.side_effect = set_password + mock_keyring.get_password.side_effect = get_password + mock_keyring.delete_password.return_value = None - assert env_file.exists() - assert env_file.parent.exists() + assert is_keyring_available() is True -def test_write_env_file_sets_permissions(tmp_path): - env_file = tmp_path / "test.env" - env_vars = {"CDA_API_KEY": "secret"} +def test_is_keyring_available_failure(mock_keyring): + """Test keyring availability check when keyring fails.""" + from cwmscli.utils.credentials import is_keyring_available + from keyring.errors import KeyringError - write_env_file(env_file, env_vars) + # Mock keyring failure + mock_keyring.get_keyring.side_effect = KeyringError("No keyring available") - if os.name != "nt": - stat_info = env_file.stat() - mode = stat_info.st_mode & 0o777 - assert mode == 0o600 + assert is_keyring_available() is False + + +def test_store_environment_no_keyring(): + """Test storing environment when keyring is not available.""" + from cwmscli.utils.credentials import CredentialStorageError, store_environment + + with patch("cwmscli.utils.credentials.is_keyring_available", return_value=False): + with pytest.raises(CredentialStorageError, match="Secure credential storage"): + store_environment("test", {"CDA_API_ROOT": "https://example.com"}) From 4b3112c58bfbdbf847060947fed9f361432a4c54 Mon Sep 17 00:00:00 2001 From: msweier Date: Mon, 27 Apr 2026 12:02:36 -0500 Subject: [PATCH 06/11] fix formatting --- tests/commands/test_env.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py index cb86582..d1b9407 100644 --- a/tests/commands/test_env.py +++ b/tests/commands/test_env.py @@ -210,9 +210,10 @@ def get_password(service, key): def test_is_keyring_available_failure(mock_keyring): """Test keyring availability check when keyring fails.""" - from cwmscli.utils.credentials import is_keyring_available from keyring.errors import KeyringError + from cwmscli.utils.credentials import is_keyring_available + # Mock keyring failure mock_keyring.get_keyring.side_effect = KeyringError("No keyring available") From 3bbe5a13c78c0d7e4d91d990943253d1fe7d1116 Mon Sep 17 00:00:00 2001 From: msweier Date: Mon, 27 Apr 2026 12:37:24 -0500 Subject: [PATCH 07/11] removed useless --name flag on show --- cwmscli/commands/env.py | 114 +++++++++++++++------------------------- docs/cli/env.rst | 21 ++------ 2 files changed, 46 insertions(+), 89 deletions(-) diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 149378f..7883a56 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -101,85 +101,57 @@ def setup_cmd( @env_group.command("show", help="Show current environment and available configurations") -@click.option( - "--name", - "-n", - help="Show details for a specific environment", -) -def show_cmd(name: Optional[str]): +def show_cmd(): """ Display current environment and list all configured environments. - Without --name: Lists all environments with API root and key status - With --name: Shows detailed configuration for a specific environment + Lists all environments with API root, office, and key status. """ current_env = os.environ.get("ENVIRONMENT") - # Show details for a specific environment if requested - if name: - env_config = get_environment(name) - if env_config: - click.echo(f"Environment '{name}' configuration:") - for key, value in sorted(env_config.items()): - if "KEY" in key.upper() and value: - display_value = "***REDACTED***" - else: - display_value = value - click.echo(f" {key}={display_value}") - else: - click.echo( - f"Environment '{name}' not found in keyring", - err=True, - ) - click.echo("Run 'cwms-cli env setup ' to create it.", err=True) - else: - # List all environments - if current_env: - click.echo( - f"Current environment: {click.style(current_env, fg='green', bold=True)}\n" - ) - else: - click.echo("No environment currently active\n") - - environments = get_environment_index() - - if environments: - click.echo("Available environments:") - for env_name in environments: - env_config = get_environment(env_name) - if env_config: - is_active = env_name == current_env - marker = "* " if is_active else " " - - api_root = env_config.get("CDA_API_ROOT", "not set") - office = env_config.get("OFFICE", "not set") - has_key = ( - "has API key" if env_config.get("CDA_API_KEY") else "no API key" - ) - - click.echo(f"{marker}{env_name}") - click.echo(f" API Root: {api_root}") - click.echo(f" Office: {office}") - click.echo(f" Status: {has_key}") - else: - click.echo("No environments configured") - click.echo("Run 'cwms-cli env setup ' to create one") - - # Check for old .env files (for migration purposes) - envs_dir = get_envs_dir() - env_files = [] - if envs_dir.exists(): - env_files = sorted(envs_dir.glob("*.env")) - - if env_files: - click.echo("\nOld .env files found (not migrated to keyring):") - for env_file in env_files: - env_name = env_file.stem - click.echo(f" - {env_name}") - + # List all environments + if current_env: click.echo( - "\nUse 'cwms-cli env show --name ' to view detailed configuration" + f"Current environment: {click.style(current_env, fg='green', bold=True)}\n" ) + else: + click.echo("No environment currently active\n") + + environments = get_environment_index() + + if environments: + click.echo("Available environments:") + for env_name in environments: + env_config = get_environment(env_name) + if env_config: + is_active = env_name == current_env + marker = "* " if is_active else " " + + api_root = env_config.get("CDA_API_ROOT", "not set") + office = env_config.get("OFFICE", "not set") + has_key = ( + "has API key" if env_config.get("CDA_API_KEY") else "no API key" + ) + + click.echo(f"{marker}{env_name}") + click.echo(f" API Root: {api_root}") + click.echo(f" Office: {office}") + click.echo(f" Status: {has_key}") + else: + click.echo("No environments configured") + click.echo("Run 'cwms-cli env setup ' to create one") + + # Check for old .env files (for migration purposes) + envs_dir = get_envs_dir() + env_files = [] + if envs_dir.exists(): + env_files = sorted(envs_dir.glob("*.env")) + + if env_files: + click.echo("\nOld .env files found (not migrated to keyring):") + for env_file in env_files: + env_name = env_file.stem + click.echo(f" - {env_name}") @env_group.command("delete", help="Delete an environment configuration") diff --git a/docs/cli/env.rst b/docs/cli/env.rst index f8f4fed..86abf47 100644 --- a/docs/cli/env.rst +++ b/docs/cli/env.rst @@ -59,9 +59,6 @@ This spawns a new shell with the environment variables set. When you're done, ty # List all environments with their API roots cwms-cli env show - # View detailed config for a specific environment - cwms-cli env show --name cwbi-dev - Commands -------- @@ -73,7 +70,7 @@ Create or update an environment configuration. .. code-block:: bash # Setup with all options - cwms-cli env setup myenv --api-root https://example.mil/cwms-data --api-key YOUR_KEY --office SWT + cwms-cli env setup myenv --api-root https://cwms-data-dev.example.mil/cwms-data --api-key YOUR_KEY --office SWT # Update just the API key cwms-cli env setup myenv --api-key NEW_KEY @@ -84,17 +81,14 @@ Create or update an environment configuration. cwms-cli env show ~~~~~~~~~~~~~~~~~~ -List all configured environments or show details for a specific one. +List all configured environments. .. code-block:: bash # List all environments with API roots and key status cwms-cli env show - # Show detailed configuration for a specific environment - cwms-cli env show --name cwbi-prod - -**Without --name flag:** +**Output:** .. code-block:: text @@ -112,15 +106,6 @@ List all configured environments or show details for a specific one. The ``*`` marks the currently active environment. -**With --name flag:** - -Shows detailed configuration including: - -- ``CDA_API_ROOT`` - Full URL -- ``CDA_API_KEY`` - Redacted (``***REDACTED***``) -- ``OFFICE`` - Office code -- ``ENVIRONMENT`` - Environment name - cwms-cli env activate ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 48a1b433dae16b16868969b0b9019dab013bcdcd Mon Sep 17 00:00:00 2001 From: msweier Date: Tue, 2 Jun 2026 07:50:59 -0500 Subject: [PATCH 08/11] refactor --- cwmscli/commands/env.py | 405 ++++++++++++++------- cwmscli/utils/credentials.py | 285 --------------- cwmscli/utils/env_store.py | 126 +++++++ cwmscli/utils/logging/__init__.py | 2 +- docs/ENV_MANAGER_SCOPE.md | 264 ++++++++++++++ poetry.lock | 545 +--------------------------- pyproject.toml | 1 - tests/commands/test_env.py | 571 +++++++++++++++++++++--------- 8 files changed, 1071 insertions(+), 1128 deletions(-) delete mode 100644 cwmscli/utils/credentials.py create mode 100644 cwmscli/utils/env_store.py create mode 100644 docs/ENV_MANAGER_SCOPE.md diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 7883a56..586c63f 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -1,39 +1,31 @@ import os +import shutil +import subprocess import sys -from pathlib import Path from typing import Dict, Optional import click -from cwmscli.utils.credentials import ( - CredentialStorageError, - add_to_environment_index, - delete_environment, - get_environment, - get_environment_from_os_environ, - get_environment_index, - is_keyring_available, - remove_from_environment_index, - store_environment, +from cwmscli.utils.env_store import ( + EnvStoreError, + delete_env, + list_envs, + load_env, + save_env, ) - -def get_envs_dir() -> Path: - """Get the directory where environment files are stored.""" - if sys.platform == "win32": - base_dir = Path(os.environ.get("APPDATA", "~/.config")).expanduser() - else: - base_dir = Path("~/.config").expanduser() - - envs_dir = base_dir / "cwms-cli" / "envs" - return envs_dir - +SENSITIVE_KEYS = {"CDA_API_KEY"} ENV_DEFAULTS = { "cwbi-prod": "https://cwms-data.usace.army.mil/cwms-data", } +def _stdout_is_tty() -> bool: + """Indirection so tests can override TTY detection.""" + return sys.stdout.isatty() + + @click.group("env", help="Manage CDA environments and API keys") def env_group(): """Environment management commands for cwms-cli.""" @@ -65,10 +57,8 @@ def setup_cmd( ENV_NAME can be: cwbi-dev, cwbi-test, cwbi-prod, onsite, localhost, or custom """ - # Get existing config from keyring, if any - existing_vars = get_environment(env_name) or {} - - env_vars = existing_vars.copy() + existing = load_env(env_name) or {} + env_vars = dict(existing) env_vars["ENVIRONMENT"] = env_name if api_root: @@ -90,15 +80,14 @@ def setup_cmd( click.echo(f"Available defaults: {', '.join(ENV_DEFAULTS.keys())}", err=True) sys.exit(1) - # Store in keyring try: - store_environment(env_name, env_vars) - add_to_environment_index(env_name) - click.echo(f"Environment '{env_name}' configured securely in system keyring") - except CredentialStorageError as e: + path = save_env(env_name, env_vars) + except EnvStoreError as e: click.echo(f"Error: {e}", err=True) sys.exit(1) + click.echo(f"Environment '{env_name}' saved to {path}") + @env_group.command("show", help="Show current environment and available configurations") def show_cmd(): @@ -109,7 +98,6 @@ def show_cmd(): """ current_env = os.environ.get("ENVIRONMENT") - # List all environments if current_env: click.echo( f"Current environment: {click.style(current_env, fg='green', bold=True)}\n" @@ -117,41 +105,26 @@ def show_cmd(): else: click.echo("No environment currently active\n") - environments = get_environment_index() - - if environments: - click.echo("Available environments:") - for env_name in environments: - env_config = get_environment(env_name) - if env_config: - is_active = env_name == current_env - marker = "* " if is_active else " " - - api_root = env_config.get("CDA_API_ROOT", "not set") - office = env_config.get("OFFICE", "not set") - has_key = ( - "has API key" if env_config.get("CDA_API_KEY") else "no API key" - ) - - click.echo(f"{marker}{env_name}") - click.echo(f" API Root: {api_root}") - click.echo(f" Office: {office}") - click.echo(f" Status: {has_key}") - else: + names = list_envs() + if not names: click.echo("No environments configured") click.echo("Run 'cwms-cli env setup ' to create one") + return - # Check for old .env files (for migration purposes) - envs_dir = get_envs_dir() - env_files = [] - if envs_dir.exists(): - env_files = sorted(envs_dir.glob("*.env")) + click.echo("Available environments:") + for env_name in names: + env_config = load_env(env_name) + if not env_config: + continue + marker = "* " if env_name == current_env else " " + api_root = env_config.get("CDA_API_ROOT", "not set") + office = env_config.get("OFFICE", "not set") + has_key = "has API key" if env_config.get("CDA_API_KEY") else "no API key" - if env_files: - click.echo("\nOld .env files found (not migrated to keyring):") - for env_file in env_files: - env_name = env_file.stem - click.echo(f" - {env_name}") + click.echo(f"{marker}{env_name}") + click.echo(f" API Root: {api_root}") + click.echo(f" Office: {office}") + click.echo(f" Status: {has_key}") @env_group.command("delete", help="Delete an environment configuration") @@ -159,54 +132,119 @@ def show_cmd(): @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") def delete_cmd(env_name: str, yes: bool): """ - Delete an environment configuration from keyring. + Delete an environment configuration. Examples: cwms-cli env delete myenv cwms-cli env delete myenv --yes """ - if not yes: - if not click.confirm(f"Delete environment '{env_name}'?"): - click.echo("Cancelled") - return + if not yes and not click.confirm(f"Delete environment '{env_name}'?"): + click.echo("Cancelled") + return try: - delete_environment(env_name) - remove_from_environment_index(env_name) - click.echo(f"Environment '{env_name}' deleted") - except CredentialStorageError as e: + existed = delete_env(env_name) + except EnvStoreError as e: click.echo(f"Error: {e}", err=True) sys.exit(1) + if existed: + click.echo(f"Environment '{env_name}' deleted") + else: + click.echo(f"Environment '{env_name}' not found", err=True) + sys.exit(1) -def spawn_shell_with_env(env_vars: Dict[str, str], env_name: str): - """Spawn a new shell with environment variables set.""" - import subprocess - # Detect user's shell - user_shell = os.environ.get("SHELL") - if not user_shell: - if sys.platform == "win32": - user_shell = os.environ.get("COMSPEC", "cmd.exe") - else: - user_shell = "/bin/bash" +def _detect_shell() -> str: + """Best-effort detection of the user's interactive shell.""" + if sys.platform == "win32": + # PowerShell sets PSModulePath; prefer pwsh/powershell when present. + if os.environ.get("PSModulePath"): + for candidate in ("pwsh", "powershell"): + found = shutil.which(candidate) + if found: + return found + return os.environ.get("COMSPEC", "cmd.exe") + + return os.environ.get("SHELL", "/bin/bash") + + +def _detect_shell_kind() -> str: + """Map the detected shell path to a known kind, defaulting to 'bash'.""" + path = _detect_shell().lower() + base = os.path.basename(path) + if "pwsh" in base or "powershell" in base: + return "powershell" + if "cmd" in base: + return "cmd" + if "fish" in base: + return "fish" + if "zsh" in base: + return "zsh" + if "bash" in base or "sh" in base: + return "bash" + return "bash" + + +def _export_help_lines(env_name: str) -> str: + """Per-shell instructions for loading an env into the current shell.""" + recipes = { + "bash": f'eval "$(cwms-cli env export {env_name} --format bash)"', + "zsh": f'eval "$(cwms-cli env export {env_name} --format bash)"', + "powershell": ( + f"cwms-cli env export {env_name} --format powershell " + "| Out-String | Invoke-Expression" + ), + "cmd": ( + f"cwms-cli env export {env_name} --format cmd " + f"--output %TEMP%\\cwms-env.cmd && call %TEMP%\\cwms-env.cmd" + ), + "fish": f"cwms-cli env export {env_name} --format fish | source", + } + detected = _detect_shell_kind() + primary = recipes.get(detected, recipes["bash"]) + + lines = [ + f"To load '{env_name}' into your current shell ({detected} detected):", + f" {primary}", + "", + "For other shells:", + ] + label_width = max(len(k) for k in recipes) + for kind, recipe in recipes.items(): + if kind == detected: + continue + lines.append(f" {kind.ljust(label_width)} {recipe}") + lines.extend( + [ + "", + f"Write a .env file: cwms-cli env export {env_name} --output .env", + "Print to terminal anyway: --show-key", + ] + ) + return "\n".join(lines) + - # Create environment dict +def spawn_shell_with_env(env_vars: Dict[str, str], env_name: str): + """Spawn a new shell with environment variables set.""" + user_shell = _detect_shell() new_env = os.environ.copy() new_env.update(env_vars) - # Show activation message click.echo( - f"Activating environment: {click.style(env_name, fg='green', bold=True)}" + f"Activating environment: {click.style(env_name, fg='green', bold=True)}", + err=True, + ) + click.echo(f"Shell: {user_shell}", err=True) + click.echo( + "Type 'exit' or press Ctrl+D to return to your original environment\n", + err=True, ) - click.echo(f"Shell: {user_shell}") - click.echo("Type 'exit' or press Ctrl+D to return to your original environment\n") - # Spawn shell with modified environment try: result = subprocess.run([user_shell], env=new_env) sys.exit(result.returncode) - except Exception as e: + except OSError as e: click.echo(f"Error spawning shell: {e}", err=True) sys.exit(1) @@ -220,49 +258,162 @@ def activate_cmd(env_name: str): The environment variables will be set in the new shell and persist until you exit the shell. Type 'exit' to return to your original environment. + Note: This spawns a child shell. Your parent shell, and any IDE + already open, will not see these variables. To populate the current + shell, use: eval "$(cwms-cli env export --format bash)" + Examples: cwms-cli env activate cwbi-prod cwms-cli env activate localhost """ - # Try to get environment from keyring - env_vars = get_environment(env_name) - + env_vars = load_env(env_name) if not env_vars: - # If not in keyring and keyring not available, try OS environment as fallback - if not is_keyring_available(): - fallback_vars = get_environment_from_os_environ() - if fallback_vars: - click.echo( - f"Using environment variables from current shell (keyring not available)", - err=True, - ) - env_vars = fallback_vars - env_vars["ENVIRONMENT"] = env_name - else: - click.echo( - "Error: Keyring not available and no environment variables found.", - err=True, - ) - click.echo( - "Set CDA_API_ROOT, CDA_API_KEY, and OFFICE in your environment,", - err=True, - ) - click.echo( - "or run 'cwms-cli env setup ' on a system with keyring support.", - err=True, - ) - sys.exit(1) + click.echo(f"Error: Environment '{env_name}' not found", err=True) + click.echo(f"Run 'cwms-cli env setup {env_name}' to create it", err=True) + sys.exit(1) + + spawn_shell_with_env(env_vars, env_name) + + +def _quote_dotenv(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _quote_bash(value: str) -> str: + return "'" + value.replace("'", "'\\''") + "'" + + +def _quote_powershell(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _quote_cmd(value: str) -> str: + # set "K=V" handles spaces and most special chars. Escape embedded " and %. + return value.replace("%", "%%").replace('"', '""') + + +def _quote_fish(value: str) -> str: + # Fish single-quoted strings: backslash escapes \ and '. + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'" + + +def _format_env(env_vars: Dict[str, str], fmt: str) -> str: + items = sorted(env_vars.items()) + lines = [] + for key, value in items: + value = str(value) + if fmt == "dotenv": + lines.append(f"{key}={_quote_dotenv(value)}") + elif fmt == "bash": + lines.append(f"export {key}={_quote_bash(value)}") + elif fmt == "powershell": + lines.append(f"$env:{key} = {_quote_powershell(value)}") + elif fmt == "cmd": + # @ prefix suppresses cmd's default echoing of each line when run via `call`. + lines.append(f'@set "{key}={_quote_cmd(value)}"') + elif fmt == "fish": + lines.append(f"set -gx {key} {_quote_fish(value)}") + else: + raise ValueError(f"Unknown format: {fmt}") + return "\n".join(lines) + + +@env_group.command( + "export", + help="Export an environment's variables to your current shell or a .env file", +) +@click.argument("env_name") +@click.option( + "--format", + "fmt", + type=click.Choice(["dotenv", "bash", "powershell", "cmd", "fish"]), + default="dotenv", + show_default=True, + help="Output syntax. Match this to your shell, or use 'dotenv' for a .env file.", +) +@click.option( + "--output", + "-o", + type=click.Path(dir_okay=False, writable=True, resolve_path=True), + default=None, + help="Write to FILE (mode 0600) instead of standard output.", +) +@click.option( + "--no-key", + is_flag=True, + default=False, + help="Omit CDA_API_KEY (useful for sharing templates).", +) +@click.option( + "--show-key", + is_flag=True, + default=False, + help="Allow the API key to be displayed in your terminal.", +) +def export_cmd( + env_name: str, + fmt: str, + output: Optional[str], + no_key: bool, + show_key: bool, +): + """ + Export a stored environment so your current shell, IDE, or another tool + can use its variables. + + A child process cannot directly modify its parent shell, so this command + emits values that you (or your shell) load. Three common ways: + + \b + # Load into the current bash/zsh shell + eval "$(cwms-cli env export cwbi-prod --format bash)" + + # Load into the current PowerShell session + cwms-cli env export cwbi-prod --format powershell | Out-String | Invoke-Expression + + # Write a .env file for an IDE, docker-compose, or direnv to read + cwms-cli env export cwbi-prod --output .env + + Run with no flags in an interactive terminal to see the right recipe + for your detected shell. The API key is never displayed in a terminal + unless you pass --show-key. + """ + env_vars = load_env(env_name) + if env_vars is None: + click.echo(f"Error: Environment '{env_name}' not found", err=True) + sys.exit(1) + + if no_key: + env_vars = {k: v for k, v in env_vars.items() if k not in SENSITIVE_KEYS} + + has_key = any(k in env_vars for k in SENSITIVE_KEYS) + writing_to_file = output is not None + + if has_key and not writing_to_file and _stdout_is_tty() and not show_key: + click.echo(_export_help_lines(env_name), err=True) + sys.exit(1) + + rendered = _format_env(env_vars, fmt) + + if writing_to_file: + path = output + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if sys.platform == "win32": + fd = os.open(path, flags) else: - click.echo( - f"Error: Environment '{env_name}' not found in keyring", - err=True, - ) - click.echo( - "Run 'cwms-cli env show --name ' to see if it exists", - err=True, - ) - click.echo(f"Or run 'cwms-cli env setup {env_name}' to create it", err=True) + fd = os.open(path, flags, 0o600) + try: + with os.fdopen(fd, "w") as f: + f.write(rendered + "\n") + if sys.platform != "win32": + os.chmod(path, 0o600) + except OSError as e: + click.echo(f"Error writing {path}: {e}", err=True) sys.exit(1) + click.echo(f"Wrote {path} (0600)", err=True) + if path.endswith(".env") or os.path.basename(path).startswith(".env"): + click.echo("Reminder: add this file to .gitignore.", err=True) + return - # Always spawn a new shell - spawn_shell_with_env(env_vars, env_name) + click.echo(rendered) diff --git a/cwmscli/utils/credentials.py b/cwmscli/utils/credentials.py deleted file mode 100644 index 9205beb..0000000 --- a/cwmscli/utils/credentials.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Credential storage using keyring for secure cross-platform credential management.""" - -import json -import os -from typing import Dict, List, Optional - -import keyring -from keyring.errors import KeyringError - - -class CredentialStorageError(Exception): - """Raised when credential storage operations fail.""" - - pass - - -def is_keyring_available() -> bool: - """ - Check if keyring backend is available and functional. - - Returns: - True if keyring can be used, False otherwise - """ - try: - backend = keyring.get_keyring() - # Test write/read/delete to ensure it actually works - test_service = "cwms-cli-test" - test_key = "__availability_test__" - test_value = "test" - - keyring.set_password(test_service, test_key, test_value) - result = keyring.get_password(test_service, test_key) - keyring.delete_password(test_service, test_key) - - return result == test_value - except (KeyringError, Exception): - return False - - -def store_credential(service: str, key: str, value: str) -> None: - """ - Store a credential in the system keyring. - - Args: - service: Service name (e.g., "cwms-cli-env") - key: Key/username for the credential - value: Value/password to store - - Raises: - CredentialStorageError: If keyring is not available - """ - if not is_keyring_available(): - raise CredentialStorageError( - "Secure credential storage (keyring) is not available.\n\n" - "For headless/CI environments, set these environment variables instead:\n" - ' export CDA_API_ROOT="https://..."\n' - ' export CDA_API_KEY="your_key"\n' - ' export OFFICE="SWT"\n\n' - "For interactive systems, install a keyring backend:\n" - " Linux: Install gnome-keyring, kwallet, or python3-secretstorage\n" - " macOS: Uses Keychain (built-in)\n" - " Windows: Uses Credential Manager (built-in)" - ) - - try: - keyring.set_password(service, key, value) - except KeyringError as e: - raise CredentialStorageError(f"Failed to store credential: {e}") from e - - -def get_credential(service: str, key: str) -> Optional[str]: - """ - Retrieve a credential from the system keyring. - - Args: - service: Service name (e.g., "cwms-cli-env") - key: Key/username for the credential - - Returns: - The credential value, or None if not found - """ - if not is_keyring_available(): - return None - - try: - return keyring.get_password(service, key) - except KeyringError: - return None - - -def delete_credential(service: str, key: str) -> None: - """ - Delete a credential from the system keyring. - - Args: - service: Service name (e.g., "cwms-cli-env") - key: Key/username for the credential - - Raises: - CredentialStorageError: If deletion fails - """ - if not is_keyring_available(): - raise CredentialStorageError("Keyring is not available") - - try: - keyring.delete_password(service, key) - except KeyringError as e: - raise CredentialStorageError(f"Failed to delete credential: {e}") from e - - -def list_stored_credentials(service_prefix: str) -> List[str]: - """ - List credential keys for a given service prefix. - - Note: This functionality is limited by keyring backend capabilities. - Some backends don't support enumeration, so this may return an empty list - even if credentials exist. - - Args: - service_prefix: Service name prefix (e.g., "cwms-cli-env") - - Returns: - List of credential keys matching the service prefix - """ - # Most keyring backends don't support enumeration - # This is a limitation we document and work around - # by having users explicitly name environments - return [] - - -# Environment-specific functions - - -def store_environment(env_name: str, config: Dict[str, str]) -> None: - """ - Store environment configuration in keyring. - - Args: - env_name: Name of the environment - config: Dictionary of environment variables to store - - Raises: - CredentialStorageError: If keyring is not available - """ - service = f"cwms-cli-env:{env_name}" - # Store the entire config as a JSON string - config_json = json.dumps(config, sort_keys=True) - store_credential(service, "config", config_json) - - -def get_environment(env_name: str) -> Optional[Dict[str, str]]: - """ - Retrieve environment configuration from keyring. - - Args: - env_name: Name of the environment - - Returns: - Dictionary of environment variables, or None if not found - """ - service = f"cwms-cli-env:{env_name}" - config_json = get_credential(service, "config") - - if config_json: - try: - return json.loads(config_json) - except json.JSONDecodeError: - return None - - return None - - -def delete_environment(env_name: str) -> None: - """ - Delete environment configuration from keyring. - - Args: - env_name: Name of the environment - - Raises: - CredentialStorageError: If deletion fails - """ - service = f"cwms-cli-env:{env_name}" - delete_credential(service, "config") - - -def get_environment_from_os_environ() -> Dict[str, str]: - """ - Build environment config from OS environment variables. - - This is used as a fallback for headless/CI environments where keyring - is not available. Only returns variables that are actually set. - - Returns: - Dictionary of environment variables found in os.environ - """ - env_vars = {} - - # Check for known environment variables - if "CDA_API_ROOT" in os.environ: - env_vars["CDA_API_ROOT"] = os.environ["CDA_API_ROOT"] - - if "CDA_API_KEY" in os.environ: - env_vars["CDA_API_KEY"] = os.environ["CDA_API_KEY"] - - if "OFFICE" in os.environ: - env_vars["OFFICE"] = os.environ["OFFICE"] - - if "ENVIRONMENT" in os.environ: - env_vars["ENVIRONMENT"] = os.environ["ENVIRONMENT"] - - return env_vars - - -# Environment index management - - -def get_environment_index() -> List[str]: - """ - Get list of all environment names from the index. - - Returns: - List of environment names, or empty list if index doesn't exist - """ - if not is_keyring_available(): - return [] - - service = "cwms-cli-meta" - index_json = get_credential(service, "environments") - - if index_json: - try: - return json.loads(index_json) - except json.JSONDecodeError: - return [] - - return [] - - -def add_to_environment_index(env_name: str) -> None: - """ - Add an environment name to the index. - - Args: - env_name: Name of the environment to add - - Raises: - CredentialStorageError: If keyring is not available - """ - if not is_keyring_available(): - raise CredentialStorageError("Keyring is not available") - - index = get_environment_index() - if env_name not in index: - index.append(env_name) - index.sort() - service = "cwms-cli-meta" - store_credential(service, "environments", json.dumps(index)) - - -def remove_from_environment_index(env_name: str) -> None: - """ - Remove an environment name from the index. - - Args: - env_name: Name of the environment to remove - - Raises: - CredentialStorageError: If keyring is not available - """ - if not is_keyring_available(): - raise CredentialStorageError("Keyring is not available") - - index = get_environment_index() - if env_name in index: - index.remove(env_name) - service = "cwms-cli-meta" - if index: - store_credential(service, "environments", json.dumps(index)) - else: - # If index is empty, delete it - try: - delete_credential(service, "environments") - except CredentialStorageError: - pass # It's okay if it doesn't exist diff --git a/cwmscli/utils/env_store.py b/cwmscli/utils/env_store.py new file mode 100644 index 0000000..8649595 --- /dev/null +++ b/cwmscli/utils/env_store.py @@ -0,0 +1,126 @@ +"""File-based storage for named CDA environments. + +One JSON file per environment under the user's config dir, mode 0600 on +POSIX (current-user-only ACL on Windows). Same threat model as +~/.aws/credentials, ~/.config/gh, ~/.kube/config: the user account is +the security boundary. +""" + +import json +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional + + +class EnvStoreError(Exception): + """Raised when an env file cannot be read, written, or deleted.""" + + +def envs_dir() -> Path: + """Return the directory where env files live, creating it if needed.""" + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA") or "~/AppData/Roaming").expanduser() + else: + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg).expanduser() if xdg else Path("~/.config").expanduser() + + path = base / "cwms-cli" / "envs" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _env_path(name: str) -> Path: + if not name or "/" in name or "\\" in name or name in (".", ".."): + raise EnvStoreError(f"Invalid environment name: {name!r}") + return envs_dir() / f"{name}.json" + + +def _restrict_windows_acl(path: Path) -> None: + """Restrict a file to the current user on Windows via icacls. + + No-op on non-Windows. Failures are swallowed: the file already exists + in the user's APPDATA, and surfacing icacls errors hurts more than + it helps. + """ + if sys.platform != "win32": + return + import subprocess + + user = os.environ.get("USERNAME") + if not user: + return + try: + subprocess.run( + ["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:F"], + check=False, + capture_output=True, + ) + except (OSError, FileNotFoundError): + pass + + +def save_env(name: str, config: Dict[str, str]) -> Path: + """Write a config dict to /.json with 0600 perms. + + Uses os.open with O_CREAT|O_TRUNC|O_WRONLY and explicit mode so the + file is never briefly world-readable between create and chmod. + """ + path = _env_path(name) + payload = json.dumps(config, indent=2, sort_keys=True) + "\n" + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if sys.platform == "win32": + # Windows ignores the mode arg; ACL is set after the write. + fd = os.open(path, flags) + else: + fd = os.open(path, flags, 0o600) + + try: + with os.fdopen(fd, "w") as f: + f.write(payload) + except OSError as e: + raise EnvStoreError(f"Failed to write env file {path}: {e}") from e + + if sys.platform != "win32": + # Re-chmod in case the file already existed with different perms. + try: + os.chmod(path, 0o600) + except OSError: + pass + else: + _restrict_windows_acl(path) + + return path + + +def load_env(name: str) -> Optional[Dict[str, str]]: + """Read and parse an env file. Returns None if missing or malformed.""" + path = _env_path(name) + if not path.exists(): + return None + try: + with open(path, "r") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + return data + + +def delete_env(name: str) -> bool: + """Remove an env file. Returns True if it existed.""" + path = _env_path(name) + try: + path.unlink() + return True + except FileNotFoundError: + return False + except OSError as e: + raise EnvStoreError(f"Failed to delete env file {path}: {e}") from e + + +def list_envs() -> List[str]: + """Return sorted env names by listing the envs dir.""" + return sorted(p.stem for p in envs_dir().glob("*.json")) diff --git a/cwmscli/utils/logging/__init__.py b/cwmscli/utils/logging/__init__.py index 6abd5eb..513245d 100644 --- a/cwmscli/utils/logging/__init__.py +++ b/cwmscli/utils/logging/__init__.py @@ -98,7 +98,7 @@ def setup_logging(cfg: LoggingConfig) -> None: base_fmt = "%(asctime)s;%(levelname)s;%(message)s" date_fmt = "%Y-%m-%d %H:%M:%S" - stream_handler = logging.StreamHandler(sys.stdout) + stream_handler = logging.StreamHandler(sys.stderr) stream_handler.setFormatter(ColorLevelFormatter(base_fmt, date_fmt, color_enabled)) root.addHandler(stream_handler) diff --git a/docs/ENV_MANAGER_SCOPE.md b/docs/ENV_MANAGER_SCOPE.md new file mode 100644 index 0000000..da6965a --- /dev/null +++ b/docs/ENV_MANAGER_SCOPE.md @@ -0,0 +1,264 @@ +# Env Manager — Revised Scope + +Working notes for the `cwms-cli env` feature (PR #209). Captures the +direction agreed in review: shift from keyring storage to plaintext config +files, drop the subshell-only activation model, add an `export` emitter, +and wire `load` into named environments. The user-facing value (named, +switchable envs) is unchanged; the storage and activation mechanisms +change. + +## Goals + +- Let users define named CDA environments once and reference them by name. +- Keep API keys out of project directories, shell history, and command lines. +- Work identically on Linux, macOS, Windows (cmd + PowerShell), and Solaris. +- Support `cwms-cli load --source-env X --target-env Y` as the headline payoff. +- No required rc-file edits, no required external services, no heavy deps. + +## Non-goals + +- Encrypted-at-rest secret storage. The threat model is "user account is the + security boundary," matching `aws`, `gcloud`, `kubectl`, `gh`. Users who + need a vault should use one (1Password CLI, Vault, AWS Secrets Manager) + and feed values in via env vars. +- Mutating the parent shell's environment. Documented as an OS constraint; + users get `export` for in-shell use and `activate` for subshell use. +- Per-project env files. Storage is per-user, in `~/.config/cwms-cli/envs/`. + +## Storage + +**Plaintext JSON, mode `0600`, one file per environment.** + +``` +~/.config/cwms-cli/envs/ + cwbi-prod.json + cwbi-dev.json + localhost.json +``` + +Windows: `%APPDATA%\cwms-cli\envs\`, ACL restricted to current user. + +File schema: + +```json +{ + "name": "cwbi-prod", + "api_root": "https://cwms-data.usace.army.mil/cwms-data", + "api_key": "abc123", + "office": "SWT" +} +``` + +Rationale captured separately; short version: works everywhere including +Solaris and headless Linux, no `keyring`/`cryptography` dep tree, enumerable +by `os.listdir`, debuggable with `cat`/`chmod`, same model as every major +dev CLI. Encryption-at-rest on Linux keyring is mostly theater (any +same-user process can read an unlocked keyring); `0600` is the actual +boundary in practice. + +## Commands + +### `cwms-cli env setup ` + +Create or update an env. Prompts for missing required fields when stdin +is a TTY; errors out otherwise (CI-friendly). + +``` +cwms-cli env setup cwbi-prod --api-key YOUR_KEY --office SWT +cwms-cli env setup cwbi-dev --api-root https://cwms-data-dev.example.mil/cwms-data --office SWT +cwms-cli env setup localhost --api-root http://localhost:8082/cwms-data --office DEV +``` + +- Writes `/.json` with `0600`. +- Default `api_root` for known names (`cwbi-prod` at minimum; add others + if URLs are stable). +- Re-running with partial flags updates only the provided fields. + +### `cwms-cli env show []` + +List all envs, or show one. **Always redacts `api_key`** (prints +`has API key` / `no API key`). Safe to paste into tickets, screen-shares, +LLMs. + +``` +$ cwms-cli env show +Available environments: + cwbi-prod + API Root: https://cwms-data.usace.army.mil/cwms-data + Office: SWT + Status: has API key + cwbi-dev + API Root: https://cwms-data-dev.example.mil/cwms-data + Office: SWT + Status: no API key +``` + +### `cwms-cli env delete [--yes]` + +Remove the env file. Confirms unless `--yes`. + +### `cwms-cli env export [--format ...] [--output FILE] [--no-key] [--show-key]` + +The high-leverage addition. Reads the env file and emits its values in +the requested syntax. **Default refuses to print `api_key` to a TTY** +(see Security below). + +Formats: + +| Flag | Output | Use | +|-----------------------|-------------------------------------------------|------------------------------------| +| `--format dotenv` (default) | `KEY=value` lines | IDE dotenv plugins, docker-compose | +| `--format bash` | `export KEY='value'` | `eval "$(... --format bash)"` | +| `--format powershell` | `$env:KEY = 'value'` | `... \| Out-String \| Invoke-Expression` | +| `--format cmd` | `set "KEY=value"` | cmd.exe | + +Options: + +- `--output FILE` writes directly to disk with `0600` perms (preferred over + shell redirection — guarantees correct mode, no scrollback exposure, no + quoting bugs). +- `--no-key` omits `api_key` (for templates / `.env.example`). +- `--show-key` overrides the TTY refusal. + +Quoting must be correct per format (single-quote escaping in bash, doubled +single quotes in PowerShell, `^` escaping in cmd, `\` and `"` escaping in +dotenv). Get this right once. + +### `cwms-cli env activate ` *(keep, with caveats documented)* + +Spawns a subshell with env vars injected. Same as the current PR. + +Documented limitations: +- Parent shell and any already-open IDE do **not** see the vars. +- On Windows, may launch `cmd.exe` even when invoked from PowerShell. +- For "I want my IDE to see this," point users at + `cwms-cli env export --output .env` instead. + +This stays as the zero-friction path for users who just want a one-liner +that "works" without thinking about shell syntax. + +## `load` integration + +The actual payoff. Add to `cwms-cli load` (and any other multi-CDA +commands): + +``` +cwms-cli load timeseries --source-env cwbi-prod --target-env localhost +``` + +- `--source-env NAME` reads `/.json` and feeds its values + into `--source-cda` and `--source-office`. CLI flags still win if both + are passed. +- `--target-env NAME` mirrors for `--target-cda` and `--target-api-key`. +- Mutually exclusive with the explicit `--source-cda` / `--target-cda` + flags (or override-with-warning — pick one and document). + +This replaces today's juggling of `CDA_SOURCE_URL`, `CDA_TARGET_URL`, +`CDA_SOURCE_OFFICE`, `CDA_API_KEY` with two named references. + +## Security posture + +**Spell this out in user-facing docs.** What this feature does and doesn't +defend against: + +| Threat | Defended? | How | +|---------------------------------------|-----------|-------------------------------------------| +| Accidental `git add` of a key | Yes | Files live in `~/.config/`, not the repo | +| Key pasted into LLM via `cat .env` | Yes | Users share `env show` output (redacted) | +| Key visible in `ps` / shell history | Yes | Users reference env name, not flag value | +| Key in tmux/terminal scrollback | Mostly | `export` refuses TTY by default; `--output FILE` skips shell entirely | +| Other user on shared box reading file | Yes (non-root) | `0600` perms, ACL on Windows | +| Root / same-user process | No | Out of scope; matches `aws`/`gcloud`/`gh` | +| Disk theft on unencrypted drive | No | Use full-disk encryption | + +`export --output FILE` is the recommended IDE setup path because it +guarantees `0600`, never touches scrollback, and emits a one-line +stderr reminder to `.gitignore` the output. + +## Code changes + +### Remove + +- `cwmscli/utils/credentials.py` — entire file. Keyring helpers, + `is_keyring_available`, the index management, the os-environ fallback + reader. +- `keyring` dependency from `pyproject.toml`. Regenerate `poetry.lock` + (drops ~544 lines: `keyring`, `cryptography`, `cffi`, `jeepney`, + `SecretStorage`, `jaraco.*`, `backports.tarfile`, `pycparser`). +- `get_envs_dir()` migration block in `env.py` (`.env` files were never + shipped — dead code). +- `tests/commands/test_env.py` keyring-mock fixtures. + +### Add / rewrite + +- `cwmscli/utils/env_store.py` — small module: + - `envs_dir() -> Path` (XDG on Linux/macOS, `%APPDATA%` on Windows). + - `load_env(name) -> dict | None`. + - `save_env(name, dict) -> None` (writes `0600`; on Windows uses + `os.open(..., 0o600)` + ACL helper or `pywin32` fallback). + - `delete_env(name) -> bool`. + - `list_envs() -> list[str]` (just `os.listdir` on the dir). +- `cwmscli/commands/env.py` — rewrite against `env_store`: + - `setup`, `show`, `delete`, `activate` (subshell, unchanged behavior). + - `export` (new) — formats, TTY-refusal, `--output`, `--no-key`. +- `cwmscli/load/root.py` — add `--source-env` / `--target-env` options + that resolve via `load_env()` and populate the existing + `--source-cda` / `--target-cda` / `--source-office` / `--target-api-key`. + +### Tests + +- `env_store`: round-trip save/load/delete/list, file mode is `0600`, + Windows path branch, malformed JSON returns None. +- `env setup`: creates file with right perms; `--api-root` required for + unknown names; partial update preserves other fields. +- `env show`: redacts key; lists multiple envs; handles empty dir. +- `env delete`: confirmation prompt; `--yes` skips it. +- `env export`: + - Each format produces parseable output (shell out to `bash -c`, + parse `KEY=value` for dotenv, etc.). + - Quoting handles `'`, `"`, `$`, spaces, backslashes. + - TTY refusal behavior (mock `isatty`). + - `--output FILE` writes `0600`. + - `--no-key` omits the key field. +- `load --source-env` / `--target-env`: populates the underlying flags; + explicit flags override; missing env errors cleanly. + +### Docs + +- `docs/cli/env.rst` — rewrite to match the file-based model. Drop the + keyring sections. Add the security table above. Document `export` + prominently with the IDE setup recipe. +- `docs/cli/load.rst` (or wherever) — document `--source-env` / + `--target-env`. +- `docs/ENV_MANAGER.md` — update or replace with this scope doc. + +## Migration + +No users yet (PR is draft, never merged). No migration needed. If keyring +data does exist on developer machines from testing, document a one-liner +to extract and rewrite as JSON, but don't ship migration code. + +## Open questions + +1. **Default env directory on Windows** — `%APPDATA%\cwms-cli\envs\` (per + user roaming) or `%LOCALAPPDATA%` (per machine, no roaming)? Roaming + matches `gh`; local matches `aws`. +2. **`--source-env` + explicit `--source-cda`**: hard error, or warn and + let CLI flag win? The latter is more flexible for ad-hoc overrides. +3. **`activate` on Windows / PowerShell**: keep current `COMSPEC` fallback, + or detect `PSModulePath` and prefer `pwsh`/`powershell`? Probably the + detection — current behavior would drop a PowerShell user into cmd.exe. +4. **JSON vs dotenv on disk**: JSON is unambiguous and easy to extend; + dotenv is one less format to parse since `export` already speaks it. + Recommend JSON — env vars aren't the only thing we may want to store + per-env (default time zones, output formats, etc.). + +## Phasing + +1. **Phase 1 (this PR, after revision):** storage + `setup`/`show`/`delete`/ + `activate`/`export`. No `load` integration yet. +2. **Phase 2 (follow-up PR):** `load --source-env` / `--target-env`. This + is when the feature becomes obviously valuable to end users. +3. **Phase 3 (optional, only on demand):** opt-in keyring backend, or + conda-style `init-shell` for in-place activation. Don't build either + until a real user asks. diff --git a/poetry.lock b/poetry.lock index a832286..a823f26 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,22 +1,5 @@ # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. -[[package]] -name = "backports-tarfile" -version = "1.2.0" -description = "Backport of CPython tarfile module" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version <= \"3.11\"" -files = [ - {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, - {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] - [[package]] name = "black" version = "24.10.0" @@ -76,104 +59,6 @@ files = [ {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - [[package]] name = "cfgv" version = "3.4.0" @@ -352,124 +237,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "cryptography" -version = "43.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.9\" and sys_platform == \"linux\"" -files = [ - {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, - {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, - {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, - {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, - {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, - {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, - {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "cryptography" -version = "47.0.0" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] -markers = "python_version >= \"3.10\" and sys_platform == \"linux\"" -files = [ - {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"}, - {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"}, - {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"}, - {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"}, - {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"}, - {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"}, - {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"}, - {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"}, - {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"}, - {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"}, - {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"}, - {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"}, - {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"}, - {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"}, - {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"}, - {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} -typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} - -[package.extras] -ssh = ["bcrypt (>=3.1.5)"] - [[package]] name = "cwms-python" version = "1.0.7" @@ -602,56 +369,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.9\"" -files = [ - {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, - {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "importlib-metadata" -version = "9.0.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\" and python_version < \"3.12\"" -files = [ - {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, - {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - [[package]] name = "iniconfig" version = "2.1.0" @@ -679,143 +396,6 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] -[[package]] -name = "jaraco-classes" -version = "3.4.0" -description = "Utility functions for Python class constructs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, - {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, -] - -[package.dependencies] -more-itertools = "*" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] - -[[package]] -name = "jaraco-context" -version = "6.1.1" -description = "Useful decorators and context managers" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.9\"" -files = [ - {file = "jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808"}, - {file = "jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581"}, -] - -[package.dependencies] -"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "jaraco-context" -version = "6.1.2" -description = "Useful decorators and context managers" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, - {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, -] - -[package.dependencies] -"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - -[[package]] -name = "jaraco-functools" -version = "4.4.0" -description = "Functools like those found in stdlib" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176"}, - {file = "jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"}, -] - -[package.dependencies] -more_itertools = "*" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "jeepney" -version = "0.9.0" -description = "Low-level, pure Python DBus protocol wrapper." -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "sys_platform == \"linux\"" -files = [ - {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, - {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, -] - -[package.extras] -test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] -trio = ["trio"] - -[[package]] -name = "keyring" -version = "25.7.0" -description = "Store and access your passwords safely." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, - {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, -] - -[package.dependencies] -importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} -"jaraco.classes" = "*" -"jaraco.context" = "*" -"jaraco.functools" = "*" -jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} -pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} -SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -completion = ["shtab (>=1.1.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] -type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] - [[package]] name = "librt" version = "0.8.1" @@ -934,32 +514,6 @@ click = ">=8.0.1,<9.0.0" pydantic = ">=1.10.13,<2.0.0" toml = ">=0.10.2,<0.11.0" -[[package]] -name = "more-itertools" -version = "10.8.0" -description = "More routines for operating on iterables, beyond itertools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.9\"" -files = [ - {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, - {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, -] - -[[package]] -name = "more-itertools" -version = "11.0.2" -description = "More routines for operating on iterables, beyond itertools" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4"}, - {file = "more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804"}, -] - [[package]] name = "mypy" version = "1.19.1" @@ -1367,32 +921,6 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" -[[package]] -name = "pycparser" -version = "2.23" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\" and python_version == \"3.9\"" -files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, -] - -[[package]] -name = "pycparser" -version = "3.0" -description = "C parser in Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and sys_platform == \"linux\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, -] - [[package]] name = "pydantic" version = "1.10.26" @@ -1533,19 +1061,6 @@ files = [ {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, ] -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -description = "A (partial) reimplementation of pywin32 using ctypes/cffi" -optional = false -python-versions = ">=3.6" -groups = ["main"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, - {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -1685,40 +1200,6 @@ setuptools = ">=39.0" [package.extras] docs = ["Sphinx"] -[[package]] -name = "secretstorage" -version = "3.3.3" -description = "Python bindings to FreeDesktop.org Secret Service API" -optional = false -python-versions = ">=3.6" -groups = ["main"] -markers = "python_version == \"3.9\" and sys_platform == \"linux\"" -files = [ - {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, - {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, -] - -[package.dependencies] -cryptography = ">=2.0" -jeepney = ">=0.6" - -[[package]] -name = "secretstorage" -version = "3.5.0" -description = "Python bindings to FreeDesktop.org Secret Service API" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\" and sys_platform == \"linux\"" -files = [ - {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, - {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, -] - -[package.dependencies] -cryptography = ">=2.0" -jeepney = ">=0.6" - [[package]] name = "setuptools" version = "82.0.1" @@ -1828,12 +1309,11 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "sys_platform == \"linux\" and python_version == \"3.10\""} [[package]] name = "tzdata" @@ -1904,28 +1384,7 @@ click = ">=8.1.3" maison = ">=1.4.0,<1.4.3" ruyaml = ">=0.91.0" -[[package]] -name = "zipp" -version = "3.23.1" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version <= \"3.11\"" -files = [ - {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, - {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "3cb5794dfc58e421338f2f7f2387afec10ee79890ea19f25efdb1f9ef974a950" +content-hash = "056bcf91885aac3e6fe947610dbf8925f3ab359f94382b8dcb12035db4610472" diff --git a/pyproject.toml b/pyproject.toml index 57daac0..8bbca93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ requests = "^2.30.0" hecdss = { version = ">=0.1.24", optional = true } # Via https://github.com/HydrologicEngineeringCenter/hec-python-library/blob/main/hec/shared.py#L9-10 cwms-python = { version = ">=1.0.7", optional = true} colorama = "^0.4.6" -keyring = "^25.0.0" [tool.poetry.group.dev.dependencies] black = "^24.2.0" diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py index d1b9407..5a4e470 100644 --- a/tests/commands/test_env.py +++ b/tests/commands/test_env.py @@ -1,229 +1,458 @@ +"""Tests for `cwms-cli env` commands and the file-based env store.""" + +import json import os -from pathlib import Path -from unittest.mock import MagicMock, patch +import stat +import subprocess +import sys import pytest +from click.testing import CliRunner + +from cwmscli.commands.env import ( + _format_env, + _quote_bash, + _quote_cmd, + _quote_dotenv, + _quote_fish, + _quote_powershell, + env_group, +) +from cwmscli.utils.env_store import ( + EnvStoreError, + delete_env, + envs_dir, + list_envs, + load_env, + save_env, +) + -from cwmscli.commands.env import get_envs_dir +@pytest.fixture +def isolated_envs(monkeypatch, tmp_path): + """Redirect env storage to a tmp dir for each test.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + monkeypatch.delenv("ENVIRONMENT", raising=False) + return tmp_path -def test_get_envs_dir_returns_path(): - envs_dir = get_envs_dir() - assert isinstance(envs_dir, Path) - assert "cwms-cli" in str(envs_dir) - assert "envs" in str(envs_dir) +# ---------- env_store ---------- -# Test keyring-based environment management -@pytest.fixture -def mock_keyring(): - """Mock keyring module for testing.""" - with patch("cwmscli.utils.credentials.keyring") as mock: - # Mock successful keyring operations - mock.get_keyring.return_value = MagicMock() - mock.get_password.return_value = None - mock.set_password.return_value = None - mock.delete_password.return_value = None - yield mock - - -def test_store_and_get_environment(mock_keyring): - """Test storing and retrieving environment configuration.""" - from cwmscli.utils.credentials import get_environment, store_environment - - env_name = "test-env" - env_vars = { - "ENVIRONMENT": "test", - "CDA_API_ROOT": "https://example.com/cwms-data", - "CDA_API_KEY": "secret123", +def test_save_load_roundtrip(isolated_envs): + config = { + "ENVIRONMENT": "demo", + "CDA_API_ROOT": "https://example.mil/cwms-data", + "CDA_API_KEY": "abc123", "OFFICE": "SWT", } + save_env("demo", config) + assert load_env("demo") == config - # Mock storage - stored_data = {} - def set_password(service, key, value): - stored_data[f"{service}:{key}"] = value +def test_save_env_writes_0600(isolated_envs): + if sys.platform == "win32": + pytest.skip("POSIX permission semantics") + path = save_env("demo", {"CDA_API_KEY": "k"}) + mode = stat.S_IMODE(os.stat(path).st_mode) + assert mode == 0o600 - def get_password(service, key): - return stored_data.get(f"{service}:{key}") - mock_keyring.set_password.side_effect = set_password - mock_keyring.get_password.side_effect = get_password +def test_save_env_overwrites_with_0600(isolated_envs): + if sys.platform == "win32": + pytest.skip("POSIX permission semantics") + path = save_env("demo", {"a": "1"}) + os.chmod(path, 0o644) + save_env("demo", {"a": "2"}) + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + assert load_env("demo") == {"a": "2"} - # Store environment - store_environment(env_name, env_vars) - # Verify it was stored - assert f"cwms-cli-env:{env_name}:config" in stored_data +def test_load_env_missing_returns_none(isolated_envs): + assert load_env("nope") is None - # Retrieve environment - retrieved_vars = get_environment(env_name) - assert retrieved_vars == env_vars +def test_load_env_malformed_returns_none(isolated_envs): + path = envs_dir() / "bad.json" + path.write_text("{not valid json") + assert load_env("bad") is None -def test_delete_environment(mock_keyring): - """Test deleting environment configuration.""" - from cwmscli.utils.credentials import ( - delete_environment, - get_environment, - store_environment, - ) - env_name = "test-env" - env_vars = {"ENVIRONMENT": "test", "CDA_API_ROOT": "https://example.com"} +def test_load_env_non_dict_returns_none(isolated_envs): + path = envs_dir() / "list.json" + path.write_text("[1, 2, 3]") + assert load_env("list") is None - # Mock storage - stored_data = {} - def set_password(service, key, value): - stored_data[f"{service}:{key}"] = value +def test_delete_env_existing(isolated_envs): + save_env("demo", {"a": "1"}) + assert delete_env("demo") is True + assert load_env("demo") is None - def get_password(service, key): - return stored_data.get(f"{service}:{key}") - def delete_password(service, key): - stored_data.pop(f"{service}:{key}", None) +def test_delete_env_missing(isolated_envs): + assert delete_env("nope") is False - mock_keyring.set_password.side_effect = set_password - mock_keyring.get_password.side_effect = get_password - mock_keyring.delete_password.side_effect = delete_password - # Store and then delete - store_environment(env_name, env_vars) - delete_environment(env_name) +def test_list_envs(isolated_envs): + assert list_envs() == [] + save_env("alpha", {"a": "1"}) + save_env("beta", {"a": "1"}) + save_env("gamma", {"a": "1"}) + assert list_envs() == ["alpha", "beta", "gamma"] - # Verify it was deleted - assert get_environment(env_name) is None +@pytest.mark.parametrize("bad", ["", ".", "..", "a/b", "a\\b"]) +def test_invalid_env_names_rejected(isolated_envs, bad): + with pytest.raises(EnvStoreError): + save_env(bad, {"a": "1"}) -def test_get_environment_nonexistent_returns_none(mock_keyring): - """Test retrieving non-existent environment returns None.""" - from cwmscli.utils.credentials import get_environment - mock_keyring.get_password.return_value = None - result = get_environment("nonexistent") - assert result is None +# ---------- env setup ---------- -def test_environment_index_management(mock_keyring): - """Test adding and removing environments from the index.""" - from cwmscli.utils.credentials import ( - add_to_environment_index, - get_environment_index, - remove_from_environment_index, +def test_setup_creates_file_with_0600(isolated_envs): + runner = CliRunner() + result = runner.invoke( + env_group, + ["setup", "myenv", "--api-root", "https://x.mil/cwms-data", "--api-key", "k"], ) + assert result.exit_code == 0, result.output + path = envs_dir() / "myenv.json" + assert path.exists() + if sys.platform != "win32": + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + stored = json.loads(path.read_text()) + assert stored["CDA_API_ROOT"] == "https://x.mil/cwms-data" + assert stored["CDA_API_KEY"] == "k" + assert stored["ENVIRONMENT"] == "myenv" + + +def test_setup_uses_default_for_known_name(isolated_envs): + runner = CliRunner() + result = runner.invoke(env_group, ["setup", "cwbi-prod", "--api-key", "k"]) + assert result.exit_code == 0, result.output + stored = load_env("cwbi-prod") + assert stored["CDA_API_ROOT"] == "https://cwms-data.usace.army.mil/cwms-data" + + +def test_setup_requires_api_root_for_unknown_name(isolated_envs): + runner = CliRunner() + result = runner.invoke(env_group, ["setup", "newenv", "--api-key", "k"]) + assert result.exit_code != 0 + assert "--api-root is required" in result.output + + +def test_setup_partial_update_preserves_fields(isolated_envs): + runner = CliRunner() + runner.invoke( + env_group, + [ + "setup", + "myenv", + "--api-root", + "https://x.mil/cwms-data", + "--api-key", + "old", + "--office", + "SWT", + ], + ) + result = runner.invoke(env_group, ["setup", "myenv", "--api-key", "new"]) + assert result.exit_code == 0, result.output + stored = load_env("myenv") + assert stored["CDA_API_KEY"] == "new" + assert stored["CDA_API_ROOT"] == "https://x.mil/cwms-data" + assert stored["OFFICE"] == "SWT" + + +def test_setup_uppercases_office(isolated_envs): + runner = CliRunner() + runner.invoke( + env_group, + ["setup", "myenv", "--api-root", "https://x", "--office", "swt"], + ) + assert load_env("myenv")["OFFICE"] == "SWT" - # Mock storage - stored_data = {} - - def set_password(service, key, value): - stored_data[f"{service}:{key}"] = value - - def get_password(service, key): - return stored_data.get(f"{service}:{key}") - - def delete_password(service, key): - stored_data.pop(f"{service}:{key}", None) - - mock_keyring.set_password.side_effect = set_password - mock_keyring.get_password.side_effect = get_password - mock_keyring.delete_password.side_effect = delete_password - # Initially empty - assert get_environment_index() == [] +# ---------- env show ---------- - # Add environments - add_to_environment_index("env1") - add_to_environment_index("env2") - assert sorted(get_environment_index()) == ["env1", "env2"] - # Remove environment - remove_from_environment_index("env1") - assert get_environment_index() == ["env2"] +def test_show_empty(isolated_envs): + runner = CliRunner() + result = runner.invoke(env_group, ["show"]) + assert result.exit_code == 0 + assert "No environments configured" in result.output -def test_get_environment_from_os_environ(): - """Test building environment config from OS environment variables.""" - from cwmscli.utils.credentials import get_environment_from_os_environ +def test_show_lists_envs_and_redacts_key(isolated_envs): + save_env( + "withkey", + {"CDA_API_ROOT": "https://x", "CDA_API_KEY": "supersecret", "OFFICE": "SWT"}, + ) + save_env("nokey", {"CDA_API_ROOT": "https://y"}) + runner = CliRunner() + result = runner.invoke(env_group, ["show"]) + assert result.exit_code == 0 + assert "withkey" in result.output + assert "nokey" in result.output + assert "has API key" in result.output + assert "no API key" in result.output + assert "supersecret" not in result.output + + +def test_show_marks_current_env(isolated_envs, monkeypatch): + save_env("active", {"CDA_API_ROOT": "https://x"}) + monkeypatch.setenv("ENVIRONMENT", "active") + runner = CliRunner() + result = runner.invoke(env_group, ["show"]) + assert "Current environment:" in result.output + assert "* active" in result.output + + +# ---------- env delete ---------- + + +def test_delete_with_yes_flag(isolated_envs): + save_env("doomed", {"a": "1"}) + runner = CliRunner() + result = runner.invoke(env_group, ["delete", "doomed", "--yes"]) + assert result.exit_code == 0 + assert load_env("doomed") is None + + +def test_delete_confirmation_cancel(isolated_envs): + save_env("safe", {"a": "1"}) + runner = CliRunner() + result = runner.invoke(env_group, ["delete", "safe"], input="n\n") + assert result.exit_code == 0 + assert "Cancelled" in result.output + assert load_env("safe") == {"a": "1"} + + +def test_delete_missing_env_errors(isolated_envs): + runner = CliRunner() + result = runner.invoke(env_group, ["delete", "ghost", "--yes"]) + assert result.exit_code != 0 + assert "not found" in result.output + + +# ---------- quoting helpers ---------- + + +@pytest.mark.parametrize( + "value", + [ + "plain", + "with spaces", + "with'apostrophe", + 'with"quote', + "with\\backslash", + "with$dollar", + "with`backtick", + "with'multi'apos", + "a'b\"c\\d$e", + ], +) +def test_bash_quoting_roundtrip(value): + """Eval the bash output and confirm the value comes back unchanged.""" + line = f"export X={_quote_bash(value)}" + out = subprocess.check_output(["bash", "-c", f'{line}; printf %s "$X"']) + assert out.decode() == value + + +@pytest.mark.parametrize( + "value", + [ + "plain", + "with spaces", + 'with"quote', + "with\\backslash", + "with$dollar", + ], +) +def test_dotenv_quoting_roundtrip(value): + """Parse dotenv with python-dotenv-style rules and confirm round-trip.""" + line = f"X={_quote_dotenv(value)}" + # Strip the wrapping double quotes, then unescape \\ and \" + raw = line.split("=", 1)[1] + assert raw.startswith('"') and raw.endswith('"') + inner = raw[1:-1] + decoded = inner.replace('\\"', '"').replace("\\\\", "\\") + assert decoded == value + + +def test_powershell_quote_doubles_apostrophes(): + assert _quote_powershell("a'b") == "'a''b'" + assert _quote_powershell("plain") == "'plain'" + + +def test_cmd_quote_doubles_percent_and_quote(): + assert _quote_cmd("100%") == "100%%" + assert _quote_cmd('a"b') == 'a""b' + + +def test_fish_quote_escapes_apostrophe_and_backslash(): + assert _quote_fish("a'b") == "'a\\'b'" + assert _quote_fish("a\\b") == "'a\\\\b'" + assert _quote_fish("plain") == "'plain'" - with patch.dict( - os.environ, - { - "CDA_API_ROOT": "https://example.com/cwms-data", - "CDA_API_KEY": "test-key", - "OFFICE": "SWT", - "ENVIRONMENT": "test", - }, - clear=False, - ): - result = get_environment_from_os_environ() - assert result == { - "CDA_API_ROOT": "https://example.com/cwms-data", - "CDA_API_KEY": "test-key", - "OFFICE": "SWT", - "ENVIRONMENT": "test", - } +# ---------- env export ---------- -def test_get_environment_from_os_environ_partial(): - """Test building config with only some variables set.""" - from cwmscli.utils.credentials import get_environment_from_os_environ - with patch.dict( - os.environ, +@pytest.fixture +def env_with_key(isolated_envs): + save_env( + "demo", { - "CDA_API_ROOT": "https://example.com/cwms-data", + "ENVIRONMENT": "demo", + "CDA_API_ROOT": "https://x.mil/cwms-data", + "CDA_API_KEY": "secret", "OFFICE": "SWT", }, - clear=True, - ): - result = get_environment_from_os_environ() - assert result == { - "CDA_API_ROOT": "https://example.com/cwms-data", - "OFFICE": "SWT", - } - assert "CDA_API_KEY" not in result - assert "ENVIRONMENT" not in result - - -def test_is_keyring_available_success(mock_keyring): - """Test keyring availability check when keyring works.""" - from cwmscli.utils.credentials import is_keyring_available - - # Mock successful keyring operations - stored_value = None + ) + return "demo" - def set_password(service, key, value): - nonlocal stored_value - stored_value = value - def get_password(service, key): - return stored_value +def test_export_dotenv_format(env_with_key): + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key, "--show-key"]) + assert result.exit_code == 0 + out = result.output + assert 'CDA_API_ROOT="https://x.mil/cwms-data"' in out + assert 'CDA_API_KEY="secret"' in out + assert 'OFFICE="SWT"' in out - mock_keyring.set_password.side_effect = set_password - mock_keyring.get_password.side_effect = get_password - mock_keyring.delete_password.return_value = None - assert is_keyring_available() is True +def test_export_bash_format_evaluates(env_with_key): + runner = CliRunner() + result = runner.invoke( + env_group, ["export", env_with_key, "--format", "bash", "--show-key"] + ) + assert result.exit_code == 0 + script = ( + result.output + '\nprintf "%s|%s|%s" "$CDA_API_ROOT" "$CDA_API_KEY" "$OFFICE"' + ) + out = subprocess.check_output(["bash", "-c", script]).decode() + assert out == "https://x.mil/cwms-data|secret|SWT" -def test_is_keyring_available_failure(mock_keyring): - """Test keyring availability check when keyring fails.""" - from keyring.errors import KeyringError +def test_export_powershell_format_structure(env_with_key): + runner = CliRunner() + result = runner.invoke( + env_group, ["export", env_with_key, "--format", "powershell", "--show-key"] + ) + assert result.exit_code == 0 + assert "$env:CDA_API_KEY = 'secret'" in result.output - from cwmscli.utils.credentials import is_keyring_available - # Mock keyring failure - mock_keyring.get_keyring.side_effect = KeyringError("No keyring available") +def test_export_cmd_format_structure(env_with_key): + runner = CliRunner() + result = runner.invoke( + env_group, ["export", env_with_key, "--format", "cmd", "--show-key"] + ) + assert result.exit_code == 0 + # @ prefix prevents cmd from echoing the line (which would leak the key) + assert '@set "CDA_API_KEY=secret"' in result.output - assert is_keyring_available() is False +def test_export_cmd_format_all_lines_silenced(env_with_key): + runner = CliRunner() + result = runner.invoke( + env_group, ["export", env_with_key, "--format", "cmd", "--show-key"] + ) + assert result.exit_code == 0 + for line in result.output.strip().splitlines(): + if line.startswith("set "): + pytest.fail(f"unsuppressed set line would echo the value: {line!r}") -def test_store_environment_no_keyring(): - """Test storing environment when keyring is not available.""" - from cwmscli.utils.credentials import CredentialStorageError, store_environment - with patch("cwmscli.utils.credentials.is_keyring_available", return_value=False): - with pytest.raises(CredentialStorageError, match="Secure credential storage"): - store_environment("test", {"CDA_API_ROOT": "https://example.com"}) +def test_export_fish_format_structure(env_with_key): + runner = CliRunner() + result = runner.invoke( + env_group, ["export", env_with_key, "--format", "fish", "--show-key"] + ) + assert result.exit_code == 0 + assert "set -gx CDA_API_KEY 'secret'" in result.output + + +def test_export_tty_refusal(env_with_key, monkeypatch): + monkeypatch.setattr("cwmscli.commands.env._stdout_is_tty", lambda: True) + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key]) + assert result.exit_code != 0 + assert "into your current shell" in result.output + assert "--format bash" in result.output + assert "Out-String | Invoke-Expression" in result.output + assert "secret" not in result.output + + +def test_export_help_uses_detected_shell(env_with_key, monkeypatch): + monkeypatch.setattr("cwmscli.commands.env._stdout_is_tty", lambda: True) + monkeypatch.setattr("cwmscli.commands.env._detect_shell_kind", lambda: "powershell") + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key]) + assert result.exit_code != 0 + # Powershell recipe is the primary; bash etc. listed as "other shells" + first_line = result.output.splitlines()[0] + assert "powershell detected" in first_line + assert "For other shells:" in result.output + + +def test_export_show_key_overrides_tty_refusal(env_with_key, monkeypatch): + monkeypatch.setattr("cwmscli.commands.env._stdout_is_tty", lambda: True) + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key, "--show-key"]) + assert result.exit_code == 0 + assert "secret" in result.output + + +def test_export_no_key_omits_key(env_with_key): + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key, "--no-key"]) + assert result.exit_code == 0 + assert "CDA_API_KEY" not in result.output + assert "CDA_API_ROOT" in result.output + + +def test_export_no_key_allows_tty_when_no_key(isolated_envs, monkeypatch): + save_env("nokey", {"CDA_API_ROOT": "https://x", "OFFICE": "SWT"}) + monkeypatch.setattr("cwmscli.commands.env._stdout_is_tty", lambda: True) + runner = CliRunner() + result = runner.invoke(env_group, ["export", "nokey"]) + assert result.exit_code == 0 + assert "https://x" in result.output + + +def test_export_output_writes_0600(env_with_key, tmp_path): + out = tmp_path / "out.env" + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key, "--output", str(out)]) + assert result.exit_code == 0, result.output + assert out.exists() + if sys.platform != "win32": + assert stat.S_IMODE(os.stat(out).st_mode) == 0o600 + contents = out.read_text() + assert 'CDA_API_KEY="secret"' in contents + + +def test_export_output_dotenv_prints_gitignore_reminder(env_with_key, tmp_path): + out = tmp_path / ".env" + runner = CliRunner() + result = runner.invoke(env_group, ["export", env_with_key, "--output", str(out)]) + assert result.exit_code == 0 + assert "gitignore" in result.output + + +def test_export_missing_env_errors(isolated_envs): + runner = CliRunner() + result = runner.invoke(env_group, ["export", "ghost"]) + assert result.exit_code != 0 + assert "not found" in result.output + + +def test_format_env_sorts_keys(): + out = _format_env({"B": "2", "A": "1"}, "dotenv") + assert out.splitlines() == ['A="1"', 'B="2"'] From 8d590b31804e81c9c3338a62e1a7486968866d13 Mon Sep 17 00:00:00 2001 From: msweier Date: Wed, 1 Jul 2026 10:31:26 -0500 Subject: [PATCH 09/11] refactor: unify per-user config dir across auth and env storage --- cwmscli/load/root.py | 76 +++++++- cwmscli/utils/auth.py | 9 +- cwmscli/utils/env_store.py | 12 +- cwmscli/utils/paths.py | 23 +++ docs/ENV_MANAGER_SCOPE.md | 264 --------------------------- docs/cli/env.rst | 241 ++++++++++++++---------- docs/cli/load_location_ids_all.rst | 16 ++ tests/load/test_source_target_env.py | 255 ++++++++++++++++++++++++++ tests/utils/test_paths.py | 33 ++++ 9 files changed, 547 insertions(+), 382 deletions(-) create mode 100644 cwmscli/utils/paths.py delete mode 100644 docs/ENV_MANAGER_SCOPE.md create mode 100644 tests/load/test_source_target_env.py create mode 100644 tests/utils/test_paths.py diff --git a/cwmscli/load/root.py b/cwmscli/load/root.py index 1366ad3..f2ba1b9 100644 --- a/cwmscli/load/root.py +++ b/cwmscli/load/root.py @@ -10,6 +10,7 @@ from cwmscli import requirements as reqs from cwmscli.utils.deps import requires +from cwmscli.utils.env_store import load_env logger = logging.getLogger(__name__) @@ -43,6 +44,51 @@ def _norm_office(o: Optional[str]) -> str: def validate_cda_targets(func): @functools.wraps(func) def wrapper(*args, **kwargs): + source_env = kwargs.pop("source_env", None) + target_env = kwargs.pop("target_env", None) + + if source_env is not None: + if _param_was_explicit("source_cda"): + raise click.ClickException( + "--source-env and --source-cda are mutually exclusive." + ) + env_data = load_env(source_env) + if env_data is None: + raise click.ClickException( + f"Environment '{source_env}' not found. " + "Run 'cwms-cli env show' to list available environments." + ) + if "CDA_API_ROOT" not in env_data: + raise click.ClickException( + f"Environment '{source_env}' has no CDA_API_ROOT configured. " + f"Run 'cwms-cli env setup {source_env} --api-root ' to fix." + ) + kwargs["source_cda"] = env_data["CDA_API_ROOT"] + if not _param_was_explicit("source_office") and env_data.get("OFFICE"): + kwargs["source_office"] = env_data["OFFICE"] + + if target_env is not None: + if _param_was_explicit("target_cda"): + raise click.ClickException( + "--target-env and --target-cda are mutually exclusive." + ) + env_data = load_env(target_env) + if env_data is None: + raise click.ClickException( + f"Environment '{target_env}' not found. " + "Run 'cwms-cli env show' to list available environments." + ) + if "CDA_API_ROOT" not in env_data: + raise click.ClickException( + f"Environment '{target_env}' has no CDA_API_ROOT configured. " + f"Run 'cwms-cli env setup {target_env} --api-root ' to fix." + ) + kwargs["target_cda"] = env_data["CDA_API_ROOT"] + if not _param_was_explicit("target_api_key") and env_data.get( + "CDA_API_KEY" + ): + kwargs["target_api_key"] = env_data["CDA_API_KEY"] + source_csv = kwargs.get("source_csv") target_csv = kwargs.get("target_csv") @@ -53,16 +99,20 @@ def wrapper(*args, **kwargs): ) if source_csv: - if kwargs.get("source_cda") and _param_was_explicit("source_cda"): + if kwargs.get("source_cda") and ( + _param_was_explicit("source_cda") or source_env is not None + ): raise click.ClickException( - "--source-csv and --source-cda are mutually exclusive." + "--source-csv and --source-cda/--source-env are mutually exclusive." ) kwargs["source_cda"] = None if target_csv: - if kwargs.get("target_cda") and _param_was_explicit("target_cda"): + if kwargs.get("target_cda") and ( + _param_was_explicit("target_cda") or target_env is not None + ): raise click.ClickException( - "--target-csv and --target-cda are mutually exclusive." + "--target-csv and --target-cda/--target-env are mutually exclusive." ) kwargs["target_cda"] = None @@ -134,6 +184,24 @@ def shared_source_target_options(f): envvar="CDA_API_KEY", help="Target API key used when no saved cwms-cli login token is available.", )(f) + f = click.option( + "--source-env", + default=None, + help=( + "Named CDA environment for the source " + "(resolves api-root and office from ~/.config/cwms-cli/envs/). " + "Mutually exclusive with --source-cda." + ), + )(f) + f = click.option( + "--target-env", + default=None, + help=( + "Named CDA environment for the target " + "(resolves api-root and api-key from ~/.config/cwms-cli/envs/). " + "Mutually exclusive with --target-cda." + ), + )(f) f = click.option( "--dry-run/--no-dry-run", is_flag=True, diff --git a/cwmscli/utils/auth.py b/cwmscli/utils/auth.py index d6f5a9c..67ac887 100644 --- a/cwmscli/utils/auth.py +++ b/cwmscli/utils/auth.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import Any, Dict, Optional +from cwmscli.utils.paths import config_dir + DEFAULT_CLIENT_ID = "cwms" DEFAULT_CDA_API_ROOT = "https://cwms-data.usace.army.mil/cwms-data" DEFAULT_OIDC_BASE_URL = ( @@ -109,12 +111,7 @@ def log_message(self, format: str, *args: Any) -> None: def default_token_file(provider: str) -> Path: - config_root = os.getenv("XDG_CONFIG_HOME") - if config_root: - base_dir = Path(config_root) - else: - base_dir = Path.home() / ".config" - return base_dir / "cwms-cli" / "auth" / f"{provider}.json" + return config_dir("auth") / f"{provider}.json" def _oidc_cache_file() -> Path: diff --git a/cwmscli/utils/env_store.py b/cwmscli/utils/env_store.py index 8649595..e31f38d 100644 --- a/cwmscli/utils/env_store.py +++ b/cwmscli/utils/env_store.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import Dict, List, Optional +from cwmscli.utils.paths import config_dir + class EnvStoreError(Exception): """Raised when an env file cannot be read, written, or deleted.""" @@ -19,15 +21,7 @@ class EnvStoreError(Exception): def envs_dir() -> Path: """Return the directory where env files live, creating it if needed.""" - if sys.platform == "win32": - base = Path(os.environ.get("APPDATA") or "~/AppData/Roaming").expanduser() - else: - xdg = os.environ.get("XDG_CONFIG_HOME") - base = Path(xdg).expanduser() if xdg else Path("~/.config").expanduser() - - path = base / "cwms-cli" / "envs" - path.mkdir(parents=True, exist_ok=True) - return path + return config_dir("envs", create=True) def _env_path(name: str) -> Path: diff --git a/cwmscli/utils/paths.py b/cwmscli/utils/paths.py new file mode 100644 index 0000000..873d1bb --- /dev/null +++ b/cwmscli/utils/paths.py @@ -0,0 +1,23 @@ +"""Per-user config directory resolution for cwms-cli. + +Single source of truth so saved logins, named environments, and any future +per-user state all live under one root on every platform. +""" + +import os +from pathlib import Path + + +def config_dir(*parts: str, create: bool = False) -> Path: + """Return cwms-cli's per-user config dir, joined with *parts. + + Uses ``$XDG_CONFIG_HOME/cwms-cli`` when set, else ``~/.config/cwms-cli``, + on all platforms so a user's tokens and environments share one root. + Pass ``create=True`` to ensure the directory exists. + """ + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg).expanduser() if xdg else Path("~/.config").expanduser() + path = base.joinpath("cwms-cli", *parts) + if create: + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/docs/ENV_MANAGER_SCOPE.md b/docs/ENV_MANAGER_SCOPE.md deleted file mode 100644 index da6965a..0000000 --- a/docs/ENV_MANAGER_SCOPE.md +++ /dev/null @@ -1,264 +0,0 @@ -# Env Manager — Revised Scope - -Working notes for the `cwms-cli env` feature (PR #209). Captures the -direction agreed in review: shift from keyring storage to plaintext config -files, drop the subshell-only activation model, add an `export` emitter, -and wire `load` into named environments. The user-facing value (named, -switchable envs) is unchanged; the storage and activation mechanisms -change. - -## Goals - -- Let users define named CDA environments once and reference them by name. -- Keep API keys out of project directories, shell history, and command lines. -- Work identically on Linux, macOS, Windows (cmd + PowerShell), and Solaris. -- Support `cwms-cli load --source-env X --target-env Y` as the headline payoff. -- No required rc-file edits, no required external services, no heavy deps. - -## Non-goals - -- Encrypted-at-rest secret storage. The threat model is "user account is the - security boundary," matching `aws`, `gcloud`, `kubectl`, `gh`. Users who - need a vault should use one (1Password CLI, Vault, AWS Secrets Manager) - and feed values in via env vars. -- Mutating the parent shell's environment. Documented as an OS constraint; - users get `export` for in-shell use and `activate` for subshell use. -- Per-project env files. Storage is per-user, in `~/.config/cwms-cli/envs/`. - -## Storage - -**Plaintext JSON, mode `0600`, one file per environment.** - -``` -~/.config/cwms-cli/envs/ - cwbi-prod.json - cwbi-dev.json - localhost.json -``` - -Windows: `%APPDATA%\cwms-cli\envs\`, ACL restricted to current user. - -File schema: - -```json -{ - "name": "cwbi-prod", - "api_root": "https://cwms-data.usace.army.mil/cwms-data", - "api_key": "abc123", - "office": "SWT" -} -``` - -Rationale captured separately; short version: works everywhere including -Solaris and headless Linux, no `keyring`/`cryptography` dep tree, enumerable -by `os.listdir`, debuggable with `cat`/`chmod`, same model as every major -dev CLI. Encryption-at-rest on Linux keyring is mostly theater (any -same-user process can read an unlocked keyring); `0600` is the actual -boundary in practice. - -## Commands - -### `cwms-cli env setup ` - -Create or update an env. Prompts for missing required fields when stdin -is a TTY; errors out otherwise (CI-friendly). - -``` -cwms-cli env setup cwbi-prod --api-key YOUR_KEY --office SWT -cwms-cli env setup cwbi-dev --api-root https://cwms-data-dev.example.mil/cwms-data --office SWT -cwms-cli env setup localhost --api-root http://localhost:8082/cwms-data --office DEV -``` - -- Writes `/.json` with `0600`. -- Default `api_root` for known names (`cwbi-prod` at minimum; add others - if URLs are stable). -- Re-running with partial flags updates only the provided fields. - -### `cwms-cli env show []` - -List all envs, or show one. **Always redacts `api_key`** (prints -`has API key` / `no API key`). Safe to paste into tickets, screen-shares, -LLMs. - -``` -$ cwms-cli env show -Available environments: - cwbi-prod - API Root: https://cwms-data.usace.army.mil/cwms-data - Office: SWT - Status: has API key - cwbi-dev - API Root: https://cwms-data-dev.example.mil/cwms-data - Office: SWT - Status: no API key -``` - -### `cwms-cli env delete [--yes]` - -Remove the env file. Confirms unless `--yes`. - -### `cwms-cli env export [--format ...] [--output FILE] [--no-key] [--show-key]` - -The high-leverage addition. Reads the env file and emits its values in -the requested syntax. **Default refuses to print `api_key` to a TTY** -(see Security below). - -Formats: - -| Flag | Output | Use | -|-----------------------|-------------------------------------------------|------------------------------------| -| `--format dotenv` (default) | `KEY=value` lines | IDE dotenv plugins, docker-compose | -| `--format bash` | `export KEY='value'` | `eval "$(... --format bash)"` | -| `--format powershell` | `$env:KEY = 'value'` | `... \| Out-String \| Invoke-Expression` | -| `--format cmd` | `set "KEY=value"` | cmd.exe | - -Options: - -- `--output FILE` writes directly to disk with `0600` perms (preferred over - shell redirection — guarantees correct mode, no scrollback exposure, no - quoting bugs). -- `--no-key` omits `api_key` (for templates / `.env.example`). -- `--show-key` overrides the TTY refusal. - -Quoting must be correct per format (single-quote escaping in bash, doubled -single quotes in PowerShell, `^` escaping in cmd, `\` and `"` escaping in -dotenv). Get this right once. - -### `cwms-cli env activate ` *(keep, with caveats documented)* - -Spawns a subshell with env vars injected. Same as the current PR. - -Documented limitations: -- Parent shell and any already-open IDE do **not** see the vars. -- On Windows, may launch `cmd.exe` even when invoked from PowerShell. -- For "I want my IDE to see this," point users at - `cwms-cli env export --output .env` instead. - -This stays as the zero-friction path for users who just want a one-liner -that "works" without thinking about shell syntax. - -## `load` integration - -The actual payoff. Add to `cwms-cli load` (and any other multi-CDA -commands): - -``` -cwms-cli load timeseries --source-env cwbi-prod --target-env localhost -``` - -- `--source-env NAME` reads `/.json` and feeds its values - into `--source-cda` and `--source-office`. CLI flags still win if both - are passed. -- `--target-env NAME` mirrors for `--target-cda` and `--target-api-key`. -- Mutually exclusive with the explicit `--source-cda` / `--target-cda` - flags (or override-with-warning — pick one and document). - -This replaces today's juggling of `CDA_SOURCE_URL`, `CDA_TARGET_URL`, -`CDA_SOURCE_OFFICE`, `CDA_API_KEY` with two named references. - -## Security posture - -**Spell this out in user-facing docs.** What this feature does and doesn't -defend against: - -| Threat | Defended? | How | -|---------------------------------------|-----------|-------------------------------------------| -| Accidental `git add` of a key | Yes | Files live in `~/.config/`, not the repo | -| Key pasted into LLM via `cat .env` | Yes | Users share `env show` output (redacted) | -| Key visible in `ps` / shell history | Yes | Users reference env name, not flag value | -| Key in tmux/terminal scrollback | Mostly | `export` refuses TTY by default; `--output FILE` skips shell entirely | -| Other user on shared box reading file | Yes (non-root) | `0600` perms, ACL on Windows | -| Root / same-user process | No | Out of scope; matches `aws`/`gcloud`/`gh` | -| Disk theft on unencrypted drive | No | Use full-disk encryption | - -`export --output FILE` is the recommended IDE setup path because it -guarantees `0600`, never touches scrollback, and emits a one-line -stderr reminder to `.gitignore` the output. - -## Code changes - -### Remove - -- `cwmscli/utils/credentials.py` — entire file. Keyring helpers, - `is_keyring_available`, the index management, the os-environ fallback - reader. -- `keyring` dependency from `pyproject.toml`. Regenerate `poetry.lock` - (drops ~544 lines: `keyring`, `cryptography`, `cffi`, `jeepney`, - `SecretStorage`, `jaraco.*`, `backports.tarfile`, `pycparser`). -- `get_envs_dir()` migration block in `env.py` (`.env` files were never - shipped — dead code). -- `tests/commands/test_env.py` keyring-mock fixtures. - -### Add / rewrite - -- `cwmscli/utils/env_store.py` — small module: - - `envs_dir() -> Path` (XDG on Linux/macOS, `%APPDATA%` on Windows). - - `load_env(name) -> dict | None`. - - `save_env(name, dict) -> None` (writes `0600`; on Windows uses - `os.open(..., 0o600)` + ACL helper or `pywin32` fallback). - - `delete_env(name) -> bool`. - - `list_envs() -> list[str]` (just `os.listdir` on the dir). -- `cwmscli/commands/env.py` — rewrite against `env_store`: - - `setup`, `show`, `delete`, `activate` (subshell, unchanged behavior). - - `export` (new) — formats, TTY-refusal, `--output`, `--no-key`. -- `cwmscli/load/root.py` — add `--source-env` / `--target-env` options - that resolve via `load_env()` and populate the existing - `--source-cda` / `--target-cda` / `--source-office` / `--target-api-key`. - -### Tests - -- `env_store`: round-trip save/load/delete/list, file mode is `0600`, - Windows path branch, malformed JSON returns None. -- `env setup`: creates file with right perms; `--api-root` required for - unknown names; partial update preserves other fields. -- `env show`: redacts key; lists multiple envs; handles empty dir. -- `env delete`: confirmation prompt; `--yes` skips it. -- `env export`: - - Each format produces parseable output (shell out to `bash -c`, - parse `KEY=value` for dotenv, etc.). - - Quoting handles `'`, `"`, `$`, spaces, backslashes. - - TTY refusal behavior (mock `isatty`). - - `--output FILE` writes `0600`. - - `--no-key` omits the key field. -- `load --source-env` / `--target-env`: populates the underlying flags; - explicit flags override; missing env errors cleanly. - -### Docs - -- `docs/cli/env.rst` — rewrite to match the file-based model. Drop the - keyring sections. Add the security table above. Document `export` - prominently with the IDE setup recipe. -- `docs/cli/load.rst` (or wherever) — document `--source-env` / - `--target-env`. -- `docs/ENV_MANAGER.md` — update or replace with this scope doc. - -## Migration - -No users yet (PR is draft, never merged). No migration needed. If keyring -data does exist on developer machines from testing, document a one-liner -to extract and rewrite as JSON, but don't ship migration code. - -## Open questions - -1. **Default env directory on Windows** — `%APPDATA%\cwms-cli\envs\` (per - user roaming) or `%LOCALAPPDATA%` (per machine, no roaming)? Roaming - matches `gh`; local matches `aws`. -2. **`--source-env` + explicit `--source-cda`**: hard error, or warn and - let CLI flag win? The latter is more flexible for ad-hoc overrides. -3. **`activate` on Windows / PowerShell**: keep current `COMSPEC` fallback, - or detect `PSModulePath` and prefer `pwsh`/`powershell`? Probably the - detection — current behavior would drop a PowerShell user into cmd.exe. -4. **JSON vs dotenv on disk**: JSON is unambiguous and easy to extend; - dotenv is one less format to parse since `export` already speaks it. - Recommend JSON — env vars aren't the only thing we may want to store - per-env (default time zones, output formats, etc.). - -## Phasing - -1. **Phase 1 (this PR, after revision):** storage + `setup`/`show`/`delete`/ - `activate`/`export`. No `load` integration yet. -2. **Phase 2 (follow-up PR):** `load --source-env` / `--target-env`. This - is when the feature becomes obviously valuable to end users. -3. **Phase 3 (optional, only on demand):** opt-in keyring backend, or - conda-style `init-shell` for in-place activation. Don't build either - until a real user asks. diff --git a/docs/cli/env.rst b/docs/cli/env.rst index 86abf47..d421d91 100644 --- a/docs/cli/env.rst +++ b/docs/cli/env.rst @@ -1,94 +1,128 @@ Environment Manager =================== -Secure environment management for CDA environments using system keyring storage. +Manage named CDA environments with ``cwms-cli env``. Each environment stores +a CDA API root URL, office code, and optional API key in a JSON file under +``~/.config/cwms-cli/envs/`` (or ``$XDG_CONFIG_HOME/cwms-cli/envs/`` when that +variable is set), on all platforms. Files are created with mode ``0600`` +(owner-only read/write) so only your user account can read them. -Overview --------- - -The environment manager stores your CDA API credentials and configuration securely using your system's keyring: +This keeps API keys out of project directories, shell history, and command +lines, and lets you reference environments by name instead of juggling +URLs and credentials. -- **Linux**: gnome-keyring, kwallet, or secretstorage -- **macOS**: Keychain (built-in) -- **Windows**: Credential Manager (built-in) -- **Solaris**: Keyring not available - use environment variables (see Headless/CI Usage below) -This keeps API keys out of plaintext files and provides a consistent, secure experience across platforms. +Quick Start +----------- -Suggested Environments ----------------------- +**1. Create environments:** -**Pre-configured (have default URLs):** +.. code-block:: bash -- ``cwbi-prod`` - Production CWBI (https://cwms-data.usace.army.mil/cwms-data) + # Production (has a default URL — just add key and office) + cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY -**Need --api-root:** + # Development (needs --api-root) + cwms-cli env setup cwbi-dev \ + --api-root https://cwms-data-dev.example.mil/cwms-data \ + --office SWT --api-key YOUR_KEY -- ``cwbi-dev`` - Development CWBI -- ``cwbi-test`` - Test CWBI -- ``localhost`` - Local development server (port varies: 8081, 8082, etc.) -- ``onsite`` - Local non-cloud server -- Custom environment names + # Test (needs --api-root) + cwms-cli env setup cwbi-test \ + --api-root https://cwms-data-test.example.mil/cwms-data \ + --office SWT --api-key YOUR_KEY -Quick Start ------------ + # Local development server + cwms-cli env setup localhost \ + --api-root http://localhost:8082/cwms-data --office DEV -**1. Setup environments:** +**2. Use environments with load commands:** .. code-block:: bash - # Pre-configured environment (just add key/office) - cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY + cwms-cli load location ids-all \ + --source-env cwbi-prod --target-env localhost - # Custom environments (need --api-root) - cwms-cli env setup cwbi-dev --api-root https://cwms-data-dev.example.mil/cwms-data --office SWT --api-key YOUR_KEY - cwms-cli env setup localhost --api-root http://localhost:8082/cwms-data --office DEV + cwms-cli load timeseries data \ + --source-env cwbi-prod --target-env localhost \ + --ts-id "Black Butte.Flow.Inst.1Hour.0.raw-cda" -**2. Activate an environment:** +**3. View and manage environments:** .. code-block:: bash - cwms-cli env activate cwbi-dev + cwms-cli env show + cwms-cli env delete old-env --yes + + +Using Environments with ``load`` +--------------------------------- -This spawns a new shell with the environment variables set. When you're done, type ``exit`` to return to your original shell. +The ``--source-env`` and ``--target-env`` options resolve a named +environment into the corresponding source/target options: -**3. View environments:** +- ``--source-env`` sets ``--source-cda`` and ``--source-office`` +- ``--target-env`` sets ``--target-cda`` and ``--target-api-key`` + +These two invocations are equivalent: .. code-block:: bash - # List all environments with their API roots - cwms-cli env show + # Explicit flags + cwms-cli load location ids-all \ + --source-cda https://cwms-data.usace.army.mil/cwms-data/ \ + --source-office SWT \ + --target-cda http://localhost:8082/cwms-data/ \ + --target-api-key "apikey 0123456789abcdef" + + # Named environments + cwms-cli load location ids-all \ + --source-env cwbi-prod --target-env localhost + +**Rules:** + +- ``--source-env`` and ``--source-cda`` are mutually exclusive. +- ``--target-env`` and ``--target-cda`` are mutually exclusive. +- Explicit ``--source-office`` or ``--target-api-key`` flags override the + values from the environment file. + Commands -------- cwms-cli env setup -~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ Create or update an environment configuration. .. code-block:: bash # Setup with all options - cwms-cli env setup myenv --api-root https://cwms-data-dev.example.mil/cwms-data --api-key YOUR_KEY --office SWT + cwms-cli env setup myenv \ + --api-root https://cwms-data-dev.example.mil/cwms-data \ + --api-key YOUR_KEY --office SWT - # Update just the API key + # Update just the API key (other fields preserved) cwms-cli env setup myenv --api-key NEW_KEY # Update just the office cwms-cli env setup myenv --office LRD +For ``cwbi-prod``, the ``--api-root`` defaults to the production URL. +All other environment names require ``--api-root``. + + cwms-cli env show -~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~ -List all configured environments. +List all configured environments with their API root, office, and key status. +The API key is always redacted — only ``has API key`` or ``no API key`` is shown. .. code-block:: bash - # List all environments with API roots and key status cwms-cli env show -**Output:** +**Example output:** .. code-block:: text @@ -97,45 +131,71 @@ List all configured environments. Available environments: * cwbi-prod API Root: https://cwms-data.usace.army.mil/cwms-data - Office: SWT - Status: has API key + Office: SWT + Status: has API key cwbi-dev API Root: https://cwms-data-dev.example.mil/cwms-data - Office: SWT - Status: no API key + Office: SWT + Status: no API key -The ``*`` marks the currently active environment. +The ``*`` marks the currently active environment (from the ``ENVIRONMENT`` +variable). -cwms-cli env activate -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Activate an environment in a new shell session. +cwms-cli env export +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Export an environment's variables to your current shell or a file. .. code-block:: bash - cwms-cli env activate cwbi-prod + # Load into the current bash/zsh shell + eval "$(cwms-cli env export cwbi-prod --format bash)" + + # Load into PowerShell + cwms-cli env export cwbi-prod --format powershell | Out-String | Invoke-Expression + + # Write a .env file for an IDE or docker-compose + cwms-cli env export cwbi-prod --output .env + +**Formats:** ``dotenv`` (default), ``bash``, ``powershell``, ``cmd``, ``fish``. + +**Safety:** The API key is never printed to a terminal by default. If stdout +is a TTY and the environment has an API key, ``export`` shows shell-specific +recipes instead. Use ``--show-key`` to override, or ``--output FILE`` to write +directly to disk (recommended — guarantees ``0600`` permissions and no +scrollback exposure). -The environment variables will be set in the new shell and persist until you exit: +**Options:** + +- ``--output FILE`` — write to a file with ``0600`` permissions instead of stdout. +- ``--no-key`` — omit ``CDA_API_KEY`` (useful for templates or sharing). +- ``--show-key`` — allow the API key to be displayed in the terminal. + + +cwms-cli env activate +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Activate an environment in a new shell session. .. code-block:: bash - # Now in the activated environment - echo $CDA_API_ROOT # Shows the API root - cwms-cli blob list # Uses environment config + cwms-cli env activate cwbi-prod + +This spawns a child shell with the environment variables set. Type ``exit`` +or press ``Ctrl+D`` to return to your original shell. - # Exit to return to original shell - exit +.. note:: -**Benefits:** + The parent shell and any already-open IDE will **not** see these variables. + For IDE integration, use ``cwms-cli env export --output .env`` + instead. -- Clean separation between environments -- Original shell remains unchanged -- Type ``exit`` to immediately return to original state cwms-cli env delete -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~ -Delete an environment configuration from keyring. +Delete an environment configuration. .. code-block:: bash @@ -145,49 +205,36 @@ Delete an environment configuration from keyring. # Delete without confirmation cwms-cli env delete myenv --yes -How It Works ------------- -**Secure storage:** Configuration is stored in your system's keyring: +Storage and Security +-------------------- -- ``~/.local/share/keyrings/`` (Linux with GNOME Keyring) -- ``~/Library/Keychains/`` (macOS Keychain) -- Windows Credential Manager (Windows) +**File locations:** -**Environment variables set when activated:** +- All platforms: ``~/.config/cwms-cli/envs/.json`` + (respects ``XDG_CONFIG_HOME`` when set) -- ``ENVIRONMENT`` - Environment name -- ``CDA_API_ROOT`` - API root URL -- ``CDA_API_KEY`` - API key (if provided) -- ``OFFICE`` - Default office (if provided) +**File permissions:** ``0600`` on POSIX (owner-only read/write). On Windows, +an ACL restricts access to the current user. -**Usage with other commands:** +**Security model:** The user account is the security boundary, matching +``aws``, ``gcloud``, ``kubectl``, and ``gh``. This feature defends against: -.. code-block:: bash +- Accidental ``git add`` of a key — files live in ``~/.config/``, not the repo +- Key pasted into an LLM — users share ``env show`` output (always redacted) +- Key visible in ``ps`` or shell history — users reference the env name, not values +- Key in terminal scrollback — ``export`` refuses TTY output by default - # Activate environment (spawns new shell) - cwms-cli env activate cwbi-prod +This feature does **not** defend against root access or same-user process +reads. For encrypted-at-rest storage, use a vault (1Password CLI, HashiCorp +Vault, AWS Secrets Manager) and feed values in via environment variables. - # Now run commands (uses environment variables automatically) - cwms-cli blob list - cwms-cli users list - # Command flags override environment variables - cwms-cli blob list --api-root https://cwms-data.usace.army.mil/cwms-data +Headless and CI Usage +--------------------- - # Exit the environment shell - exit - -**Variable persistence:** - -- Variables persist until you ``exit`` the spawned shell -- Variables do NOT affect your original shell -- Variables do NOT persist across terminal restarts (activate again when needed) - -Headless/CI Usage (and Solaris) --------------------------------- - -For headless, CI, or Solaris environments where keyring is not available, set environment variables directly: +For headless or CI environments where ``cwms-cli env`` is not practical, +set environment variables directly: .. code-block:: bash @@ -195,12 +242,8 @@ For headless, CI, or Solaris environments where keyring is not available, set en export CDA_API_KEY="your_key" export OFFICE="SWT" - # Commands will use these variables cwms-cli blob list -The CLI will automatically fall back to reading from ``os.environ`` when keyring is unavailable. - -**Note for Solaris users:** Since system keyring backends are not available on Solaris, you must use this environment variable approach. The ``cwms-cli env setup`` and ``cwms-cli env activate`` commands will not work without a keyring backend. Instead, set the variables directly in your shell profile (e.g., ``~/.bashrc`` or ``~/.profile``). .. click:: cwmscli.commands.env:env_group :prog: cwms-cli env diff --git a/docs/cli/load_location_ids_all.rst b/docs/cli/load_location_ids_all.rst index 1c2189d..e92178e 100644 --- a/docs/cli/load_location_ids_all.rst +++ b/docs/cli/load_location_ids_all.rst @@ -17,6 +17,22 @@ to the source CDA catalog, so both options use CDA regular expression behavior. The CLI does not apply extra exact-match filtering. For CDA regex syntax, see the :doc:`CWMS Data API regular expression guide `. +Using Named Environments +------------------------ + +If you have configured environments with ``cwms-cli env setup``, use +``--source-env`` and ``--target-env`` instead of spelling out URLs and keys: + +.. code-block:: shell + + cwms-cli load location ids-all \ + --source-env cwbi-prod \ + --target-env localhost \ + --like "^Black Butte$" \ + --location-kind-like PROJECT + +See :doc:`env` for environment setup. + CDA to CDA Examples ------------------- diff --git a/tests/load/test_source_target_env.py b/tests/load/test_source_target_env.py new file mode 100644 index 0000000..f6b52e7 --- /dev/null +++ b/tests/load/test_source_target_env.py @@ -0,0 +1,255 @@ +"""Tests for --source-env / --target-env resolution in load commands.""" + +import pytest +from click.testing import CliRunner + +from cwmscli.load.location.location import location as location_group +from cwmscli.utils.env_store import save_env + + +@pytest.fixture +def isolated_envs(monkeypatch, tmp_path): + """Redirect env storage to a tmp dir for each test.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + monkeypatch.delenv("ENVIRONMENT", raising=False) + monkeypatch.delenv("CDA_SOURCE_URL", raising=False) + monkeypatch.delenv("CDA_TARGET_URL", raising=False) + monkeypatch.delenv("CDA_SOURCE_OFFICE", raising=False) + monkeypatch.delenv("CDA_API_KEY", raising=False) + return tmp_path + + +@pytest.fixture +def capture_load(monkeypatch): + """Monkeypatch the inner load function to capture kwargs instead of calling CDA.""" + captured = {} + + def fake_load(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("cwmscli.load.location.location_ids.load_locations", fake_load) + monkeypatch.setattr("cwmscli.utils.get_saved_login_token", lambda *a, **kw: None) + return captured + + +def _invoke(args): + return CliRunner().invoke(location_group, ["ids-all"] + args) + + +# ---------- resolution ---------- + + +def test_source_env_resolves_cda_and_office(isolated_envs, capture_load): + save_env( + "prod", + { + "CDA_API_ROOT": "https://prod.mil/cwms-data", + "OFFICE": "SWT", + }, + ) + result = _invoke( + [ + "--source-env", + "prod", + "--target-cda", + "http://localhost:8082/cwms-data", + ] + ) + assert result.exit_code == 0, result.output + assert capture_load["source_cda"] == "https://prod.mil/cwms-data" + assert capture_load["source_office"] == "SWT" + + +def test_target_env_resolves_cda_and_api_key(isolated_envs, capture_load): + save_env( + "local", + { + "CDA_API_ROOT": "http://localhost:8082/cwms-data", + "CDA_API_KEY": "apikey abc123", + }, + ) + result = _invoke( + [ + "--source-cda", + "https://prod.mil/cwms-data", + "--source-office", + "SWT", + "--target-env", + "local", + ] + ) + assert result.exit_code == 0, result.output + assert capture_load["target_cda"] == "http://localhost:8082/cwms-data" + assert capture_load["target_api_key"] == "apikey abc123" + + +def test_both_envs_resolve(isolated_envs, capture_load): + save_env( + "prod", + { + "CDA_API_ROOT": "https://prod.mil/cwms-data", + "OFFICE": "SWT", + }, + ) + save_env( + "local", + { + "CDA_API_ROOT": "http://localhost:8082/cwms-data", + "CDA_API_KEY": "apikey abc123", + }, + ) + result = _invoke(["--source-env", "prod", "--target-env", "local"]) + assert result.exit_code == 0, result.output + assert capture_load["source_cda"] == "https://prod.mil/cwms-data" + assert capture_load["source_office"] == "SWT" + assert capture_load["target_cda"] == "http://localhost:8082/cwms-data" + assert capture_load["target_api_key"] == "apikey abc123" + + +# ---------- explicit override ---------- + + +def test_explicit_source_office_overrides_env(isolated_envs, capture_load): + save_env( + "prod", + { + "CDA_API_ROOT": "https://prod.mil/cwms-data", + "OFFICE": "SWT", + }, + ) + result = _invoke( + [ + "--source-env", + "prod", + "--source-office", + "LRD", + "--target-cda", + "http://localhost:8082/cwms-data", + ] + ) + assert result.exit_code == 0, result.output + assert capture_load["source_office"] == "LRD" + + +# ---------- mutual exclusivity ---------- + + +def test_source_env_and_source_cda_mutually_exclusive(isolated_envs): + save_env("prod", {"CDA_API_ROOT": "https://prod.mil/cwms-data", "OFFICE": "SWT"}) + result = _invoke( + [ + "--source-env", + "prod", + "--source-cda", + "https://other.mil/cwms-data", + "--source-office", + "SWT", + ] + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + +def test_target_env_and_target_cda_mutually_exclusive(isolated_envs): + save_env("local", {"CDA_API_ROOT": "http://localhost/cwms-data"}) + result = _invoke( + [ + "--source-cda", + "https://prod.mil/cwms-data", + "--source-office", + "SWT", + "--target-env", + "local", + "--target-cda", + "http://other:8082/cwms-data", + ] + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + +def test_source_env_and_source_csv_mutually_exclusive(isolated_envs, tmp_path): + save_env("prod", {"CDA_API_ROOT": "https://prod.mil/cwms-data", "OFFICE": "SWT"}) + csv = tmp_path / "in.csv" + csv.write_text("name,office-id,active\nLOC_A,SWT,True\n") + result = _invoke( + [ + "--source-env", + "prod", + "--source-csv", + str(csv), + "--target-cda", + "http://localhost:8082/cwms-data", + ] + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + +# ---------- error paths ---------- + + +def test_source_env_not_found(isolated_envs): + result = _invoke( + [ + "--source-env", + "nonexistent", + "--target-cda", + "http://localhost:8082/cwms-data", + ] + ) + assert result.exit_code != 0 + assert "not found" in result.output + + +def test_target_env_not_found(isolated_envs): + result = _invoke( + [ + "--source-cda", + "https://prod.mil/cwms-data", + "--source-office", + "SWT", + "--target-env", + "nonexistent", + ] + ) + assert result.exit_code != 0 + assert "not found" in result.output + + +def test_source_env_missing_api_root(isolated_envs): + save_env("broken", {"OFFICE": "SWT"}) + result = _invoke( + [ + "--source-env", + "broken", + "--target-cda", + "http://localhost:8082/cwms-data", + ] + ) + assert result.exit_code != 0 + assert "CDA_API_ROOT" in result.output + + +# ---------- kwargs don't leak ---------- + + +def test_env_kwargs_not_passed_to_command(isolated_envs, capture_load): + save_env( + "prod", + { + "CDA_API_ROOT": "https://prod.mil/cwms-data", + "OFFICE": "SWT", + }, + ) + save_env( + "local", + { + "CDA_API_ROOT": "http://localhost:8082/cwms-data", + }, + ) + result = _invoke(["--source-env", "prod", "--target-env", "local"]) + assert result.exit_code == 0, result.output + assert "source_env" not in capture_load + assert "target_env" not in capture_load diff --git a/tests/utils/test_paths.py b/tests/utils/test_paths.py new file mode 100644 index 0000000..a072d98 --- /dev/null +++ b/tests/utils/test_paths.py @@ -0,0 +1,33 @@ +"""Tests for the shared per-user config-dir helper.""" + +from pathlib import Path + +from cwmscli.utils.auth import default_token_file +from cwmscli.utils.env_store import envs_dir +from cwmscli.utils.paths import config_dir + + +def test_config_dir_uses_xdg_when_set(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert config_dir() == tmp_path / "cwms-cli" + assert config_dir("envs") == tmp_path / "cwms-cli" / "envs" + + +def test_config_dir_falls_back_to_home_config(monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + assert config_dir("auth") == Path("~/.config").expanduser() / "cwms-cli" / "auth" + + +def test_config_dir_create_makes_directory(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + path = config_dir("envs", create=True) + assert path.is_dir() + + +def test_auth_and_env_share_one_root(monkeypatch, tmp_path): + """Saved logins and named environments must live under the same root.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + token = default_token_file("federation-eams") + envs = envs_dir() + assert token.parent.parent == envs.parent + assert token.parent.parent == tmp_path / "cwms-cli" From afc1f1d490c57b6a0113f640aeed553cdd6819fd Mon Sep 17 00:00:00 2001 From: msweier Date: Wed, 1 Jul 2026 13:34:15 -0500 Subject: [PATCH 10/11] add prod as default --- cwmscli/commands/env.py | 11 ++++----- cwmscli/utils/env_store.py | 21 ++++++++++++---- docs/cli/env.rst | 35 ++++++++++++++++++++++++--- tests/commands/test_env.py | 49 ++++++++++++++++++++++++++++++++++---- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 586c63f..449199b 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -7,8 +7,10 @@ import click from cwmscli.utils.env_store import ( + ENV_DEFAULTS, EnvStoreError, delete_env, + env_exists_on_disk, list_envs, load_env, save_env, @@ -16,10 +18,6 @@ SENSITIVE_KEYS = {"CDA_API_KEY"} -ENV_DEFAULTS = { - "cwbi-prod": "https://cwms-data.usace.army.mil/cwms-data", -} - def _stdout_is_tty() -> bool: """Indirection so tests can override TTY detection.""" @@ -64,7 +62,7 @@ def setup_cmd( if api_root: env_vars["CDA_API_ROOT"] = api_root elif "CDA_API_ROOT" not in env_vars and env_name in ENV_DEFAULTS: - env_vars["CDA_API_ROOT"] = ENV_DEFAULTS[env_name] + env_vars["CDA_API_ROOT"] = ENV_DEFAULTS[env_name]["CDA_API_ROOT"] if api_key: env_vars["CDA_API_KEY"] = api_key @@ -117,11 +115,12 @@ def show_cmd(): if not env_config: continue marker = "* " if env_name == current_env else " " + builtin = " (built-in)" if not env_exists_on_disk(env_name) else "" api_root = env_config.get("CDA_API_ROOT", "not set") office = env_config.get("OFFICE", "not set") has_key = "has API key" if env_config.get("CDA_API_KEY") else "no API key" - click.echo(f"{marker}{env_name}") + click.echo(f"{marker}{env_name}{builtin}") click.echo(f" API Root: {api_root}") click.echo(f" Office: {office}") click.echo(f" Status: {has_key}") diff --git a/cwmscli/utils/env_store.py b/cwmscli/utils/env_store.py index e31f38d..6500250 100644 --- a/cwmscli/utils/env_store.py +++ b/cwmscli/utils/env_store.py @@ -14,6 +14,13 @@ from cwmscli.utils.paths import config_dir +ENV_DEFAULTS = { + "cwbi-prod": { + "ENVIRONMENT": "cwbi-prod", + "CDA_API_ROOT": "https://cwms-data.usace.army.mil/cwms-data", + }, +} + class EnvStoreError(Exception): """Raised when an env file cannot be read, written, or deleted.""" @@ -88,11 +95,16 @@ def save_env(name: str, config: Dict[str, str]) -> Path: return path +def env_exists_on_disk(name: str) -> bool: + """Return True if a user-created env file exists for *name*.""" + return _env_path(name).exists() + + def load_env(name: str) -> Optional[Dict[str, str]]: - """Read and parse an env file. Returns None if missing or malformed.""" + """Read and parse an env file, falling back to built-in defaults.""" path = _env_path(name) if not path.exists(): - return None + return dict(ENV_DEFAULTS[name]) if name in ENV_DEFAULTS else None try: with open(path, "r") as f: data = json.load(f) @@ -116,5 +128,6 @@ def delete_env(name: str) -> bool: def list_envs() -> List[str]: - """Return sorted env names by listing the envs dir.""" - return sorted(p.stem for p in envs_dir().glob("*.json")) + """Return sorted env names, including built-in defaults.""" + on_disk = {p.stem for p in envs_dir().glob("*.json")} + return sorted(on_disk | ENV_DEFAULTS.keys()) diff --git a/docs/cli/env.rst b/docs/cli/env.rst index d421d91..cdf87b9 100644 --- a/docs/cli/env.rst +++ b/docs/cli/env.rst @@ -12,6 +12,30 @@ lines, and lets you reference environments by name instead of juggling URLs and credentials. +Built-in Environments +--------------------- + +``cwbi-prod`` ships preconfigured with the production CDA URL. It is +available immediately — no ``env setup`` required — and appears in +``env show`` as ``(built-in)``. + +Because the built-in has no office or API key, you still need to pass +``--source-office`` when using it as a source: + +.. code-block:: bash + + cwms-cli load location ids-all \ + --source-env cwbi-prod --source-office SWT \ + --target-env localhost + +To avoid repeating ``--source-office`` every time, run ``env setup`` once +to attach an office (and optionally an API key): + +.. code-block:: bash + + cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY + + Quick Start ----------- @@ -19,7 +43,7 @@ Quick Start .. code-block:: bash - # Production (has a default URL — just add key and office) + # Production — customize the built-in with your office and key cwms-cli env setup cwbi-prod --office SWT --api-key YOUR_KEY # Development (needs --api-root) @@ -108,8 +132,10 @@ Create or update an environment configuration. # Update just the office cwms-cli env setup myenv --office LRD -For ``cwbi-prod``, the ``--api-root`` defaults to the production URL. -All other environment names require ``--api-root``. +``cwbi-prod`` is built-in and already has the production URL. Running +``env setup cwbi-prod`` creates a user file that overrides the built-in, +letting you attach an office and API key. All other environment names +require ``--api-root``. cwms-cli env show @@ -138,6 +164,9 @@ The API key is always redacted — only ``has API key`` or ``no API key`` is sho Office: SWT Status: no API key +On a fresh install (before any ``env setup``), ``cwbi-prod`` appears with +``(built-in)`` and shows ``Office: not set``. + The ``*`` marks the currently active environment (from the ``ENVIRONMENT`` variable). diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py index 5a4e470..3dc175a 100644 --- a/tests/commands/test_env.py +++ b/tests/commands/test_env.py @@ -96,11 +96,16 @@ def test_delete_env_missing(isolated_envs): def test_list_envs(isolated_envs): - assert list_envs() == [] + assert list_envs() == ["cwbi-prod"] save_env("alpha", {"a": "1"}) save_env("beta", {"a": "1"}) save_env("gamma", {"a": "1"}) - assert list_envs() == ["alpha", "beta", "gamma"] + assert list_envs() == ["alpha", "beta", "cwbi-prod", "gamma"] + + +def test_list_envs_deduplicates_defaults(isolated_envs): + save_env("cwbi-prod", {"CDA_API_ROOT": "https://custom"}) + assert list_envs().count("cwbi-prod") == 1 @pytest.mark.parametrize("bad", ["", ".", "..", "a/b", "a\\b"]) @@ -109,6 +114,42 @@ def test_invalid_env_names_rejected(isolated_envs, bad): save_env(bad, {"a": "1"}) +# ---------- built-in defaults ---------- + + +def test_load_env_returns_builtin_default(isolated_envs): + data = load_env("cwbi-prod") + assert data is not None + assert data["CDA_API_ROOT"] == "https://cwms-data.usace.army.mil/cwms-data" + assert data["ENVIRONMENT"] == "cwbi-prod" + + +def test_on_disk_env_overrides_builtin(isolated_envs): + save_env("cwbi-prod", {"CDA_API_ROOT": "https://custom", "CDA_API_KEY": "k"}) + data = load_env("cwbi-prod") + assert data["CDA_API_ROOT"] == "https://custom" + assert data["CDA_API_KEY"] == "k" + + +def test_show_labels_builtin(isolated_envs): + runner = CliRunner() + result = runner.invoke(env_group, ["show"]) + assert result.exit_code == 0 + assert "cwbi-prod (built-in)" in result.output + + +def test_show_no_builtin_label_when_customized(isolated_envs): + save_env( + "cwbi-prod", + {"CDA_API_ROOT": "https://cwms-data.usace.army.mil/cwms-data", "OFFICE": "SWT"}, + ) + runner = CliRunner() + result = runner.invoke(env_group, ["show"]) + assert result.exit_code == 0 + assert "cwbi-prod" in result.output + assert "(built-in)" not in result.output + + # ---------- env setup ---------- @@ -179,11 +220,11 @@ def test_setup_uppercases_office(isolated_envs): # ---------- env show ---------- -def test_show_empty(isolated_envs): +def test_show_empty_still_shows_builtins(isolated_envs): runner = CliRunner() result = runner.invoke(env_group, ["show"]) assert result.exit_code == 0 - assert "No environments configured" in result.output + assert "cwbi-prod (built-in)" in result.output def test_show_lists_envs_and_redacts_key(isolated_envs): From 60571ed73e8073b0db46e347ca26286c3fc7bef7 Mon Sep 17 00:00:00 2001 From: msweier Date: Wed, 1 Jul 2026 14:46:09 -0500 Subject: [PATCH 11/11] add --check flag to check connectivity for enviroments --- cwmscli/commands/env.py | 92 ++++++++++++++++++++++++++- docs/cli/env.rst | 24 +++++++ tests/commands/test_env.py | 126 +++++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 449199b..4135da7 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -2,6 +2,7 @@ import shutil import subprocess import sys +import time from typing import Dict, Optional import click @@ -24,6 +25,69 @@ def _stdout_is_tty() -> bool: return sys.stdout.isatty() +def _check_env(env_config: Dict[str, str]) -> Dict: + import requests + + api_root = env_config.get("CDA_API_ROOT", "").rstrip("/") + if not api_root: + return { + "reachable": False, + "latency_ms": None, + "auth": "skipped", + "error": "no API root", + } + + url = f"{api_root}/offices" + try: + t0 = time.monotonic() + resp = requests.get(url, timeout=5) + latency_ms = int((time.monotonic() - t0) * 1000) + except requests.RequestException as e: + return { + "reachable": False, + "latency_ms": None, + "auth": "skipped", + "error": str(e), + } + + if resp.status_code >= 400: + return { + "reachable": False, + "latency_ms": latency_ms, + "auth": "skipped", + "error": f"HTTP {resp.status_code}", + } + + api_key = env_config.get("CDA_API_KEY") + if not api_key: + return { + "reachable": True, + "latency_ms": latency_ms, + "auth": "skipped", + "error": None, + } + + try: + auth_resp = requests.get(url, headers={"Authorization": api_key}, timeout=5) + except requests.RequestException: + return { + "reachable": True, + "latency_ms": latency_ms, + "auth": "failed", + "error": None, + } + + if auth_resp.status_code == 401: + return { + "reachable": True, + "latency_ms": latency_ms, + "auth": "failed", + "error": None, + } + + return {"reachable": True, "latency_ms": latency_ms, "auth": "ok", "error": None} + + @click.group("env", help="Manage CDA environments and API keys") def env_group(): """Environment management commands for cwms-cli.""" @@ -88,11 +152,18 @@ def setup_cmd( @env_group.command("show", help="Show current environment and available configurations") -def show_cmd(): +@click.option( + "--check", + is_flag=True, + default=False, + help="Test connectivity and authentication for each environment.", +) +def show_cmd(check: bool): """ Display current environment and list all configured environments. Lists all environments with API root, office, and key status. + Use --check to test connectivity and API key validity (requires network). """ current_env = os.environ.get("ENVIRONMENT") @@ -125,6 +196,25 @@ def show_cmd(): click.echo(f" Office: {office}") click.echo(f" Status: {has_key}") + if check: + result = _check_env(env_config) + if result["reachable"]: + latency = f" ({result['latency_ms']}ms)" + reach_str = click.style("reachable", fg="green") + latency + else: + err = f" — {result['error']}" if result["error"] else "" + reach_str = click.style("unreachable", fg="red") + err + + auth_str = "" + if result["auth"] == "ok": + auth_str = click.style("authenticated", fg="green") + elif result["auth"] == "failed": + auth_str = click.style("auth failed", fg="red") + + click.echo(f" Connect: {reach_str}") + if auth_str: + click.echo(f" Auth: {auth_str}") + @env_group.command("delete", help="Delete an environment configuration") @click.argument("env_name") diff --git a/docs/cli/env.rst b/docs/cli/env.rst index cdf87b9..531290a 100644 --- a/docs/cli/env.rst +++ b/docs/cli/env.rst @@ -170,6 +170,30 @@ On a fresh install (before any ``env setup``), ``cwbi-prod`` appears with The ``*`` marks the currently active environment (from the ``ENVIRONMENT`` variable). +**Options:** + +- ``--check`` — test connectivity and API key validity for each environment + (requires network access). Adds ``Connect`` and ``Auth`` lines to the output. + +.. code-block:: bash + + cwms-cli env show --check + +.. code-block:: text + + Available environments: + * cwbi-prod + API Root: https://cwms-data.usace.army.mil/cwms-data + Office: SWT + Status: has API key + Connect: reachable (284ms) + Auth: authenticated + cwbi-dev + API Root: https://cwms-data-dev.example.mil/cwms-data + Office: SWT + Status: no API key + Connect: unreachable — Connection refused + cwms-cli env export ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py index 3dc175a..9238c5f 100644 --- a/tests/commands/test_env.py +++ b/tests/commands/test_env.py @@ -252,6 +252,132 @@ def test_show_marks_current_env(isolated_envs, monkeypatch): assert "* active" in result.output +# ---------- env show --check ---------- + +from cwmscli.commands.env import _check_env + + +def _fake_check(result_map): + """Return a _check_env replacement that returns canned results by API root.""" + default = { + "reachable": True, + "latency_ms": 42, + "auth": "skipped", + "error": None, + } + + def _check(env_config): + root = env_config.get("CDA_API_ROOT", "") + return result_map.get(root, default) + + return _check + + +def test_show_check_reachable(isolated_envs, monkeypatch): + save_env("demo", {"CDA_API_ROOT": "https://x.mil/cwms-data"}) + monkeypatch.setattr( + "cwmscli.commands.env._check_env", + _fake_check({}), + ) + runner = CliRunner() + result = runner.invoke(env_group, ["show", "--check"]) + assert result.exit_code == 0 + assert "reachable" in result.output + assert "42ms)" in result.output + + +def test_show_check_unreachable(isolated_envs, monkeypatch): + save_env("demo", {"CDA_API_ROOT": "https://x.mil/cwms-data"}) + monkeypatch.setattr( + "cwmscli.commands.env._check_env", + _fake_check( + { + "https://x.mil/cwms-data": { + "reachable": False, + "latency_ms": None, + "auth": "skipped", + "error": "Connection refused", + }, + } + ), + ) + runner = CliRunner() + result = runner.invoke(env_group, ["show", "--check"]) + assert result.exit_code == 0 + assert "unreachable" in result.output + assert "Connection refused" in result.output + + +def test_show_check_auth_ok(isolated_envs, monkeypatch): + save_env( + "demo", + {"CDA_API_ROOT": "https://x.mil/cwms-data", "CDA_API_KEY": "apikey mykey"}, + ) + monkeypatch.setattr( + "cwmscli.commands.env._check_env", + _fake_check( + { + "https://x.mil/cwms-data": { + "reachable": True, + "latency_ms": 100, + "auth": "ok", + "error": None, + }, + } + ), + ) + runner = CliRunner() + result = runner.invoke(env_group, ["show", "--check"]) + assert result.exit_code == 0 + assert "authenticated" in result.output + + +def test_show_check_auth_failed(isolated_envs, monkeypatch): + save_env( + "demo", + {"CDA_API_ROOT": "https://x.mil/cwms-data", "CDA_API_KEY": "apikey bad"}, + ) + monkeypatch.setattr( + "cwmscli.commands.env._check_env", + _fake_check( + { + "https://x.mil/cwms-data": { + "reachable": True, + "latency_ms": 100, + "auth": "failed", + "error": None, + }, + } + ), + ) + runner = CliRunner() + result = runner.invoke(env_group, ["show", "--check"]) + assert result.exit_code == 0 + assert "auth failed" in result.output + + +def test_show_check_no_key_skips_auth(isolated_envs, monkeypatch): + save_env("demo", {"CDA_API_ROOT": "https://x.mil/cwms-data"}) + monkeypatch.setattr( + "cwmscli.commands.env._check_env", + _fake_check({}), + ) + runner = CliRunner() + result = runner.invoke(env_group, ["show", "--check"]) + assert result.exit_code == 0 + assert "authenticated" not in result.output + assert "auth failed" not in result.output + + +def test_show_without_check_flag_unchanged(isolated_envs): + save_env("demo", {"CDA_API_ROOT": "https://x.mil/cwms-data"}) + runner = CliRunner() + result = runner.invoke(env_group, ["show"]) + assert result.exit_code == 0 + assert "Connect:" not in result.output + assert "Auth:" not in result.output + + # ---------- env delete ----------