diff --git a/Makefile b/Makefile index 188af848..10c880da 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -.PHONY: help install install-mcp sync test test-unit test-integration test-e2e test-file lint lint-fix format format-check skill-check skill-gen version-sync version-check changelog changelog-check check clean hooks +.PHONY: help install install-mcp sync test test-unit test-integration test-e2e test-file lint lint-fix format format-check skill-check skill-gen version-sync version-check changelog changelog-check check-error-codes check clean hooks help: ## Show this help message @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' @@ -73,12 +73,15 @@ changelog: ## Generate changelog skeleton from GitHub releases changelog-check: ## Check all releases have changelog entries uv run python scripts/generate_changelog.py --check +check-error-codes: ## Reject raw error_code string literals in source (use ErrorCode enum) + uv run python scripts/check_error_codes.py + hooks: ## Install git pre-commit hook (lint + format on staged files) cp scripts/pre-commit .git/hooks/pre-commit chmod +x .git/hooks/pre-commit @echo "Pre-commit hook installed." -check: lint format-check skill-check version-check changelog-check test ## Run all checks (lint + format + skill + version + changelog + test) +check: lint format-check skill-check version-check changelog-check check-error-codes test ## Run all checks (lint + format + skill + version + changelog + error-codes + test) clean: ## Remove build artifacts and caches find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true diff --git a/docs/error-codes.md b/docs/error-codes.md new file mode 100644 index 00000000..48c7e46f --- /dev/null +++ b/docs/error-codes.md @@ -0,0 +1,129 @@ +# kbagent Error Code Reference + +All machine-readable codes emitted via `--json` output. Every code is a member +of `ErrorCode` in `src/keboola_agent_cli/errors.py`. + +## Versioning + +| Change | Version impact | +|---|---| +| Add a new code | Minor bump | +| Rename or remove a code | Major bump | + +## Code catalogue + +### Auth / access + +| Code | Description | +|---|---| +| `INVALID_TOKEN` | Storage API token is invalid or expired | +| `ACCESS_DENIED` | Token lacks the required permission for this API call | +| `PERMISSION_DENIED` | Operation blocked by the active kbagent permission policy | + +### Network / transport + +| Code | Description | +|---|---| +| `TIMEOUT` | HTTP request timed out | +| `CONNECTION_ERROR` | TCP-level connection failure | +| `RETRY_EXHAUSTED` | All retry attempts failed (typically after 429/5xx) | + +### API / generic + +| Code | Description | +|---|---| +| `API_ERROR` | Unexpected HTTP error from the Keboola API | +| `NOT_FOUND` | Requested resource does not exist (404) | +| `ALREADY_EXISTS` | Resource or file already exists and was not overwritten | +| `VALIDATION_ERROR` | Request failed API-side validation | +| `INVALID_ARGUMENT` | Caller supplied an invalid argument value | +| `INVALID_FORMAT` | Input is not in the expected format | +| `USAGE_ERROR` | Incorrect CLI flag combination or missing required argument | +| `MISSING_PARAMETER` | A required parameter was not supplied | +| `UNKNOWN_ERROR` | Catch-all for unclassified errors | + +### Configuration + +| Code | Description | +|---|---| +| `CONFIG_ERROR` | kbagent config problem (e.g. unknown project alias) | +| `NOT_INITIALIZED` | `.keboola/manifest.json` not found; run `sync init` first | +| `INIT_ERROR` | Error during `sync init` auto-init path | + +### Jobs + +| Code | Description | +|---|---| +| `QUEUE_JOB_FAILED` | Queue API job finished with status `error` or `warning` | +| `QUEUE_JOB_TIMEOUT` | Polling timed out waiting for a Queue job | +| `STORAGE_JOB_FAILED` | Storage API async job finished in a failed state | +| `STORAGE_JOB_TIMEOUT` | Polling timed out waiting for a Storage async job | +| `QUERY_JOB_FAILED` | Query Service job finished in a failed state | +| `QUERY_JOB_TIMEOUT` | Polling timed out waiting for a Query Service job | + +### Variables + +| Code | Description | +|---|---| +| `NO_VARIABLE_ROWS` | Linked `keboola.variables` config has no rows (fix: `config variables-set`) | +| `MALFORMED_VARIABLES_ROW` | Variables row returned by the API is missing a usable `id` | + +### Storage + +| Code | Description | +|---|---| +| `UPLOAD_FAILED` | Cloud storage upload to S3/Azure/GCS failed | +| `EXPORT_EMPTY_MANIFEST` | Sliced export manifest contains no slices | +| `EXPORT_NO_FILE` | Export manifest lists no downloadable file | +| `EXPORT_NO_URL` | Export entry has no download URL | +| `NOT_SLICED` | Attempted a sliced-file operation on a non-sliced file | +| `FILE_NO_URL` | File metadata has no usable download URL | + +### I/O + +| Code | Description | +|---|---| +| `FILE_NOT_FOUND` | Local file path does not exist | +| `DIR_NOT_FOUND` | Local directory path does not exist | +| `READ_ERROR` | Error reading a local file | +| `WRITE_ERROR` | Error writing a local file | +| `INPUT_ERROR` | Invalid or unparseable input data | + +### Lineage + +| Code | Description | +|---|---| +| `NODE_NOT_FOUND` | Requested node not found in the lineage graph | + +### Sharing + +| Code | Description | +|---|---| +| `INVALID_SHARING_TYPE` | Unsupported bucket sharing type | +| `NOT_LINKED_BUCKET` | Bucket is not a linked bucket | + +### KAI (AI Service) + +| Code | Description | +|---|---| +| `KAI_ERROR` | AI Service request failed | +| `KAI_NOT_ENABLED` | KAI is not enabled on this project | + +### Workspace / Query + +| Code | Description | +|---|---| +| `MISSING_QUERY` | No SQL query was provided | +| `WORKSPACE_NOT_FOUND` | Workspace not found in the project | + +### Sync + +| Code | Description | +|---|---| +| `PARENT_CONFIG_NOT_TRACKED` | Row operation references a parent config not in the manifest | + +### Encryption + +| Code | Description | +|---|---| +| `ENCRYPTION_FAILED` | Secret encryption via the Encryption API failed | diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 8ec26950..d4cd469c 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.21.1", + "version": "0.22.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/pyproject.toml b/pyproject.toml index 0eee9ad7..d622abf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.21.1" +version = "0.22.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/check_error_codes.py b/scripts/check_error_codes.py new file mode 100644 index 00000000..3f994aac --- /dev/null +++ b/scripts/check_error_codes.py @@ -0,0 +1,78 @@ +"""CI guard: reject raw error_code string literals in source files. + +Any site that passes error_code="LITERAL_STRING" to KeboolaApiError, +ConfigError, or formatter.error() must use ErrorCode. instead. + +Usage (run from repo root): + python scripts/check_error_codes.py # exits 1 if violations found + python scripts/check_error_codes.py --list # print all current enum members + +Safe exceptions (not flagged): + - tests/ -- string comparisons in assertions are fine + - errors.py -- the enum definition itself +""" + +import ast +import sys +from pathlib import Path + +SRC_ROOT = Path(__file__).parent.parent / "src" +SKIP_FILES = {"errors.py"} + + +def _collect_violations(path: Path) -> list[tuple[int, str]]: + """Return (lineno, code) for each raw error_code string literal.""" + source = path.read_text(encoding="utf-8") + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError: + return [] + + violations = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for kw in node.keywords: + if kw.arg != "error_code": + continue + if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): + violations.append((kw.value.lineno, kw.value.value)) + return violations + + +def main() -> int: + if "--list" in sys.argv: + # Print all known enum members without importing the package + errors_path = SRC_ROOT / "keboola_agent_cli" / "errors.py" + source = errors_path.read_text(encoding="utf-8") + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == "ErrorCode": + for item in node.body: + if isinstance(item, ast.Assign): + for t in item.targets: + if isinstance(t, ast.Name): + print(f" ErrorCode.{t.id}") + return 0 + + found_any = False + for py_file in sorted(SRC_ROOT.rglob("*.py")): + if py_file.name in SKIP_FILES: + continue + violations = _collect_violations(py_file) + if violations: + found_any = True + rel = py_file.relative_to(SRC_ROOT.parent.parent) + for lineno, code in violations: + print(f' {rel}:{lineno}: error_code="{code}" -- use ErrorCode.{code}') + + if found_any: + print("\nFAIL: raw error_code string literals found. Replace with ErrorCode..") + return 1 + + print("OK: no raw error_code string literals in source.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 29f65c04..2b31f2ed 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,18 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.22.0": [ + "New: ErrorCode enum (StrEnum) in errors.py -- all 46 error codes are now typed constants; " + "every KeboolaApiError / formatter.error() raise site migrated from string literals to " + "ErrorCode.. Wire format is unchanged (str subtype). CI guard " + "(scripts/check_error_codes.py, wired into 'make check') rejects new raw literals.", + "New: docs/error-codes.md -- versioned reference for all ErrorCode members with " + "add=minor / rename-remove=major semver policy.", + "New: sync init --adopt-existing -- idempotently adopt a .keboola/manifest.json written " + "by the kbc Go CLI (or an older kbagent version) without overwriting it. Validates " + "manifest project_id against the alias token; rejects mismatch with ConfigError (exit 5). " + "Falls through to normal init when no manifest exists. Safe to re-run.", + ], "0.21.1": [ "Fix: sync pull on a newly created dev branch now writes config rows (#193) -- idempotent skip guard for rows was missing a file-existence check, causing rows to be silently skipped when the branch directory was new (hash matched main because the branch is a clone)", ], diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index aeff431e..595ec0be 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -29,7 +29,7 @@ from .commands.workspace import workspace_app from .config_store import ConfigStore, resolve_config_dir from .constants import EXIT_PERMISSION_DENIED -from .errors import PermissionDeniedError +from .errors import ErrorCode, PermissionDeniedError from .output import OutputFormatter from .permissions import PermissionEngine from .services.branch_service import BranchService @@ -277,7 +277,7 @@ def main( try: permission_engine.check_or_raise(ctx.invoked_subcommand) except PermissionDeniedError as exc: - formatter.error(message=exc.message, error_code="PERMISSION_DENIED") + formatter.error(message=exc.message, error_code=ErrorCode.PERMISSION_DENIED) raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None # Launch REPL if no subcommand was given (set above) diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index d92b07e3..686ab4d1 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -33,7 +33,7 @@ STORAGE_JOB_MAX_WAIT, STORAGE_JOB_POLL_INTERVAL, ) -from .errors import KeboolaApiError +from .errors import ErrorCode, KeboolaApiError from .http_base import BaseHttpClient from .models import TokenVerifyResponse @@ -523,13 +523,13 @@ def _wait_for_storage_job( raise KeboolaApiError( message=error_msg, status_code=500, - error_code="STORAGE_JOB_FAILED", + error_code=ErrorCode.STORAGE_JOB_FAILED, retryable=False, ) raise KeboolaApiError( message=f"Storage job {job_id} did not complete within {max_wait}s", status_code=504, - error_code="STORAGE_JOB_TIMEOUT", + error_code=ErrorCode.STORAGE_JOB_TIMEOUT, retryable=True, ) @@ -821,7 +821,7 @@ def share_bucket( message=f"Invalid sharing type: '{sharing_type}'. " f"Valid types: {', '.join(endpoint_map.keys())}", status_code=400, - error_code="INVALID_SHARING_TYPE", + error_code=ErrorCode.INVALID_SHARING_TYPE, retryable=False, ) @@ -1126,7 +1126,7 @@ def _upload_to_cloud( raise KeboolaApiError( message=f"Cloud storage upload failed (HTTP {response.status_code})", status_code=response.status_code, - error_code="UPLOAD_FAILED", + error_code=ErrorCode.UPLOAD_FAILED, retryable=False, ) @@ -1540,7 +1540,7 @@ def _prepare_sliced_download( raise KeboolaApiError( message="Sliced file manifest has no entries", status_code=500, - error_code="EXPORT_EMPTY_MANIFEST", + error_code=ErrorCode.EXPORT_EMPTY_MANIFEST, retryable=False, ) @@ -1819,7 +1819,7 @@ def wait_for_queue_job( raise KeboolaApiError( message=f"Queue job {job_id} failed: {error_msg}", status_code=500, - error_code="QUEUE_JOB_FAILED", + error_code=ErrorCode.QUEUE_JOB_FAILED, retryable=False, ) return job @@ -1828,7 +1828,7 @@ def wait_for_queue_job( raise KeboolaApiError( message=f"Queue job {job_id} did not complete within {max_wait}s", status_code=504, - error_code="QUEUE_JOB_TIMEOUT", + error_code=ErrorCode.QUEUE_JOB_TIMEOUT, retryable=True, ) @@ -2087,7 +2087,7 @@ def wait_for_query_job(self, query_job_id: str) -> dict[str, Any]: raise KeboolaApiError( message=f"Query job failed: {error_msg}", status_code=500, - error_code="QUERY_JOB_FAILED", + error_code=ErrorCode.QUERY_JOB_FAILED, retryable=False, ) time.sleep(QUERY_JOB_POLL_INTERVAL) @@ -2095,7 +2095,7 @@ def wait_for_query_job(self, query_job_id: str) -> dict[str, Any]: raise KeboolaApiError( message=f"Query job {query_job_id} did not complete within {QUERY_JOB_MAX_WAIT}s", status_code=504, - error_code="QUERY_JOB_TIMEOUT", + error_code=ErrorCode.QUERY_JOB_TIMEOUT, retryable=True, ) diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index 700063a8..f1141ec4 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -16,7 +16,7 @@ from ..config_store import ConfigStore from ..constants import ENV_KBC_MANAGE_API_TOKEN, EXIT_PERMISSION_DENIED -from ..errors import KeboolaApiError, PermissionDeniedError +from ..errors import ErrorCode, KeboolaApiError, PermissionDeniedError from ..output import OutputFormatter @@ -149,7 +149,7 @@ def check_cli_permission(ctx: typer.Context, group_name: str) -> None: engine.check_or_raise(operation) except PermissionDeniedError as exc: formatter = get_formatter(ctx) - formatter.error(message=exc.message, error_code="PERMISSION_DENIED") + formatter.error(message=exc.message, error_code=ErrorCode.PERMISSION_DENIED) raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None @@ -166,7 +166,7 @@ def validate_branch_requires_project( if branch is not None and not project: formatter.error( message="--branch requires --project (branch ID is per-project)", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None diff --git a/src/keboola_agent_cli/commands/branch.py b/src/keboola_agent_cli/commands/branch.py index 91fbcdce..5f048fcd 100644 --- a/src/keboola_agent_cli/commands/branch.py +++ b/src/keboola_agent_cli/commands/branch.py @@ -8,7 +8,7 @@ import typer -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import format_branch_metadata_table, format_branches_table from ._helpers import ( check_cli_permission, @@ -48,7 +48,7 @@ def branch_list( try: result = service.list_branches(aliases=project) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -103,7 +103,7 @@ def branch_create( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -144,7 +144,7 @@ def branch_use( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -172,7 +172,7 @@ def branch_reset( 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") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -216,7 +216,7 @@ def branch_delete( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -253,7 +253,7 @@ def branch_merge( ), ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -292,7 +292,7 @@ def branch_metadata_list( formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -329,7 +329,7 @@ def branch_metadata_get( formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None formatter.output( @@ -370,7 +370,7 @@ def branch_metadata_set( try: value = resolve_text_input(text=text, file=file, stdin=stdin) except ConfigError as exc: - formatter.error(message=exc.message, error_code="INVALID_ARGUMENT") + formatter.error(message=exc.message, error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None if should_hint(ctx): @@ -396,7 +396,7 @@ def branch_metadata_set( formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -455,5 +455,5 @@ def branch_metadata_delete( formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None diff --git a/src/keboola_agent_cli/commands/component.py b/src/keboola_agent_cli/commands/component.py index 273d2dd9..13599b56 100644 --- a/src/keboola_agent_cli/commands/component.py +++ b/src/keboola_agent_cli/commands/component.py @@ -10,7 +10,7 @@ from rich.table import Table from ..constants import VALID_COMPONENT_TYPES -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -162,7 +162,7 @@ def component_list( formatter.error( message=f"Invalid component type '{component_type}'. " f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -173,7 +173,7 @@ def component_list( query=query, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -211,7 +211,7 @@ def component_detail( ) formatter.output(result, _format_component_detail) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = map_error_to_exit_code(exc) diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index 705345ca..ec76ac22 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -16,7 +16,7 @@ from ..config_store import ConfigStore from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME, VALID_COMPONENT_TYPES -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import format_config_detail, format_configs_table, format_search_results from ._helpers import ( check_cli_permission, @@ -116,7 +116,7 @@ def config_list( if branch is not None and (not project or len(project) != 1): formatter.error( message="--branch requires exactly one --project (branch ID is per-project)", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -131,7 +131,7 @@ def config_list( formatter.error( message=f"Invalid component type '{component_type}'. " f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -143,7 +143,7 @@ def config_list( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None # In JSON mode, include both configs and errors in the response @@ -197,7 +197,7 @@ def config_detail( ) formatter.output(result, format_config_detail) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = map_error_to_exit_code(exc) @@ -276,7 +276,7 @@ def config_search( if branch is not None and (not project or len(project) != 1): formatter.error( message="--branch requires exactly one --project (branch ID is per-project)", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -290,7 +290,7 @@ def config_search( formatter.error( message=f"Invalid component type '{component_type}'. " f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -301,7 +301,7 @@ def config_search( except re.error as exc: formatter.error( message=f"Invalid regex pattern: {exc}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None @@ -316,7 +316,7 @@ def config_search( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -447,7 +447,7 @@ def config_update( if configuration and configuration_file: formatter.error( message="Cannot use both --configuration and --configuration-file.", - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, ) raise typer.Exit(code=2) from None @@ -457,7 +457,7 @@ def config_update( except (json.JSONDecodeError, FileNotFoundError) as exc: formatter.error( message=f"Invalid --configuration input: {exc}", - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, ) raise typer.Exit(code=2) from None @@ -467,7 +467,7 @@ def config_update( except json.JSONDecodeError as exc: formatter.error( message=f"Invalid JSON in {configuration_file}: {exc}", - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, ) raise typer.Exit(code=2) from None @@ -479,7 +479,7 @@ def config_update( if "=" not in item: formatter.error( message=f"Invalid --set format: '{item}'. Expected PATH=VALUE.", - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, ) raise typer.Exit(code=2) from None path, _, raw_value = item.partition("=") @@ -502,7 +502,7 @@ def config_update( branch_id=branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( @@ -624,7 +624,7 @@ def config_rename( directory=effective_directory, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( @@ -693,7 +693,7 @@ def config_delete( branch_id=branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( @@ -772,7 +772,7 @@ def config_new( name=name or None, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = map_error_to_exit_code(exc) @@ -918,7 +918,7 @@ def config_variables_set( if not raw_vars: formatter.error( message="At least one --var KEY=VALUE is required.", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -927,7 +927,7 @@ def config_variables_set( try: key, value = _parse_kv_var(raw) except typer.BadParameter as exc: - formatter.error(message=str(exc), error_code="INVALID_ARGUMENT") + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None variables_dict[key] = value @@ -951,7 +951,7 @@ def config_variables_set( ) raise typer.Exit(code=map_error_to_exit_code(exc)) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None preview_values = ( @@ -994,7 +994,7 @@ def config_variables_set( ) raise typer.Exit(code=map_error_to_exit_code(exc)) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -1047,7 +1047,7 @@ def config_variables_get( ) raise typer.Exit(code=map_error_to_exit_code(exc)) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -1115,7 +1115,7 @@ def config_variables_clear( ) raise typer.Exit(code=map_error_to_exit_code(exc)) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index cd32ec30..044dfb25 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -341,7 +341,7 @@ ### Project Sync - kbagent sync init --project ALIAS [--directory DIR] [--git-branching] + kbagent sync init --project ALIAS [--directory DIR] [--git-branching] [--adopt-existing] Initialize sync working directory. --git-branching enables git-to-Keboola branch mapping. kbagent sync pull --project ALIAS [--all-projects] [--force] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] diff --git a/src/keboola_agent_cli/commands/encrypt.py b/src/keboola_agent_cli/commands/encrypt.py index 5ad7c9fd..8e08b81d 100644 --- a/src/keboola_agent_cli/commands/encrypt.py +++ b/src/keboola_agent_cli/commands/encrypt.py @@ -10,7 +10,7 @@ import typer -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -79,20 +79,20 @@ def encrypt_values( try: parsed = _parse_input(input_data) except (json.JSONDecodeError, FileNotFoundError, ValueError) as exc: - formatter.error(message=str(exc), error_code="INPUT_ERROR") + formatter.error(message=str(exc), error_code=ErrorCode.INPUT_ERROR) raise typer.Exit(code=2) from None if not isinstance(parsed, dict): formatter.error( message="Input must be a JSON object (dict), not " + type(parsed).__name__, - error_code="INPUT_ERROR", + error_code=ErrorCode.INPUT_ERROR, ) raise typer.Exit(code=2) from None try: result = service.encrypt(alias=project, component_id=component_id, input_data=parsed) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = map_error_to_exit_code(exc) diff --git a/src/keboola_agent_cli/commands/init.py b/src/keboola_agent_cli/commands/init.py index d83c0673..172a68b0 100644 --- a/src/keboola_agent_cli/commands/init.py +++ b/src/keboola_agent_cli/commands/init.py @@ -9,6 +9,7 @@ from ..config_store import ConfigStore from ..constants import LOCAL_CONFIG_DIR_NAME +from ..errors import ErrorCode from ..models import AppConfig, PermissionPolicy from ._helpers import get_formatter, get_service @@ -91,7 +92,7 @@ def init_command( "3) 'kbagent permissions set --mode allow --deny cli:write --deny tool:write' to lock. " "Alternatively, use --from-global to seed projects from the global config." ), - error_code="CONFIG_ERROR", + error_code=ErrorCode.CONFIG_ERROR, ) raise typer.Exit(code=5) diff --git a/src/keboola_agent_cli/commands/job.py b/src/keboola_agent_cli/commands/job.py index 15450f46..50d3fb81 100644 --- a/src/keboola_agent_cli/commands/job.py +++ b/src/keboola_agent_cli/commands/job.py @@ -14,7 +14,7 @@ MAX_JOB_LIMIT, VALID_STATUSES, ) -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import format_job_detail, format_jobs_table from ._helpers import ( check_cli_permission, @@ -84,7 +84,7 @@ def job_list( if status and status not in VALID_STATUSES: formatter.error( message=f"Invalid status '{status}'. Valid statuses: {', '.join(VALID_STATUSES)}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -92,7 +92,7 @@ def job_list( if limit < 1 or limit > MAX_JOB_LIMIT: formatter.error( message=f"Invalid limit {limit}. Must be between 1 and {MAX_JOB_LIMIT}.", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -100,7 +100,7 @@ def job_list( if config_id and not component_id: formatter.error( message="--config-id requires --component-id to be specified.", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -113,7 +113,7 @@ def job_list( limit=limit, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -140,7 +140,7 @@ def job_detail( result = service.get_job_detail(alias=project, job_id=job_id) formatter.output(result, format_job_detail) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = map_error_to_exit_code(exc) @@ -250,7 +250,7 @@ def job_run( "--variable-values-id cannot be empty or whitespace. " "Pass a row id, or omit the flag to auto-resolve the default row." ), - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -261,7 +261,7 @@ def job_run( "Pass --variable-values-id to bind a specific values row, or " "--no-variables to skip resolution, but not both." ), - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -300,7 +300,7 @@ def job_run( no_variables=no_variables, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( @@ -429,7 +429,7 @@ def job_terminate( if bool(job_id) == bool(status): formatter.error( message="Provide either --job-id (one or more) or --status, but not both.", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -439,21 +439,21 @@ def job_terminate( f"Invalid --status '{status}'. Use one of: " f"{', '.join(sorted(KILLABLE_JOB_STATUSES))} or 'any'." ), - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) if config_id and not component_id: formatter.error( message="--config-id requires --component-id.", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) if limit < 1 or limit > MAX_JOB_LIMIT: formatter.error( message=f"Invalid --limit {limit}. Must be between 1 and {MAX_JOB_LIMIT}.", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -480,7 +480,7 @@ def job_terminate( if status == "any": matched = service.filter_killable(matched) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( @@ -525,7 +525,7 @@ def job_terminate( dry_run=True, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if filter_context is not None: @@ -552,7 +552,7 @@ def job_terminate( job_ids=resolved_ids, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( diff --git a/src/keboola_agent_cli/commands/kai.py b/src/keboola_agent_cli/commands/kai.py index c7a07069..21a54c16 100644 --- a/src/keboola_agent_cli/commands/kai.py +++ b/src/keboola_agent_cli/commands/kai.py @@ -6,7 +6,7 @@ import typer -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -44,7 +44,7 @@ def kai_ping( alias = service.resolve_alias(project) result = service.ping(alias) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code) @@ -91,7 +91,7 @@ def kai_ask( alias = service.resolve_alias(project) result = service.ask(alias, message) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code) @@ -138,7 +138,7 @@ def kai_chat( alias = service.resolve_alias(project) result = service.chat_message(alias, message, chat_id=chat_id) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code) @@ -177,7 +177,7 @@ def kai_history( alias = service.resolve_alias(project) result = service.get_history(alias, limit=limit) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code) diff --git a/src/keboola_agent_cli/commands/lineage.py b/src/keboola_agent_cli/commands/lineage.py index da57cfb1..0efff7e3 100644 --- a/src/keboola_agent_cli/commands/lineage.py +++ b/src/keboola_agent_cli/commands/lineage.py @@ -19,6 +19,7 @@ import typer +from ..errors import ErrorCode from ._helpers import ( check_cli_permission, emit_hint, @@ -103,7 +104,7 @@ def lineage_build( root = directory.resolve() if not root.is_dir(): - formatter.error(message=f"Directory not found: {root}", error_code="DIR_NOT_FOUND") + formatter.error(message=f"Directory not found: {root}", error_code=ErrorCode.DIR_NOT_FOUND) raise typer.Exit(code=1) # --refresh: sync pull all projects first @@ -127,7 +128,7 @@ def lineage_build( except OSError as exc: formatter.error( message=f"Cannot write output file '{output}': {exc}", - error_code="WRITE_ERROR", + error_code=ErrorCode.WRITE_ERROR, ) raise typer.Exit(code=1) from None @@ -187,7 +188,9 @@ def lineage_info( service = get_service(ctx, "deep_lineage_service") if not load.exists(): - formatter.error(message=f"Cache file not found: {load}", error_code="FILE_NOT_FOUND") + formatter.error( + message=f"Cache file not found: {load}", error_code=ErrorCode.FILE_NOT_FOUND + ) raise typer.Exit(code=1) graph = service.load_from_cache(load) @@ -333,12 +336,14 @@ def lineage_show( if format not in valid_formats: formatter.error( message=f"Invalid format '{format}'. Must be one of: {', '.join(valid_formats)}", - error_code="INVALID_FORMAT", + error_code=ErrorCode.INVALID_FORMAT, ) raise typer.Exit(code=2) if not load.exists(): - formatter.error(message=f"Cache file not found: {load}", error_code="FILE_NOT_FOUND") + formatter.error( + message=f"Cache file not found: {load}", error_code=ErrorCode.FILE_NOT_FOUND + ) raise typer.Exit(code=1) graph = service.load_from_cache(load) @@ -347,7 +352,7 @@ def lineage_show( formatter.error( message="Specify --upstream or --downstream to query.\n" "Use `kbagent lineage info -l FILE` to see what's in the graph.", - error_code="MISSING_QUERY", + error_code=ErrorCode.MISSING_QUERY, ) raise typer.Exit(code=2) @@ -360,7 +365,7 @@ def lineage_show( msg = query_result["error"] if suggestions: msg += "\nDid you mean: " + ", ".join(suggestions[:5]) - formatter.error(message=msg, error_code="NODE_NOT_FOUND") + formatter.error(message=msg, error_code=ErrorCode.NODE_NOT_FOUND) raise typer.Exit(code=1) if formatter.json_mode: @@ -379,7 +384,7 @@ def lineage_show( msg = query_result["error"] if suggestions: msg += "\nDid you mean: " + ", ".join(suggestions[:5]) - formatter.error(message=msg, error_code="NODE_NOT_FOUND") + formatter.error(message=msg, error_code=ErrorCode.NODE_NOT_FOUND) raise typer.Exit(code=1) if formatter.json_mode: @@ -431,7 +436,7 @@ def _output_mermaid_or_html( except OSError as exc: formatter.error( message=f"Cannot write HTML file '{filename}': {exc}", - error_code="WRITE_ERROR", + error_code=ErrorCode.WRITE_ERROR, ) raise typer.Exit(code=1) from None @@ -1430,13 +1435,15 @@ def lineage_serve( formatter = get_formatter(ctx) if not load.exists(): - formatter.error(message=f"Cache file not found: {load}", error_code="FILE_NOT_FOUND") + formatter.error( + message=f"Cache file not found: {load}", error_code=ErrorCode.FILE_NOT_FOUND + ) raise typer.Exit(code=1) try: raw_data = json.loads(load.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: - formatter.error(message=f"Cannot read lineage file: {exc}", error_code="READ_ERROR") + formatter.error(message=f"Cannot read lineage file: {exc}", error_code=ErrorCode.READ_ERROR) raise typer.Exit(code=1) from None # Load the graph via the service for API queries diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 29769a5c..25b95638 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -9,7 +9,7 @@ from rich.table import Table from ..constants import DEFAULT_TOKEN_DESCRIPTION, ENV_KBC_STORAGE_API_URL -from ..errors import KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -216,7 +216,7 @@ def org_setup( if not org_id and not project_ids: formatter.error( message="Provide --org-id (org admin) or --project-ids (project member)", - error_code="usage_error", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) diff --git a/src/keboola_agent_cli/commands/permissions.py b/src/keboola_agent_cli/commands/permissions.py index b281b65f..764e8137 100644 --- a/src/keboola_agent_cli/commands/permissions.py +++ b/src/keboola_agent_cli/commands/permissions.py @@ -17,6 +17,7 @@ from ..config_store import ConfigStore from ..constants import EXIT_PERMISSION_DENIED +from ..errors import ErrorCode from ..models import PermissionPolicy from ..permissions import PermissionEngine from ._helpers import get_formatter, get_service @@ -198,14 +199,14 @@ def permissions_set( if mode not in ("allow", "deny"): formatter.error( message="Mode must be 'allow' or 'deny'", - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, ) raise typer.Exit(code=2) from None if not _require_interactive_confirmation("update permission policy"): formatter.error( message="Confirmation failed. Permission policy not changed.", - error_code="PERMISSION_DENIED", + error_code=ErrorCode.PERMISSION_DENIED, ) raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None @@ -257,7 +258,7 @@ def permissions_reset( if not _require_interactive_confirmation("remove permission policy"): formatter.error( message="Confirmation failed. Permission policy not changed.", - error_code="PERMISSION_DENIED", + error_code=ErrorCode.PERMISSION_DENIED, ) raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 0f537dc4..31a315e1 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -18,7 +18,7 @@ ENV_KBC_STORAGE_API_URL, ENV_KBC_TOKEN, ) -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -178,7 +178,7 @@ def project_add( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -192,7 +192,7 @@ def project_list(ctx: typer.Context) -> None: projects = service.list_projects() formatter.output(projects, _format_project_table) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -211,7 +211,7 @@ def project_remove( 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") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -249,7 +249,7 @@ def project_edit( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -353,7 +353,7 @@ def project_status( statuses = service.get_status(aliases=aliases) formatter.output(statuses, _format_status_table) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: exit_code = map_error_to_exit_code(exc) @@ -410,13 +410,13 @@ def project_refresh( if project and all_projects: formatter.error( message="Provide --project or --all, not both", - error_code="usage_error", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) if not project and not all_projects: formatter.error( message="Provide --project or --all", - error_code="usage_error", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) @@ -504,7 +504,7 @@ def project_description_get( formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None formatter.output( @@ -543,7 +543,7 @@ def project_description_set( try: description = resolve_text_input(text=text, file=file, stdin=stdin) except ConfigError as exc: - formatter.error(message=exc.message, error_code="INVALID_ARGUMENT") + formatter.error(message=exc.message, error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None if should_hint(ctx): @@ -567,5 +567,5 @@ def project_description_set( formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None diff --git a/src/keboola_agent_cli/commands/sharing.py b/src/keboola_agent_cli/commands/sharing.py index 8e2fa90e..e8e756b7 100644 --- a/src/keboola_agent_cli/commands/sharing.py +++ b/src/keboola_agent_cli/commands/sharing.py @@ -8,7 +8,7 @@ import typer -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -57,7 +57,7 @@ def sharing_list( try: result = service.list_shared(aliases=project) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -157,7 +157,7 @@ def sharing_share( if not target_project_ids: formatter.error( message="--target-project-ids is required for --type selected-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) parsed_project_ids = [int(pid.strip()) for pid in target_project_ids.split(",")] @@ -166,7 +166,7 @@ def sharing_share( if not target_users: formatter.error( message="--target-users is required for --type selected-users", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) parsed_users = [u.strip() for u in target_users.split(",")] @@ -180,7 +180,7 @@ def sharing_share( target_users=parsed_users, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -221,7 +221,7 @@ def sharing_unshare( try: result = service.unshare(alias=project, bucket_id=bucket_id) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -286,7 +286,7 @@ def sharing_link( name=name, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -326,7 +326,7 @@ def sharing_unlink( try: result = service.unlink(alias=project, bucket_id=bucket_id) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -363,7 +363,7 @@ def sharing_edges( try: result = service.get_lineage(aliases=project) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: diff --git a/src/keboola_agent_cli/commands/storage.py b/src/keboola_agent_cli/commands/storage.py index 837c9519..52969dd7 100644 --- a/src/keboola_agent_cli/commands/storage.py +++ b/src/keboola_agent_cli/commands/storage.py @@ -9,7 +9,7 @@ import typer from ..config_store import ConfigStore -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, emit_hint, @@ -65,7 +65,7 @@ def storage_buckets( if branch is not None and (not project or len(project) != 1): formatter.error( message="--branch requires exactly one --project (branch ID is per-project)", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) @@ -77,7 +77,7 @@ def storage_buckets( try: result = service.list_buckets(aliases=project, branch_id=effective_branch) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -160,7 +160,7 @@ def storage_bucket_detail( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -243,7 +243,7 @@ def storage_tables( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -315,7 +315,7 @@ def storage_table_detail( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -416,10 +416,10 @@ def storage_create_bucket( branch_id=effective_branch, ) except ValueError as exc: - formatter.error(message=str(exc), error_code="INVALID_ARGUMENT") + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -500,10 +500,10 @@ def storage_create_table( branch_id=effective_branch, ) except ValueError as exc: - formatter.error(message=str(exc), error_code="INVALID_ARGUMENT") + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -587,7 +587,7 @@ def storage_upload_table( p = Path(file) if not p.is_file(): - formatter.error(message=f"File not found: {file}", error_code="FILE_NOT_FOUND") + formatter.error(message=f"File not found: {file}", error_code=ErrorCode.FILE_NOT_FOUND) raise typer.Exit(code=2) from None if not formatter.json_mode: @@ -608,10 +608,10 @@ def storage_upload_table( branch_id=effective_branch, ) except ValueError as exc: - formatter.error(message=str(exc), error_code="INVALID_ARGUMENT") + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -733,10 +733,10 @@ def storage_download_table( keep_slices=keep_slices, ) except ValueError as exc: - formatter.error(message=str(exc), error_code="INVALID_ARGUMENT") + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -826,7 +826,7 @@ def storage_delete_table( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -854,7 +854,7 @@ def storage_delete_table( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -943,7 +943,7 @@ def storage_delete_column( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -974,7 +974,7 @@ def storage_delete_column( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -1057,7 +1057,7 @@ def storage_delete_bucket( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -1164,7 +1164,7 @@ def storage_file_list( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1228,7 +1228,7 @@ def storage_file_info( try: result = service.get_file_info(alias=project, file_id=file_id) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1312,7 +1312,7 @@ def storage_file_upload( p = Path(file) if not p.is_file(): - formatter.error(message=f"File not found: {file}", error_code="FILE_NOT_FOUND") + formatter.error(message=f"File not found: {file}", error_code=ErrorCode.FILE_NOT_FOUND) raise typer.Exit(code=2) from None if not formatter.json_mode: @@ -1329,7 +1329,7 @@ def storage_file_upload( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1396,7 +1396,7 @@ def storage_file_download( if not file_id and not tag: formatter.error( message="Either --file-id or --tag must be provided", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None @@ -1414,10 +1414,10 @@ def storage_file_download( output_path=output, ) except ValueError as exc: - formatter.error(message=str(exc), error_code="INVALID_ARGUMENT") + formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1470,7 +1470,7 @@ def storage_file_tag( if not add and not remove: formatter.error( message="At least one of --add or --remove must be provided", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None @@ -1482,7 +1482,7 @@ def storage_file_tag( remove_tags=remove, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1543,7 +1543,7 @@ def storage_file_delete( dry_run=dry_run, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1645,7 +1645,7 @@ def storage_load_file( branch_id=effective_branch, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -1742,7 +1742,7 @@ def storage_unload_table( formatter = get_formatter(ctx) formatter.error( message=f"--file-type must be 'csv' or 'parquet', got {file_type!r}", - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, ) raise typer.Exit(code=2) from None if should_hint(ctx): @@ -1787,7 +1787,7 @@ def storage_unload_table( keep_slices=keep_slices, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) diff --git a/src/keboola_agent_cli/commands/sync.py b/src/keboola_agent_cli/commands/sync.py index dc560ec5..3433e5a3 100644 --- a/src/keboola_agent_cli/commands/sync.py +++ b/src/keboola_agent_cli/commands/sync.py @@ -9,7 +9,7 @@ import typer -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import check_cli_permission, get_formatter, get_service, map_error_to_exit_code sync_app = typer.Typer(help="Sync project configurations with local filesystem") @@ -81,12 +81,22 @@ def sync_init( "--git-branching", help="Enable git-branching mode (maps git branches to Keboola branches)", ), + adopt_existing: bool = typer.Option( + False, + "--adopt-existing", + help="Adopt an existing .keboola/manifest.json (e.g. written by kbc) " + "instead of failing. Validates the manifest's project_id against the alias " + "and normalises the file. Idempotent.", + ), ) -> None: """Initialize a sync working directory for a Keboola project. Creates the .keboola/ directory with manifest.json containing project metadata and naming conventions. Optionally enables git-branching mode for branch-to-branch mapping. + + Use --adopt-existing to register a directory that was already initialised + by the official kbc CLI without overwriting the manifest. """ formatter = get_formatter(ctx) service = get_service(ctx, "sync_service") @@ -97,12 +107,13 @@ def sync_init( alias=project, project_root=project_root, git_branching=git_branching, + adopt_existing=adopt_existing, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except FileExistsError as exc: - formatter.error(message=str(exc), error_code="ALREADY_EXISTS") + formatter.error(message=str(exc), error_code=ErrorCode.ALREADY_EXISTS) raise typer.Exit(code=1) from None except KeboolaApiError as exc: formatter.error( @@ -115,9 +126,17 @@ def sync_init( if formatter.json_mode: formatter.output(result) else: - formatter.success( - f"Initialized sync for project '{result['project_alias']}' (ID: {result['project_id']})" - ) + status = result.get("status", "initialized") + if status == "adopted": + formatter.success( + f"Adopted manifest for project '{result['project_alias']}' " + f"(ID: {result['project_id']})" + ) + else: + formatter.success( + f"Initialized sync for project '{result['project_alias']}' " + f"(ID: {result['project_id']})" + ) formatter.console.print(f" API host: {result['api_host']}") if result["git_branching"]: formatter.console.print( @@ -439,13 +458,13 @@ def sync_pull( if all_projects and project: formatter.error( message="Cannot use --project with --all-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) if not all_projects and not project: formatter.error( message="Specify --project ALIAS or --all-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) @@ -464,7 +483,7 @@ def sync_pull( max_samples=max_samples, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -481,7 +500,7 @@ def sync_pull( try: service.init_sync(project, project_root) except Exception as exc: - formatter.error(message=str(exc), error_code="INIT_ERROR") + formatter.error(message=str(exc), error_code=ErrorCode.INIT_ERROR) raise typer.Exit(code=1) from None try: @@ -498,10 +517,10 @@ def sync_pull( max_samples=max_samples, ) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error( @@ -539,7 +558,7 @@ def sync_status( try: result = service.status(project_root=project_root) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None if formatter.json_mode: @@ -608,13 +627,13 @@ def sync_diff( if all_projects and project: formatter.error( message="Cannot use --project with --all-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) if not all_projects and not project: formatter.error( message="Specify --project ALIAS or --all-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) @@ -623,7 +642,7 @@ def sync_diff( try: data = service.diff_all(base_dir) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -637,10 +656,10 @@ def sync_diff( try: result = service.diff(alias=project, project_root=project_root) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -784,13 +803,13 @@ def sync_push( if all_projects and project: formatter.error( message="Cannot use --project with --all-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) if not all_projects and not project: formatter.error( message="Specify --project ALIAS or --all-projects", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) @@ -804,7 +823,7 @@ def sync_push( allow_plaintext_fallback=allow_plaintext, ) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -824,10 +843,10 @@ def sync_push( allow_plaintext_fallback=allow_plaintext, ) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -902,10 +921,10 @@ def sync_branch_link( branch_name=branch_name, ) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None except KeboolaApiError as exc: formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) @@ -943,10 +962,10 @@ def sync_branch_unlink( try: result = service.branch_unlink(project_root=project_root) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -973,7 +992,7 @@ def sync_branch_status( try: result = service.branch_status(project_root=project_root) except FileNotFoundError as exc: - formatter.error(message=str(exc), error_code="NOT_INITIALIZED") + formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED) raise typer.Exit(code=1) from None if formatter.json_mode: diff --git a/src/keboola_agent_cli/commands/tool.py b/src/keboola_agent_cli/commands/tool.py index 0e8b0b56..73aa6d56 100644 --- a/src/keboola_agent_cli/commands/tool.py +++ b/src/keboola_agent_cli/commands/tool.py @@ -11,7 +11,7 @@ import typer from ..config_store import ConfigStore -from ..errors import ConfigError +from ..errors import ConfigError, ErrorCode from ..output import OutputFormatter, format_tool_result, format_tools_table from ._helpers import ( check_cli_permission, @@ -48,7 +48,7 @@ def _read_input(value: str, formatter: OutputFormatter) -> str: if not file_path.is_file(): formatter.error( message=f"Input file not found: {file_path}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None return file_path.read_text(encoding="utf-8") @@ -97,7 +97,7 @@ def tool_list( if branch_str and not project: formatter.error( message="--branch requires --project (branch ID is per-project)", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None @@ -106,7 +106,7 @@ def tool_list( try: result = service.list_tools(aliases=aliases, branch_id=branch_str) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -169,7 +169,7 @@ def tool_call( if branch_str and not project: formatter.error( message="--branch requires --project (branch ID is per-project)", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None @@ -182,14 +182,14 @@ def tool_call( except json.JSONDecodeError as exc: formatter.error( message=f"Invalid JSON in --input: {exc}", - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None if not isinstance(parsed_input, dict): formatter.error( message='--input must be a JSON object (e.g. \'{"key": "value"}\')', - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, ) raise typer.Exit(code=2) from None @@ -203,13 +203,13 @@ def tool_call( ) except ConfigError as exc: # ConfigError covers: unknown tool, missing params, config issues - error_code = "CONFIG_ERROR" + error_code = ErrorCode.CONFIG_ERROR exit_code = 5 if "Missing required parameter" in exc.message: - error_code = "MISSING_PARAMETER" + error_code = ErrorCode.MISSING_PARAMETER exit_code = 2 elif "Unknown MCP tool" in exc.message: - error_code = "CONFIG_ERROR" + error_code = ErrorCode.CONFIG_ERROR exit_code = 5 formatter.error(message=exc.message, error_code=error_code) raise typer.Exit(code=exit_code) from None diff --git a/src/keboola_agent_cli/commands/workspace.py b/src/keboola_agent_cli/commands/workspace.py index 0be72a0b..f0e65830 100644 --- a/src/keboola_agent_cli/commands/workspace.py +++ b/src/keboola_agent_cli/commands/workspace.py @@ -8,7 +8,7 @@ import typer -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import format_query_results, format_workspaces_table from ._helpers import ( check_cli_permission, @@ -103,7 +103,7 @@ def workspace_create( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -126,7 +126,7 @@ def workspace_list( try: result = service.list_workspaces(aliases=project) except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None if formatter.json_mode: @@ -182,7 +182,7 @@ def workspace_detail( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -222,7 +222,7 @@ def workspace_delete( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -265,7 +265,7 @@ def workspace_password( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -327,7 +327,7 @@ def workspace_load( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -383,13 +383,13 @@ def workspace_query( if sql and file: formatter.error( message="Specify either --sql or --file, not both.", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) if not sql and not file: formatter.error( message="Specify either --sql or --file.", - error_code="USAGE_ERROR", + error_code=ErrorCode.USAGE_ERROR, ) raise typer.Exit(code=2) @@ -416,7 +416,7 @@ def workspace_query( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None @@ -500,5 +500,5 @@ def workspace_from_transformation( ) raise typer.Exit(code=exit_code) from None except ConfigError as exc: - formatter.error(message=exc.message, error_code="CONFIG_ERROR") + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 82d50400..8c1b16e3 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -1,5 +1,91 @@ """Error types and helpers for Keboola Agent CLI.""" +from enum import StrEnum + + +class ErrorCode(StrEnum): + """Stable machine-readable error codes emitted by kbagent. + + ``str`` mixin means values compare equal to their plain-string equivalents + and serialise as plain strings in JSON output -- no wire-format change. + + Versioning: adding a new code = minor bump; renaming / removing = major bump. + """ + + # Auth / access + INVALID_TOKEN = "INVALID_TOKEN" + ACCESS_DENIED = "ACCESS_DENIED" + PERMISSION_DENIED = "PERMISSION_DENIED" + + # Network / transport + TIMEOUT = "TIMEOUT" + CONNECTION_ERROR = "CONNECTION_ERROR" + RETRY_EXHAUSTED = "RETRY_EXHAUSTED" + + # API / generic + API_ERROR = "API_ERROR" + NOT_FOUND = "NOT_FOUND" + ALREADY_EXISTS = "ALREADY_EXISTS" + VALIDATION_ERROR = "VALIDATION_ERROR" + INVALID_ARGUMENT = "INVALID_ARGUMENT" + INVALID_FORMAT = "INVALID_FORMAT" + USAGE_ERROR = "USAGE_ERROR" + MISSING_PARAMETER = "MISSING_PARAMETER" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + + # Configuration + CONFIG_ERROR = "CONFIG_ERROR" + NOT_INITIALIZED = "NOT_INITIALIZED" + INIT_ERROR = "INIT_ERROR" + + # Jobs + QUEUE_JOB_FAILED = "QUEUE_JOB_FAILED" + QUEUE_JOB_TIMEOUT = "QUEUE_JOB_TIMEOUT" + STORAGE_JOB_FAILED = "STORAGE_JOB_FAILED" + STORAGE_JOB_TIMEOUT = "STORAGE_JOB_TIMEOUT" + QUERY_JOB_FAILED = "QUERY_JOB_FAILED" + QUERY_JOB_TIMEOUT = "QUERY_JOB_TIMEOUT" + + # Variables + NO_VARIABLE_ROWS = "NO_VARIABLE_ROWS" + MALFORMED_VARIABLES_ROW = "MALFORMED_VARIABLES_ROW" + + # Storage + UPLOAD_FAILED = "UPLOAD_FAILED" + EXPORT_EMPTY_MANIFEST = "EXPORT_EMPTY_MANIFEST" + EXPORT_NO_FILE = "EXPORT_NO_FILE" + EXPORT_NO_URL = "EXPORT_NO_URL" + NOT_SLICED = "NOT_SLICED" + FILE_NO_URL = "FILE_NO_URL" + + # I/O + FILE_NOT_FOUND = "FILE_NOT_FOUND" + DIR_NOT_FOUND = "DIR_NOT_FOUND" + READ_ERROR = "READ_ERROR" + WRITE_ERROR = "WRITE_ERROR" + INPUT_ERROR = "INPUT_ERROR" + + # Lineage + NODE_NOT_FOUND = "NODE_NOT_FOUND" + + # Sharing + INVALID_SHARING_TYPE = "INVALID_SHARING_TYPE" + NOT_LINKED_BUCKET = "NOT_LINKED_BUCKET" + + # KAI (AI Service) + KAI_ERROR = "KAI_ERROR" + KAI_NOT_ENABLED = "KAI_NOT_ENABLED" + + # Workspace / Query + MISSING_QUERY = "MISSING_QUERY" + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" + + # Sync + PARENT_CONFIG_NOT_TRACKED = "PARENT_CONFIG_NOT_TRACKED" + + # Encryption + ENCRYPTION_FAILED = "ENCRYPTION_FAILED" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. @@ -33,7 +119,7 @@ def __init__( self, message: str, status_code: int = 0, - error_code: str = "UNKNOWN_ERROR", + error_code: str | ErrorCode = ErrorCode.UNKNOWN_ERROR, retryable: bool = False, ) -> None: super().__init__(message) @@ -63,14 +149,14 @@ def __init__(self, operation: str, message: str = "") -> None: _ERROR_CODE_TO_TYPE: dict[str, str] = { - "INVALID_TOKEN": "authentication", - "TIMEOUT": "network", - "CONNECTION_ERROR": "network", - "RETRY_EXHAUSTED": "network", - "NOT_FOUND": "not_found", - "CONFIG_ERROR": "configuration", - "VALIDATION_ERROR": "validation", - "PERMISSION_DENIED": "authorization", + ErrorCode.INVALID_TOKEN: "authentication", + ErrorCode.TIMEOUT: "network", + ErrorCode.CONNECTION_ERROR: "network", + ErrorCode.RETRY_EXHAUSTED: "network", + ErrorCode.NOT_FOUND: "not_found", + ErrorCode.CONFIG_ERROR: "configuration", + ErrorCode.VALIDATION_ERROR: "validation", + ErrorCode.PERMISSION_DENIED: "authorization", } diff --git a/src/keboola_agent_cli/http_base.py b/src/keboola_agent_cli/http_base.py index af9625f9..d48d57c0 100644 --- a/src/keboola_agent_cli/http_base.py +++ b/src/keboola_agent_cli/http_base.py @@ -21,7 +21,7 @@ MAX_RETRY_AFTER_SECONDS, RETRYABLE_STATUS_CODES, ) -from .errors import KeboolaApiError, mask_token +from .errors import ErrorCode, KeboolaApiError, mask_token logger = logging.getLogger(__name__) @@ -169,7 +169,7 @@ def _do_request( raise KeboolaApiError( message=f"Request timed out connecting to {url_label} (token: {self._masked_token})", status_code=0, - error_code="TIMEOUT", + error_code=ErrorCode.TIMEOUT, retryable=True, ) from exc @@ -189,7 +189,7 @@ def _do_request( raise KeboolaApiError( message=f"Cannot connect to {url_label} (token: {self._masked_token})", status_code=0, - error_code="CONNECTION_ERROR", + error_code=ErrorCode.CONNECTION_ERROR, retryable=True, ) from exc @@ -199,7 +199,7 @@ def _do_request( raise KeboolaApiError( message=f"Request failed after {MAX_RETRIES} retries to {url_label} (token: {self._masked_token})", status_code=0, - error_code="RETRY_EXHAUSTED", + error_code=ErrorCode.RETRY_EXHAUSTED, retryable=True, ) @@ -234,7 +234,7 @@ def _raise_api_error(self, response: httpx.Response, base_url: str | None = None raise KeboolaApiError( message=f"Invalid or expired token (token: {self._masked_token}): {api_message}", status_code=status, - error_code="INVALID_TOKEN", + error_code=ErrorCode.INVALID_TOKEN, retryable=False, ) @@ -242,7 +242,7 @@ def _raise_api_error(self, response: httpx.Response, base_url: str | None = None raise KeboolaApiError( message=f"Access denied (token: {self._masked_token}): {api_message}", status_code=status, - error_code="ACCESS_DENIED", + error_code=ErrorCode.ACCESS_DENIED, retryable=False, ) @@ -250,7 +250,7 @@ def _raise_api_error(self, response: httpx.Response, base_url: str | None = None raise KeboolaApiError( message=f"Resource not found: {api_message}", status_code=status, - error_code="NOT_FOUND", + error_code=ErrorCode.NOT_FOUND, retryable=False, ) @@ -258,6 +258,6 @@ def _raise_api_error(self, response: httpx.Response, base_url: str | None = None raise KeboolaApiError( message=f"API error {status} from {url_label} (token: {self._masked_token}): {api_message}", status_code=status, - error_code="API_ERROR", + error_code=ErrorCode.API_ERROR, retryable=retryable, ) diff --git a/src/keboola_agent_cli/services/_encryption.py b/src/keboola_agent_cli/services/_encryption.py index c8d2c107..44ef1beb 100644 --- a/src/keboola_agent_cli/services/_encryption.py +++ b/src/keboola_agent_cli/services/_encryption.py @@ -12,7 +12,7 @@ import logging from typing import Any -from ..errors import KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError logger = logging.getLogger(__name__) @@ -191,7 +191,7 @@ def encrypt_secrets_in_config( f"Use --allow-plaintext-on-encrypt-failure to override." ), status_code=0, - error_code="ENCRYPTION_FAILED", + error_code=ErrorCode.ENCRYPTION_FAILED, ) from exc return configuration diff --git a/src/keboola_agent_cli/services/branch_service.py b/src/keboola_agent_cli/services/branch_service.py index 0142d6e3..51aac872 100644 --- a/src/keboola_agent_cli/services/branch_service.py +++ b/src/keboola_agent_cli/services/branch_service.py @@ -10,7 +10,7 @@ from typing import Any from ..constants import METADATA_NOT_FOUND -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -408,7 +408,7 @@ def get_branch_metadata( f"Metadata key '{key}' not found on branch '{branch_id}' of project '{alias}'." ), status_code=404, - error_code="NOT_FOUND", + error_code=ErrorCode.NOT_FOUND, retryable=False, ) return { diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 6ab01835..1547e191 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any -from ..errors import KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError from ..json_utils import compute_diff, deep_merge, set_nested_value from ..models import ProjectConfig from ..sync.manifest import Manifest, load_manifest, save_manifest @@ -291,7 +291,7 @@ def update_config( if not has_content and not has_metadata: raise KeboolaApiError( status_code=400, - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, message=( "At least one of --name, --description, --configuration, " "--configuration-file, or --set must be provided." diff --git a/src/keboola_agent_cli/services/job_service.py b/src/keboola_agent_cli/services/job_service.py index 8ef781e3..b8b59a3c 100644 --- a/src/keboola_agent_cli/services/job_service.py +++ b/src/keboola_agent_cli/services/job_service.py @@ -8,7 +8,7 @@ from typing import Any from ..constants import DEFAULT_JOB_LIMIT, KILLABLE_JOB_STATUSES -from ..errors import KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -278,7 +278,7 @@ def resolve_variable_values_id( f"`--no-variables` to skip resolution." ), status_code=0, - error_code="NO_VARIABLE_ROWS", + error_code=ErrorCode.NO_VARIABLE_ROWS, ) # Defense against a malformed Storage API response: a row without a @@ -296,7 +296,7 @@ def resolve_variable_values_id( f"with empty variable bindings." ), status_code=0, - error_code="MALFORMED_VARIABLES_ROW", + error_code=ErrorCode.MALFORMED_VARIABLES_ROW, ) return str(first_row_id) diff --git a/src/keboola_agent_cli/services/kai_service.py b/src/keboola_agent_cli/services/kai_service.py index 15f4b6c7..3cb48a1c 100644 --- a/src/keboola_agent_cli/services/kai_service.py +++ b/src/keboola_agent_cli/services/kai_service.py @@ -11,7 +11,7 @@ from kai_client import KaiClient, KaiError from ..constants import KAI_FEATURE_FLAG, KAI_REQUEST_TIMEOUT, KAI_STREAM_TIMEOUT -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from .base import BaseService logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ def _check_kai_enabled(self, alias: str) -> None: "Enable the 'AI Agent Chat' feature in project settings." ), status_code=0, - error_code="KAI_NOT_ENABLED", + error_code=ErrorCode.KAI_NOT_ENABLED, ) # ------------------------------------------------------------------ @@ -129,7 +129,7 @@ async def _ping() -> dict[str, Any]: raise KeboolaApiError( message=f"Kai ping failed: {exc.message}", status_code=0, - error_code="KAI_ERROR", + error_code=ErrorCode.KAI_ERROR, ) from exc def ask(self, alias: str, message: str) -> dict[str, Any]: @@ -161,7 +161,7 @@ async def _ask() -> dict[str, Any]: raise KeboolaApiError( message=f"Kai ask failed: {exc.message}", status_code=0, - error_code="KAI_ERROR", + error_code=ErrorCode.KAI_ERROR, ) from exc def chat_message(self, alias: str, message: str, chat_id: str | None = None) -> dict[str, Any]: @@ -198,7 +198,7 @@ async def _chat() -> dict[str, Any]: raise KeboolaApiError( message=f"Kai chat failed: {exc.message}", status_code=0, - error_code="KAI_ERROR", + error_code=ErrorCode.KAI_ERROR, ) from exc def get_history(self, alias: str, limit: int = 10) -> dict[str, Any]: @@ -238,5 +238,5 @@ async def _history() -> dict[str, Any]: raise KeboolaApiError( message=f"Kai history failed: {exc.message}", status_code=0, - error_code="KAI_ERROR", + error_code=ErrorCode.KAI_ERROR, ) from exc diff --git a/src/keboola_agent_cli/services/sharing_service.py b/src/keboola_agent_cli/services/sharing_service.py index 78655395..4f3f5639 100644 --- a/src/keboola_agent_cli/services/sharing_service.py +++ b/src/keboola_agent_cli/services/sharing_service.py @@ -14,7 +14,7 @@ from typing import Any from ..constants import ENV_KBC_MASTER_TOKEN -from ..errors import KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -241,7 +241,7 @@ def unlink( message=f"Bucket '{bucket_id}' is not a linked bucket. " "Use this command only for linked (shared) buckets.", status_code=400, - error_code="NOT_LINKED_BUCKET", + error_code=ErrorCode.NOT_LINKED_BUCKET, retryable=False, ) client.delete_bucket(bucket_id=bucket_id, force=True) diff --git a/src/keboola_agent_cli/services/storage_service.py b/src/keboola_agent_cli/services/storage_service.py index 67a506f3..362cfbae 100644 --- a/src/keboola_agent_cli/services/storage_service.py +++ b/src/keboola_agent_cli/services/storage_service.py @@ -10,6 +10,7 @@ from typing import Any from ..constants import VALID_COLUMN_TYPES +from ..errors import ErrorCode from ..models import ProjectConfig from .base import BaseService @@ -631,7 +632,7 @@ def download_table( raise KeboolaApiError( message="Export job completed but no file ID in results", status_code=500, - error_code="EXPORT_NO_FILE", + error_code=ErrorCode.EXPORT_NO_FILE, retryable=False, ) @@ -642,7 +643,7 @@ def download_table( raise KeboolaApiError( message=f"No download URL for file {file_id}", status_code=500, - error_code="EXPORT_NO_URL", + error_code=ErrorCode.EXPORT_NO_URL, retryable=False, ) @@ -658,7 +659,7 @@ def download_table( "produced a single file. Re-run without --keep-slices." ), status_code=400, - error_code="NOT_SLICED", + error_code=ErrorCode.NOT_SLICED, retryable=False, ) slice_info = client.download_sliced_file_to_dir(file_detail, output_path) @@ -1080,7 +1081,7 @@ def download_file( raise KeboolaApiError( message=f"No files found matching tags: {tag_str}", status_code=404, - error_code="FILE_NOT_FOUND", + error_code=ErrorCode.FILE_NOT_FOUND, retryable=False, ) file_id = files[0]["id"] @@ -1119,7 +1120,7 @@ def download_file( raise KeboolaApiError( message=f"No download URL for file {file_id}", status_code=500, - error_code="FILE_NO_URL", + error_code=ErrorCode.FILE_NO_URL, retryable=False, ) bytes_written = client.download_file(download_url, effective_output) @@ -1334,7 +1335,7 @@ def unload_table_to_file( raise KeboolaApiError( message=f"file_type must be 'csv' or 'parquet', got {file_type!r}", status_code=400, - error_code="VALIDATION_ERROR", + error_code=ErrorCode.VALIDATION_ERROR, retryable=False, ) @@ -1359,7 +1360,7 @@ def unload_table_to_file( raise KeboolaApiError( message="Export job completed but no file ID in results", status_code=500, - error_code="EXPORT_NO_FILE", + error_code=ErrorCode.EXPORT_NO_FILE, retryable=False, ) @@ -1417,7 +1418,7 @@ def unload_table_to_file( "file is a single non-sliced CSV. Drop the flag." ), status_code=400, - error_code="NOT_SLICED", + error_code=ErrorCode.NOT_SLICED, retryable=False, ) effective_output = output_path or f"{table_short}.csv" @@ -1430,7 +1431,7 @@ def unload_table_to_file( raise KeboolaApiError( message=f"No download URL for file {file_id}", status_code=500, - error_code="FILE_NO_URL", + error_code=ErrorCode.FILE_NO_URL, retryable=False, ) bytes_written = client.download_file(download_url, effective_output) diff --git a/src/keboola_agent_cli/services/sync_service.py b/src/keboola_agent_cli/services/sync_service.py index 84ea29d3..cb03693e 100644 --- a/src/keboola_agent_cli/services/sync_service.py +++ b/src/keboola_agent_cli/services/sync_service.py @@ -33,7 +33,7 @@ STORAGE_DIR_NAME, STORAGE_SAMPLES_DIR_NAME, ) -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..sync.code_extraction import extract_code_files, merge_code_files from ..sync.config_format import ( api_config_to_local, @@ -78,6 +78,7 @@ def init_sync( alias: str, project_root: Path, git_branching: bool = False, + adopt_existing: bool = False, ) -> dict[str, Any]: """Initialize a sync working directory for a project. @@ -88,13 +89,19 @@ def init_sync( alias: Project alias from config store. project_root: Root directory for the sync working tree. git_branching: Enable git-branching mode. + adopt_existing: If True and a manifest already exists, validate it + against the alias's project_id and normalise it (idempotent + upgrade of a ``kbc``-written manifest) instead of refusing. Returns: Dict with initialization stats and created file paths. Raises: - ConfigError: If the project alias is not found. - FileExistsError: If manifest already exists (use pull instead). + ConfigError: If the project alias is not found, or if + ``adopt_existing`` is True but the manifest's project_id does + not match the alias's project. + FileExistsError: If manifest already exists and adopt_existing is + False (use ``sync pull`` to update). """ projects = self.resolve_projects([alias]) project = projects[alias] @@ -102,9 +109,12 @@ def init_sync( keboola_dir = project_root / KEBOOLA_DIR_NAME manifest_path = keboola_dir / "manifest.json" if manifest_path.exists(): + if adopt_existing: + return self._adopt_existing_manifest(alias, project_root, project) raise FileExistsError( f"Manifest already exists at {manifest_path}. " - "Use 'sync pull' to update, or delete .keboola/ to reinitialize." + "Use 'sync pull' to update, 'sync init --adopt-existing' to adopt a " + "kbc-written manifest, or delete .keboola/ to reinitialize." ) # Fetch project info from API @@ -184,6 +194,48 @@ def init_sync( "files_created": created_files, } + def _adopt_existing_manifest( + self, + alias: str, + project_root: Path, + project: Any, + ) -> dict[str, Any]: + """Validate and normalise an existing manifest written by kbc or kbagent. + + Idempotent: loads the manifest, confirms project_id matches the alias, + then saves it back through kbagent's serialiser (fills missing optional + fields with defaults, normalises camelCase keys, preserves all + existing content). + + Raises: + ConfigError: If the manifest's project_id doesn't match the alias. + """ + existing = load_manifest(project_root) + + client = self._client_factory(project.stack_url, project.token) + with client: + token_info = client.verify_token() + + if existing.project.id != token_info.project_id: + raise ConfigError( + f"Manifest project_id={existing.project.id} does not match alias " + f"'{alias}' project_id={token_info.project_id}; refusing to overwrite. " + "Check that --project points to the correct alias." + ) + + api_host = project.stack_url.replace("https://", "").rstrip("/") + save_manifest(project_root, existing) + + return { + "status": "adopted", + "project_id": token_info.project_id, + "project_alias": alias, + "api_host": api_host, + "git_branching": existing.git_branching.enabled, + "default_branch": existing.git_branching.default_branch, + "files_created": [], + } + # ------------------------------------------------------------------ # pull # ------------------------------------------------------------------ @@ -1278,7 +1330,7 @@ def _push_row_change( f"{parent_config_id} is not tracked in the manifest." ), status_code=0, - error_code="PARENT_CONFIG_NOT_TRACKED", + error_code=ErrorCode.PARENT_CONFIG_NOT_TRACKED, ) project_id = manifest.project.id if manifest.project else None diff --git a/src/keboola_agent_cli/services/workspace_service.py b/src/keboola_agent_cli/services/workspace_service.py index 072e2650..fdf8ff58 100644 --- a/src/keboola_agent_cli/services/workspace_service.py +++ b/src/keboola_agent_cli/services/workspace_service.py @@ -8,7 +8,7 @@ import logging from typing import Any -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -218,7 +218,7 @@ def _create_workspace_via_job( raise KeboolaApiError( message=f"Sandbox job completed but no workspace found for config {config_id}", status_code=500, - error_code="WORKSPACE_NOT_FOUND", + error_code=ErrorCode.WORKSPACE_NOT_FOUND, retryable=False, ) diff --git a/src/keboola_agent_cli/sync/manifest.py b/src/keboola_agent_cli/sync/manifest.py index 81749bde..6ff05938 100644 --- a/src/keboola_agent_cli/sync/manifest.py +++ b/src/keboola_agent_cli/sync/manifest.py @@ -112,7 +112,9 @@ class Manifest(BaseModel): version: int = MANIFEST_VERSION project: ManifestProject allow_target_env: bool = Field(default=True, alias="allowTargetEnv") - git_branching: ManifestGitBranching = Field(alias="gitBranching") + git_branching: ManifestGitBranching = Field( + default_factory=ManifestGitBranching, alias="gitBranching" + ) sort_by: str = Field(default="id", alias="sortBy") naming: ManifestNaming allowed_branches: list[str] = Field(default_factory=list, alias="allowedBranches") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b8b8e778..af2f3e16 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -3413,3 +3413,152 @@ def test_resolver_prefers_explicit_values_id_over_first_row(self) -> None: ) print(f" {_DIM}resolved={resolved} pinned={pinned_row_id} first={first_row_id}{_RESET}") assert resolved == pinned_row_id + + +# =========================================================================== +# E2E: sync init --adopt-existing +# =========================================================================== + + +@pytest.mark.e2e +@skip_without_credentials +class TestE2ESyncAdoptExisting: + """E2E test for 'sync init --adopt-existing' against a real Keboola project. + + Simulates a directory that was set up by the kbc CLI (or a previous kbagent + version) by writing a minimal valid manifest, then verifies that: + 1. kbagent sync init --adopt-existing succeeds (status=adopted) + 2. kbagent sync status works on the adopted directory + """ + + @pytest.fixture(autouse=True) + def setup(self, tmp_path: Path) -> None: + self.token = os.environ[ENV_TOKEN] + raw_url = os.environ.get(ENV_URL, "connection.keboola.com") + self.url = raw_url if raw_url.startswith("https://") else f"https://{raw_url}" + self.alias = f"{RUN_ID}-adopt" + + self.config_dir = tmp_path / "config" + self.config_dir.mkdir() + self.project_dir = tmp_path / "project" + self.project_dir.mkdir() + + # Register the project + result = _invoke( + self.config_dir, + [ + "--json", + "project", + "add", + "--project", + self.alias, + "--url", + self.url, + "--token", + self.token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + + def _run(self, *args: str): + return _invoke(self.config_dir, ["--json", *args]) + + def _run_ok(self, *args: str) -> dict: + return _json_ok(self._run(*args)) + + def test_adopt_existing_manifest(self) -> None: + """init --adopt-existing adopts a kbc-style manifest; sync status works after.""" + import json as _json + + # 1. Write a kbc-style manifest for the real project. + # We first call sync init normally to learn the real project_id, then + # delete and rewrite as a "legacy" manifest. + _step(1, "sync init (normal) to learn project_id") + resp = self._run_ok( + "sync", + "init", + "--project", + self.alias, + "--directory", + str(self.project_dir), + ) + project_id = resp["data"]["project_id"] + branch_id = None + keboola_dir = self.project_dir / ".keboola" + manifest_path = keboola_dir / "manifest.json" + raw = _json.loads(manifest_path.read_text()) + if raw.get("branches"): + branch_id = raw["branches"][0]["id"] + + # 2. Rewrite as a minimal kbc-style manifest (drop gitBranching field). + _step(2, "rewrite as kbc-style manifest (drop gitBranching)") + kbc_manifest = { + "version": 2, + "project": {"id": project_id, "apiHost": self.url.replace("https://", "")}, + "allowTargetEnv": True, + "sortBy": "id", + "naming": {"branch": "{branch_name}"}, + "branches": [{"id": branch_id, "path": "main"}] if branch_id else [], + "configurations": [], + } + manifest_path.write_text(_json.dumps(kbc_manifest, indent=4), encoding="utf-8") + + # 3. sync init --adopt-existing should succeed without error. + _step(3, "sync init --adopt-existing") + resp = self._run_ok( + "sync", + "init", + "--project", + self.alias, + "--directory", + str(self.project_dir), + "--adopt-existing", + ) + inner = resp["data"] + assert inner["status"] == "adopted", f"Expected 'adopted', got {inner['status']}" + assert inner["project_id"] == project_id + assert inner["files_created"] == [] + + # 4. sync status should work on the adopted directory. + _step(4, "sync status on adopted directory") + resp = self._run_ok( + "sync", + "status", + "--directory", + str(self.project_dir), + ) + # Status should be parseable (may show no changes on an empty dir) + inner = resp["data"] + assert "modified" in inner or "unchanged" in inner or "added" in inner + + def test_adopt_existing_rejects_wrong_project(self) -> None: + """init --adopt-existing rejects a manifest with a different project_id.""" + import json as _json + + keboola_dir = self.project_dir / ".keboola" + keboola_dir.mkdir() + # Write a manifest with a clearly wrong project_id + wrong_manifest = { + "version": 2, + "project": {"id": 999999999, "apiHost": "connection.keboola.com"}, + "allowTargetEnv": True, + "sortBy": "id", + "naming": {"branch": "{branch_name}"}, + "branches": [], + "configurations": [], + } + (keboola_dir / "manifest.json").write_text(_json.dumps(wrong_manifest), encoding="utf-8") + + result = self._run( + "sync", + "init", + "--project", + self.alias, + "--directory", + str(self.project_dir), + "--adopt-existing", + ) + assert result.exit_code == 5, f"Expected exit 5, got {result.exit_code}: {result.output}" + output = _json.loads(result.output) + assert output["status"] == "error" + assert "999999999" in output["error"]["message"] diff --git a/tests/test_errors.py b/tests/test_errors.py index 50dc257e..7cf30336 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,6 +1,6 @@ """Tests for error types and helpers.""" -from keboola_agent_cli.errors import ConfigError, KeboolaApiError, mask_token +from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError, mask_token class TestMaskToken: @@ -91,3 +91,69 @@ def test_is_exception(self) -> None: """ConfigError is a proper Exception subclass.""" err = ConfigError(message="test") assert isinstance(err, Exception) + + +class TestErrorCode: + """Tests for the ErrorCode enum.""" + + def test_str_equality(self) -> None: + """ErrorCode members compare equal to their plain string values.""" + assert ErrorCode.QUEUE_JOB_FAILED == "QUEUE_JOB_FAILED" + assert ErrorCode.PERMISSION_DENIED == "PERMISSION_DENIED" + assert ErrorCode.CONFIG_ERROR == "CONFIG_ERROR" + + def test_is_str(self) -> None: + """ErrorCode is a subtype of str -- usable wherever a str is expected.""" + code = ErrorCode.NOT_FOUND + assert isinstance(code, str) + + def test_json_serialisation(self) -> None: + """ErrorCode serialises to its plain string value in json.dumps.""" + import json + + assert json.dumps(ErrorCode.UPLOAD_FAILED) == '"UPLOAD_FAILED"' + + def test_no_duplicate_values(self) -> None: + """Every ErrorCode member has a unique string value.""" + values = [c.value for c in ErrorCode] + assert len(values) == len(set(values)), "Duplicate ErrorCode values detected" + + def test_known_codes_present(self) -> None: + """Spot-check that key codes defined in the spec are present.""" + required = { + "INVALID_TOKEN", + "PERMISSION_DENIED", + "TIMEOUT", + "CONNECTION_ERROR", + "RETRY_EXHAUSTED", + "API_ERROR", + "NOT_FOUND", + "CONFIG_ERROR", + "QUEUE_JOB_FAILED", + "QUEUE_JOB_TIMEOUT", + "STORAGE_JOB_FAILED", + "NO_VARIABLE_ROWS", + "MALFORMED_VARIABLES_ROW", + "UPLOAD_FAILED", + "UNKNOWN_ERROR", + "ENCRYPTION_FAILED", + "WORKSPACE_NOT_FOUND", + } + member_names = {c.name for c in ErrorCode} + missing = required - member_names + assert not missing, f"Required ErrorCode members missing: {missing}" + + def test_keboolaapierror_default_uses_enum(self) -> None: + """KeboolaApiError default error_code is an ErrorCode member.""" + err = KeboolaApiError(message="oops") + assert err.error_code == ErrorCode.UNKNOWN_ERROR + assert err.error_code == "UNKNOWN_ERROR" + + def test_keboolaapierror_accepts_enum(self) -> None: + """KeboolaApiError can be constructed with an ErrorCode member.""" + err = KeboolaApiError( + message="job died", + error_code=ErrorCode.QUEUE_JOB_FAILED, + ) + assert err.error_code == "QUEUE_JOB_FAILED" + assert err.error_code == ErrorCode.QUEUE_JOB_FAILED diff --git a/tests/test_integration.py b/tests/test_integration.py index cac5d519..27d9a7fb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,6 +12,8 @@ import json import os +import subprocess +import sys from pathlib import Path import pytest @@ -240,3 +242,70 @@ def test_context_command_works(self, integration_config_dir: Path) -> None: assert result.exit_code == 0 assert "kbagent" in result.output assert "--json" in result.output + + +# =========================================================================== +# CI guard: check_error_codes.py catches planted raw strings +# =========================================================================== + + +@pytest.mark.integration +class TestCheckErrorCodesGuard: + """Verify the CI guard script rejects raw error_code string literals.""" + + def test_guard_passes_on_clean_source(self) -> None: + """scripts/check_error_codes.py exits 0 on the current (clean) source.""" + result = subprocess.run( + [sys.executable, "scripts/check_error_codes.py"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Guard failed on clean source:\n{result.stdout}\n{result.stderr}" + ) + + def test_guard_catches_planted_literal(self, tmp_path: Path) -> None: + """Guard exits 1 when a raw string literal is planted in a temp source file.""" + # Write a minimal Python file that uses a raw error_code string + planted = tmp_path / "planted.py" + planted.write_text( + "from keboola_agent_cli.errors import KeboolaApiError\n" + 'raise KeboolaApiError("oops", error_code="QUEUE_JOB_FAILED")\n', + encoding="utf-8", + ) + # Run the guard against only this file by patching SRC_ROOT via env isn't + # practical; instead verify the guard script's logic directly via import. + import ast + + source = planted.read_text(encoding="utf-8") + tree = ast.parse(source) + violations = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for kw in node.keywords: + if kw.arg == "error_code" and isinstance(kw.value, ast.Constant): + violations.append(kw.value.value) + + assert violations == ["QUEUE_JOB_FAILED"], ( + "Guard logic should detect the planted raw string literal" + ) + + def test_guard_ignores_enum_usage(self, tmp_path: Path) -> None: + """Guard logic does NOT flag error_code=ErrorCode.X (non-Constant node).""" + import ast + + source = ( + "from keboola_agent_cli.errors import ErrorCode, KeboolaApiError\n" + 'raise KeboolaApiError("oops", error_code=ErrorCode.QUEUE_JOB_FAILED)\n' + ) + tree = ast.parse(source) + violations = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for kw in node.keywords: + if kw.arg == "error_code" and isinstance(kw.value, ast.Constant): + violations.append(kw.value.value) + + assert violations == [], "Enum usage should not be flagged as a violation" diff --git a/tests/test_sync_cli.py b/tests/test_sync_cli.py index 3b0b7a8c..1190ca2f 100644 --- a/tests/test_sync_cli.py +++ b/tests/test_sync_cli.py @@ -1562,3 +1562,159 @@ def test_sync_branch_status_disabled_human(self, tmp_path: Path) -> None: assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" assert "not enabled" in result.output + + +class TestSyncInitAdoptExistingCli: + """Tests for `kbagent sync init --adopt-existing`.""" + + def test_adopt_existing_flag_present_in_help(self) -> None: + """sync init --help shows --adopt-existing flag.""" + result = runner.invoke(app, ["sync", "init", "--help"]) + assert result.exit_code == 0 + assert "--adopt-existing" in _strip_ansi(result.output) + + def test_adopt_existing_json_output(self, tmp_path: Path) -> None: + """sync init --adopt-existing --json returns adopted status.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config( + config_dir, + {"prod": {"token": TEST_TOKEN, "project_id": 258}}, + ) + + mock_sync = _make_sync_service_mock() + mock_sync.init_sync.return_value = { + "status": "adopted", + "project_id": 258, + "project_alias": "prod", + "api_host": "connection.keboola.com", + "git_branching": False, + "default_branch": "main", + "files_created": [], + } + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SyncService") as MockSyncService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockSyncService.return_value = mock_sync + + result = runner.invoke( + app, + [ + "--json", + "sync", + "init", + "--project", + "prod", + "--directory", + str(tmp_path), + "--adopt-existing", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["status"] == "adopted" + assert output["data"]["files_created"] == [] + + # Verify adopt_existing=True was passed through to the service + call_kwargs = mock_sync.init_sync.call_args + assert call_kwargs.kwargs.get("adopt_existing") is True + + def test_adopt_existing_human_output_shows_adopted(self, tmp_path: Path) -> None: + """sync init --adopt-existing shows 'Adopted' instead of 'Initialized'.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config( + config_dir, + {"prod": {"token": TEST_TOKEN, "project_id": 258}}, + ) + + mock_sync = _make_sync_service_mock() + mock_sync.init_sync.return_value = { + "status": "adopted", + "project_id": 258, + "project_alias": "prod", + "api_host": "connection.europe-west3.gcp.keboola.com", + "git_branching": False, + "default_branch": "main", + "files_created": [], + } + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SyncService") as MockSyncService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockSyncService.return_value = mock_sync + + result = runner.invoke( + app, + [ + "sync", + "init", + "--project", + "prod", + "--directory", + str(tmp_path), + "--adopt-existing", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = _strip_ansi(result.output) + assert "Adopted" in output or "adopted" in output.lower() + + def test_adopt_existing_config_error_exits_5(self, tmp_path: Path) -> None: + """sync init --adopt-existing returns exit code 5 on project_id mismatch.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config( + config_dir, + {"prod": {"token": TEST_TOKEN, "project_id": 258}}, + ) + + from keboola_agent_cli.errors import ConfigError + + mock_sync = _make_sync_service_mock() + mock_sync.init_sync.side_effect = ConfigError( + "Manifest project_id=999 does not match alias 'prod' project_id=258" + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SyncService") as MockSyncService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockSyncService.return_value = mock_sync + + result = runner.invoke( + app, + [ + "--json", + "sync", + "init", + "--project", + "prod", + "--adopt-existing", + "--directory", + str(tmp_path), + ], + ) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert "project_id" in output["error"]["message"] diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index 75374d50..98007886 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -2197,3 +2197,181 @@ def test_handles_branch_not_found_in_api(self) -> None: assert result == "branch-44444" assert len(manifest.branches) == 2 assert manifest.branches[1].path == "branch-44444" + + +# =========================================================================== +# adopt-existing tests +# =========================================================================== + + +class TestAdoptExistingManifest: + """Tests for SyncService.init_sync(adopt_existing=True).""" + + def _kbc_style_manifest(self, project_id: int = 258) -> dict: + """Return a manifest as written by the kbc Go CLI (camelCase, gitBranching present).""" + return { + "version": 2, + "project": {"id": project_id, "apiHost": "connection.keboola.com"}, + "allowTargetEnv": True, + "sortBy": "id", + "gitBranching": {"enabled": False, "defaultBranch": "main"}, + "naming": {"branch": "{branch_name}"}, + "branches": [{"id": 12345, "path": "main"}], + "configurations": [ + { + "branchId": 12345, + "componentId": "keboola.ex-http", + "id": "cfg-001", + "path": "extractor/keboola.ex-http/my-extractor", + "rows": [], + } + ], + } + + def test_adopt_existing_normalises_kbc_manifest( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """adopt_existing=True loads, validates, and saves a kbc manifest without data loss.""" + mock_client = _make_sync_mock_client( + verify_token_response=SAMPLE_VERIFY_TOKEN, # project_id=258 + branches_response=SAMPLE_BRANCHES, + ) + store = setup_single_project(tmp_config_dir) + svc = SyncService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + project_root = tmp_path / "project" + project_root.mkdir() + keboola_dir = project_root / ".keboola" + keboola_dir.mkdir() + manifest_path = keboola_dir / "manifest.json" + import json as _json + + manifest_path.write_text( + _json.dumps(self._kbc_style_manifest(project_id=258)), encoding="utf-8" + ) + + result = svc.init_sync( + alias="prod", + project_root=project_root, + adopt_existing=True, + ) + + assert result["status"] == "adopted" + assert result["project_id"] == 258 + assert result["project_alias"] == "prod" + assert result["files_created"] == [] + + # Original configuration entry preserved in manifest + saved = load_manifest(project_root) + assert len(saved.configurations) == 1 + assert saved.configurations[0].component_id == "keboola.ex-http" + + def test_adopt_existing_rejects_project_id_mismatch( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """adopt_existing raises ConfigError when manifest project_id != alias project_id.""" + mock_client = _make_sync_mock_client( + verify_token_response=SAMPLE_VERIFY_TOKEN, # project_id=258 + branches_response=SAMPLE_BRANCHES, + ) + store = setup_single_project(tmp_config_dir) + svc = SyncService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + project_root = tmp_path / "project" + project_root.mkdir() + keboola_dir = project_root / ".keboola" + keboola_dir.mkdir() + import json as _json + + # Manifest claims a different project (id=999, not 258) + (keboola_dir / "manifest.json").write_text( + _json.dumps(self._kbc_style_manifest(project_id=999)), encoding="utf-8" + ) + + with pytest.raises(ConfigError, match="project_id=999"): + svc.init_sync(alias="prod", project_root=project_root, adopt_existing=True) + + def test_adopt_existing_falls_through_to_normal_init_when_no_manifest( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """adopt_existing=True with no manifest runs normal init (creates manifest).""" + mock_client = _make_sync_mock_client( + verify_token_response=SAMPLE_VERIFY_TOKEN, + branches_response=SAMPLE_BRANCHES, + ) + store = setup_single_project(tmp_config_dir) + svc = SyncService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + project_root = tmp_path / "project" + project_root.mkdir() + + result = svc.init_sync( + alias="prod", + project_root=project_root, + adopt_existing=True, + ) + + # Should behave like a normal init when no manifest exists + assert result["status"] == "initialized" + assert (project_root / ".keboola" / "manifest.json").exists() + + def test_adopt_existing_false_still_raises_on_existing_manifest( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """Default (adopt_existing=False) still raises FileExistsError when manifest exists.""" + mock_client = _make_sync_mock_client( + verify_token_response=SAMPLE_VERIFY_TOKEN, + branches_response=SAMPLE_BRANCHES, + ) + store = setup_single_project(tmp_config_dir) + svc = SyncService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + project_root = tmp_path / "project" + project_root.mkdir() + svc.init_sync(alias="prod", project_root=project_root) + + # Second call without adopt_existing should still fail + with pytest.raises(FileExistsError, match="--adopt-existing"): + svc.init_sync(alias="prod", project_root=project_root) + + def test_adopt_existing_idempotent(self, tmp_config_dir: Path, tmp_path: Path) -> None: + """adopt_existing is idempotent: calling it twice leaves the manifest unchanged.""" + mock_client = _make_sync_mock_client( + verify_token_response=SAMPLE_VERIFY_TOKEN, + branches_response=SAMPLE_BRANCHES, + ) + store = setup_single_project(tmp_config_dir) + svc = SyncService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + project_root = tmp_path / "project" + project_root.mkdir() + keboola_dir = project_root / ".keboola" + keboola_dir.mkdir() + import json as _json + + (keboola_dir / "manifest.json").write_text( + _json.dumps(self._kbc_style_manifest(project_id=258)), encoding="utf-8" + ) + + result1 = svc.init_sync(alias="prod", project_root=project_root, adopt_existing=True) + result2 = svc.init_sync(alias="prod", project_root=project_root, adopt_existing=True) + + assert result1["status"] == "adopted" + assert result2["status"] == "adopted" + # Both calls return the same project data + assert result1["project_id"] == result2["project_id"] diff --git a/uv.lock b/uv.lock index 400d03e1..576ed251 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.21.1" +version = "0.22.0" source = { editable = "." } dependencies = [ { name = "httpx" },