From e7db8eab67430fdf478d91f9f7711e9dc9ff2e21 Mon Sep 17 00:00:00 2001 From: Petr Simecek Date: Thu, 26 Feb 2026 13:29:09 +0100 Subject: [PATCH] Phase 5: Integration tests, edge cases, linting, final polish Add comprehensive test coverage and code quality tooling: - Integration test suite (tests/test_integration.py) with full workflow test that exercises add/list/status/config-list/doctor/remove cycle, skipped without KBA_TEST_TOKEN_AWS env var - Edge case tests for client: malformed JSON, empty responses, large responses, 404 handling, stack URL normalization - Edge case tests for config_store: corrupted files (binary, truncated, null JSON, array), missing directories, permission denied, multiple save/load cycles - Additional CLI tests: help output, verbose flag, missing required args, token re-verification on edit - Ruff linting and formatting configuration with ruff>=0.8 dev dependency - Fixed all linting issues: unused imports, unused variables, raise-from in except clauses, modern type annotations - Hardened config_store to handle non-dict JSON and binary file content - All 209 tests pass (173 original + 36 new), 3 integration tests skipped Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 16 + src/keboola_agent_cli/client.py | 9 +- src/keboola_agent_cli/commands/config.py | 18 +- src/keboola_agent_cli/commands/doctor.py | 64 +- src/keboola_agent_cli/commands/project.py | 52 +- src/keboola_agent_cli/config_store.py | 7 + src/keboola_agent_cli/errors.py | 2 +- src/keboola_agent_cli/models.py | 4 +- src/keboola_agent_cli/output.py | 16 +- .../services/config_service.py | 39 +- .../services/project_service.py | 21 +- tests/test_cli.py | 1331 ++++++++++++----- tests/test_client.py | 270 +++- tests/test_config_store.py | 314 +++- tests/test_integration.py | 218 +++ tests/test_services.py | 348 +++-- uv.lock | 27 + 17 files changed, 2085 insertions(+), 671 deletions(-) create mode 100644 tests/test_integration.py diff --git a/pyproject.toml b/pyproject.toml index fde412b1..9d7ffa5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,25 @@ packages = ["src/keboola_agent_cli"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "integration: marks tests as integration tests requiring real API credentials (deselect with '-m \"not integration\"')", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"] +ignore = ["E501", "B008"] + +[tool.ruff.lint.isort] +known-first-party = ["keboola_agent_cli"] [dependency-groups] dev = [ "pytest>=8", "pytest-httpx>=0.30", + "ruff>=0.8", ] diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index afe19f54..1b7a2971 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -58,7 +58,6 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: Raises: KeboolaApiError: On HTTP errors (with masked token) or after retries exhausted. """ - last_exception: Exception | None = None last_response: httpx.Response | None = None for attempt in range(MAX_RETRIES): @@ -69,7 +68,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: return response if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2 ** attempt) + delay = BACKOFF_BASE * (2**attempt) time.sleep(delay) last_response = response continue @@ -78,9 +77,8 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: except httpx.TimeoutException as exc: if attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2 ** attempt) + delay = BACKOFF_BASE * (2**attempt) time.sleep(delay) - last_exception = exc continue raise KeboolaApiError( message=f"Request timed out connecting to {self._stack_url} (token: {self._masked_token})", @@ -91,9 +89,8 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: except httpx.ConnectError as exc: if attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2 ** attempt) + delay = BACKOFF_BASE * (2**attempt) time.sleep(delay) - last_exception = exc continue raise KeboolaApiError( message=f"Cannot connect to {self._stack_url} (token: {self._masked_token})", diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index 32f76d03..9d0ebf4a 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -4,8 +4,6 @@ No business logic belongs here. """ -from typing import Optional - import typer from ..errors import ConfigError, KeboolaApiError @@ -30,17 +28,17 @@ def _get_service(ctx: typer.Context) -> ConfigService: @config_app.command("list") def config_list( ctx: typer.Context, - project: Optional[list[str]] = typer.Option( + project: list[str] | None = typer.Option( None, "--project", help="Project alias to query (can be repeated for multiple projects)", ), - component_type: Optional[str] = typer.Option( + component_type: str | None = typer.Option( None, "--component-type", help="Filter by component type: extractor, writer, transformation, application", ), - component_id: Optional[str] = typer.Option( + component_id: str | None = typer.Option( None, "--component-id", help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)", @@ -67,7 +65,7 @@ def config_list( ) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None # In JSON mode, include both configs and errors in the response if formatter.json_mode: @@ -78,9 +76,7 @@ def config_list( # Show error warnings on stderr too for err in result.get("errors", []): - formatter.warning( - f"Project '{err['project_alias']}': {err['message']}" - ) + formatter.warning(f"Project '{err['project_alias']}': {err['message']}") @config_app.command("detail") @@ -103,7 +99,7 @@ def config_detail( formatter.output(result, format_config_detail) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None except KeboolaApiError as exc: if exc.error_code == "INVALID_TOKEN": exit_code = 3 @@ -117,4 +113,4 @@ def config_detail( project=project, retryable=exc.retryable, ) - raise typer.Exit(code=exit_code) + raise typer.Exit(code=exit_code) from None diff --git a/src/keboola_agent_cli/commands/doctor.py b/src/keboola_agent_cli/commands/doctor.py index 3510894d..28835ca5 100644 --- a/src/keboola_agent_cli/commands/doctor.py +++ b/src/keboola_agent_cli/commands/doctor.py @@ -16,12 +16,10 @@ import typer from rich.console import Console from rich.panel import Panel -from rich.table import Table from .. import __version__ -from ..client import KeboolaClient from ..config_store import ConfigStore -from ..errors import KeboolaApiError, mask_token +from ..errors import KeboolaApiError from ..models import AppConfig from ..output import OutputFormatter from ..services.project_service import ClientFactory, default_client_factory @@ -141,12 +139,14 @@ def _check_connectivity( List of check result dicts, one per project. """ if config is None or not config.projects: - return [{ - "check": "connectivity", - "name": "Project connectivity", - "status": "skip", - "message": "No projects configured.", - }] + return [ + { + "check": "connectivity", + "name": "Project connectivity", + "status": "skip", + "message": "No projects configured.", + } + ] results = [] for alias, project in config.projects.items(): @@ -155,29 +155,33 @@ def _check_connectivity( try: token_info = client.verify_token() elapsed = time.monotonic() - start_time - results.append({ - "check": "connectivity", - "name": f"Project '{alias}'", - "status": "pass", - "message": ( - f"Connected to {project.stack_url} " - f"(project: {token_info.project_name}, id: {token_info.project_id}) " - f"in {round(elapsed * 1000)}ms" - ), - "alias": alias, - "response_time_ms": round(elapsed * 1000), - }) + results.append( + { + "check": "connectivity", + "name": f"Project '{alias}'", + "status": "pass", + "message": ( + f"Connected to {project.stack_url} " + f"(project: {token_info.project_name}, id: {token_info.project_id}) " + f"in {round(elapsed * 1000)}ms" + ), + "alias": alias, + "response_time_ms": round(elapsed * 1000), + } + ) except KeboolaApiError as exc: elapsed = time.monotonic() - start_time - results.append({ - "check": "connectivity", - "name": f"Project '{alias}'", - "status": "fail", - "message": f"Failed: {exc.message}", - "alias": alias, - "error_code": exc.error_code, - "response_time_ms": round(elapsed * 1000), - }) + results.append( + { + "check": "connectivity", + "name": f"Project '{alias}'", + "status": "fail", + "message": f"Failed: {exc.message}", + "alias": alias, + "error_code": exc.error_code, + "response_time_ms": round(elapsed * 1000), + } + ) finally: client.close() diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index c7eedd7f..e80ce357 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -4,7 +4,7 @@ No business logic belongs here. """ -from typing import Any, Optional +from typing import Any import typer from rich.console import Console @@ -106,10 +106,13 @@ def project_add( try: result = service.add_project(alias=alias, stack_url=url, token=token) - formatter.output(result, lambda c, d: c.print( - f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] added " - f"(project: {d['project_name']}, id: {d['project_id']})" - )) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] added " + f"(project: {d['project_name']}, id: {d['project_id']})" + ), + ) except KeboolaApiError as exc: exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4 formatter.error( @@ -117,10 +120,10 @@ def project_add( error_code=exc.error_code, retryable=exc.retryable, ) - raise typer.Exit(code=exit_code) + raise typer.Exit(code=exit_code) from None except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None @project_app.command("list") @@ -134,7 +137,7 @@ def project_list(ctx: typer.Context) -> None: formatter.output(projects, _format_project_table) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None @project_app.command("remove") @@ -148,20 +151,20 @@ def project_remove( try: result = service.remove_project(alias=alias) - formatter.output(result, lambda c, d: c.print( - f"[bold green]Success:[/bold green] {d['message']}" - )) + formatter.output( + result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}") + ) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None @project_app.command("edit") def project_edit( ctx: typer.Context, alias: str = typer.Option(..., help="Alias of the project to edit"), - url: Optional[str] = typer.Option(None, help="New Keboola stack URL"), - token: Optional[str] = typer.Option(None, help="New Storage API token"), + url: str | None = typer.Option(None, help="New Keboola stack URL"), + token: str | None = typer.Option(None, help="New Storage API token"), ) -> None: """Edit an existing Keboola project connection.""" formatter = _get_formatter(ctx) @@ -169,9 +172,12 @@ def project_edit( try: result = service.edit_project(alias=alias, stack_url=url, token=token) - formatter.output(result, lambda c, d: c.print( - f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated." - )) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated." + ), + ) except KeboolaApiError as exc: exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4 formatter.error( @@ -179,16 +185,18 @@ def project_edit( error_code=exc.error_code, retryable=exc.retryable, ) - raise typer.Exit(code=exit_code) + raise typer.Exit(code=exit_code) from None except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None @project_app.command("status") def project_status( ctx: typer.Context, - project: Optional[str] = typer.Option(None, "--project", help="Check only this project (default: all)"), + project: str | None = typer.Option( + None, "--project", help="Check only this project (default: all)" + ), ) -> None: """Test connectivity to connected Keboola projects.""" formatter = _get_formatter(ctx) @@ -201,7 +209,7 @@ def project_status( formatter.output(statuses, _format_status_table) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") - raise typer.Exit(code=5) + raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4 formatter.error( @@ -209,4 +217,4 @@ def project_status( error_code=exc.error_code, retryable=exc.retryable, ) - raise typer.Exit(code=exit_code) + raise typer.Exit(code=exit_code) from None diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py index fa15263c..4e284222 100644 --- a/src/keboola_agent_cli/config_store.py +++ b/src/keboola_agent_cli/config_store.py @@ -52,12 +52,19 @@ def load(self) -> AppConfig: raw = self._config_path.read_text(encoding="utf-8") except OSError as exc: raise ConfigError(f"Cannot read config file {self._config_path}: {exc}") from exc + except UnicodeDecodeError as exc: + raise ConfigError(f"Config file is not valid UTF-8 text: {exc}") from exc try: data = json.loads(raw) except json.JSONDecodeError as exc: raise ConfigError(f"Config file is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError( + f"Config file has invalid structure: expected JSON object, got {type(data).__name__}" + ) + version = data.get("version", 1) if version > CURRENT_CONFIG_VERSION: raise ConfigError( diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index f0595672..c43c69d2 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -21,7 +21,7 @@ def mask_token(token: str) -> str: if dash_index == -1 or dash_index >= len(token) - 4: return "***" - prefix = token[: dash_index] + prefix = token[:dash_index] last4 = token[-4:] return f"{prefix}-...{last4}" diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 6c52fcd9..45982af5 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -10,7 +10,9 @@ class ProjectConfig(BaseModel): stack_url: str = Field(description="Keboola stack URL, e.g. https://connection.keboola.com") token: str = Field(description="Storage API token") - project_name: str = Field(default="", description="Human-readable project name (populated on add)") + project_name: str = Field( + default="", description="Human-readable project name (populated on add)" + ) project_id: int = Field(default=0, description="Keboola project ID (populated on add)") diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py index e0fbcb32..80852300 100644 --- a/src/keboola_agent_cli/output.py +++ b/src/keboola_agent_cli/output.py @@ -2,7 +2,8 @@ import json import sys -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from rich.console import Console from rich.panel import Panel @@ -38,7 +39,9 @@ def __init__( force_terminal=force_terminal, ) - def output(self, data: Any, human_formatter: Callable[[Console, Any], None] | None = None) -> None: + def output( + self, data: Any, human_formatter: Callable[[Console, Any], None] | None = None + ) -> None: """Output data in the appropriate format. Args: @@ -56,7 +59,9 @@ def output(self, data: Any, human_formatter: Callable[[Console, Any], None] | No else: self.console.print(data) - def error(self, message: str, error_code: str = "ERROR", project: str = "", retryable: bool = False) -> None: + def error( + self, message: str, error_code: str = "ERROR", project: str = "", retryable: bool = False + ) -> None: """Output an error message. Args: @@ -121,7 +126,9 @@ def format_configs_table(console: Console, data: dict[str, Any]) -> None: if not configs: if not errors: - console.print("No configurations found. Use [bold]kbagent project add[/bold] to connect a project first.") + console.print( + "No configurations found. Use [bold]kbagent project add[/bold] to connect a project first." + ) else: console.print("No configurations retrieved (all projects failed).") return @@ -185,6 +192,7 @@ def format_config_detail(console: Console, data: dict[str, Any]) -> None: configuration = data.get("configuration", {}) if configuration: import json as _json + config_str = _json.dumps(configuration, indent=2) lines.append(f"\n[bold]Configuration:[/bold]\n{config_str}") diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 354ec3ba..9ca6d44c 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -4,7 +4,8 @@ without knowing about CLI or HTTP details. """ -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from ..client import KeboolaClient from ..config_store import ConfigStore @@ -37,9 +38,7 @@ def __init__( self._config_store = config_store self._client_factory = client_factory or default_client_factory - def resolve_projects( - self, aliases: list[str] | None = None - ) -> dict[str, ProjectConfig]: + def resolve_projects(self, aliases: list[str] | None = None) -> dict[str, ProjectConfig]: """Resolve project aliases to ProjectConfig instances. Args: @@ -115,21 +114,25 @@ def list_configs( configurations = component.get("configurations", []) for cfg in configurations: - all_configs.append({ - "project_alias": alias, - "component_id": comp_id, - "component_name": comp_name, - "component_type": comp_type, - "config_id": str(cfg.get("id", "")), - "config_name": cfg.get("name", ""), - "config_description": cfg.get("description", ""), - }) + all_configs.append( + { + "project_alias": alias, + "component_id": comp_id, + "component_name": comp_name, + "component_type": comp_type, + "config_id": str(cfg.get("id", "")), + "config_name": cfg.get("name", ""), + "config_description": cfg.get("description", ""), + } + ) except KeboolaApiError as exc: - errors.append({ - "project_alias": alias, - "error_code": exc.error_code, - "message": exc.message, - }) + errors.append( + { + "project_alias": alias, + "error_code": exc.error_code, + "message": exc.message, + } + ) finally: client.close() diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index 7c2477c5..79a18760 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -4,7 +4,8 @@ """ import time -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from ..client import KeboolaClient from ..config_store import ConfigStore @@ -158,14 +159,16 @@ def list_projects(self) -> list[dict[str, Any]]: config = self._config_store.load() result = [] for alias, project in config.projects.items(): - result.append({ - "alias": alias, - "project_name": project.project_name, - "project_id": project.project_id, - "stack_url": project.stack_url, - "token": mask_token(project.token), - "is_default": alias == config.default_project, - }) + result.append( + { + "alias": alias, + "project_name": project.project_name, + "project_id": project.project_id, + "stack_url": project.stack_url, + "token": mask_token(project.token), + "is_default": alias == config.default_project, + } + ) return result def get_status(self, aliases: list[str] | None = None) -> list[dict[str, Any]]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 5b461fdc..61d804b6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,18 +1,15 @@ """Tests for CLI commands via CliRunner - project, config, context, doctor commands.""" import json -import os -import stat from pathlib import Path from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from keboola_agent_cli.cli import app from keboola_agent_cli.config_store import ConfigStore -from keboola_agent_cli.errors import ConfigError, KeboolaApiError -from keboola_agent_cli.models import AppConfig, ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse from keboola_agent_cli.services.config_service import ConfigService from keboola_agent_cli.services.project_service import ProjectService @@ -44,9 +41,10 @@ def test_project_add_success_json(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -56,13 +54,20 @@ def test_project_add_success_json(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - result = runner.invoke(app, [ - "--json", - "project", "add", - "--alias", "prod", - "--url", "https://connection.keboola.com", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + "prod", + "--url", + "https://connection.keboola.com", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" output = json.loads(result.output) @@ -79,9 +84,10 @@ def test_project_add_success_human(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -91,12 +97,19 @@ def test_project_add_success_human(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - result = runner.invoke(app, [ - "project", "add", - "--alias", "test", - "--url", "https://connection.keboola.com", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + result = runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--url", + "https://connection.keboola.com", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" assert "test" in result.output @@ -115,9 +128,10 @@ def test_project_add_invalid_token_exit_code_3(self, tmp_path: Path) -> None: retryable=False, ) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -127,12 +141,18 @@ def test_project_add_invalid_token_exit_code_3(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - result = runner.invoke(app, [ - "--json", - "project", "add", - "--alias", "bad", - "--token", "invalid-token-abcdefgh", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + "bad", + "--token", + "invalid-token-abcdefgh", + ], + ) assert result.exit_code == 3 output = json.loads(result.output) @@ -152,9 +172,10 @@ def test_project_add_timeout_exit_code_4(self, tmp_path: Path) -> None: retryable=True, ) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -164,12 +185,18 @@ def test_project_add_timeout_exit_code_4(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - result = runner.invoke(app, [ - "--json", - "project", "add", - "--alias", "timeout", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + "timeout", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) assert result.exit_code == 4 @@ -182,9 +209,10 @@ def test_project_list_json_empty(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService(config_store=store_instance) @@ -202,9 +230,10 @@ def test_project_list_json_with_projects(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -215,12 +244,19 @@ def test_project_list_json_with_projects(self, tmp_path: Path) -> None: MockService.return_value = service_instance # Add a project first - runner.invoke(app, [ - "project", "add", - "--alias", "test", - "--url", "https://connection.keboola.com", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--url", + "https://connection.keboola.com", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) result = runner.invoke(app, ["--json", "project", "list"]) @@ -239,9 +275,10 @@ def test_project_list_human_mode(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client(project_name="My Production") - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -251,12 +288,19 @@ def test_project_list_human_mode(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - runner.invoke(app, [ - "project", "add", - "--alias", "prod", - "--url", "https://connection.keboola.com", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "prod", + "--url", + "https://connection.keboola.com", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) result = runner.invoke(app, ["project", "list"]) @@ -269,9 +313,10 @@ def test_project_list_human_empty(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService(config_store=store_instance) @@ -291,9 +336,10 @@ def test_project_remove_success_json(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -303,17 +349,28 @@ def test_project_remove_success_json(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - runner.invoke(app, [ - "project", "add", - "--alias", "test", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) - result = runner.invoke(app, [ - "--json", - "project", "remove", - "--alias", "test", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "remove", + "--alias", + "test", + ], + ) assert result.exit_code == 0 output = json.loads(result.output) @@ -325,18 +382,24 @@ def test_project_remove_nonexistent_exit_code_5(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService(config_store=store_instance) - result = runner.invoke(app, [ - "--json", - "project", "remove", - "--alias", "nonexistent", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "remove", + "--alias", + "nonexistent", + ], + ) assert result.exit_code == 5 output = json.loads(result.output) @@ -353,9 +416,10 @@ def test_project_status_json(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client(project_name="Prod", project_id=123) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -365,11 +429,17 @@ def test_project_status_json(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - runner.invoke(app, [ - "project", "add", - "--alias", "prod", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "prod", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) result = runner.invoke(app, ["--json", "project", "status"]) @@ -387,9 +457,10 @@ def test_project_status_human(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -399,11 +470,17 @@ def test_project_status_human(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - runner.invoke(app, [ - "project", "add", - "--alias", "test", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) result = runner.invoke(app, ["project", "status"]) @@ -420,9 +497,10 @@ def test_project_edit_url_json(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -432,19 +510,32 @@ def test_project_edit_url_json(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - runner.invoke(app, [ - "project", "add", - "--alias", "test", - "--url", "https://old.keboola.com", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--url", + "https://old.keboola.com", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) - result = runner.invoke(app, [ - "--json", - "project", "edit", - "--alias", "test", - "--url", "https://new.keboola.com", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "edit", + "--alias", + "test", + "--url", + "https://new.keboola.com", + ], + ) assert result.exit_code == 0 output = json.loads(result.output) @@ -457,9 +548,10 @@ def test_project_edit_config_error_exit_code_5(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_mock_client() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance @@ -469,17 +561,28 @@ def test_project_edit_config_error_exit_code_5(self, tmp_path: Path) -> None: ) MockService.return_value = service_instance - runner.invoke(app, [ - "project", "add", - "--alias", "test", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) - result = runner.invoke(app, [ - "--json", - "project", "edit", - "--alias", "test", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "edit", + "--alias", + "test", + ], + ) assert result.exit_code == 5 output = json.loads(result.output) @@ -558,12 +661,15 @@ def _setup_config_test(config_dir: Path, projects: dict[str, dict] | None = None store = ConfigStore(config_dir=config_dir) if projects: for alias, info in projects.items(): - store.add_project(alias, ProjectConfig( - stack_url=info.get("stack_url", "https://connection.keboola.com"), - token=info["token"], - project_name=info.get("project_name", alias), - project_id=info.get("project_id", 1234), - )) + store.add_project( + alias, + ProjectConfig( + stack_url=info.get("stack_url", "https://connection.keboola.com"), + token=info["token"], + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + ), + ) return store @@ -576,14 +682,18 @@ def test_config_list_json_output(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_list_components_client(SAMPLE_COMPONENTS) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -610,14 +720,18 @@ def test_config_list_human_output(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_list_components_client(SAMPLE_COMPONENTS) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -645,20 +759,24 @@ def test_config_list_project_filter(self, tmp_path: Path) -> None: prod_client = _make_list_components_client(SAMPLE_COMPONENTS) dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - "dev": {"token": "532-abcdef-ghijklmnopqrst"}, - }) + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + "dev": {"token": "532-abcdef-ghijklmnopqrst"}, + }, + ) def factory(url, token): if "901" in token: return prod_client return dev_client - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -668,10 +786,16 @@ def factory(url, token): ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "list", - "--project", "prod", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--project", + "prod", + ], + ) assert result.exit_code == 0 output = json.loads(result.output) @@ -687,20 +811,24 @@ def test_config_list_multiple_projects(self, tmp_path: Path) -> None: prod_client = _make_list_components_client(SAMPLE_COMPONENTS) dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - "dev": {"token": "532-abcdef-ghijklmnopqrst"}, - }) + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + "dev": {"token": "532-abcdef-ghijklmnopqrst"}, + }, + ) def factory(url, token): if "901" in token: return prod_client return dev_client - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -710,11 +838,18 @@ def factory(url, token): ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "list", - "--project", "prod", - "--project", "dev", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--project", + "prod", + "--project", + "dev", + ], + ) assert result.exit_code == 0 output = json.loads(result.output) @@ -732,14 +867,18 @@ def test_config_list_type_filter(self, tmp_path: Path) -> None: extractor_only = [SAMPLE_COMPONENTS[0]] mock_client = _make_list_components_client(extractor_only) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -749,10 +888,16 @@ def test_config_list_type_filter(self, tmp_path: Path) -> None: ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "list", - "--component-type", "extractor", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--component-type", + "extractor", + ], + ) assert result.exit_code == 0 output = json.loads(result.output) @@ -766,14 +911,18 @@ def test_config_list_component_id_filter(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_list_components_client(SAMPLE_COMPONENTS) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -783,10 +932,16 @@ def test_config_list_component_id_filter(self, tmp_path: Path) -> None: ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "list", - "--component-id", "keboola.wr-db-snowflake", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--component-id", + "keboola.wr-db-snowflake", + ], + ) assert result.exit_code == 0 output = json.loads(result.output) @@ -801,18 +956,25 @@ def test_config_list_unknown_alias_exit_code_5(self, tmp_path: Path) -> None: store = _setup_config_test(config_dir) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) MockCfgService.return_value = ConfigService(config_store=store) - result = runner.invoke(app, [ - "--json", "config", "list", - "--project", "nonexistent", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--project", + "nonexistent", + ], + ) assert result.exit_code == 5 output = json.loads(result.output) @@ -834,20 +996,24 @@ def test_config_list_partial_failure_json(self, tmp_path: Path) -> None: retryable=False, ) - store = _setup_config_test(config_dir, { - "good": {"token": "901-good-abcdefghijklmnop"}, - "bad": {"token": "532-bad-abcdefghijklmnopq"}, - }) + store = _setup_config_test( + config_dir, + { + "good": {"token": "901-good-abcdefghijklmnop"}, + "bad": {"token": "532-bad-abcdefghijklmnopq"}, + }, + ) def factory(url, token): if "good" in token: return good_client return bad_client - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -887,20 +1053,24 @@ def test_config_list_partial_failure_human(self, tmp_path: Path) -> None: retryable=False, ) - store = _setup_config_test(config_dir, { - "good": {"token": "901-good-abcdefghijklmnop"}, - "bad": {"token": "532-bad-abcdefghijklmnopq"}, - }) + store = _setup_config_test( + config_dir, + { + "good": {"token": "901-good-abcdefghijklmnop"}, + "bad": {"token": "532-bad-abcdefghijklmnopq"}, + }, + ) def factory(url, token): if "good" in token: return good_client return bad_client - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -926,14 +1096,18 @@ def test_config_list_empty_json(self, tmp_path: Path) -> None: config_dir.mkdir() mock_client = _make_list_components_client([]) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -956,22 +1130,32 @@ def test_config_list_invalid_component_type_exit_code_2(self, tmp_path: Path) -> config_dir = tmp_path / "config" config_dir.mkdir() - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) MockCfgService.return_value = ConfigService(config_store=store) - result = runner.invoke(app, [ - "--json", "config", "list", - "--component-type", "invalid-type", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--component-type", + "invalid-type", + ], + ) assert result.exit_code == 2 output = json.loads(result.output) @@ -999,14 +1183,18 @@ def test_config_detail_json_output(self, tmp_path: Path) -> None: mock_client = MagicMock() mock_client.get_config_detail.return_value = detail_response - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -1016,12 +1204,20 @@ def test_config_detail_json_output(self, tmp_path: Path) -> None: ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "prod", - "--component-id", "keboola.ex-db-snowflake", - "--config-id", "101", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "101", + ], + ) assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" output = json.loads(result.output) @@ -1048,14 +1244,18 @@ def test_config_detail_human_output(self, tmp_path: Path) -> None: mock_client = MagicMock() mock_client.get_config_detail.return_value = detail_response - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -1065,12 +1265,19 @@ def test_config_detail_human_output(self, tmp_path: Path) -> None: ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "config", "detail", - "--project", "prod", - "--component-id", "keboola.ex-db-snowflake", - "--config-id", "101", - ]) + result = runner.invoke( + app, + [ + "config", + "detail", + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "101", + ], + ) assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" assert "Production Load" in result.output @@ -1083,20 +1290,29 @@ def test_config_detail_unknown_alias_exit_code_5(self, tmp_path: Path) -> None: store = _setup_config_test(config_dir) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) MockCfgService.return_value = ConfigService(config_store=store) - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "nonexistent", - "--component-id", "keboola.ex-db-snowflake", - "--config-id", "101", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "nonexistent", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "101", + ], + ) assert result.exit_code == 5 output = json.loads(result.output) @@ -1117,14 +1333,18 @@ def test_config_detail_api_error_exit_code(self, tmp_path: Path) -> None: retryable=False, ) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -1134,12 +1354,20 @@ def test_config_detail_api_error_exit_code(self, tmp_path: Path) -> None: ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "prod", - "--component-id", "keboola.ex-db-snowflake", - "--config-id", "999", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "999", + ], + ) assert result.exit_code == 1 output = json.loads(result.output) @@ -1159,14 +1387,18 @@ def test_config_detail_auth_error_exit_code_3(self, tmp_path: Path) -> None: retryable=False, ) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) @@ -1176,12 +1408,20 @@ def test_config_detail_auth_error_exit_code_3(self, tmp_path: Path) -> None: ) MockCfgService.return_value = config_service - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "prod", - "--component-id", "keboola.ex-db-snowflake", - "--config-id", "101", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "101", + ], + ) assert result.exit_code == 3 output = json.loads(result.output) @@ -1321,12 +1561,15 @@ def test_doctor_with_valid_config(self, tmp_path: Path) -> None: config_dir.mkdir() store = ConfigStore(config_dir=config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - project_name="Test", - project_id=1234, - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Test", + project_id=1234, + ), + ) with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: MockStore.return_value = store @@ -1386,18 +1629,22 @@ def test_doctor_connectivity_with_mock_client(self, tmp_path: Path) -> None: config_dir.mkdir() store = ConfigStore(config_dir=config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - project_name="Prod", - project_id=1234, - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Prod", + project_id=1234, + ), + ) mock_client = _make_mock_client(project_name="Prod", project_id=1234) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.commands.doctor.default_client_factory") as MockFactory: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.commands.doctor.default_client_factory") as MockFactory, + ): MockStore.return_value = store MockFactory.return_value = mock_client @@ -1418,12 +1665,15 @@ def test_doctor_connectivity_failure(self, tmp_path: Path) -> None: config_dir.mkdir() store = ConfigStore(config_dir=config_dir) - store.add_project("bad", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-badtoken-abcdefghijklmn", - project_name="Bad", - project_id=9999, - )) + store.add_project( + "bad", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-badtoken-abcdefghijklmn", + project_name="Bad", + project_id=9999, + ), + ) fail_client = MagicMock() fail_client.verify_token.side_effect = KeboolaApiError( @@ -1433,9 +1683,10 @@ def test_doctor_connectivity_failure(self, tmp_path: Path) -> None: retryable=False, ) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.commands.doctor.default_client_factory") as MockFactory: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.commands.doctor.default_client_factory") as MockFactory, + ): MockStore.return_value = store MockFactory.return_value = fail_client @@ -1513,9 +1764,10 @@ def test_no_color_project_list(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService(config_store=store_instance) @@ -1558,9 +1810,10 @@ def test_auth_error_exit_code_3(self, tmp_path: Path) -> None: retryable=False, ) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService( @@ -1568,11 +1821,18 @@ def test_auth_error_exit_code_3(self, tmp_path: Path) -> None: client_factory=lambda url, token: fail_client, ) - result = runner.invoke(app, [ - "--json", "project", "add", - "--alias", "bad", - "--token", "invalid-token-abcdefgh", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + "bad", + "--token", + "invalid-token-abcdefgh", + ], + ) assert result.exit_code == 3 output = json.loads(result.output) @@ -1592,9 +1852,10 @@ def test_network_error_exit_code_4(self, tmp_path: Path) -> None: retryable=True, ) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService( @@ -1602,11 +1863,18 @@ def test_network_error_exit_code_4(self, tmp_path: Path) -> None: client_factory=lambda url, token: fail_client, ) - result = runner.invoke(app, [ - "--json", "project", "add", - "--alias", "unreachable", - "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + "unreachable", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) assert result.exit_code == 4 output = json.loads(result.output) @@ -1617,17 +1885,24 @@ def test_config_error_exit_code_5(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): store_instance = ConfigStore(config_dir=config_dir) MockStore.return_value = store_instance MockService.return_value = ProjectService(config_store=store_instance) - result = runner.invoke(app, [ - "--json", "project", "remove", - "--alias", "nonexistent", - ]) + result = runner.invoke( + app, + [ + "--json", + "project", + "remove", + "--alias", + "nonexistent", + ], + ) assert result.exit_code == 5 output = json.loads(result.output) @@ -1641,20 +1916,29 @@ def test_config_error_exit_code_5_config_detail(self, tmp_path: Path) -> None: store = _setup_config_test(config_dir) - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: - + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) MockCfgService.return_value = ConfigService(config_store=store) - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "nonexistent", - "--component-id", "test", - "--config-id", "123", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "nonexistent", + "--component-id", + "test", + "--config-id", + "123", + ], + ) assert result.exit_code == 5 @@ -1671,14 +1955,18 @@ def test_auth_error_exit_code_3_config_detail(self, tmp_path: Path) -> None: retryable=False, ) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) MockCfgService.return_value = ConfigService( @@ -1686,12 +1974,20 @@ def test_auth_error_exit_code_3_config_detail(self, tmp_path: Path) -> None: client_factory=lambda url, token: mock_client, ) - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "prod", - "--component-id", "test", - "--config-id", "123", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "prod", + "--component-id", + "test", + "--config-id", + "123", + ], + ) assert result.exit_code == 3 @@ -1708,14 +2004,18 @@ def test_network_error_exit_code_4_config_detail(self, tmp_path: Path) -> None: retryable=True, ) - store = _setup_config_test(config_dir, { - "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, - }) - - with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ - patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ - patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + store = _setup_config_test( + config_dir, + { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }, + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): MockStore.return_value = store MockProjService.return_value = ProjectService(config_store=store) MockCfgService.return_value = ConfigService( @@ -1723,11 +2023,266 @@ def test_network_error_exit_code_4_config_detail(self, tmp_path: Path) -> None: client_factory=lambda url, token: mock_client, ) - result = runner.invoke(app, [ - "--json", "config", "detail", - "--project", "prod", - "--component-id", "test", - "--config-id", "123", - ]) + result = runner.invoke( + app, + [ + "--json", + "config", + "detail", + "--project", + "prod", + "--component-id", + "test", + "--config-id", + "123", + ], + ) assert result.exit_code == 4 + + +# --------------------------------------------------------------------------- +# Help and usage tests +# --------------------------------------------------------------------------- + + +class TestHelp: + """Tests for help output on all commands.""" + + def test_root_help(self) -> None: + """Root --help shows app description and command groups.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "project" in result.output + assert "config" in result.output + assert "context" in result.output + assert "doctor" in result.output + + def test_project_help(self) -> None: + """project --help shows subcommands.""" + result = runner.invoke(app, ["project", "--help"]) + assert result.exit_code == 0 + assert "add" in result.output + assert "list" in result.output + assert "remove" in result.output + assert "edit" in result.output + assert "status" in result.output + + def test_config_help(self) -> None: + """config --help shows subcommands.""" + result = runner.invoke(app, ["config", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + assert "detail" in result.output + + def test_project_add_help(self) -> None: + """project add --help shows options.""" + result = runner.invoke(app, ["project", "add", "--help"]) + assert result.exit_code == 0 + assert "--alias" in result.output + assert "--token" in result.output + assert "--url" in result.output + + def test_config_list_help(self) -> None: + """config list --help shows options.""" + result = runner.invoke(app, ["config", "list", "--help"]) + assert result.exit_code == 0 + assert "--project" in result.output + assert "--component-type" in result.output + assert "--component-id" in result.output + + def test_config_detail_help(self) -> None: + """config detail --help shows required options.""" + result = runner.invoke(app, ["config", "detail", "--help"]) + assert result.exit_code == 0 + assert "--project" in result.output + assert "--component-id" in result.output + assert "--config-id" in result.output + + +class TestVerboseFlag: + """Tests for --verbose global flag.""" + + def test_verbose_flag_accepted(self, tmp_path: Path) -> None: + """--verbose flag is accepted without error.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--verbose", "context"]) + + assert result.exit_code == 0 + + def test_verbose_with_json(self, tmp_path: Path) -> None: + """--verbose and --json can be used together.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--verbose", "--json", "context"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + + +class TestMissingRequiredArgs: + """Tests for missing required arguments.""" + + def test_project_add_missing_alias(self, tmp_path: Path) -> None: + """project add without --alias shows error.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): + MockStore.return_value = ConfigStore(config_dir=config_dir) + MockService.return_value = ProjectService(config_store=MockStore.return_value) + + result = runner.invoke( + app, + [ + "project", + "add", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) + + assert result.exit_code != 0 + + def test_project_add_missing_token(self, tmp_path: Path) -> None: + """project add without --token shows error.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): + MockStore.return_value = ConfigStore(config_dir=config_dir) + MockService.return_value = ProjectService(config_store=MockStore.return_value) + + result = runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + ], + ) + + assert result.exit_code != 0 + + def test_project_remove_missing_alias(self, tmp_path: Path) -> None: + """project remove without --alias shows error.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): + MockStore.return_value = ConfigStore(config_dir=config_dir) + MockService.return_value = ProjectService(config_store=MockStore.return_value) + + result = runner.invoke(app, ["project", "remove"]) + + assert result.exit_code != 0 + + def test_config_detail_missing_project(self, tmp_path: Path) -> None: + """config detail without --project shows error.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + ): + MockStore.return_value = ConfigStore(config_dir=config_dir) + MockProjService.return_value = ProjectService(config_store=MockStore.return_value) + MockCfgService.return_value = ConfigService(config_store=MockStore.return_value) + + result = runner.invoke( + app, + [ + "config", + "detail", + "--component-id", + "test", + "--config-id", + "123", + ], + ) + + assert result.exit_code != 0 + + +class TestProjectEditTokenReverify: + """Tests for project edit with token changes.""" + + def test_project_edit_token_reverify_json(self, tmp_path: Path) -> None: + """project edit --token triggers re-verification and returns updated info.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client(project_name="Original", project_id=100) + new_mock_client = _make_mock_client(project_name="Updated", project_id=200) + + call_count = 0 + + def client_factory(url, token): + nonlocal call_count + call_count += 1 + if call_count == 1: + return mock_client + return new_mock_client + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockService, + ): + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=client_factory, + ) + MockService.return_value = service_instance + + # Add project first + runner.invoke( + app, + [ + "project", + "add", + "--alias", + "test", + "--token", + "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ], + ) + + # Edit with new token + result = runner.invoke( + app, + [ + "--json", + "project", + "edit", + "--alias", + "test", + "--token", + "902-newtoken-abcdefghijklmn", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" diff --git a/tests/test_client.py b/tests/test_client.py index f0910c72..7cfbfe5e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,10 +3,9 @@ import httpx import pytest -from keboola_agent_cli.client import KeboolaClient, MAX_RETRIES +from keboola_agent_cli.client import MAX_RETRIES, KeboolaClient from keboola_agent_cli.errors import KeboolaApiError - VERIFY_TOKEN_RESPONSE = { "id": "12345", "description": "My test token", @@ -105,6 +104,7 @@ def test_retry_on_503_then_success(self, httpx_mock) -> None: # Monkeypatch time.sleep to avoid actual delays in tests import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep client_module.time.sleep = lambda x: None try: @@ -129,6 +129,7 @@ def test_retry_exhausted_raises_error(self, httpx_mock) -> None: ) import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep client_module.time.sleep = lambda x: None try: @@ -158,6 +159,7 @@ def test_retry_on_429(self, httpx_mock) -> None: ) import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep client_module.time.sleep = lambda x: None try: @@ -212,6 +214,7 @@ def test_timeout_raises_api_error(self, httpx_mock) -> None: ) import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep client_module.time.sleep = lambda x: None try: @@ -244,6 +247,7 @@ def test_connect_error_raises_api_error(self, httpx_mock) -> None: ) import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep client_module.time.sleep = lambda x: None try: @@ -297,6 +301,7 @@ def test_timeout_error_masks_token(self, httpx_mock) -> None: ) import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep client_module.time.sleep = lambda x: None try: @@ -426,3 +431,264 @@ def test_get_config_detail_success(self, httpx_mock) -> None: result = client.get_config_detail("keboola.ex-db-snowflake", "42") assert result["id"] == "42" assert result["name"] == "My Config" + + +class TestMalformedJsonResponse: + """Tests for handling malformed JSON responses from the API.""" + + def test_malformed_json_in_error_response(self, httpx_mock) -> None: + """Client handles non-JSON error response body gracefully.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="502 Bad Gateway", + status_code=502, + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="502 Bad Gateway", + status_code=502, + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="502 Bad Gateway", + status_code=502, + ) + + import keboola_agent_cli.client as client_module + + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert exc_info.value.retryable is True + # Error message should contain the raw text body + assert "502" in exc_info.value.message + finally: + client_module.time.sleep = original_sleep + + def test_malformed_json_in_success_response(self, httpx_mock) -> None: + """Client raises error when success response has non-parseable JSON.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="not json at all", + status_code=200, + headers={"content-type": "text/plain"}, + ) + + with ( + KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client, + pytest.raises((ValueError, KeyError)), + ): + # verify_token calls response.json() which will fail + client.verify_token() + + def test_empty_json_error_body(self, httpx_mock) -> None: + """Client handles empty JSON object in error response.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json={}, + status_code=401, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert exc_info.value.error_code == "INVALID_TOKEN" + assert exc_info.value.status_code == 401 + + +class TestEmptyResponse: + """Tests for handling empty responses.""" + + def test_empty_body_error_response(self, httpx_mock) -> None: + """Client handles completely empty error response body.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="", + status_code=500, + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="", + status_code=500, + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + text="", + status_code=500, + ) + + import keboola_agent_cli.client as client_module + + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert exc_info.value.retryable is True + assert exc_info.value.status_code == 500 + finally: + client_module.time.sleep = original_sleep + + def test_empty_components_list(self, httpx_mock) -> None: + """list_components returns empty list when API returns empty array.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components?include=configuration", + json=[], + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.list_components() + assert result == [] + + def test_verify_token_minimal_response(self, httpx_mock) -> None: + """verify_token handles response with minimal/missing fields.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json={"id": "1"}, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.verify_token() + assert result.token_id == "1" + assert result.project_name == "" + assert result.project_id == 0 + + +class TestLargeResponse: + """Tests for handling large API responses.""" + + def test_large_components_list(self, httpx_mock) -> None: + """Client handles response with many components.""" + # Generate 200 components with 10 configs each + components = [] + for i in range(200): + configs = [] + for j in range(10): + configs.append( + { + "id": str(i * 10 + j), + "name": f"Config {j} of Component {i}", + "description": f"Description for config {j}", + } + ) + components.append( + { + "id": f"keboola.component-{i}", + "name": f"Component {i}", + "type": "extractor", + "configurations": configs, + } + ) + + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components?include=configuration", + json=components, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.list_components() + assert len(result) == 200 + assert len(result[0]["configurations"]) == 10 + assert result[199]["id"] == "keboola.component-199" + + def test_large_config_detail(self, httpx_mock) -> None: + """Client handles config detail with large configuration payload.""" + # Simulate a large configuration with nested parameters + large_config = { + "id": "42", + "name": "Large Config", + "description": "A config with large parameters", + "componentId": "keboola.ex-db-snowflake", + "configuration": { + "parameters": {f"param_{i}": f"value_{i}" for i in range(500)}, + "storage": { + "input": { + "tables": [ + {"source": f"in.c-data.table_{i}", "destination": f"table_{i}.csv"} + for i in range(100) + ] + } + }, + }, + "rows": [{"id": str(i), "name": f"Row {i}"} for i in range(50)], + } + + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components/keboola.ex-db-snowflake/configs/42", + json=large_config, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.get_config_detail("keboola.ex-db-snowflake", "42") + assert result["id"] == "42" + assert len(result["configuration"]["parameters"]) == 500 + assert len(result["rows"]) == 50 + + +class TestStackUrlNormalization: + """Tests for stack URL handling edge cases.""" + + def test_trailing_slash_removed(self, httpx_mock) -> None: + """Client strips trailing slash from stack URL.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com/", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.verify_token() + assert result.project_name == "Test Project" + + def test_404_returns_not_found_error(self, httpx_mock) -> None: + """Client returns NOT_FOUND error code for 404 responses.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components/nonexistent/configs/999", + json={"error": "Configuration not found"}, + status_code=404, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + with pytest.raises(KeboolaApiError) as exc_info: + client.get_config_detail("nonexistent", "999") + assert exc_info.value.error_code == "NOT_FOUND" + assert exc_info.value.status_code == 404 + assert exc_info.value.retryable is False diff --git a/tests/test_config_store.py b/tests/test_config_store.py index b2567c01..1bc6a76c 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -135,12 +135,20 @@ def test_add_first_project_becomes_default(self, tmp_config_dir: Path) -> None: def test_add_second_project_does_not_change_default(self, tmp_config_dir: Path) -> None: """Adding a second project does not change the default.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("first", ProjectConfig( - stack_url="https://a.com", token="901-abcdef-12345678", - )) - store.add_project("second", ProjectConfig( - stack_url="https://b.com", token="902-abcdef-12345678", - )) + store.add_project( + "first", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) + store.add_project( + "second", + ProjectConfig( + stack_url="https://b.com", + token="902-abcdef-12345678", + ), + ) config = store.load() assert config.default_project == "first" @@ -164,9 +172,13 @@ class TestRemoveProject: def test_remove_project_success(self, tmp_config_dir: Path) -> None: """Removing a project deletes it from config.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://a.com", token="901-abcdef-12345678", - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) store.remove_project("test") @@ -176,12 +188,20 @@ def test_remove_project_success(self, tmp_config_dir: Path) -> None: def test_remove_default_project_updates_default(self, tmp_config_dir: Path) -> None: """Removing the default project updates the default to the next available.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("first", ProjectConfig( - stack_url="https://a.com", token="901-abcdef-12345678", - )) - store.add_project("second", ProjectConfig( - stack_url="https://b.com", token="902-abcdef-12345678", - )) + store.add_project( + "first", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) + store.add_project( + "second", + ProjectConfig( + stack_url="https://b.com", + token="902-abcdef-12345678", + ), + ) store.remove_project("first") config = store.load() @@ -191,9 +211,13 @@ def test_remove_default_project_updates_default(self, tmp_config_dir: Path) -> N def test_remove_last_project_clears_default(self, tmp_config_dir: Path) -> None: """Removing the last project clears the default.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("only", ProjectConfig( - stack_url="https://a.com", token="901-abcdef-12345678", - )) + store.add_project( + "only", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) store.remove_project("only") config = store.load() @@ -215,10 +239,13 @@ class TestEditProject: def test_edit_stack_url(self, tmp_config_dir: Path) -> None: """Editing stack_url updates it in the config.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://old.com", - token="901-abcdef-12345678", - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://old.com", + token="901-abcdef-12345678", + ), + ) store.edit_project("test", stack_url="https://new.com") @@ -229,10 +256,13 @@ def test_edit_stack_url(self, tmp_config_dir: Path) -> None: def test_edit_token(self, tmp_config_dir: Path) -> None: """Editing token updates it in the config.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://a.com", - token="901-abcdef-12345678", - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) store.edit_project("test", token="902-newtoken-87654321") @@ -243,11 +273,14 @@ def test_edit_token(self, tmp_config_dir: Path) -> None: def test_edit_multiple_fields(self, tmp_config_dir: Path) -> None: """Editing multiple fields at once works.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://old.com", - token="901-abcdef-12345678", - project_name="Old Name", - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://old.com", + token="901-abcdef-12345678", + project_name="Old Name", + ), + ) store.edit_project("test", stack_url="https://new.com", project_name="New Name") @@ -259,10 +292,13 @@ def test_edit_multiple_fields(self, tmp_config_dir: Path) -> None: def test_edit_none_values_ignored(self, tmp_config_dir: Path) -> None: """None values in kwargs are ignored and don't overwrite existing data.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://a.com", - token="901-abcdef-12345678", - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) store.edit_project("test", stack_url=None, token="new-token-1234abcd") @@ -285,11 +321,14 @@ class TestGetProject: def test_get_existing_project(self, tmp_config_dir: Path) -> None: """Getting an existing project returns it.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("test", ProjectConfig( - stack_url="https://a.com", - token="901-abcdef-12345678", - project_name="Test", - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + project_name="Test", + ), + ) project = store.get_project("test") assert project is not None @@ -317,10 +356,14 @@ def test_future_version_raises_config_error(self, tmp_config_dir: Path) -> None: """Config with a future version raises ConfigError.""" store = ConfigStore(config_dir=tmp_config_dir) config_file = tmp_config_dir / "config.json" - config_file.write_text(json.dumps({ - "version": CURRENT_CONFIG_VERSION + 1, - "projects": {}, - })) + config_file.write_text( + json.dumps( + { + "version": CURRENT_CONFIG_VERSION + 1, + "projects": {}, + } + ) + ) with pytest.raises(ConfigError, match="newer than supported"): store.load() @@ -338,10 +381,185 @@ def test_invalid_structure_raises_config_error(self, tmp_config_dir: Path) -> No """Config file with wrong structure raises ConfigError.""" store = ConfigStore(config_dir=tmp_config_dir) config_file = tmp_config_dir / "config.json" - config_file.write_text(json.dumps({ - "version": 1, - "projects": {"bad": {"not_a_valid_field_only": True}}, - })) + config_file.write_text( + json.dumps( + { + "version": 1, + "projects": {"bad": {"not_a_valid_field_only": True}}, + } + ) + ) with pytest.raises(ConfigError, match="invalid structure"): store.load() + + +class TestCorruptedFile: + """Tests for handling corrupted config files.""" + + def test_binary_garbage_in_config(self, tmp_config_dir: Path) -> None: + """Loading a config file with binary garbage raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_bytes(b"\x00\x01\x02\x03\xff\xfe\xfd") + + with pytest.raises(ConfigError, match="not valid UTF-8"): + store.load() + + def test_truncated_json(self, tmp_config_dir: Path) -> None: + """Loading a truncated JSON config raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text('{"version": 1, "projects": {') + + with pytest.raises(ConfigError, match="not valid JSON"): + store.load() + + def test_json_array_instead_of_object(self, tmp_config_dir: Path) -> None: + """Loading a JSON array instead of object raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text("[1, 2, 3]") + + with pytest.raises(ConfigError, match="invalid structure"): + store.load() + + def test_json_null_value(self, tmp_config_dir: Path) -> None: + """Loading JSON null raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text("null") + + with pytest.raises(ConfigError, match="invalid structure"): + store.load() + + def test_empty_file(self, tmp_config_dir: Path) -> None: + """Loading an empty file raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text("") + + with pytest.raises(ConfigError, match="not valid JSON"): + store.load() + + def test_config_with_extra_fields_loads(self, tmp_config_dir: Path) -> None: + """Config with extra fields (forward compatibility) loads successfully.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text( + json.dumps( + { + "version": 1, + "default_project": "", + "projects": {}, + "future_field": "some value", + "another_future": 42, + } + ) + ) + + config = store.load() + assert config.version == 1 + assert config.projects == {} + + +class TestMissingDirectory: + """Tests for config operations when directory does not exist.""" + + def test_load_from_nonexistent_directory(self, tmp_path: Path) -> None: + """Loading from a nonexistent directory returns empty config.""" + nonexistent = tmp_path / "does" / "not" / "exist" + store = ConfigStore(config_dir=nonexistent) + config = store.load() + + assert isinstance(config, AppConfig) + assert config.projects == {} + + def test_save_creates_nested_directory(self, tmp_path: Path) -> None: + """Save creates deeply nested directory structure.""" + deep_dir = tmp_path / "a" / "b" / "c" / "d" / "config" + store = ConfigStore(config_dir=deep_dir) + store.save(AppConfig(default_project="test")) + + assert deep_dir.exists() + loaded = store.load() + assert loaded.default_project == "test" + + def test_add_project_creates_directory(self, tmp_path: Path) -> None: + """add_project creates the config directory and file if needed.""" + new_dir = tmp_path / "fresh" / "config" + store = ConfigStore(config_dir=new_dir) + store.add_project( + "test", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) + + assert (new_dir / "config.json").exists() + config = store.load() + assert "test" in config.projects + + +class TestPermissionDenied: + """Tests for permission-related errors.""" + + def test_save_to_readonly_directory(self, tmp_config_dir: Path) -> None: + """Saving to a read-only directory raises ConfigError.""" + # Make the directory read-only + tmp_config_dir.chmod(0o444) + store = ConfigStore(config_dir=tmp_config_dir) + + try: + with pytest.raises(ConfigError, match="Cannot write"): + store.save(AppConfig()) + finally: + # Restore permissions for cleanup + tmp_config_dir.chmod(0o755) + + def test_load_unreadable_config_file(self, tmp_config_dir: Path) -> None: + """Loading an unreadable config file raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text(json.dumps({"version": 1, "projects": {}})) + + # Make the file unreadable + config_file.chmod(0o000) + + try: + with pytest.raises(ConfigError, match="Cannot read"): + store.load() + finally: + # Restore permissions for cleanup + config_file.chmod(0o644) + + +class TestConfigPath: + """Tests for config_path property.""" + + def test_config_path_returns_correct_path(self, tmp_config_dir: Path) -> None: + """config_path property returns the full path to config.json.""" + store = ConfigStore(config_dir=tmp_config_dir) + assert store.config_path == tmp_config_dir / "config.json" + + def test_multiple_save_load_cycles(self, tmp_config_dir: Path) -> None: + """Multiple save/load cycles preserve data integrity.""" + store = ConfigStore(config_dir=tmp_config_dir) + + for i in range(10): + store.add_project( + f"project-{i}", + ProjectConfig( + stack_url=f"https://stack-{i}.keboola.com", + token=f"901-token-{i}-abcdefgh", + project_name=f"Project {i}", + project_id=i, + ), + ) + + config = store.load() + assert len(config.projects) == 10 + assert config.projects["project-0"].project_name == "Project 0" + assert config.projects["project-9"].project_id == 9 + assert config.default_project == "project-0" diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 00000000..6e64391b --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,218 @@ +"""Integration tests for Keboola Agent CLI using real API credentials. + +These tests are skipped unless the following environment variables are set: + - KBA_TEST_TOKEN_AWS: Storage API token for AWS stack + - KBA_TEST_URL_AWS: Stack URL for AWS stack (default: https://connection.keboola.com) + +To run integration tests: + KBA_TEST_TOKEN_AWS=your-token uv run pytest tests/test_integration.py -v + +These tests exercise the full workflow: add project, list, status, config list, remove. +""" + +import json +import os +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore + +runner = CliRunner() + +# Environment variable names for test credentials +ENV_TOKEN_AWS = "KBA_TEST_TOKEN_AWS" +ENV_URL_AWS = "KBA_TEST_URL_AWS" + +# Skip all tests in this module if credentials are not available +HAS_AWS_CREDENTIALS = os.environ.get(ENV_TOKEN_AWS) is not None + +skip_without_credentials = pytest.mark.skipif( + not HAS_AWS_CREDENTIALS, + reason=f"Integration tests require {ENV_TOKEN_AWS} environment variable", +) + + +@pytest.fixture +def integration_config_dir(tmp_path: Path) -> Path: + """Provide a temporary config directory for integration tests.""" + config_dir = tmp_path / "integration_config" + config_dir.mkdir() + return config_dir + + +def _invoke_with_store(config_dir: Path, args: list[str]): + """Invoke the CLI app with a custom config store pointed at config_dir.""" + from unittest.mock import patch + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + return runner.invoke(app, args) + + +@skip_without_credentials +@pytest.mark.integration +class TestFullWorkflow: + """End-to-end integration test: add project, list, status, config list, remove.""" + + def test_full_workflow(self, integration_config_dir: Path) -> None: + """Full workflow: add -> list -> status -> config list -> remove.""" + token = os.environ[ENV_TOKEN_AWS] + url = os.environ.get(ENV_URL_AWS, "https://connection.keboola.com") + alias = "integration-test" + + from unittest.mock import patch + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + store = ConfigStore(config_dir=integration_config_dir) + MockStore.return_value = store + + # Step 1: Add project + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + alias, + "--url", + url, + "--token", + token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + add_output = json.loads(result.output) + assert add_output["status"] == "ok" + assert add_output["data"]["alias"] == alias + assert add_output["data"]["project_name"] # Should have a name + assert add_output["data"]["project_id"] > 0 # Should have an ID + + # Verify token is masked in output + assert token not in result.output + + # Step 2: List projects + result = runner.invoke(app, ["--json", "project", "list"]) + assert result.exit_code == 0, f"project list failed: {result.output}" + list_output = json.loads(result.output) + assert list_output["status"] == "ok" + assert len(list_output["data"]) >= 1 + project_aliases = [p["alias"] for p in list_output["data"]] + assert alias in project_aliases + + # Verify token is masked in list output too + assert token not in result.output + + # Step 3: Project status + result = runner.invoke( + app, + [ + "--json", + "project", + "status", + "--project", + alias, + ], + ) + assert result.exit_code == 0, f"project status failed: {result.output}" + status_output = json.loads(result.output) + assert status_output["status"] == "ok" + assert len(status_output["data"]) == 1 + assert status_output["data"][0]["alias"] == alias + assert status_output["data"][0]["status"] == "ok" + assert status_output["data"][0]["response_time_ms"] >= 0 + + # Step 4: Config list + result = runner.invoke( + app, + [ + "--json", + "config", + "list", + "--project", + alias, + ], + ) + assert result.exit_code == 0, f"config list failed: {result.output}" + config_output = json.loads(result.output) + assert config_output["status"] == "ok" + assert "configs" in config_output["data"] + assert "errors" in config_output["data"] + assert config_output["data"]["errors"] == [] + # Configs may or may not be empty depending on the project + # but the structure should be correct + for cfg in config_output["data"]["configs"]: + assert cfg["project_alias"] == alias + assert "component_id" in cfg + assert "config_name" in cfg + + # Step 5: Doctor check + result = runner.invoke(app, ["--json", "doctor"]) + assert result.exit_code == 0, f"doctor failed: {result.output}" + doctor_output = json.loads(result.output) + assert doctor_output["status"] == "ok" + assert doctor_output["data"]["summary"]["healthy"] is True + + # Step 6: Remove project + result = runner.invoke( + app, + [ + "--json", + "project", + "remove", + "--alias", + alias, + ], + ) + assert result.exit_code == 0, f"project remove failed: {result.output}" + remove_output = json.loads(result.output) + assert remove_output["status"] == "ok" + + # Verify project is gone + result = runner.invoke(app, ["--json", "project", "list"]) + assert result.exit_code == 0 + final_list = json.loads(result.output) + remaining_aliases = [p["alias"] for p in final_list["data"]] + assert alias not in remaining_aliases + + def test_add_with_invalid_token_returns_error(self, integration_config_dir: Path) -> None: + """Adding a project with a deliberately invalid token returns an auth error.""" + from unittest.mock import patch + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=integration_config_dir) + + result = runner.invoke( + app, + [ + "--json", + "project", + "add", + "--alias", + "bad-project", + "--url", + "https://connection.keboola.com", + "--token", + "000-invalid-token-definitely-wrong", + ], + ) + + assert result.exit_code == 3 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "INVALID_TOKEN" + + def test_context_command_works(self, integration_config_dir: Path) -> None: + """Context command outputs useful agent instructions.""" + from unittest.mock import patch + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=integration_config_dir) + result = runner.invoke(app, ["context"]) + + assert result.exit_code == 0 + assert "kbagent" in result.output + assert "--json" in result.output diff --git a/tests/test_services.py b/tests/test_services.py index 2be50eb5..1ba9af0b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -333,7 +333,7 @@ def test_list_projects_token_never_fully_shown(self, tmp_config_dir: Path) -> No result = service.list_projects() assert result[0]["token"] != full_token - assert "901-...pt0k" == result[0]["token"] + assert result[0]["token"] == "901-...pt0k" class TestGetStatus: @@ -388,18 +388,24 @@ def factory(url, token): client_factory=factory, ) - store.add_project("ok-project", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-ok-abcdefghijklmnop", - project_name="OK", - project_id=1, - )) - store.add_project("bad-project", ProjectConfig( - stack_url="https://connection.keboola.com", - token="902-bad-abcdefghijklmnop", - project_name="Bad", - project_id=2, - )) + store.add_project( + "ok-project", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-ok-abcdefghijklmnop", + project_name="OK", + project_id=1, + ), + ) + store.add_project( + "bad-project", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="902-bad-abcdefghijklmnop", + project_name="Bad", + project_id=2, + ), + ) result = service.get_status() assert len(result) == 2 @@ -423,14 +429,20 @@ def test_status_specific_project(self, tmp_config_dir: Path) -> None: client_factory=lambda url, token: mock_client, ) - store.add_project("first", ProjectConfig( - stack_url="https://a.com", - token="901-abcdef-12345678", - )) - store.add_project("second", ProjectConfig( - stack_url="https://b.com", - token="902-abcdef-12345678", - )) + store.add_project( + "first", + ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + ), + ) + store.add_project( + "second", + ProjectConfig( + stack_url="https://b.com", + token="902-abcdef-12345678", + ), + ) result = service.get_status(aliases=["first"]) assert len(result) == 1 @@ -454,20 +466,24 @@ def test_status_token_masked(self, tmp_config_dir: Path) -> None: client_factory=lambda url, token: mock_client, ) - store.add_project("test", ProjectConfig( - stack_url="https://connection.keboola.com", - token=full_token, - )) + store.add_project( + "test", + ProjectConfig( + stack_url="https://connection.keboola.com", + token=full_token, + ), + ) result = service.get_status() assert result[0]["token"] != full_token - assert "901-...pt0k" == result[0]["token"] + assert result[0]["token"] == "901-...pt0k" # --------------------------------------------------------------------------- # Helpers for ConfigService tests # --------------------------------------------------------------------------- + def _make_list_components_client( components: list[dict], ) -> MagicMock: @@ -531,12 +547,15 @@ class TestConfigServiceListConfigs: def test_list_configs_single_project_all_configs(self, tmp_config_dir: Path) -> None: """list_configs returns all configs from a single project.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - project_name="Production", - project_id=1234, - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Production", + project_id=1234, + ), + ) mock_client = _make_list_components_client(SAMPLE_COMPONENTS) service = ConfigService( @@ -564,18 +583,24 @@ def test_list_configs_single_project_all_configs(self, tmp_config_dir: Path) -> def test_list_configs_multi_project_aggregation(self, tmp_config_dir: Path) -> None: """list_configs aggregates configs across multiple projects.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - project_name="Production", - project_id=1234, - )) - store.add_project("dev", ProjectConfig( - stack_url="https://connection.north-europe.azure.keboola.com", - token="532-abcdef-ghijklmnopqrst", - project_name="Development", - project_id=5678, - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Production", + project_id=1234, + ), + ) + store.add_project( + "dev", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-abcdef-ghijklmnopqrst", + project_name="Development", + project_id=5678, + ), + ) prod_client = _make_list_components_client(SAMPLE_COMPONENTS) dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) @@ -607,10 +632,13 @@ def factory(url, token): def test_list_configs_filter_by_component_type(self, tmp_config_dir: Path) -> None: """list_configs passes component_type filter to the client.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) # When filtering by type, API returns only matching components extractor_only = [SAMPLE_COMPONENTS[0]] @@ -632,10 +660,13 @@ def test_list_configs_filter_by_component_type(self, tmp_config_dir: Path) -> No def test_list_configs_filter_by_component_id(self, tmp_config_dir: Path) -> None: """list_configs filters configs to only the specified component_id.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) mock_client = _make_list_components_client(SAMPLE_COMPONENTS) service = ConfigService( @@ -653,14 +684,20 @@ def test_list_configs_filter_by_component_id(self, tmp_config_dir: Path) -> None def test_list_configs_filter_by_project_alias(self, tmp_config_dir: Path) -> None: """list_configs with aliases only queries specified projects.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) - store.add_project("dev", ProjectConfig( - stack_url="https://connection.north-europe.azure.keboola.com", - token="532-abcdef-ghijklmnopqrst", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) + store.add_project( + "dev", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-abcdef-ghijklmnopqrst", + ), + ) prod_client = _make_list_components_client(SAMPLE_COMPONENTS) dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) @@ -688,14 +725,20 @@ def factory(url, token): def test_list_configs_partial_failure(self, tmp_config_dir: Path) -> None: """list_configs continues when one project fails, reporting the error.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("good", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-good-abcdefghijklmnop", - )) - store.add_project("bad", ProjectConfig( - stack_url="https://connection.north-europe.azure.keboola.com", - token="532-bad-abcdefghijklmnopq", - )) + store.add_project( + "good", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-good-abcdefghijklmnop", + ), + ) + store.add_project( + "bad", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-bad-abcdefghijklmnopq", + ), + ) good_client = _make_list_components_client(SAMPLE_COMPONENTS) bad_client = MagicMock() @@ -733,10 +776,13 @@ def factory(url, token): def test_list_configs_empty_results(self, tmp_config_dir: Path) -> None: """list_configs returns empty configs list when no configurations exist.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("empty", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "empty", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) # No components returned mock_client = _make_list_components_client([]) @@ -769,10 +815,13 @@ def test_list_configs_unknown_alias_raises_config_error(self, tmp_config_dir: Pa def test_list_configs_client_closed_after_use(self, tmp_config_dir: Path) -> None: """list_configs always closes the client after querying.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) mock_client = _make_list_components_client(SAMPLE_COMPONENTS) service = ConfigService( @@ -786,10 +835,13 @@ def test_list_configs_client_closed_after_use(self, tmp_config_dir: Path) -> Non def test_list_configs_client_closed_on_error(self, tmp_config_dir: Path) -> None: """list_configs closes the client even when the API call fails.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("bad", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "bad", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) mock_client = MagicMock() mock_client.list_components.side_effect = KeboolaApiError( @@ -809,10 +861,13 @@ def test_list_configs_client_closed_on_error(self, tmp_config_dir: Path) -> None def test_list_configs_combined_type_and_component_id_filter(self, tmp_config_dir: Path) -> None: """list_configs applies both component_type and component_id filters.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) mock_client = _make_list_components_client(SAMPLE_COMPONENTS) service = ConfigService( @@ -835,18 +890,27 @@ def test_list_configs_combined_type_and_component_id_filter(self, tmp_config_dir def test_list_configs_multiple_aliases(self, tmp_config_dir: Path) -> None: """list_configs with multiple aliases queries exactly those projects.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("proj-a", ProjectConfig( - stack_url="https://a.com", - token="901-aaa-abcdefghijklmnop", - )) - store.add_project("proj-b", ProjectConfig( - stack_url="https://b.com", - token="902-bbb-abcdefghijklmnop", - )) - store.add_project("proj-c", ProjectConfig( - stack_url="https://c.com", - token="903-ccc-abcdefghijklmnop", - )) + store.add_project( + "proj-a", + ProjectConfig( + stack_url="https://a.com", + token="901-aaa-abcdefghijklmnop", + ), + ) + store.add_project( + "proj-b", + ProjectConfig( + stack_url="https://b.com", + token="902-bbb-abcdefghijklmnop", + ), + ) + store.add_project( + "proj-c", + ProjectConfig( + stack_url="https://c.com", + token="903-ccc-abcdefghijklmnop", + ), + ) client_a = _make_list_components_client(SAMPLE_COMPONENTS) client_b = _make_list_components_client(SAMPLE_COMPONENTS_2) @@ -881,10 +945,13 @@ class TestConfigServiceGetConfigDetail: def test_get_config_detail_success(self, tmp_config_dir: Path) -> None: """get_config_detail returns full config detail with project_alias.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) detail_response = { "id": "101", @@ -913,9 +980,7 @@ def test_get_config_detail_success(self, tmp_config_dir: Path) -> None: assert result["name"] == "Production Load" assert result["project_alias"] == "prod" assert result["configuration"] == {"parameters": {"db": "prod"}} - mock_client.get_config_detail.assert_called_once_with( - "keboola.ex-db-snowflake", "101" - ) + mock_client.get_config_detail.assert_called_once_with("keboola.ex-db-snowflake", "101") mock_client.close.assert_called_once() def test_get_config_detail_unknown_alias(self, tmp_config_dir: Path) -> None: @@ -933,10 +998,13 @@ def test_get_config_detail_unknown_alias(self, tmp_config_dir: Path) -> None: def test_get_config_detail_api_error(self, tmp_config_dir: Path) -> None: """get_config_detail propagates KeboolaApiError from the client.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) mock_client = MagicMock() mock_client.get_config_detail.side_effect = KeboolaApiError( @@ -964,10 +1032,13 @@ def test_get_config_detail_api_error(self, tmp_config_dir: Path) -> None: def test_get_config_detail_client_closed_on_error(self, tmp_config_dir: Path) -> None: """get_config_detail closes the client even when API call fails.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://connection.keboola.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) mock_client = MagicMock() mock_client.get_config_detail.side_effect = KeboolaApiError( @@ -994,14 +1065,20 @@ class TestResolveProjects: def test_resolve_all_projects(self, tmp_config_dir: Path) -> None: """resolve_projects with no aliases returns all projects.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://a.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) - store.add_project("dev", ProjectConfig( - stack_url="https://b.com", - token="532-abcdef-ghijklmnopqrst", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) + store.add_project( + "dev", + ProjectConfig( + stack_url="https://b.com", + token="532-abcdef-ghijklmnopqrst", + ), + ) service = ConfigService(config_store=store) result = service.resolve_projects() @@ -1010,14 +1087,20 @@ def test_resolve_all_projects(self, tmp_config_dir: Path) -> None: def test_resolve_specific_aliases(self, tmp_config_dir: Path) -> None: """resolve_projects with aliases returns only matching projects.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://a.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) - store.add_project("dev", ProjectConfig( - stack_url="https://b.com", - token="532-abcdef-ghijklmnopqrst", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) + store.add_project( + "dev", + ProjectConfig( + stack_url="https://b.com", + token="532-abcdef-ghijklmnopqrst", + ), + ) service = ConfigService(config_store=store) result = service.resolve_projects(aliases=["prod"]) @@ -1034,10 +1117,13 @@ def test_resolve_unknown_alias_raises_config_error(self, tmp_config_dir: Path) - def test_resolve_empty_aliases_list(self, tmp_config_dir: Path) -> None: """resolve_projects with empty list returns all projects.""" store = ConfigStore(config_dir=tmp_config_dir) - store.add_project("prod", ProjectConfig( - stack_url="https://a.com", - token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", - )) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ), + ) service = ConfigService(config_store=store) result = service.resolve_projects(aliases=[]) diff --git a/uv.lock b/uv.lock index f9c16285..68754704 100644 --- a/uv.lock +++ b/uv.lock @@ -134,6 +134,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-httpx" }, + { name = "ruff" }, ] [package.metadata] @@ -149,6 +150,7 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=8" }, { name = "pytest-httpx", specifier = ">=0.30" }, + { name = "ruff", specifier = ">=0.8" }, ] [[package]] @@ -336,6 +338,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"