From dcc54130e84c3b61e790c6cfe2a1eb289d325585 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 3 Mar 2026 23:07:18 +0100 Subject: [PATCH 1/3] v0.6.0: Branch lifecycle management + security hardening Branch lifecycle commands: - branch create: create dev branch + auto-activate for subsequent tool calls - branch use: switch to existing branch (validates via API) - branch reset: revert to main/production branch - branch delete: delete branch (auto-resets if it was active) - branch merge: generate KBC UI merge URL (safe review, no force-merge) Active branch persistence: - active_branch_id stored per-project in config.json - Auto-resolved in tool list/call (no need to pass --branch every time) - Displayed in project list/status and branch list tables Async job handling: - Branch create/delete are async Storage API operations - Client polls job status until completion (1s interval, 60s max) - Real branch ID extracted from job.results (not job ID) Security fixes: - TOCTOU race in config_store: atomic write via os.open(0o600) + os.replace() - XSS in kbc-explorer: escapeHtml() for all innerHTML with dynamic data - None key validation in lineage_service and org_service Architecture improvements: - MCP timeouts read lazily at runtime (not at import) - Domain constants (VALID_COMPONENT_TYPES, VALID_STATUSES) centralized - --branch flag typed as int (consistent with branch.py) - Duplicate branch validation extracted to helper - explorer DEFAULT_OUTPUT_DIR uses Path.cwd() instead of package-relative path - explorer uses MAX_JOB_LIMIT from constants instead of local duplicate Tests: 687 passed, 3 skipped --- .gitignore | 3 + CLAUDE.md | 11 +- kbc-explorer/index.html | 21 +- pyproject.toml | 4 +- src/keboola_agent_cli/__init__.py | 2 +- src/keboola_agent_cli/cli.py | 10 + src/keboola_agent_cli/client.py | 86 ++- src/keboola_agent_cli/commands/branch.py | 207 +++++++- src/keboola_agent_cli/commands/config.py | 3 +- src/keboola_agent_cli/commands/context.py | 94 +++- src/keboola_agent_cli/commands/explorer.py | 7 +- src/keboola_agent_cli/commands/job.py | 4 +- src/keboola_agent_cli/commands/project.py | 8 + src/keboola_agent_cli/commands/tool.py | 116 +++- src/keboola_agent_cli/config_store.py | 34 +- src/keboola_agent_cli/constants.py | 16 + src/keboola_agent_cli/models.py | 4 + src/keboola_agent_cli/output.py | 16 + .../services/branch_service.py | 242 ++++++++- .../services/explorer_service.py | 17 +- .../services/lineage_service.py | 6 + src/keboola_agent_cli/services/mcp_service.py | 36 +- src/keboola_agent_cli/services/org_service.py | 2 +- .../services/project_service.py | 2 + tests/test_branch_service.py | 302 +++++++++++ tests/test_cli.py | 501 ++++++++++++++++++ tests/test_client.py | 109 ++++ tests/test_config_store.py | 84 +++ tests/test_explorer_service.py | 20 +- tests/test_lineage_service.py | 40 ++ tests/test_mcp_service.py | 59 +-- tests/test_models.py | 27 + tests/test_org_service.py | 40 ++ uv.lock | 376 ++++++++++++- 34 files changed, 2400 insertions(+), 109 deletions(-) diff --git a/.gitignore b/.gitignore index 8834d4ad..961a04a6 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ kbc-explorer/catalog.js kbc-explorer/orchestrations.json kbc-explorer/orchestrations.js +# Docs (plans, reports, PDFs - local only) +docs/ + # OS .DS_Store Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md index e62d8c6e..5a5c981e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ src/keboola_agent_cli/ lineage.py # LAYER 1: CLI commands for cross-project data lineage org.py # LAYER 1: CLI commands for organization bulk onboarding tool.py # LAYER 1: CLI commands for MCP tool list/call (supports --branch) - branch.py # LAYER 1: CLI commands for development branch listing + branch.py # LAYER 1: CLI commands for branch lifecycle (list/create/use/reset/delete/merge) explorer.py # LAYER 1: CLI commands for KBC Explorer dashboard generation context.py # LAYER 1: Agent usage instructions doctor.py # LAYER 1: Health check command @@ -77,7 +77,7 @@ src/keboola_agent_cli/ lineage_service.py # LAYER 2: Cross-project lineage via bucket sharing org_service.py # LAYER 2: Organization setup orchestration mcp_service.py # LAYER 2: MCP tool integration (keboola-mcp-server wrapper) - branch_service.py # LAYER 2: Development branch listing across projects + branch_service.py # LAYER 2: Branch lifecycle (create/use/reset/delete/merge, async job polling) explorer_service.py # LAYER 2: KBC Explorer catalog/orchestration generation doctor_service.py # LAYER 2: Health check business logic @@ -95,7 +95,7 @@ tests/ test_base_service.py # BaseService unit tests (resolve, workers, parallel) test_lineage_service.py # Lineage service tests test_mcp_service.py # MCP service tests (incl. branch_id propagation) - test_branch_service.py # Branch service tests (multi-project, errors) + test_branch_service.py # Branch service tests (lifecycle, multi-project, errors) test_org_service.py # Org service tests (slugify, setup, idempotency) test_explorer_service.py # Explorer service tests (tier assignment, job stats, generation) test_doctor_service.py # Doctor service tests @@ -185,6 +185,11 @@ kbagent tool list [--project NAME] [--branch ID] kbagent tool call TOOL_NAME [--project NAME] [--input JSON] [--branch ID] kbagent branch list [--project NAME] +kbagent branch create --project ALIAS --name "..." [--description "..."] +kbagent branch use --project ALIAS --branch ID +kbagent branch reset --project ALIAS +kbagent branch delete --project ALIAS --branch ID +kbagent branch merge --project ALIAS [--branch ID] kbagent explorer [--project NAME] [--output-dir DIR] [--job-limit N] [--tiers FILE] [--no-open] diff --git a/kbc-explorer/index.html b/kbc-explorer/index.html index f1466181..53d8a807 100644 --- a/kbc-explorer/index.html +++ b/kbc-explorer/index.html @@ -992,6 +992,11 @@

KBC Explorer

'use strict'; const $ = id => document.getElementById(id); + const escapeHtml = (str) => { + const div = document.createElement('div'); + div.textContent = String(str ?? ''); + return div.innerHTML; + }; const h = (tag, attrs, ...children) => { const el = document.createElement(tag); if (attrs) Object.entries(attrs).forEach(([k, v]) => { @@ -2344,12 +2349,12 @@

KBC Explorer

const sharesOut = (adjOut[ne.data.alias] || []).length; const sharesIn = (adjIn[ne.data.alias] || []).length; - tooltip.innerHTML = `${proj.name}
-
Tier${ne.data.tier}
-
Configs${configs}
-
Success rate${rateStr}
-
Shares out${sharesOut}
-
Shares in${sharesIn}
`; + tooltip.innerHTML = `${escapeHtml(proj.name)}
+
Tier${escapeHtml(ne.data.tier)}
+
Configs${escapeHtml(configs)}
+
Success rate${escapeHtml(rateStr)}
+
Shares out${escapeHtml(sharesOut)}
+
Shares in${escapeHtml(sharesIn)}
`; tooltip.style.display = 'block'; } } @@ -2645,9 +2650,9 @@

KBC Explorer

if (selectedNode) return; const s = nodeMap[ee.data.source]; const t = nodeMap[ee.data.target]; - tooltip.innerHTML = `${s?.name || ee.data.source} \u2192 ${t?.name || ee.data.target}
+ tooltip.innerHTML = `${escapeHtml(s?.name || ee.data.source)} \u2192 ${escapeHtml(t?.name || ee.data.target)}
- ${ee.data.buckets.map(b => `${b}`).join('')} + ${ee.data.buckets.map(b => `${escapeHtml(b)}`).join('')}
`; tooltip.style.display = 'block'; }); diff --git a/pyproject.toml b/pyproject.toml index 4d552a70..7713be35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.5.0" +version = "0.6.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" @@ -54,4 +54,6 @@ dev = [ "pytest-httpx>=0.30", "pytest-asyncio>=0.23", "ruff>=0.8", + "bandit>=1.9.4", + "pip-audit>=2.10.0", ] diff --git a/src/keboola_agent_cli/__init__.py b/src/keboola_agent_cli/__init__.py index 9986b929..7f9807e7 100644 --- a/src/keboola_agent_cli/__init__.py +++ b/src/keboola_agent_cli/__init__.py @@ -1,3 +1,3 @@ """Keboola Agent CLI - AI-friendly interface to Keboola projects.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index c7794080..b580c58a 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -12,9 +12,11 @@ from .commands.explorer import explorer_app from .commands.job import job_app from .commands.lineage import lineage_app +from .commands.llm import llm_app from .commands.org import org_app from .commands.project import project_app from .commands.tool import tool_app +from .commands.version import version_command from .config_store import ConfigStore from .output import OutputFormatter from .services.branch_service import BranchService @@ -22,10 +24,12 @@ from .services.doctor_service import DoctorService from .services.explorer_service import ExplorerService from .services.job_service import JobService +from .services.kbc_service import KbcService from .services.lineage_service import LineageService from .services.mcp_service import McpService from .services.org_service import OrgService from .services.project_service import ProjectService +from .services.version_service import VersionService app = typer.Typer( name="kbagent", @@ -41,8 +45,10 @@ app.add_typer(tool_app, name="tool") app.add_typer(branch_app, name="branch") app.add_typer(explorer_app, name="explorer") +app.add_typer(llm_app, name="llm") app.command("context")(context_command) app.command("doctor")(doctor_command) +app.command("version")(version_command) @app.callback() @@ -92,6 +98,7 @@ def main( org_service = OrgService(config_store=config_store) mcp_service = McpService(config_store=config_store) branch_service = BranchService(config_store=config_store) + kbc_service = KbcService(config_store=config_store) doctor_service = DoctorService(config_store=config_store, mcp_service=mcp_service) explorer_service = ExplorerService( config_store=config_store, @@ -99,6 +106,7 @@ def main( job_service=job_service, lineage_service=lineage_service, ) + version_service = VersionService() ctx.ensure_object(dict) ctx.obj["formatter"] = formatter @@ -113,5 +121,7 @@ def main( ctx.obj["org_service"] = org_service ctx.obj["mcp_service"] = mcp_service ctx.obj["branch_service"] = branch_service + ctx.obj["kbc_service"] = kbc_service ctx.obj["doctor_service"] = doctor_service ctx.obj["explorer_service"] = explorer_service + ctx.obj["version_service"] = version_service diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index fe498488..796440fc 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -8,13 +8,20 @@ """ import logging +import time from typing import Any from urllib.parse import quote, urlparse, urlunparse import httpx from . import __version__ -from .constants import DEFAULT_JOB_LIMIT, DEFAULT_TIMEOUT +from .constants import ( + DEFAULT_JOB_LIMIT, + DEFAULT_TIMEOUT, + STORAGE_JOB_MAX_WAIT, + STORAGE_JOB_POLL_INTERVAL, +) +from .errors import KeboolaApiError from .http_base import BaseHttpClient from .models import TokenVerifyResponse @@ -144,6 +151,83 @@ def get_config_detail(self, component_id: str, config_id: str) -> dict[str, Any] ) return response.json() + def _wait_for_storage_job(self, job: dict[str, Any]) -> dict[str, Any]: + """Poll a Storage API job until it reaches a terminal state. + + Branch create/delete are async operations that return a job object. + This method polls until the job completes or fails. + + Args: + job: Initial job response from POST/DELETE. + + Returns: + Completed job dict (with results on success). + + Raises: + KeboolaApiError: If the job fails or times out. + """ + job_id = job.get("id") + if job.get("status") in ("success", "error"): + return job + + deadline = time.monotonic() + STORAGE_JOB_MAX_WAIT + while time.monotonic() < deadline: + time.sleep(STORAGE_JOB_POLL_INTERVAL) + response = self._request("GET", f"/v2/storage/jobs/{job_id}") + job = response.json() + status = job.get("status") + if status == "success": + return job + if status == "error": + error_msg = job.get("error", {}).get("message", "Storage job failed") + raise KeboolaApiError( + message=error_msg, + status_code=500, + error_code="STORAGE_JOB_FAILED", + retryable=False, + ) + raise KeboolaApiError( + message=f"Storage job {job_id} did not complete within {STORAGE_JOB_MAX_WAIT}s", + status_code=504, + error_code="STORAGE_JOB_TIMEOUT", + retryable=True, + ) + + def create_dev_branch(self, name: str, description: str = "") -> dict[str, Any]: + """Create a new development branch (waits for async job to complete). + + The Storage API returns an async job. This method polls until the job + completes and returns the branch data from the job results. + + Args: + name: Branch name. + description: Optional branch description. + + Returns: + Branch dict with id, name, description, created, etc. + + Raises: + KeboolaApiError: If the API call or job fails. + """ + body: dict[str, str] = {"name": name} + if description: + body["description"] = description + response = self._request("POST", "/v2/storage/dev-branches", json=body) + job = self._wait_for_storage_job(response.json()) + return job.get("results", {}) + + def delete_dev_branch(self, branch_id: int) -> None: + """Delete a development branch (waits for async job to complete). + + Args: + branch_id: The branch ID to delete. + + Raises: + KeboolaApiError: If the API call or job fails. + """ + response = self._request("DELETE", f"/v2/storage/dev-branches/{branch_id}") + self._wait_for_storage_job(response.json()) + def list_dev_branches(self) -> list[dict[str, Any]]: """List development branches for the project. diff --git a/src/keboola_agent_cli/commands/branch.py b/src/keboola_agent_cli/commands/branch.py index 39f63502..f60dd413 100644 --- a/src/keboola_agent_cli/commands/branch.py +++ b/src/keboola_agent_cli/commands/branch.py @@ -1,4 +1,4 @@ -"""Branch commands - list development branches. +"""Branch commands - list, create, use, reset, delete, and merge development branches. Thin CLI layer: parses arguments, calls BranchService, formats output. No business logic belongs here. @@ -6,9 +6,9 @@ import typer -from ..errors import ConfigError +from ..errors import ConfigError, KeboolaApiError from ..output import format_branches_table -from ._helpers import emit_project_warnings, get_formatter, get_service +from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code branch_app = typer.Typer(help="Manage development branches") @@ -37,3 +37,204 @@ def branch_list( else: format_branches_table(formatter.console, result) emit_project_warnings(formatter, result) + + +@branch_app.command("create") +def branch_create( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias to create the branch in", + ), + name: str = typer.Option( + ..., + "--name", + help="Name for the new development branch", + ), + description: str = typer.Option( + "", + "--description", + help="Optional description for the branch", + ), +) -> None: + """Create a new development branch and auto-activate it. + + The created branch becomes the active branch for the project, + so subsequent tool calls will automatically use it. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "branch_service") + + try: + result = service.create_branch(alias=project, name=name, description=description) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] {d['message']}" + ), + ) + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + 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") + raise typer.Exit(code=5) from None + + +@branch_app.command("use") +def branch_use( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias to set the active branch for", + ), + branch: int = typer.Option( + ..., + "--branch", + help="Branch ID to activate", + ), +) -> None: + """Set an existing development branch as active. + + Validates the branch exists via the API before activating it. + Subsequent tool calls will automatically use this branch. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "branch_service") + + try: + result = service.set_active_branch(alias=project, branch_id=branch) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] {d['message']}" + ), + ) + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + 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") + raise typer.Exit(code=5) from None + + +@branch_app.command("reset") +def branch_reset( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias to reset the active branch for", + ), +) -> None: + """Reset the active branch back to main/production. + + Clears the active development branch so subsequent tool calls + operate on the main branch. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "branch_service") + + try: + result = service.reset_branch(alias=project) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] {d['message']}" + ), + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + + +@branch_app.command("delete") +def branch_delete( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias to delete the branch from", + ), + branch: int = typer.Option( + ..., + "--branch", + help="Branch ID to delete", + ), +) -> None: + """Delete a development branch. + + If the deleted branch was the active branch, it is automatically + reset to main/production. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "branch_service") + + try: + result = service.delete_branch(alias=project, branch_id=branch) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] {d['message']}" + ), + ) + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + 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") + raise typer.Exit(code=5) from None + + +@branch_app.command("merge") +def branch_merge( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Branch ID to merge (uses active branch if not set)", + ), +) -> None: + """Get the KBC UI merge URL for a development branch. + + Does NOT perform the merge via API. Instead, generates the URL + to the Keboola UI where you can review and merge safely. + After displaying the URL, resets the active branch to main. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "branch_service") + + try: + result = service.get_merge_url(alias=project, branch_id=branch) + formatter.output( + result, + lambda c, d: ( + c.print(f"\n[bold]Merge URL:[/bold] {d['url']}"), + c.print(f"\n{d['message']}"), + ), + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index d0930b72..abb90bbc 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -6,14 +6,13 @@ import typer +from ..constants import VALID_COMPONENT_TYPES from ..errors import ConfigError, KeboolaApiError from ..output import format_config_detail, format_configs_table from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code config_app = typer.Typer(help="Browse and inspect configurations") -VALID_COMPONENT_TYPES = ["extractor", "writer", "transformation", "application"] - @config_app.command("list") def config_list( diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index c06a9453..646007e5 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -188,17 +188,85 @@ my-l1-transform: L1 unclassified-project: L0 # TODO: assign correct tier +### LLM Export (Project Context for AI) + + kbagent llm export --project ALIAS [--with-samples] [--sample-limit N] [--max-samples N] + Export project to "Twin Format" -- an AI-optimized directory of JSON files + containing table schemas, transformation SQL code, internal lineage graph, + job statistics, and component configurations. + Output is written to ./${{ALIAS}}/ directory (created automatically). + Requires the kbc CLI binary (brew install keboola-cli). + + After export, start by reading ./${{ALIAS}}/ai/AGENT_INSTRUCTIONS.md -- it explains + the directory structure, how to interpret each file, and recommended workflows. + + The twin format includes: + - ai/AGENT_INSTRUCTIONS.md -- how to read and use the exported data + - buckets/*/metadata.json -- table schemas, columns, row counts + - transformations/*/metadata.json -- SQL/Python code, input/output tables + - indices/graph.jsonl -- internal lineage (table->transformation->table) + - jobs/index.json -- job execution stats + - components/*/ -- extractor/writer configurations + + Use --with-samples to include actual data samples (CSV) from tables. + + Examples: + kbagent llm export --project prod + kbagent llm export --project prod --with-samples --sample-limit 50 + +### Version Information + + kbagent version + Show kbagent version and check for updates of dependencies (kbc, keboola-mcp-server). + Example: + kbagent --json version + ### Development Branches kbagent branch list [--project NAME] List development branches from connected projects. --project can be repeated to query multiple projects. - Each branch shows: ID, name, whether it is the default branch, and creation date. + Each branch shows: ID, name, whether it is the default branch, active marker, and creation date. Examples: kbagent --json branch list kbagent --json branch list --project prod kbagent --json branch list --project prod --project dev + kbagent branch create --project ALIAS --name "branch-name" [--description "..."] + Create a new development branch and auto-activate it. + Branch creation is an async operation on the Keboola API -- the CLI waits + for the job to complete (typically 1-3 seconds) before returning. + The created branch becomes the active branch for the project, so subsequent + tool calls will automatically use it (no need to pass --branch every time). + Example: + kbagent --json branch create --project prod --name "fix-transform-x" + + kbagent branch use --project ALIAS --branch ID + Set an existing development branch as active. + Validates the branch exists via the API before activating it. + Example: + kbagent --json branch use --project prod --branch 456 + + kbagent branch reset --project ALIAS + Reset the active branch back to main/production. + Subsequent tool calls will operate on the main branch. + Example: + kbagent --json branch reset --project prod + + kbagent branch delete --project ALIAS --branch ID + Delete a development branch via API (async operation, CLI waits for completion). + If the deleted branch was active, it is automatically reset to main. + Example: + kbagent --json branch delete --project prod --branch 456 + + kbagent branch merge --project ALIAS [--branch ID] + Get the KBC UI merge URL for a development branch. + Does NOT merge via API -- generates the URL for safe review and merge in the UI. + If --branch is not set, uses the active branch from config. + After displaying the URL, resets the active branch to main. + Example: + kbagent --json branch merge --project prod + ### Utility Commands kbagent context @@ -281,7 +349,29 @@ KBC_STORAGE_API_URL - Default stack URL (fallback for --url in project add, org setup) KBC_MANAGE_API_TOKEN - Manage API token (for org setup) -11. Setting up projects -- two approaches: +11. Branch workflow -- develop on a branch without passing --branch every time: + kbagent --json branch create --project prod --name "fix-transform-x" + # ^ creates the branch AND sets it as "active" for the project + # all subsequent tool calls on this project auto-use this branch + kbagent --json tool call list_configs --project prod # auto-uses active branch + kbagent --json tool call update_sql_transformation --project prod --input '{{...}}' # auto-uses active branch + kbagent --json branch merge --project prod + # ^ does NOT merge! Returns a URL to Keboola UI where you review and merge manually. + # After displaying the URL, resets the active branch back to main. + +12. Branch create and delete are async operations on the Keboola API. + The CLI handles this transparently -- it waits for the async job to complete + before returning (typically 1-3 seconds). You do NOT need to poll or retry. + If the job takes too long (>60s), the CLI returns an error. + +13. Project context for AI -- get a full offline snapshot of a project: + kbagent llm export --project prod + # Creates ./prod/ directory with Twin Format JSON files + # FIRST read ./prod/ai/AGENT_INSTRUCTIONS.md -- it explains the structure + # and how to interpret each file (schemas, transformations, lineage, etc.) + # This is much faster than querying each piece via MCP tool calls. + +14. Setting up projects -- two approaches: a) Single project (you have a Storage API token): kbagent --json project add --alias my-proj --url https://connection.keboola.com --token 901-xxxxx diff --git a/src/keboola_agent_cli/commands/explorer.py b/src/keboola_agent_cli/commands/explorer.py index c6fa99ec..d5b706cf 100644 --- a/src/keboola_agent_cli/commands/explorer.py +++ b/src/keboola_agent_cli/commands/explorer.py @@ -5,7 +5,6 @@ """ from pathlib import Path -from typing import Optional import typer @@ -18,12 +17,12 @@ @explorer_app.callback(invoke_without_command=True) def explorer( ctx: typer.Context, - project: Optional[list[str]] = typer.Option( + project: list[str] | None = typer.Option( None, "--project", help="Project alias(es) to include (repeatable, default: all)", ), - output_dir: Optional[Path] = typer.Option( + output_dir: Path | None = typer.Option( None, "--output-dir", help="Directory to write catalog/orchestration files (default: kbc-explorer/)", @@ -33,7 +32,7 @@ def explorer( "--job-limit", help="Max jobs per project for statistics (default: 500)", ), - tiers: Optional[Path] = typer.Option( + tiers: Path | None = typer.Option( None, "--tiers", help="Path to YAML tier config file for project tier assignments", diff --git a/src/keboola_agent_cli/commands/job.py b/src/keboola_agent_cli/commands/job.py index 1f4d59af..4ac71f18 100644 --- a/src/keboola_agent_cli/commands/job.py +++ b/src/keboola_agent_cli/commands/job.py @@ -6,15 +6,13 @@ import typer -from ..constants import DEFAULT_JOB_LIMIT, MAX_JOB_LIMIT +from ..constants import DEFAULT_JOB_LIMIT, MAX_JOB_LIMIT, VALID_STATUSES from ..errors import ConfigError, KeboolaApiError from ..output import format_job_detail, format_jobs_table from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code job_app = typer.Typer(help="Browse job history") -VALID_STATUSES = ["processing", "terminated", "cancelled", "success", "error"] - @job_app.command("list") def job_list( diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 12f71c8f..461984f5 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -31,9 +31,12 @@ def _format_project_table(console: Console, projects: list[dict[str, Any]]) -> N table.add_column("Stack URL") table.add_column("Token", style="dim") table.add_column("Default", justify="center") + table.add_column("Branch", justify="center") for p in projects: default_marker = "*" if p.get("is_default") else "" + branch_id = p.get("active_branch_id") + branch_display = str(branch_id) if branch_id is not None else "[dim]main[/dim]" table.add_row( p["alias"], p.get("project_name", ""), @@ -41,6 +44,7 @@ def _format_project_table(console: Console, projects: list[dict[str, Any]]) -> N p["stack_url"], p["token"], default_marker, + branch_display, ) console.print(table) @@ -58,6 +62,7 @@ def _format_status_table(console: Console, statuses: list[dict[str, Any]]) -> No table.add_column("Response Time", justify="right") table.add_column("Project Name") table.add_column("Stack URL") + table.add_column("Branch", justify="center") for s in statuses: if s["status"] == "ok": @@ -65,12 +70,15 @@ def _format_status_table(console: Console, statuses: list[dict[str, Any]]) -> No else: status_str = f"[bold red]ERROR[/bold red]: {s.get('error', 'Unknown')}" response_time = f"{s.get('response_time_ms', 0)}ms" + branch_id = s.get("active_branch_id") + branch_display = str(branch_id) if branch_id is not None else "[dim]main[/dim]" table.add_row( s["alias"], status_str, response_time, s.get("project_name", ""), s["stack_url"], + branch_display, ) console.print(table) diff --git a/src/keboola_agent_cli/commands/tool.py b/src/keboola_agent_cli/commands/tool.py index 9f49a915..6ecbf972 100644 --- a/src/keboola_agent_cli/commands/tool.py +++ b/src/keboola_agent_cli/commands/tool.py @@ -8,13 +8,93 @@ import typer +from ..config_store import ConfigStore from ..errors import ConfigError -from ..output import format_tool_result, format_tools_table +from ..output import OutputFormatter, format_tool_result, format_tools_table from ._helpers import emit_project_warnings, get_formatter, get_service tool_app = typer.Typer(help="MCP tools - interact with Keboola via MCP server") +def _validate_branch_requires_project( + formatter: OutputFormatter, + branch: int | None, + project: str | None, +) -> None: + """Validate that --branch is always accompanied by --project. + + Raises: + typer.Exit: With code 2 if branch is set but project is not. + """ + if branch is not None and not project: + formatter.error( + message="--branch requires --project (branch ID is per-project)", + error_code="INVALID_ARGUMENT", + ) + raise typer.Exit(code=2) from None + + +def _resolve_branch( + config_store: ConfigStore, + formatter: OutputFormatter, + project: str | None, + branch: int | None, +) -> tuple[str | None, str | None]: + """Resolve the effective branch and project for tool commands. + + Resolution order: + 1. Explicit --branch always wins (no change) + 2. If no --branch, check active_branch_id from config for the resolved project + 3. If active branch found, use it and print info message in human mode + + When an active branch is resolved from config, --project is also set + to the project alias (branch is per-project). + + Args: + config_store: Config store for looking up project configs. + formatter: Output formatter for info messages. + project: Explicit --project alias or None. + branch: Explicit --branch integer or None. + + Returns: + Tuple of (effective_project, effective_branch_as_str). + """ + if branch is not None: + return project, str(branch) + + if project is not None: + # Specific project requested - check its active branch + proj_config = config_store.get_project(project) + if proj_config and proj_config.active_branch_id is not None: + branch_id_str = str(proj_config.active_branch_id) + if not formatter.json_mode: + formatter.err_console.print( + f"[bold blue]Info:[/bold blue] Using active branch " + f"(ID: {proj_config.active_branch_id}) for project '{project}'" + ) + return project, branch_id_str + else: + # No project specified - check all projects for an active branch. + # If exactly one project has an active branch, use it to avoid ambiguity. + config = config_store.load() + active_projects = [ + (alias, proj) + for alias, proj in config.projects.items() + if proj.active_branch_id is not None + ] + if len(active_projects) == 1: + alias, proj = active_projects[0] + branch_id_str = str(proj.active_branch_id) + if not formatter.json_mode: + formatter.err_console.print( + f"[bold blue]Info:[/bold blue] Using active branch " + f"(ID: {proj.active_branch_id}) for project '{alias}'" + ) + return alias, branch_id_str + + return project, None + + @tool_app.command("list") def tool_list( ctx: typer.Context, @@ -23,17 +103,23 @@ def tool_list( "--project", help="Project alias to query tools from (uses first available if not set)", ), - branch: str | None = typer.Option( + branch: int | None = typer.Option( None, "--branch", - help="Development branch ID (requires --project)", + help="Development branch ID (requires --project or active branch)", ), ) -> None: """List available MCP tools from the keboola-mcp-server.""" formatter = get_formatter(ctx) service = get_service(ctx, "mcp_service") + config_store: ConfigStore = ctx.obj["config_store"] + + _validate_branch_requires_project(formatter, branch, project) + + # Auto-resolve active branch from config + project, branch_str = _resolve_branch(config_store, formatter, project, branch) - if branch and not project: + if branch_str and not project: formatter.error( message="--branch requires --project (branch ID is per-project)", error_code="INVALID_ARGUMENT", @@ -43,7 +129,7 @@ def tool_list( aliases = [project] if project else None try: - result = service.list_tools(aliases=aliases, branch_id=branch) + result = service.list_tools(aliases=aliases, branch_id=branch_str) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") raise typer.Exit(code=5) from None @@ -69,10 +155,10 @@ def tool_call( "--input", help="Tool input as JSON string (e.g. '{\"query\": \"test\"}')", ), - branch: str | None = typer.Option( + branch: int | None = typer.Option( None, "--branch", - help="Development branch ID (requires --project, forces single-project mode)", + help="Development branch ID (forces single-project mode)", ), ) -> None: """Call an MCP tool on keboola-mcp-server. @@ -83,13 +169,19 @@ def tool_call( Write tools (create_*, update_*, delete_*, add_*) run on a single project. Use --project to specify the target, or the default project is used. - Use --branch to scope the tool call to a specific development branch. - This forces single-project mode (branch ID is per-project). + If an active branch is set for the project, it is used automatically. + Use --branch to override or scope the call to a specific development branch. """ formatter = get_formatter(ctx) service = get_service(ctx, "mcp_service") + config_store: ConfigStore = ctx.obj["config_store"] + + _validate_branch_requires_project(formatter, branch, project) + + # Auto-resolve active branch from config + project, branch_str = _resolve_branch(config_store, formatter, project, branch) - if branch and not project: + if branch_str and not project: formatter.error( message="--branch requires --project (branch ID is per-project)", error_code="INVALID_ARGUMENT", @@ -121,7 +213,7 @@ def tool_call( tool_name=tool_name, tool_input=parsed_input, aliases=[project] if project else None, - branch_id=branch, + branch_id=branch_str, ) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") @@ -144,7 +236,7 @@ def tool_call( tool_name=tool_name, tool_input=parsed_input, alias=project, - branch_id=branch, + branch_id=branch_str, ) except ConfigError as exc: formatter.error(message=exc.message, error_code="CONFIG_ERROR") diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py index b4d4c910..72ad25eb 100644 --- a/src/keboola_agent_cli/config_store.py +++ b/src/keboola_agent_cli/config_store.py @@ -2,10 +2,12 @@ Manages reading and writing of config.json with project connections. File permissions are set to 0600 to protect stored tokens. +Uses atomic writes to prevent TOCTOU race conditions. """ import json import logging +import os from pathlib import Path import platformdirs @@ -86,6 +88,8 @@ def save(self, config: AppConfig) -> None: """Save configuration to disk with secure file permissions (0600). Creates the config directory if it does not exist. + Uses atomic write to ensure the file is never on disk with + permissions broader than 0600 (prevents TOCTOU race condition). Raises: ConfigError: If the file cannot be written. @@ -94,8 +98,18 @@ def save(self, config: AppConfig) -> None: try: self._config_dir.mkdir(parents=True, exist_ok=True, mode=0o700) json_str = config.model_dump_json(indent=2) - self._config_path.write_text(json_str + "\n", encoding="utf-8") - self._config_path.chmod(0o600) + data = (json_str + "\n").encode("utf-8") + + # Write to a temp file created with 0600 from the start, + # then atomically rename into place. This avoids any window + # where the config file exists with world-readable permissions. + tmp_path = self._config_path.with_suffix(".tmp") + fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, data) + finally: + os.close(fd) + os.replace(str(tmp_path), str(self._config_path)) except OSError as exc: raise ConfigError(f"Cannot write config file {self._config_path}: {exc}") from exc @@ -143,6 +157,22 @@ def get_project(self, alias: str) -> ProjectConfig | None: config = self.load() return config.projects.get(alias) + def set_project_branch(self, alias: str, branch_id: int | None) -> None: + """Set or clear the active development branch for a project. + + Args: + alias: The project alias. + branch_id: Branch ID to activate, or None to reset to main. + + Raises: + ConfigError: If the alias does not exist. + """ + config = self.load() + if alias not in config.projects: + raise ConfigError(f"Project '{alias}' not found.") + config.projects[alias].active_branch_id = branch_id + self.save(config) + def edit_project(self, alias: str, **kwargs: str | int | None) -> None: """Update fields on an existing project. diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index f1870a77..216e76d4 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -37,6 +37,10 @@ DEFAULT_MCP_TOOL_TIMEOUT: int = 60 DEFAULT_MCP_INIT_TIMEOUT: int = 30 +# --- Storage Job Polling --- +STORAGE_JOB_POLL_INTERVAL: float = 1.0 # seconds between polls +STORAGE_JOB_MAX_WAIT: float = 60.0 # max seconds to wait for a storage job + # --- Parallel Workers --- MAX_PARALLEL_WORKERS_LIMIT: int = 100 @@ -47,3 +51,15 @@ ENV_KBC_MANAGE_API_TOKEN: str = "KBC_MANAGE_API_TOKEN" ENV_MCP_TOOL_TIMEOUT: str = "KBAGENT_MCP_TOOL_TIMEOUT" ENV_MCP_INIT_TIMEOUT: str = "KBAGENT_MCP_INIT_TIMEOUT" + +# --- Version Check --- +VERSION_CHECK_TIMEOUT: float = 4.0 # seconds for fetching latest version from remote +KBC_SUBPROCESS_TIMEOUT: float = 5.0 # seconds for running kbc/mcp subprocess commands +KBC_GITHUB_RELEASES_URL: str = ( + "https://api.github.com/repos/keboola/keboola-as-code/releases/latest" +) +MCP_PYPI_URL: str = "https://pypi.org/pypi/keboola-mcp-server/json" + +# --- Domain Validation Constants --- +VALID_COMPONENT_TYPES: list[str] = ["extractor", "writer", "transformation", "application"] +VALID_STATUSES: list[str] = ["processing", "terminated", "cancelled", "success", "error"] diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 7b0f831d..1933dd07 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -14,6 +14,10 @@ class ProjectConfig(BaseModel): default="", description="Human-readable project name (populated on add)" ) project_id: int | None = Field(default=None, description="Keboola project ID (populated on add)") + active_branch_id: int | None = Field( + default=None, + description="Active development branch ID (None = main/production branch)", + ) @field_validator("stack_url") @classmethod diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py index 5a8d1abc..1d3f89e3 100644 --- a/src/keboola_agent_cli/output.py +++ b/src/keboola_agent_cli/output.py @@ -712,11 +712,15 @@ def format_branches_table(console: Console, data: dict[str, Any]) -> None: console.print("No branches retrieved (all projects failed).") return + active_branches = data.get("active_branches", {}) + table = Table(title="Development Branches") table.add_column("Project", style="bold magenta") table.add_column("Branch ID", justify="right") table.add_column("Name", style="bold cyan") table.add_column("Default", justify="center") + table.add_column("Active", justify="center") + table.add_column("Description", style="dim", max_width=40) table.add_column("Created", style="dim") prev_alias = None @@ -725,6 +729,16 @@ def format_branches_table(console: Console, data: dict[str, Any]) -> None: is_default = branch.get("isDefault", False) default_display = "[green]yes[/green]" if is_default else "[dim]no[/dim]" + branch_id = branch.get("id") + active_id = active_branches.get(alias) + # Compare as int to handle potential type mismatch from API + is_active = ( + branch_id is not None + and active_id is not None + and int(branch_id) == int(active_id) + ) + active_display = "[bold green]>>>[/bold green]" if is_active else "" + display_alias = alias if alias != prev_alias else "" prev_alias = alias @@ -733,6 +747,8 @@ def format_branches_table(console: Console, data: dict[str, Any]) -> None: str(branch.get("id", "")), branch.get("name", ""), default_display, + active_display, + branch.get("description", ""), branch.get("created", ""), ) diff --git a/src/keboola_agent_cli/services/branch_service.py b/src/keboola_agent_cli/services/branch_service.py index fd41a7be..1fa3b7a8 100644 --- a/src/keboola_agent_cli/services/branch_service.py +++ b/src/keboola_agent_cli/services/branch_service.py @@ -1,23 +1,27 @@ -"""Branch listing service - business logic for listing development branches. +"""Branch service - business logic for branch lifecycle management. Orchestrates multi-project branch retrieval in parallel, annotates with -project alias, and aggregates results. +project alias, and aggregates results. Provides create, activate, reset, +delete, and merge URL generation for development branches. """ from typing import Any -from ..errors import KeboolaApiError +from ..errors import ConfigError, KeboolaApiError from ..models import ProjectConfig from .base import BaseService class BranchService(BaseService): - """Business logic for listing Keboola development branches. + """Business logic for managing Keboola development branches. Supports multi-project aggregation: queries multiple projects in parallel using ThreadPoolExecutor, collects results, and reports per-project errors without stopping others. + Provides branch lifecycle operations: create, activate (use), reset, + delete, and merge URL generation. + Uses dependency injection for config_store and client_factory. """ @@ -78,6 +82,9 @@ def list_branches( flattens them into a unified list. Per-project errors are collected but do not stop other projects from being queried. + Includes active_branches dict mapping alias -> active_branch_id for + display purposes. + Args: aliases: Project aliases to query. None means all projects. @@ -87,12 +94,18 @@ def list_branches( id, name, isDefault, created, description - "errors": list of error dicts with project_alias, error_code, message + - "active_branches": dict mapping alias -> active_branch_id Raises: ConfigError: If a specified alias is not found (before querying). """ projects = self.resolve_projects(aliases) + # Collect active branch IDs for display + active_branches: dict[str, int | None] = {} + for alias, project in projects.items(): + active_branches[alias] = project.active_branch_id + def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: return self._fetch_project_branches(alias, project) @@ -109,4 +122,223 @@ def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: ) errors.sort(key=lambda e: e.get("project_alias", "")) - return {"branches": all_branches, "errors": errors} + return { + "branches": all_branches, + "errors": errors, + "active_branches": active_branches, + } + + def create_branch( + self, + alias: str, + name: str, + description: str = "", + ) -> dict[str, Any]: + """Create a new development branch and auto-activate it. + + Args: + alias: Project alias. + name: Branch name. + description: Optional branch description. + + Returns: + Dict with branch details and activation info. + + Raises: + ConfigError: If the project alias is not found. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + + client = self._client_factory(project.stack_url, project.token) + try: + # create_dev_branch waits for the async job and returns branch data + # from job.results (contains the real branch ID) + branch_data = client.create_dev_branch(name=name, description=description) + finally: + client.close() + + branch_id = int(branch_data["id"]) + + # Auto-activate the created branch + self._config_store.set_project_branch(alias, branch_id) + + return { + "project_alias": alias, + "branch_id": branch_id, + "branch_name": branch_data.get("name", name), + "description": branch_data.get("description", description), + "created": branch_data.get("created", ""), + "activated": True, + "message": ( + f"Branch '{name}' (ID: {branch_id}) created and activated " + f"for project '{alias}'." + ), + } + + def set_active_branch(self, alias: str, branch_id: int) -> dict[str, Any]: + """Validate and set an existing branch as active. + + Calls the API to verify the branch exists before setting it. + + Args: + alias: Project alias. + branch_id: Branch ID to activate. + + Returns: + Dict with activation details. + + Raises: + ConfigError: If the project alias is not found or branch does not exist. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + + client = self._client_factory(project.stack_url, project.token) + try: + raw_branches = client.list_dev_branches() + finally: + client.close() + + # Find the branch by ID + target_branch = None + for branch in raw_branches: + if branch.get("id") == branch_id: + target_branch = branch + break + + if target_branch is None: + raise ConfigError( + f"Branch ID {branch_id} not found in project '{alias}'. " + f"Use 'kbagent branch list --project {alias}' to see available branches." + ) + + self._config_store.set_project_branch(alias, branch_id) + + branch_name = target_branch.get("name", "") + return { + "project_alias": alias, + "branch_id": branch_id, + "branch_name": branch_name, + "message": ( + f"Active branch set to '{branch_name}' (ID: {branch_id}) " + f"for project '{alias}'." + ), + } + + def reset_branch(self, alias: str) -> dict[str, Any]: + """Clear the active branch, reverting to the main/production branch. + + Args: + alias: Project alias. + + Returns: + Dict confirming the reset. + + Raises: + ConfigError: If the project alias is not found. + """ + projects = self.resolve_projects([alias]) + previous_branch = projects[alias].active_branch_id + + self._config_store.set_project_branch(alias, None) + + return { + "project_alias": alias, + "previous_branch_id": previous_branch, + "message": ( + f"Active branch reset to main for project '{alias}'." + ), + } + + def delete_branch(self, alias: str, branch_id: int) -> dict[str, Any]: + """Delete a development branch via API. Auto-resets if it was active. + + Args: + alias: Project alias. + branch_id: Branch ID to delete. + + Returns: + Dict confirming the deletion. + + Raises: + ConfigError: If the project alias is not found. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + + client = self._client_factory(project.stack_url, project.token) + try: + client.delete_dev_branch(branch_id) + finally: + client.close() + + # Auto-reset if the deleted branch was active + was_active = project.active_branch_id == branch_id + if was_active: + self._config_store.set_project_branch(alias, None) + + return { + "project_alias": alias, + "branch_id": branch_id, + "was_active": was_active, + "message": ( + f"Branch ID {branch_id} deleted from project '{alias}'." + + (" Active branch reset to main." if was_active else "") + ), + } + + def get_merge_url(self, alias: str, branch_id: int | None = None) -> dict[str, Any]: + """Generate KBC UI merge URL for a development branch. + + Does not call any API. Constructs the URL from stored project config. + If no branch_id is provided, uses the active branch from config. + After generating the URL, resets the active branch to main. + + Args: + alias: Project alias. + branch_id: Branch ID. If None, uses active_branch_id from config. + + Returns: + Dict with merge URL and instructions. + + Raises: + ConfigError: If project not found or no branch ID available. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + + effective_branch_id = branch_id if branch_id is not None else project.active_branch_id + if effective_branch_id is None: + raise ConfigError( + f"No branch specified and no active branch set for project '{alias}'. " + f"Use --branch ID or set an active branch with 'kbagent branch use'." + ) + + if project.project_id is None: + raise ConfigError( + f"Project '{alias}' has no project_id stored. " + "Re-add the project with 'kbagent project edit' to populate it." + ) + + stack_url = project.stack_url.rstrip("/") + merge_url = ( + f"{stack_url}/admin/projects/{project.project_id}" + f"/branch/{effective_branch_id}/development-overview" + ) + + # Reset active branch to main after generating merge URL + self._config_store.set_project_branch(alias, None) + + return { + "project_alias": alias, + "branch_id": effective_branch_id, + "url": merge_url, + "message": ( + f"Open this URL to review and merge branch {effective_branch_id} " + f"in project '{alias}'. Active branch has been reset to main." + ), + } diff --git a/src/keboola_agent_cli/services/explorer_service.py b/src/keboola_agent_cli/services/explorer_service.py index b8dfcf12..9be91f5d 100644 --- a/src/keboola_agent_cli/services/explorer_service.py +++ b/src/keboola_agent_cli/services/explorer_service.py @@ -10,7 +10,7 @@ import logging import os import webbrowser -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -19,7 +19,8 @@ from .. import __version__ from ..config_store import ConfigStore -from ..errors import ConfigError, KeboolaApiError +from ..constants import MAX_JOB_LIMIT +from ..errors import ConfigError from .base import BaseService, ClientFactory from .config_service import ConfigService from .job_service import JobService @@ -27,8 +28,10 @@ logger = logging.getLogger(__name__) -DEFAULT_JOB_LIMIT = 500 -DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parent.parent.parent.parent / "kbc-explorer" + +def _default_output_dir() -> Path: + """Return the default output directory for explorer files (relative to CWD).""" + return Path.cwd() / "kbc-explorer" def _assign_tier(alias: str, tier_map: dict[str, str] | None = None) -> tuple[str, bool]: @@ -267,7 +270,7 @@ def generate( self, aliases: list[str] | None = None, output_dir: Path | None = None, - job_limit: int = DEFAULT_JOB_LIMIT, + job_limit: int = MAX_JOB_LIMIT, open_browser: bool = True, tiers_config: Path | None = None, ) -> dict[str, Any]: @@ -284,7 +287,7 @@ def generate( Dict with generation summary and any errors. """ if output_dir is None: - output_dir = DEFAULT_OUTPUT_DIR + output_dir = _default_output_dir() projects = self.resolve_projects(aliases) if not projects: @@ -463,7 +466,7 @@ def generate( catalog = { "metadata": { - "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "tool": f"kbagent CLI v{__version__}", "stack_url": stack_url, "description": description, diff --git a/src/keboola_agent_cli/services/lineage_service.py b/src/keboola_agent_cli/services/lineage_service.py index 46ef3667..d616c88f 100644 --- a/src/keboola_agent_cli/services/lineage_service.py +++ b/src/keboola_agent_cli/services/lineage_service.py @@ -5,12 +5,15 @@ all projects in parallel using BaseService._run_parallel(). """ +import logging from typing import Any from ..errors import KeboolaApiError from ..models import ProjectConfig from .base import BaseService +logger = logging.getLogger(__name__) + class LineageService(BaseService): """Business logic for cross-project data lineage via bucket sharing. @@ -98,6 +101,9 @@ def get_lineage(self, aliases: list[str] | None = None) -> dict[str, Any]: # Build project_id -> alias lookup for cross-referencing project_id_to_alias: dict[int, str] = {} for alias, project in projects.items(): + if project.project_id is None: + logger.warning("Project '%s' has no project_id; skipping lineage", alias) + continue project_id_to_alias[project.project_id] = alias all_shared_buckets: list[dict[str, Any]] = [] diff --git a/src/keboola_agent_cli/services/mcp_service.py b/src/keboola_agent_cli/services/mcp_service.py index 4549ed78..330e803a 100644 --- a/src/keboola_agent_cli/services/mcp_service.py +++ b/src/keboola_agent_cli/services/mcp_service.py @@ -28,14 +28,14 @@ logger = logging.getLogger(__name__) -# Timeout for individual MCP operations (seconds) -MCP_TOOL_TIMEOUT_SECONDS = int( - os.environ.get(ENV_MCP_TOOL_TIMEOUT, DEFAULT_MCP_TOOL_TIMEOUT) -) -# Timeout for MCP session initialization (seconds) -MCP_INIT_TIMEOUT_SECONDS = int( - os.environ.get(ENV_MCP_INIT_TIMEOUT, DEFAULT_MCP_INIT_TIMEOUT) -) +def _get_tool_timeout() -> int: + """Get MCP tool timeout (seconds), reading env var at call time.""" + return int(os.environ.get(ENV_MCP_TOOL_TIMEOUT, DEFAULT_MCP_TOOL_TIMEOUT)) + + +def _get_init_timeout() -> int: + """Get MCP init timeout (seconds), reading env var at call time.""" + return int(os.environ.get(ENV_MCP_INIT_TIMEOUT, DEFAULT_MCP_INIT_TIMEOUT)) # Prefixes that indicate write/mutating tools WRITE_PREFIXES = ( @@ -69,7 +69,7 @@ def detect_mcp_server_command() -> list[str] | None: """Detect the best way to run keboola-mcp-server. Checks in order: - 1. uvx keboola_mcp_server (if uvx is available) + 1. uvx keboola_mcp_server@latest (if uvx is available -- always latest version) 2. keboola_mcp_server (if installed as standalone command) 3. python -m keboola_mcp_server (last resort) @@ -77,7 +77,7 @@ def detect_mcp_server_command() -> list[str] | None: List of command parts, or None if no method is available. """ if shutil.which("uvx"): - return ["uvx", "keboola_mcp_server"] + return ["uvx", "keboola_mcp_server@latest"] if shutil.which("keboola_mcp_server"): return ["keboola_mcp_server"] if shutil.which("python"): @@ -144,16 +144,16 @@ async def _connect_and_list_tools( exit_stack.enter_async_context( stdio_client(params, errlog=subprocess.DEVNULL) ), - timeout=MCP_INIT_TIMEOUT_SECONDS, + timeout=_get_init_timeout(), ) session = await exit_stack.enter_async_context( ClientSession(read_stream, write_stream) ) - await asyncio.wait_for(session.initialize(), timeout=MCP_INIT_TIMEOUT_SECONDS) + await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout()) response = await asyncio.wait_for( - session.list_tools(), timeout=MCP_TOOL_TIMEOUT_SECONDS + session.list_tools(), timeout=_get_tool_timeout() ) tools = [] @@ -199,13 +199,13 @@ async def _open_session( exit_stack.enter_async_context( stdio_client(params, errlog=subprocess.DEVNULL) ), - timeout=MCP_INIT_TIMEOUT_SECONDS, + timeout=_get_init_timeout(), ) session = await exit_stack.enter_async_context( ClientSession(read_stream, write_stream) ) - await asyncio.wait_for(session.initialize(), timeout=MCP_INIT_TIMEOUT_SECONDS) + await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout()) return session @@ -233,7 +233,7 @@ async def _connect_and_call_tool( result = await asyncio.wait_for( session.call_tool(tool_name, tool_input), - timeout=MCP_TOOL_TIMEOUT_SECONDS, + timeout=_get_tool_timeout(), ) return { @@ -278,7 +278,7 @@ async def _connect_and_auto_expand( # Step 1: Call resolve tool (e.g. list_buckets) resolve_result = await asyncio.wait_for( session.call_tool(resolve_tool, {}), - timeout=MCP_TOOL_TIMEOUT_SECONDS, + timeout=_get_tool_timeout(), ) if resolve_result.isError: @@ -302,7 +302,7 @@ async def _connect_and_auto_expand( call_input = {**tool_input, param_name: item_id} result = await asyncio.wait_for( session.call_tool(tool_name, call_input), - timeout=MCP_TOOL_TIMEOUT_SECONDS, + timeout=_get_tool_timeout(), ) content = _parse_content(result) diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py index 4b2e6ea2..bd2ed7a3 100644 --- a/src/keboola_agent_cli/services/org_service.py +++ b/src/keboola_agent_cli/services/org_service.py @@ -101,7 +101,7 @@ def setup_organization( # Load existing config to check for already-registered projects config = self._config_store.load() existing_project_ids = { - p.project_id for p in config.projects.values() + p.project_id for p in config.projects.values() if p.project_id is not None } # Track used aliases for uniqueness diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index 86d2e8f5..1b43bc7a 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -153,6 +153,7 @@ def list_projects(self) -> list[dict[str, Any]]: "stack_url": project.stack_url, "token": mask_token(project.token), "is_default": alias == config.default_project, + "active_branch_id": project.active_branch_id, } ) return result @@ -174,6 +175,7 @@ def _check_project_status( "alias": alias, "stack_url": project.stack_url, "token": mask_token(project.token), + "active_branch_id": project.active_branch_id, } client = self._client_factory(project.stack_url, project.token) diff --git a/tests/test_branch_service.py b/tests/test_branch_service.py index 8fdd3f3e..2f7212d6 100644 --- a/tests/test_branch_service.py +++ b/tests/test_branch_service.py @@ -176,3 +176,305 @@ def test_list_branches_unexpected_error(self, tmp_config_dir: Path) -> None: assert len(result["errors"]) == 1 assert result["errors"][0]["error_code"] == "UNEXPECTED_ERROR" assert "Something broke" in result["errors"][0]["message"] + + +class TestCreateBranch: + """Tests for BranchService.create_branch().""" + + def test_create_branch_success(self, tmp_config_dir: Path) -> None: + """create_branch returns branch data and auto-activates it in config.""" + mock_client = MagicMock() + # create_dev_branch now waits for async job and returns branch data + # from job.results (with the real branch ID) + mock_client.create_dev_branch.return_value = { + "id": 777, + "name": "my-feature", + "description": "A feature branch", + "created": "2025-07-01T12:00:00Z", + } + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = svc.create_branch(alias="prod", name="my-feature", description="A feature branch") + + assert result["project_alias"] == "prod" + assert result["branch_id"] == 777 + assert result["branch_name"] == "my-feature" + assert result["activated"] is True + + # Verify auto-activation persisted in config + project = store.get_project("prod") + assert project is not None + assert project.active_branch_id == 777 + + mock_client.create_dev_branch.assert_called_once_with( + name="my-feature", description="A feature branch" + ) + + def test_create_branch_unknown_project(self, tmp_config_dir: Path) -> None: + """create_branch raises ConfigError for an unknown project alias.""" + store = setup_single_project(tmp_config_dir) + svc = BranchService(config_store=store) + + with pytest.raises(ConfigError, match="Project 'nonexistent' not found"): + svc.create_branch(alias="nonexistent", name="some-branch") + + def test_create_branch_api_error(self, tmp_config_dir: Path) -> None: + """create_branch propagates KeboolaApiError from the client.""" + mock_client = MagicMock() + mock_client.create_dev_branch.side_effect = KeboolaApiError( + message="Branch name already exists", + error_code="CONFLICT", + status_code=409, + retryable=False, + ) + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError, match="Branch name already exists"): + svc.create_branch(alias="prod", name="duplicate-branch") + + +class TestSetActiveBranch: + """Tests for BranchService.set_active_branch().""" + + def test_set_active_branch_success(self, tmp_config_dir: Path) -> None: + """set_active_branch validates branch exists and stores it in config.""" + mock_client = MagicMock() + mock_client.list_dev_branches.return_value = SAMPLE_BRANCHES + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = svc.set_active_branch(alias="prod", branch_id=456) + + assert result["project_alias"] == "prod" + assert result["branch_id"] == 456 + assert result["branch_name"] == "feature-x" + + # Verify config was updated + project = store.get_project("prod") + assert project is not None + assert project.active_branch_id == 456 + + def test_set_active_branch_not_found(self, tmp_config_dir: Path) -> None: + """set_active_branch raises ConfigError when branch ID does not exist.""" + mock_client = MagicMock() + mock_client.list_dev_branches.return_value = SAMPLE_BRANCHES + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(ConfigError, match="Branch ID 999 not found"): + svc.set_active_branch(alias="prod", branch_id=999) + + def test_set_active_branch_api_error(self, tmp_config_dir: Path) -> None: + """set_active_branch propagates KeboolaApiError from the client.""" + mock_client = MagicMock() + mock_client.list_dev_branches.side_effect = KeboolaApiError( + message="Forbidden", + error_code="AUTH_ERROR", + status_code=403, + retryable=False, + ) + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError, match="Forbidden"): + svc.set_active_branch(alias="prod", branch_id=456) + + +class TestResetBranch: + """Tests for BranchService.reset_branch().""" + + def test_reset_branch_success(self, tmp_config_dir: Path) -> None: + """reset_branch clears the active branch, reverting to main.""" + store = setup_single_project(tmp_config_dir) + + # Set an active branch first + store.set_project_branch("prod", 456) + project = store.get_project("prod") + assert project is not None + assert project.active_branch_id == 456 + + svc = BranchService(config_store=store) + + result = svc.reset_branch(alias="prod") + + assert result["project_alias"] == "prod" + assert result["previous_branch_id"] == 456 + + # Verify config was cleared + project = store.get_project("prod") + assert project is not None + assert project.active_branch_id is None + + def test_reset_branch_unknown_project(self, tmp_config_dir: Path) -> None: + """reset_branch raises ConfigError for an unknown project alias.""" + store = setup_single_project(tmp_config_dir) + svc = BranchService(config_store=store) + + with pytest.raises(ConfigError, match="Project 'ghost' not found"): + svc.reset_branch(alias="ghost") + + +class TestDeleteBranch: + """Tests for BranchService.delete_branch().""" + + def test_delete_branch_success(self, tmp_config_dir: Path) -> None: + """delete_branch calls API and returns confirmation.""" + mock_client = MagicMock() + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = svc.delete_branch(alias="prod", branch_id=456) + + assert result["project_alias"] == "prod" + assert result["branch_id"] == 456 + assert result["was_active"] is False + mock_client.delete_dev_branch.assert_called_once_with(456) + + def test_delete_branch_auto_reset(self, tmp_config_dir: Path) -> None: + """delete_branch resets active_branch_id when deleting the active branch.""" + mock_client = MagicMock() + + store = setup_single_project(tmp_config_dir) + # Set the branch as active first + store.set_project_branch("prod", 456) + + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = svc.delete_branch(alias="prod", branch_id=456) + + assert result["was_active"] is True + assert "reset to main" in result["message"] + + # Verify active branch was cleared + project = store.get_project("prod") + assert project is not None + assert project.active_branch_id is None + + def test_delete_branch_api_error(self, tmp_config_dir: Path) -> None: + """delete_branch propagates KeboolaApiError from the client.""" + mock_client = MagicMock() + mock_client.delete_dev_branch.side_effect = KeboolaApiError( + message="Branch not found", + error_code="NOT_FOUND", + status_code=404, + retryable=False, + ) + + store = setup_single_project(tmp_config_dir) + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError, match="Branch not found"): + svc.delete_branch(alias="prod", branch_id=999) + + +class TestGetMergeUrl: + """Tests for BranchService.get_merge_url().""" + + def test_get_merge_url_with_explicit_branch(self, tmp_config_dir: Path) -> None: + """get_merge_url generates correct URL when branch_id is provided.""" + store = setup_single_project(tmp_config_dir) + svc = BranchService(config_store=store) + + result = svc.get_merge_url(alias="prod", branch_id=456) + + expected_url = ( + "https://connection.keboola.com/admin/projects/258" + "/branch/456/development-overview" + ) + assert result["url"] == expected_url + assert result["project_alias"] == "prod" + assert result["branch_id"] == 456 + + def test_get_merge_url_uses_active_branch(self, tmp_config_dir: Path) -> None: + """get_merge_url falls back to active_branch_id when no branch_id is given.""" + store = setup_single_project(tmp_config_dir) + store.set_project_branch("prod", 789) + + svc = BranchService(config_store=store) + + result = svc.get_merge_url(alias="prod") + + expected_url = ( + "https://connection.keboola.com/admin/projects/258" + "/branch/789/development-overview" + ) + assert result["url"] == expected_url + assert result["branch_id"] == 789 + + def test_get_merge_url_no_branch_raises(self, tmp_config_dir: Path) -> None: + """get_merge_url raises ConfigError when no branch_id and no active branch.""" + store = setup_single_project(tmp_config_dir) + svc = BranchService(config_store=store) + + with pytest.raises(ConfigError, match="No branch specified and no active branch"): + svc.get_merge_url(alias="prod") + + def test_get_merge_url_resets_active_branch(self, tmp_config_dir: Path) -> None: + """get_merge_url resets active_branch_id to None after generating the URL.""" + store = setup_single_project(tmp_config_dir) + store.set_project_branch("prod", 456) + + svc = BranchService(config_store=store) + + result = svc.get_merge_url(alias="prod") + assert result["branch_id"] == 456 + + # After generating URL, active branch should be reset + project = store.get_project("prod") + assert project is not None + assert project.active_branch_id is None + + +class TestListBranchesActiveBranches: + """Tests for active_branches key in list_branches() response.""" + + def test_list_branches_includes_active_branches(self, tmp_config_dir: Path) -> None: + """list_branches result contains an active_branches dict mapping alias to branch ID.""" + mock_client = MagicMock() + mock_client.list_dev_branches.return_value = SAMPLE_BRANCHES + + store = setup_single_project(tmp_config_dir) + # Set an active branch + store.set_project_branch("prod", 456) + + svc = BranchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = svc.list_branches(aliases=["prod"]) + + assert "active_branches" in result + assert result["active_branches"] == {"prod": 456} diff --git a/tests/test_cli.py b/tests/test_cli.py index 372d2ec8..d3268ab9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4777,3 +4777,504 @@ def test_non_tty_error(self) -> None: _resolve_manage_token() assert exc_info.value.exit_code == 2 + + +# --------------------------------------------------------------------------- +# Branch lifecycle commands (create, use, reset, delete, merge) +# --------------------------------------------------------------------------- + + +class TestBranchCreate: + """Tests for `kbagent branch create` command.""" + + def test_branch_create_json(self, tmp_path: Path) -> None: + """branch create --json returns structured JSON with branch details.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.create_branch.return_value = { + "project_alias": "prod", + "branch_id": 789, + "branch_name": "feature-abc", + "description": "My feature branch", + "created": "2026-03-03T12:00:00Z", + "activated": True, + "message": "Branch 'feature-abc' (ID: 789) created and activated for project 'prod'.", + } + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + [ + "--json", + "branch", + "create", + "--project", + "prod", + "--name", + "feature-abc", + "--description", + "My feature branch", + ], + ) + + 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"]["branch_id"] == 789 + assert output["data"]["branch_name"] == "feature-abc" + assert output["data"]["activated"] is True + assert output["data"]["project_alias"] == "prod" + mock_branch.create_branch.assert_called_once_with( + alias="prod", name="feature-abc", description="My feature branch" + ) + + def test_branch_create_api_error(self, tmp_path: Path) -> None: + """branch create with API error returns exit code 1.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.create_branch.side_effect = KeboolaApiError( + message="Branch name already exists", + status_code=400, + error_code="BRANCH_EXISTS", + retryable=False, + ) + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + [ + "--json", + "branch", + "create", + "--project", + "prod", + "--name", + "feature-abc", + ], + ) + + assert result.exit_code == 1 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "BRANCH_EXISTS" + assert "Branch name already exists" in output["error"]["message"] + + +class TestBranchUse: + """Tests for `kbagent branch use` command.""" + + def test_branch_use_json(self, tmp_path: Path) -> None: + """branch use --json returns structured JSON confirming activation.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.set_active_branch.return_value = { + "project_alias": "prod", + "branch_id": 456, + "branch_name": "feature-x", + "message": "Active branch set to 'feature-x' (ID: 456) for project 'prod'.", + } + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + ["--json", "branch", "use", "--project", "prod", "--branch", "456"], + ) + + 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"]["branch_id"] == 456 + assert output["data"]["branch_name"] == "feature-x" + assert output["data"]["project_alias"] == "prod" + mock_branch.set_active_branch.assert_called_once_with(alias="prod", branch_id=456) + + def test_branch_use_branch_not_found(self, tmp_path: Path) -> None: + """branch use with nonexistent branch returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.set_active_branch.side_effect = ConfigError( + "Branch ID 999 not found in project 'prod'. " + "Use 'kbagent branch list --project prod' to see available branches." + ) + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + ["--json", "branch", "use", "--project", "prod", "--branch", "999"], + ) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + assert "Branch ID 999 not found" in output["error"]["message"] + + +class TestBranchReset: + """Tests for `kbagent branch reset` command.""" + + def test_branch_reset_json(self, tmp_path: Path) -> None: + """branch reset --json returns structured JSON confirming reset.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.reset_branch.return_value = { + "project_alias": "prod", + "previous_branch_id": 456, + "message": "Active branch reset to main for project 'prod'.", + } + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + ["--json", "branch", "reset", "--project", "prod"], + ) + + 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"]["project_alias"] == "prod" + assert output["data"]["previous_branch_id"] == 456 + assert "reset to main" in output["data"]["message"] + mock_branch.reset_branch.assert_called_once_with(alias="prod") + + +class TestBranchDelete: + """Tests for `kbagent branch delete` command.""" + + def test_branch_delete_json(self, tmp_path: Path) -> None: + """branch delete --json returns structured JSON confirming deletion.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.delete_branch.return_value = { + "project_alias": "prod", + "branch_id": 456, + "was_active": True, + "message": "Branch ID 456 deleted from project 'prod'. Active branch reset to main.", + } + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + ["--json", "branch", "delete", "--project", "prod", "--branch", "456"], + ) + + 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"]["branch_id"] == 456 + assert output["data"]["was_active"] is True + assert output["data"]["project_alias"] == "prod" + assert "deleted" in output["data"]["message"] + mock_branch.delete_branch.assert_called_once_with(alias="prod", branch_id=456) + + +class TestBranchMerge: + """Tests for `kbagent branch merge` command.""" + + def test_branch_merge_json(self, tmp_path: Path) -> None: + """branch merge --json returns structured JSON with merge URL.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.get_merge_url.return_value = { + "project_alias": "prod", + "branch_id": 456, + "url": "https://connection.keboola.com/admin/projects/1234/branch/456/development-overview", + "message": ( + "Open this URL to review and merge branch 456 " + "in project 'prod'. Active branch has been reset to main." + ), + } + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + ["--json", "branch", "merge", "--project", "prod", "--branch", "456"], + ) + + 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"]["branch_id"] == 456 + assert "connection.keboola.com" in output["data"]["url"] + assert "/branch/456/" in output["data"]["url"] + assert output["data"]["project_alias"] == "prod" + mock_branch.get_merge_url.assert_called_once_with(alias="prod", branch_id=456) + + def test_branch_merge_no_branch(self, tmp_path: Path) -> None: + """branch merge with no active branch and no --branch returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.BranchService") as MockBranchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_branch = MagicMock() + mock_branch.get_merge_url.side_effect = ConfigError( + "No branch specified and no active branch set for project 'prod'. " + "Use --branch ID or set an active branch with 'kbagent branch use'." + ) + MockBranchService.return_value = mock_branch + + result = runner.invoke( + app, + ["--json", "branch", "merge", "--project", "prod"], + ) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + assert "No branch specified" in output["error"]["message"] + + +# --------------------------------------------------------------------------- +# Tool auto-resolve active branch +# --------------------------------------------------------------------------- + + +class TestToolAutoResolveBranch: + """Tests for tool commands auto-resolving active_branch_id from config.""" + + def test_tool_list_auto_resolves_active_branch(self, tmp_path: Path) -> None: + """tool list auto-resolves active_branch_id from project config.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Set up a project with an active branch + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + store.set_project_branch("prod", 456) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.McpService") as MockMcpService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_mcp = MagicMock() + mock_mcp.list_tools.return_value = { + "tools": SAMPLE_TOOLS, + "errors": [], + } + MockMcpService.return_value = mock_mcp + + # No --branch flag, but project has active_branch_id=456 + result = runner.invoke(app, ["--json", "tool", "list"]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + # Verify the service was called with the auto-resolved branch + mock_mcp.list_tools.assert_called_once_with( + aliases=["prod"], branch_id="456" + ) + + def test_tool_call_auto_resolves_active_branch(self, tmp_path: Path) -> None: + """tool call auto-resolves active_branch_id from project config.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Set up a project with an active branch + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + store.set_project_branch("prod", 456) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + patch("keboola_agent_cli.cli.McpService") as MockMcpService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + MockJobService.return_value = JobService(config_store=store) + + mock_mcp = MagicMock() + mock_mcp.validate_tool_input.return_value = [] + mock_mcp.call_tool.return_value = { + "results": [ + { + "content": [{"configs": ["cfg1"]}], + "isError": False, + "project_alias": "prod", + } + ], + "errors": [], + } + MockMcpService.return_value = mock_mcp + + # No --branch flag, but project has active_branch_id=456 + result = runner.invoke( + app, + ["--json", "tool", "call", "list_configs"], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + # Verify the service was called with auto-resolved branch + mock_mcp.call_tool.assert_called_once_with( + tool_name="list_configs", + tool_input={}, + alias="prod", + branch_id="456", + ) diff --git a/tests/test_client.py b/tests/test_client.py index 4d5691a3..c95ff908 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1390,3 +1390,112 @@ def test_standard_url_no_warning(self) -> None: _ = client._queue_base_url mock_logger.warning.assert_not_called() client.close() + + +class TestCreateDevBranch: + """Tests for create_dev_branch() - async Storage API branch creation.""" + + def test_create_dev_branch_success(self, httpx_mock) -> None: + """create_dev_branch() polls job and returns branch data from results.""" + # POST returns an async job + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/dev-branches", + json={ + "id": 999999, + "status": "success", + "operationName": "devBranchCreate", + "results": {"id": 789, "name": "my-feature", "description": "", "isDefault": False}, + }, + status_code=201, + method="POST", + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.create_dev_branch("my-feature") + assert result["id"] == 789 + assert result["name"] == "my-feature" + + def test_create_dev_branch_with_description(self, httpx_mock) -> None: + """create_dev_branch() sends description in the request body.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/dev-branches", + json={ + "id": 999998, + "status": "success", + "operationName": "devBranchCreate", + "results": {"id": 790, "name": "my-feature", "description": "A feature branch"}, + }, + status_code=201, + method="POST", + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.create_dev_branch("my-feature", description="A feature branch") + assert result["id"] == 790 + assert result["description"] == "A feature branch" + + # Verify the POST request body contained the description + request = httpx_mock.get_requests()[0] + import json + body = json.loads(request.content) + assert body["name"] == "my-feature" + assert body["description"] == "A feature branch" + + def test_create_dev_branch_polls_waiting_job(self, httpx_mock) -> None: + """create_dev_branch() polls a waiting job until success.""" + # POST returns a waiting job + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/dev-branches", + json={"id": 111, "status": "waiting"}, + status_code=201, + method="POST", + ) + # First poll: still processing + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/jobs/111", + json={"id": 111, "status": "processing"}, + method="GET", + ) + # Second poll: success + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/jobs/111", + json={ + "id": 111, + "status": "success", + "results": {"id": 555, "name": "polled-branch"}, + }, + method="GET", + ) + + from unittest.mock import patch + with patch("keboola_agent_cli.client.time.sleep"), KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.create_dev_branch("polled-branch") + assert result["id"] == 555 + + +class TestDeleteDevBranch: + """Tests for delete_dev_branch() - async Storage API branch deletion.""" + + def test_delete_dev_branch_success(self, httpx_mock) -> None: + """delete_dev_branch() polls job until success.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/dev-branches/789", + json={"id": 222, "status": "success", "operationName": "devBranchDelete"}, + method="DELETE", + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + # Should not raise any exception + client.delete_dev_branch(789) diff --git a/tests/test_config_store.py b/tests/test_config_store.py index 9a34afe0..97e1769b 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -4,6 +4,7 @@ import os import stat from pathlib import Path +from unittest.mock import patch import pytest @@ -99,6 +100,38 @@ def test_permissions_preserved_on_resave(self, tmp_config_dir: Path) -> None: assert mode == 0o600 + def test_file_never_created_with_broad_permissions(self, tmp_config_dir: Path) -> None: + """Config file is never on disk with permissions broader than 0600 (TOCTOU fix). + + Verifies that os.open is called with 0o600 mode, ensuring the file + descriptor is created with restricted permissions from the start, + rather than creating with default umask and then chmod-ing. + """ + store = ConfigStore(config_dir=tmp_config_dir) + + original_os_open = os.open + open_modes_seen: list[int] = [] + + def tracking_os_open(path: str, flags: int, mode: int = 0o777) -> int: + if "config" in path: + open_modes_seen.append(mode) + return original_os_open(path, flags, mode) + + with patch("keboola_agent_cli.config_store.os.open", side_effect=tracking_os_open): + store.save(AppConfig()) + + # The file must have been opened with 0o600 mode + assert len(open_modes_seen) == 1 + assert open_modes_seen[0] == 0o600 + + def test_temp_file_cleaned_up_after_save(self, tmp_config_dir: Path) -> None: + """Temporary file used during atomic write is not left behind.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.save(AppConfig()) + + tmp_file = tmp_config_dir / "config.tmp" + assert not tmp_file.exists() + class TestAddProject: """Tests for add_project().""" @@ -596,3 +629,54 @@ def test_multiple_save_load_cycles(self, tmp_config_dir: Path) -> None: assert config.projects["project-0"].project_name == "Project 0" assert config.projects["project-9"].project_id == 9 assert config.default_project == "project-0" + + +class TestSetProjectBranch: + """Tests for set_project_branch().""" + + def test_set_project_branch_set(self, tmp_config_dir: Path) -> None: + """Setting a branch ID stores it on the project config.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "test", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abcdef-12345678", + ), + ) + + store.set_project_branch("test", 456) + + project = store.get_project("test") + assert project is not None + assert project.active_branch_id == 456 + + def test_set_project_branch_clear(self, tmp_config_dir: Path) -> None: + """Setting branch_id to None clears the active branch.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "test", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abcdef-12345678", + ), + ) + + # First set a branch + store.set_project_branch("test", 789) + project = store.get_project("test") + assert project is not None + assert project.active_branch_id == 789 + + # Then clear it + store.set_project_branch("test", None) + project = store.get_project("test") + assert project is not None + assert project.active_branch_id is None + + def test_set_project_branch_unknown_alias(self, tmp_config_dir: Path) -> None: + """Setting branch on a nonexistent alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + + with pytest.raises(ConfigError, match="not found"): + store.set_project_branch("nonexistent", 456) diff --git a/tests/test_explorer_service.py b/tests/test_explorer_service.py index 0d1148ee..87e9b0bc 100644 --- a/tests/test_explorer_service.py +++ b/tests/test_explorer_service.py @@ -6,17 +6,16 @@ import pytest +from helpers import setup_single_project from keboola_agent_cli.services.explorer_service import ( ExplorerService, _assign_tier, _build_mermaid, _compute_job_stats, + _default_output_dir, _type_icon, ) -from helpers import setup_single_project - - # --------------------------------------------------------------------------- # Pure function tests: _assign_tier # --------------------------------------------------------------------------- @@ -145,6 +144,21 @@ def test_application(self) -> None: assert _type_icon("keboola.app-something") == "AP" +# --------------------------------------------------------------------------- +# Pure function tests: _default_output_dir +# --------------------------------------------------------------------------- + +class TestDefaultOutputDir: + + def test_returns_cwd_based_path(self) -> None: + result = _default_output_dir() + assert result == Path.cwd() / "kbc-explorer" + + def test_returns_path_instance(self) -> None: + result = _default_output_dir() + assert isinstance(result, Path) + + # --------------------------------------------------------------------------- # Pure function tests: _build_mermaid # --------------------------------------------------------------------------- diff --git a/tests/test_lineage_service.py b/tests/test_lineage_service.py index e2d70d07..41c1f73d 100644 --- a/tests/test_lineage_service.py +++ b/tests/test_lineage_service.py @@ -8,6 +8,7 @@ from helpers import setup_single_project, setup_two_projects from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig from keboola_agent_cli.services.base import ENV_MAX_PARALLEL_WORKERS from keboola_agent_cli.services.lineage_service import LineageService @@ -778,3 +779,42 @@ def test_invalid_env_var_falls_back_to_config(self, tmp_config_dir: Path, monkey monkeypatch.setenv(ENV_MAX_PARALLEL_WORKERS, "not-a-number") service = LineageService(config_store=store) assert service._resolve_max_workers() == 15 + + +class TestLineageProjectIdNone: + """Tests for projects with project_id=None being skipped in lineage.""" + + def test_project_with_none_project_id_skipped(self, tmp_config_dir: Path) -> None: + """A project with project_id=None is excluded from project_id_to_alias lookup.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "no-id", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-xxx", + project_name="No ID Project", + # project_id defaults to None + ), + ) + store.add_project( + "with-id", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-yyy", + project_name="With ID Project", + project_id=999, + ), + ) + + mock_client = _make_lineage_client([]) + + service = LineageService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.get_lineage() + + # Both projects are queried, but project_id_to_alias only has the one with an ID + assert result["summary"]["projects_queried"] == 2 + assert result["summary"]["projects_with_errors"] == 0 diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 49b8087a..1dec69fc 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -141,10 +141,10 @@ class TestDetectMcpServerCommand: @patch("keboola_agent_cli.services.mcp_service.shutil.which") def test_uvx_available(self, mock_which: MagicMock) -> None: - """When uvx is available, returns ['uvx', 'keboola_mcp_server'].""" + """When uvx is available, returns ['uvx', 'keboola_mcp_server@latest'].""" mock_which.side_effect = lambda cmd: "/usr/local/bin/uvx" if cmd == "uvx" else None result = detect_mcp_server_command() - assert result == ["uvx", "keboola_mcp_server"] + assert result == ["uvx", "keboola_mcp_server@latest"] @patch("keboola_agent_cli.services.mcp_service.shutil.which") def test_keboola_mcp_server_available(self, mock_which: MagicMock) -> None: @@ -697,48 +697,43 @@ class TestMcpTimeoutFromEnv: def test_mcp_timeout_from_env(self) -> None: """KBAGENT_MCP_TOOL_TIMEOUT and KBAGENT_MCP_INIT_TIMEOUT env vars override defaults.""" - import importlib import os - import keboola_agent_cli.services.mcp_service as mcp_mod - - try: - with patch.dict( - os.environ, - { - "KBAGENT_MCP_TOOL_TIMEOUT": "120", - "KBAGENT_MCP_INIT_TIMEOUT": "45", - }, - ): - importlib.reload(mcp_mod) - assert mcp_mod.MCP_TOOL_TIMEOUT_SECONDS == 120 - assert mcp_mod.MCP_INIT_TIMEOUT_SECONDS == 45 - finally: - # Reload to restore original state - importlib.reload(mcp_mod) + from keboola_agent_cli.services.mcp_service import ( + _get_init_timeout, + _get_tool_timeout, + ) + + with patch.dict( + os.environ, + { + "KBAGENT_MCP_TOOL_TIMEOUT": "120", + "KBAGENT_MCP_INIT_TIMEOUT": "45", + }, + ): + assert _get_tool_timeout() == 120 + assert _get_init_timeout() == 45 def test_mcp_timeout_defaults(self) -> None: """Without env vars, MCP timeouts use default values from constants.""" - import importlib import os - import keboola_agent_cli.services.mcp_service as mcp_mod from keboola_agent_cli.constants import ( DEFAULT_MCP_INIT_TIMEOUT, DEFAULT_MCP_TOOL_TIMEOUT, ) + from keboola_agent_cli.services.mcp_service import ( + _get_init_timeout, + _get_tool_timeout, + ) - try: - # Clear any env vars that might be set - env = os.environ.copy() - env.pop("KBAGENT_MCP_TOOL_TIMEOUT", None) - env.pop("KBAGENT_MCP_INIT_TIMEOUT", None) - with patch.dict(os.environ, env, clear=True): - importlib.reload(mcp_mod) - assert mcp_mod.MCP_TOOL_TIMEOUT_SECONDS == DEFAULT_MCP_TOOL_TIMEOUT - assert mcp_mod.MCP_INIT_TIMEOUT_SECONDS == DEFAULT_MCP_INIT_TIMEOUT - finally: - importlib.reload(mcp_mod) + # Clear any env vars that might be set + env = os.environ.copy() + env.pop("KBAGENT_MCP_TOOL_TIMEOUT", None) + env.pop("KBAGENT_MCP_INIT_TIMEOUT", None) + with patch.dict(os.environ, env, clear=True): + assert _get_tool_timeout() == DEFAULT_MCP_TOOL_TIMEOUT + assert _get_init_timeout() == DEFAULT_MCP_INIT_TIMEOUT # --------------------------------------------------------------------------- diff --git a/tests/test_models.py b/tests/test_models.py index 772bd695..bb7cde57 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -359,3 +359,30 @@ def test_max_workers_at_1_is_valid(self) -> None: """max_parallel_workers = 1 is accepted.""" config = AppConfig(max_parallel_workers=1) assert config.max_parallel_workers == 1 + + +class TestProjectConfigBackwardCompat: + """Tests for backward compatibility of ProjectConfig with active_branch_id.""" + + def test_project_config_without_active_branch_id(self) -> None: + """ProjectConfig created from dict without active_branch_id defaults to None.""" + data = { + "stack_url": "https://connection.keboola.com", + "token": "901-secret-token", + "project_name": "My Project", + "project_id": 1234, + } + config = ProjectConfig.model_validate(data) + assert config.active_branch_id is None + + def test_project_config_with_active_branch_id(self) -> None: + """ProjectConfig created with active_branch_id preserves the value.""" + data = { + "stack_url": "https://connection.keboola.com", + "token": "901-secret-token", + "project_name": "My Project", + "project_id": 1234, + "active_branch_id": 123, + } + config = ProjectConfig.model_validate(data) + assert config.active_branch_id == 123 diff --git a/tests/test_org_service.py b/tests/test_org_service.py index 5dc70837..9fe9c2e5 100644 --- a/tests/test_org_service.py +++ b/tests/test_org_service.py @@ -387,6 +387,46 @@ def test_custom_token_description(self, tmp_path: Path) -> None: ) or "my-custom-prefix" in str(call_args) +class TestExistingProjectIdNone: + """Tests for existing projects with project_id=None not polluting the set.""" + + def test_none_project_id_not_in_existing_set(self, tmp_path: Path) -> None: + """A pre-registered project with project_id=None does not block new projects.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + + # Pre-register a project without project_id (defaults to None) + store.add_project( + "legacy", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-legacy-tokenValue12345678901", + project_name="Legacy", + # project_id is None + ), + ) + + projects = [{"id": 100, "name": "Alpha"}] + + service = OrgService( + config_store=store, + manage_client_factory=_make_manage_client(projects), + storage_client_factory=_make_storage_client(), + ) + + result = service.setup_organization( + stack_url="https://connection.keboola.com", + manage_token="manage-token-123456789012345678", + org_id=42, + ) + + # Project 100 should be added, not skipped + assert len(result["projects_added"]) == 1 + assert result["projects_added"][0]["project_id"] == 100 + assert len(result["projects_skipped"]) == 0 + + class TestUniqueAlias: """Tests for OrgService._unique_alias() static method.""" diff --git a/uv.lock b/uv.lock index 6ef6ffc6..a7d945d3 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -108,6 +150,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -182,6 +281,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] +[[package]] +name = "cyclonedx-python-lib" +version = "11.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -275,7 +408,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -290,6 +423,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "bandit" }, + { name = "pip-audit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx" }, @@ -310,12 +445,26 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "bandit", specifier = ">=1.9.4" }, + { name = "pip-audit", specifier = ">=2.10.0" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.23" }, { name = "pytest-httpx", specifier = ">=0.30" }, { name = "ruff", specifier = ">=0.8" }, ] +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -362,6 +511,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -371,6 +573,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pip" +version = "26.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + [[package]] name = "platformdirs" version = "4.9.2" @@ -389,6 +646,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -521,6 +790,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -657,6 +935,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -785,6 +1078,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sse-starlette" version = "3.2.0" @@ -811,6 +1113,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -847,6 +1212,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.41.0" From 8ca1ba31b48bb72290c949a504fe3760cb0fedb9 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 3 Mar 2026 23:10:48 +0100 Subject: [PATCH 2/3] Add llm export and version commands - kbagent llm export: AI-optimized project export via kbc Go binary Wraps 'kbc llm export' with credentials auto-resolved from config store. Detects kbc binary, validates project, runs export to per-project dirs. - kbagent version: Show kbagent version and check for dependency updates Checks kbc (via GitHub releases) and keboola-mcp-server (via PyPI). Parallel version fetching with rich formatted output. - Remove docs/PLAN-catalog.md (implemented as kbagent explorer) --- docs/PLAN-catalog.md | 553 ------------------ src/keboola_agent_cli/commands/llm.py | 84 +++ src/keboola_agent_cli/commands/version.py | 86 +++ src/keboola_agent_cli/services/kbc_service.py | 184 ++++++ .../services/version_service.py | 189 ++++++ tests/test_kbc_service.py | 235 ++++++++ tests/test_version_service.py | 269 +++++++++ 7 files changed, 1047 insertions(+), 553 deletions(-) delete mode 100644 docs/PLAN-catalog.md create mode 100644 src/keboola_agent_cli/commands/llm.py create mode 100644 src/keboola_agent_cli/commands/version.py create mode 100644 src/keboola_agent_cli/services/kbc_service.py create mode 100644 src/keboola_agent_cli/services/version_service.py create mode 100644 tests/test_kbc_service.py create mode 100644 tests/test_version_service.py diff --git a/docs/PLAN-catalog.md b/docs/PLAN-catalog.md deleted file mode 100644 index 97f85360..00000000 --- a/docs/PLAN-catalog.md +++ /dev/null @@ -1,553 +0,0 @@ -# PLAN: `kbagent catalog generate` — Automated Catalog Generation - -## Problem Statement - -KBC Explorer (`kbc-explorer/index.html`) is a powerful CDO-grade dashboard for -visualizing a Keboola project ecosystem. However, its data file — `catalog.json` -(including orchestrations) — is currently produced through a **manual 7-step -process** described in `kbc-explorer/README.md`, typically executed by giving a -long prompt to an AI assistant. - -This is unsustainable because: -- **No reproducibility** — each generation run depends on AI interpretation. -- **No scheduling** — data goes stale; a CDO needs daily/weekly refreshes. -- **No validation** — manual assembly can produce schema-violating output. -- **No diffing** — no way to compare two snapshots and see what changed. -- **Slow** — a human-in-the-loop process takes 10-20 minutes per run. - -## Goal - -A single CLI command that produces a complete, schema-valid catalog snapshot: - -```bash -# Generate catalog from all registered projects -kbagent catalog generate --output kbc-explorer/ - -# With custom tier configuration -kbagent catalog generate --tiers tiers.yaml --output kbc-explorer/ - -# Dry run — show what would be fetched without writing files -kbagent catalog generate --dry-run - -# JSON output of the catalog (pipe-friendly) -kbagent --json catalog generate -``` - -Expected output files: -- `catalog.json` — plain JSON (for tooling, validation), includes orchestrations under the `orchestrations` key -- `catalog.js` — JS wrapper (`const CATALOG = {...};`) for the HTML viewer - ---- - -## Architecture - -### New Files - -``` -src/keboola_agent_cli/ - commands/catalog.py # LAYER 1: CLI command - services/catalog_service.py # LAYER 2: Orchestration + aggregation logic -``` - -### Layer Responsibilities - -Following the existing 3-layer architecture: - -``` -commands/catalog.py → CatalogService → KeboolaClient - CLI args, output Orchestration HTTP calls - Aggregation - Schema validation -``` - -`CatalogService` reuses the existing service layer: -- `ConfigService.list_configs()` — configuration data -- `JobService.list_jobs()` — job data (raw, for aggregation) -- `LineageService.get_lineage()` — sharing/lineage data -- `ConfigService.get_config_detail()` — orchestration flow details - -No new API endpoints are needed. All data is already fetchable. - ---- - -## Tier Configuration - -Tiers (L0/L1/L2) cannot be inferred from the Keboola API — they represent a -business-level classification. The user must provide a tier mapping. - -### Option A: YAML config file (recommended) - -```yaml -# tiers.yaml -description: "IR (Internal Reporting) project ecosystem" - -tiers: - L0: - name: "Data Sources / Extraction" - description: "Raw data extraction from external systems" - L1: - name: "Data Processing / Transformation" - description: "Business logic, transformations, data modeling" - L2: - name: "Data Output / Delivery" - description: "Final outputs - BI dashboards, data sharing" - -# Mapping: project alias -> tier -projects: - ir-l0-finance: L0 - ir-l0-kbc-telemetry-to-catalog: L0 - ir-l0-marketing: L0 - ir-l1-data-processes-product: L1 - ir-l2-internal-bi-output: L2 - # ... -``` - -### Option B: Convention-based auto-detection - -If no tier config is provided, attempt to infer tier from alias patterns: -- `*-l0-*` or `*-l0` → L0 -- `*-l1-*` or `*-l1` → L1 -- `*-l2-*` or `*-l2` → L2 -- Everything else → a configurable default tier or `UNCLASSIFIED` - -### Decision - -Support **both**: auto-detection as fallback, `--tiers` file as override. If a -project appears in `--tiers` file, use that. If not, try alias-based convention. -If neither works, assign `UNCLASSIFIED` and emit a warning. - ---- - -## Data Collection Pipeline - -### Phase 1: Parallel fetch (per-project) - -Uses `BaseService._run_parallel()` with `ThreadPoolExecutor`: - -``` -For each registered project (in parallel): - ├── 1. config list → raw component/config data - ├── 2. job list --limit 500 → raw job data - └── 3. lineage → sharing_out, sharing_in, edges -``` - -All three calls per project can also run in parallel (they are independent), -but the current `_run_parallel` pattern groups by project. This is fine for -the initial implementation. A future optimization could issue all three per -project concurrently via `asyncio` or nested thread pools. - -### Phase 2: Orchestration detail fetch (selective, parallel) - -From Phase 1 config data, identify all `keboola.orchestrator` configurations. -Then fetch their details in parallel: - -``` -For each orchestrator config across all projects (in parallel): - └── config detail (component_id=keboola.orchestrator, config_id=X) - → phase/task structure, mermaid graph -``` - -### Phase 3: Aggregation (local, CPU-bound) - -No API calls. Pure computation: - -``` -For each project: - ├── Configurations: group by type, count totals - ├── Job stats: compute aggregates from raw jobs - │ ├── total_jobs - │ ├── status_counts (success, error, warning, cancelled, terminated) - │ ├── success_rate_pct = success / total * 100 - │ ├── avg_duration_seconds = sum(durations) / total - │ ├── date_range (earliest, latest timestamps) - │ ├── component_stats (per component_id: success, error, other, total) - │ └── failing_configs (configs with error_rate > 0, sorted desc) - ├── Sharing: already structured from lineage service - └── Tier: look up from tiers config or infer from alias - -Global: - ├── lineage.edges: deduplicated cross-project edges - └── lineage.summary: count aggregates -``` - -### Phase 4: Assembly & Validation - -``` -1. Assemble catalog.json structure (metadata + tiers + projects + lineage + orchestrations) -2. Validate against schema.json (using jsonschema library) -3. Write output files: - ├── catalog.json - └── catalog.js (wrapped: const CATALOG = ...;) -``` - ---- - -## Job Statistics Aggregation Logic - -This is the most complex transformation. Given raw jobs from Queue API: - -```python -def aggregate_job_stats(jobs: list[dict]) -> dict: - """ - Input: raw job list from Queue API - Each job has: status, durationSeconds, createdTime, component.id, - config.id, etc. - - Output: job_stats object matching schema.json#/definitions/job_stats - """ - if not jobs: - return { - "total_jobs": 0, - "status_counts": {}, - "success_rate_pct": 0, - "avg_duration_seconds": 0, - "date_range": {"earliest": None, "latest": None}, - "component_stats": {}, - "failing_configs": [] - } - - # Status counts - status_counts = Counter(j["status"] for j in jobs) - total = len(jobs) - success = status_counts.get("success", 0) - - # Success rate - success_rate = (success / total * 100) if total > 0 else 0 - - # Average duration - durations = [j.get("durationSeconds", 0) for j in jobs if j.get("durationSeconds")] - avg_duration = sum(durations) / len(durations) if durations else 0 - - # Date range - timestamps = [j["createdTime"] for j in jobs if j.get("createdTime")] - earliest = min(timestamps) if timestamps else None - latest = max(timestamps) if timestamps else None - - # Component stats - comp_stats = defaultdict(lambda: {"success": 0, "error": 0, "other": 0, "total": 0}) - for j in jobs: - comp_id = j.get("component", {}).get("id", "unknown") - comp_stats[comp_id]["total"] += 1 - if j["status"] == "success": - comp_stats[comp_id]["success"] += 1 - elif j["status"] == "error": - comp_stats[comp_id]["error"] += 1 - else: - comp_stats[comp_id]["other"] += 1 - - # Failing configs - config_runs = defaultdict(lambda: {"error": 0, "total": 0, "last_run": None, "component_id": ""}) - for j in jobs: - comp_id = j.get("component", {}).get("id", "unknown") - cfg_id = j.get("config", {}).get("id", "unknown") - key = f"{comp_id}/{cfg_id}" - config_runs[key]["total"] += 1 - config_runs[key]["component_id"] = comp_id - if j["status"] == "error": - config_runs[key]["error"] += 1 - ts = j.get("createdTime") - if ts and (not config_runs[key]["last_run"] or ts > config_runs[key]["last_run"]): - config_runs[key]["last_run"] = ts - - failing = [ - { - "config_key": key, - "component_id": data["component_id"], - "error_count": data["error"], - "total_runs": data["total"], - "error_rate_pct": round(data["error"] / data["total"] * 100, 1), - "last_run": data["last_run"] - } - for key, data in config_runs.items() - if data["error"] > 0 - ] - failing.sort(key=lambda x: x["error_rate_pct"], reverse=True) - - return { - "total_jobs": total, - "status_counts": dict(status_counts), - "success_rate_pct": round(success_rate, 1), - "avg_duration_seconds": round(avg_duration), - "date_range": {"earliest": earliest, "latest": latest}, - "component_stats": dict(comp_stats), - "failing_configs": failing - } -``` - ---- - -## Orchestration Assembly Logic - -For each `keboola.orchestrator` config, the detail response contains phases -and tasks. The assembly transforms this into the `orchestrations` entry format: - -```python -def assemble_orchestration(alias: str, config_detail: dict) -> dict: - """ - Transform config detail response into orchestration catalog entry. - - Input: raw response from client.get_config_detail() - Output: orchestration entry with phases, tasks, mermaid graph - """ - config = config_detail - rows = config.get("rows", []) # phases come from config rows or configuration.phases - - # Extract phases and tasks from the orchestrator configuration - phases = config.get("configuration", {}).get("phases", []) - - assembled_phases = [] - for phase in phases: - tasks = [] - for task in phase.get("tasks", []): - comp_id = task.get("task", {}).get("componentId", "") - comp_short = comp_id.replace("keboola.", "").replace("ex-generic-v2", "generic-extractor") - type_map = { - "extractor": "EX", "writer": "WR", - "transformation": "TR", "application": "AP" - } - # Determine type icon from component type - type_icon = type_map.get(task.get("task", {}).get("type", ""), "OT") - - tasks.append({ - "name": task.get("name", ""), - "component_id": comp_id, - "component_short": comp_short, - "config_id": task.get("task", {}).get("configId", ""), - "enabled": task.get("enabled", True), - "continue_on_failure": task.get("continueOnFailure", False), - "type_icon": type_icon - }) - - assembled_phases.append({ - "id": phase.get("id", 0), - "name": phase.get("name", f"Phase {phase.get('id', '?')}"), - "depends_on": phase.get("dependsOn", []), - "tasks": tasks - }) - - total_tasks = sum(len(p["tasks"]) for p in assembled_phases) - - # Generate Mermaid graph - mermaid = generate_mermaid(assembled_phases) - - return { - "project_alias": alias, - "config_id": config.get("id", ""), - "name": config.get("name", ""), - "description": config.get("description", ""), - "is_disabled": config.get("isDisabled", False), - "version": config.get("version", 0), - "last_modified": config.get("changeDescription", ""), - "last_modified_by": config.get("creatorToken", {}).get("description", ""), - "phases": assembled_phases, - "total_tasks": total_tasks, - "total_phases": len(assembled_phases), - "mermaid": mermaid - } -``` - ---- - -## CLI Command Interface - -### `kbagent catalog generate` - -``` -Usage: kbagent catalog generate [OPTIONS] - - Generate catalog.json (with orchestrations) for KBC Explorer. - -Options: - --output DIR Output directory (default: ./kbc-explorer/) - --tiers FILE Tier configuration YAML file (optional) - --job-limit N Max jobs to fetch per project (default: 500) - --skip-orchestrations Skip fetching orchestration details (faster) - --validate-only Only validate existing catalog against schema - --dry-run Show what would be fetched, don't write files - --project ALIAS Only generate for specific project(s) (repeatable) -``` - -### `kbagent catalog validate` - -``` -Usage: kbagent catalog validate [OPTIONS] - - Validate catalog.json against the JSON schema. - -Options: - --catalog FILE Path to catalog.json (default: ./kbc-explorer/catalog.json) - --schema FILE Path to schema.json (default: ./kbc-explorer/schema.json) -``` - -### `kbagent catalog diff` - -``` -Usage: kbagent catalog diff [OPTIONS] OLD_CATALOG NEW_CATALOG - - Compare two catalog snapshots and show what changed. - -Options: - --format TEXT Output format: text, json (default: text) - -Output: - - New/removed projects - - Config count changes per project - - Success rate changes - - New/removed lineage edges - - New/removed orchestrations -``` - ---- - -## Implementation Plan - -### Step 1: Tier configuration loader - -**File:** `src/keboola_agent_cli/services/catalog_service.py` - -- Parse `tiers.yaml` if provided -- Fallback to alias-based convention detection (`*-l0-*` → L0) -- Validate that every registered project has a tier assignment -- Emit warnings for unclassified projects - -### Step 2: CatalogService core — data fetching - -**File:** `src/keboola_agent_cli/services/catalog_service.py` - -- `generate(output_dir, tiers_config, job_limit, skip_orchestrations)` — main entry point -- `_fetch_all_projects()` — parallel fetch configs + jobs + lineage for all projects -- `_fetch_orchestrations(orchestrator_configs)` — parallel fetch config details -- Reuse existing services via composition (not inheritance): - ```python - class CatalogService: - def __init__(self, config_store, client_factory): - self.config_svc = ConfigService(config_store, client_factory) - self.job_svc = JobService(config_store, client_factory) - self.lineage_svc = LineageService(config_store, client_factory) - ``` - -### Step 3: Aggregation functions - -**File:** `src/keboola_agent_cli/services/catalog_service.py` - -- `_aggregate_job_stats(raw_jobs)` — compute all job_stats fields -- `_build_configurations(raw_configs)` — group by type, count -- `_build_lineage(all_project_lineage)` — deduplicate edges, compute summary -- `_build_orchestrations(flow_details)` — assemble orchestration entries for catalog - -### Step 4: Schema validation - -**File:** `src/keboola_agent_cli/services/catalog_service.py` - -- Load `kbc-explorer/schema.json` -- Validate assembled catalog against it using `jsonschema` library -- Report all validation errors with JSONPath locations -- New dependency: `jsonschema` (add to pyproject.toml) - -### Step 5: Output writers - -**File:** `src/keboola_agent_cli/services/catalog_service.py` - -- `_write_json(data, path)` — write JSON with consistent formatting -- `_write_js_wrapper(data, variable_name, path)` — write `const X = {...};` -- Write both files atomically (write to .tmp, then rename) - -### Step 6: CLI command - -**File:** `src/keboola_agent_cli/commands/catalog.py` - -- Typer command group: `catalog` -- Subcommands: `generate`, `validate`, `diff` -- Wire into `cli.py` app -- Rich progress display: show per-project progress during fetch -- JSON mode support via `OutputFormatter` - -### Step 7: Tests - -**File:** `tests/test_catalog_service.py` - -- Unit tests for `_aggregate_job_stats()` with various job distributions -- Unit tests for tier assignment (YAML + convention fallback) -- Unit tests for configuration grouping -- Unit tests for lineage edge deduplication -- Integration test: mock all API calls, verify full catalog output matches schema -- Validate that generated catalog.json passes schema.json validation - ---- - -## Future Enhancements (Tier 2 & 3) - -### KBC Explorer — Visualization Gaps - -These represent CDO questions that the current explorer cannot answer: - -| Feature | CDO Question | Data Source | -|---------|-------------|-------------| -| **Data freshness** | "When did jobs last run? Is data current?" | `job_stats.date_range.latest` (already in catalog) + real-time check | -| **Cost/volume metrics** | "How many credits per tier? Costliest project?" | Keboola Telemetry API (new data source) | -| **Change timeline** | "What changed this week? New configs, deleted flows?" | `catalog diff` between snapshots | -| **Alerting rules view** | "Which projects have no monitoring?" | Custom metadata (not in API) | -| **Flow Gantt chart** | "What runs in parallel vs sequential?" | `catalog.orchestrations` phases (already available) | -| **Table-level lineage** | "Where does table X originate and flow to?" | MCP `get_lineage` tool or Storage API metadata | -| **Config parameter audit** | "Which extractors target which external systems?" | Would require config parameter access (security risk) | - -### kbagent CLI — Agent Gaps - -| Feature | Command | Purpose | -|---------|---------|---------| -| `kbagent catalog watch` | `catalog generate --watch --interval 6h` | Scheduled regeneration with delta detection | -| `kbagent project health` | `project health [--project ALIAS]` | Composite health score: success_rate * freshness * config_coverage | -| `kbagent flow list` | `flow list [--project ALIAS]` | Dedicated flow listing (currently buried in config detail) | -| `kbagent flow run` | `flow run --project ALIAS --flow-id ID` | Trigger flow execution via Queue API | -| `kbagent catalog publish` | `catalog publish --to s3://...` | Upload catalog snapshot to S3/GCS for hosted explorer | - ---- - -## Dependencies - -### New Python packages - -| Package | Purpose | Already in project? | -|---------|---------|---------------------| -| `jsonschema` | Validate catalog against schema.json | No — add to pyproject.toml | -| `pyyaml` | Parse tiers.yaml config | No — add to pyproject.toml | - -### Existing packages (no changes) - -- `httpx` — HTTP client (used by KeboolaClient) -- `typer` — CLI framework -- `rich` — Progress bars, tables -- `pydantic` — Models - ---- - -## Estimated Effort - -| Step | Description | Size | -|------|-------------|------| -| 1 | Tier config loader + YAML parsing | Small | -| 2 | CatalogService data fetching (reuses existing services) | Medium | -| 3 | Aggregation functions (job stats, lineage, configs) | Medium | -| 4 | Schema validation integration | Small | -| 5 | Output writers (JSON + JS wrappers) | Small | -| 6 | CLI command + Rich progress | Medium | -| 7 | Tests | Medium | -| **Total** | | **~400-600 lines of new code + tests** | - -The biggest risk is in Step 3 (aggregation) — the job stats calculation has -many edge cases (empty projects, projects with no jobs, malformed timestamps). -The reference implementation above covers these, but thorough testing is needed. - ---- - -## Success Criteria - -1. `kbagent catalog generate` produces identical output to the current manually - generated `catalog.json` (modulo timestamp and ordering differences). -2. Output passes `jsonschema` validation against `schema.json`. -3. Full generation for 27 projects completes in under 60 seconds. -4. `--dry-run` shows expected API call count without making requests. -5. Tier auto-detection correctly classifies all `ir-l0-*`, `ir-l1-*`, `ir-l2-*` - projects without a config file. -6. Error in one project does not block generation for others (error accumulation - pattern, consistent with existing multi-project commands). diff --git a/src/keboola_agent_cli/commands/llm.py b/src/keboola_agent_cli/commands/llm.py new file mode 100644 index 00000000..27c620f1 --- /dev/null +++ b/src/keboola_agent_cli/commands/llm.py @@ -0,0 +1,84 @@ +"""LLM commands - AI-optimized project export via kbc CLI. + +Thin CLI layer: parses arguments, calls KbcService, formats output. +No business logic belongs here. +""" + +import typer + +from ..errors import ConfigError +from ._helpers import get_formatter, get_service + +llm_app = typer.Typer(help="LLM tools - AI-optimized project export") + + +@llm_app.command("export") +def llm_export( + ctx: typer.Context, + project: str | None = typer.Option( + None, + "--project", + help="Project alias to export (required if multiple projects configured)", + ), + with_samples: bool = typer.Option( + False, + "--with-samples", + help="Include data samples (CSV) from tables", + ), + sample_limit: int | None = typer.Option( + None, + "--sample-limit", + help="Max rows per table sample (requires --with-samples)", + ), + max_samples: int | None = typer.Option( + None, + "--max-samples", + help="Max number of tables to sample (requires --with-samples)", + ), +) -> None: + """Export project to Twin Format for AI consumption. + + Creates an AI-optimized directory of JSON files containing table schemas, + transformation SQL code, internal lineage graph, job statistics, and + component configurations. Requires the kbc CLI (brew install keboola-cli). + """ + formatter = get_formatter(ctx) + + # Validate sample options require --with-samples + if (sample_limit is not None or max_samples is not None) and not with_samples: + formatter.error( + message="--sample-limit and --max-samples require --with-samples", + error_code="USAGE_ERROR", + ) + raise typer.Exit(code=2) + + kbc_service = get_service(ctx, "kbc_service") + + try: + exit_code = kbc_service.run_llm_export( + alias=project, + with_samples=with_samples, + sample_limit=sample_limit, + max_samples=max_samples, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + + if exit_code != 0: + formatter.error( + message=f"kbc llm export failed with exit code {exit_code}", + error_code="KBC_ERROR", + ) + raise typer.Exit(code=1) from None + + # Success output + if formatter.json_mode: + projects = kbc_service.resolve_projects([project] if project else None) + resolved_alias = next(iter(projects.keys())) + formatter.output({ + "message": f"LLM export completed for project '{resolved_alias}'", + "output_dir": str(resolved_alias), + }) + else: + formatter.console.print("[bold green]LLM export completed successfully.[/bold green]") diff --git a/src/keboola_agent_cli/commands/version.py b/src/keboola_agent_cli/commands/version.py new file mode 100644 index 00000000..0473c9b3 --- /dev/null +++ b/src/keboola_agent_cli/commands/version.py @@ -0,0 +1,86 @@ +"""Version command - show kbagent version and dependency update checks. + +Thin CLI layer: calls VersionService and formats output. +No business logic belongs here. +""" + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from ._helpers import get_formatter, get_service + + +def _format_dep_standard(text: Text, dep: dict) -> None: + """Format a standard dependency (local install with upgrade check).""" + name = dep["name"] + desc = dep["description"] + local = dep.get("local_version") + latest = dep.get("latest_version") + up_to_date = dep.get("up_to_date") + + if local is None: + text.append(f" {name:<28}", style="dim") + text.append("not installed\n", style="red") + return + + label = f"{name} ({desc})" + text.append(f" {label:<28}") + text.append(f"v{local}") + + if up_to_date is False and latest is not None: + text.append(f" -> v{latest} available", style="yellow") + text.append(f" ({dep['upgrade_command']})", style="dim") + elif up_to_date is True: + text.append(" up to date", style="green") + else: + text.append(" (update check failed)", style="dim") + + text.append("\n") + + +def _format_dep_auto_update(text: Text, dep: dict) -> None: + """Format an auto-updating dependency (runs via uvx @latest).""" + name = dep["name"] + desc = dep["description"] + latest = dep.get("latest_version") + uvx_available = dep.get("uvx_available", False) + + label = f"{name} ({desc})" + text.append(f" {label:<28}") + + if not uvx_available: + text.append("uvx not found", style="red") + text.append(" (install: brew install uv)", style="dim") + elif latest: + text.append(f"v{latest}", style="green") + text.append(" auto-updates", style="dim") + else: + text.append("available", style="green") + text.append(" (version check failed)", style="dim") + + text.append("\n") + + +def _format_version_panel(console: Console, data: dict) -> None: + """Render version information as a Rich panel.""" + text = Text() + text.append(f"kbagent v{data['kbagent']['version']}", style="bold") + text.append("\n\nDependencies:\n") + + for dep in data["dependencies"]: + if dep.get("auto_updates"): + _format_dep_auto_update(text, dep) + else: + _format_dep_standard(text, dep) + + console.print(Panel(text, title="Version Info", border_style="blue")) + + +def version_command(ctx: typer.Context) -> None: + """Show kbagent version and check for dependency updates.""" + formatter = get_formatter(ctx) + version_service = get_service(ctx, "version_service") + result = version_service.get_versions() + formatter.output(result, _format_version_panel) diff --git a/src/keboola_agent_cli/services/kbc_service.py b/src/keboola_agent_cli/services/kbc_service.py new file mode 100644 index 00000000..4a2fd807 --- /dev/null +++ b/src/keboola_agent_cli/services/kbc_service.py @@ -0,0 +1,184 @@ +"""KBC CLI integration service - wraps the kbc Go binary for LLM export. + +Provides detection of the kbc binary and execution of 'kbc llm export' +with credentials auto-resolved from the config store. +""" + +import logging +import re +import shutil +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +from ..config_store import ConfigStore +from ..constants import KBC_SUBPROCESS_TIMEOUT +from ..errors import ConfigError +from .base import BaseService + +logger = logging.getLogger(__name__) + + +def detect_kbc_command() -> str | None: + """Detect the kbc binary on PATH. + + Returns: + The path to kbc binary, or None if not found. + """ + return shutil.which("kbc") + + +def _extract_host_from_url(stack_url: str) -> str: + """Extract hostname from a Keboola stack URL. + + Args: + stack_url: Full URL like 'https://connection.keboola.com'. + + Returns: + Hostname like 'connection.keboola.com'. + + Raises: + ValueError: If the URL has no hostname. + """ + hostname = urlparse(stack_url).hostname + if not hostname: + msg = f"Cannot extract hostname from URL: {stack_url}" + raise ValueError(msg) + return hostname + + +class KbcService(BaseService): + """Business logic for kbc CLI integration. + + Wraps the kbc Go binary for operations that are not available + through the Storage API or MCP server. + """ + + def __init__(self, config_store: ConfigStore) -> None: + super().__init__(config_store=config_store) + + def check_kbc_available(self) -> dict: + """Check if kbc binary is available (doctor-compatible format). + + Returns: + Dict with check name, status, and message. + """ + kbc_path = detect_kbc_command() + if kbc_path is None: + return { + "check": "kbc_binary", + "name": "kbc CLI", + "status": "warn", + "message": ( + "kbc binary not found on PATH. " + "Install with: brew install keboola-cli" + ), + } + + version = self.get_kbc_version() + version_info = f" v{version}" if version else "" + return { + "check": "kbc_binary", + "name": "kbc CLI", + "status": "pass", + "message": f"kbc{version_info} found at {kbc_path}", + } + + @staticmethod + def get_kbc_version() -> str | None: + """Parse the kbc version from 'kbc --version' output. + + Returns: + Version string like '2.44.0', or None if detection fails. + """ + kbc_path = detect_kbc_command() + if kbc_path is None: + return None + + try: + result = subprocess.run( + [kbc_path, "--version"], + capture_output=True, + text=True, + timeout=KBC_SUBPROCESS_TIMEOUT, + check=False, + ) + match = re.search(r"(\d+\.\d+\.\d+)", result.stdout) + return match.group(1) if match else None + except (subprocess.TimeoutExpired, OSError): + logger.debug("Failed to detect kbc version", exc_info=True) + return None + + def run_llm_export( + self, + alias: str | None = None, + with_samples: bool = False, + sample_limit: int | None = None, + max_samples: int | None = None, + ) -> int: + """Run 'kbc llm export' for a specific project. + + Resolves credentials from config store, creates output directory, + and streams kbc output directly to terminal. + + Args: + alias: Project alias. If None, uses the single configured project. + with_samples: Include data samples in export. + sample_limit: Max rows per table sample. + max_samples: Max number of tables to sample. + + Returns: + Exit code from the kbc process. + + Raises: + ConfigError: If kbc binary is not found or project not found. + """ + kbc_path = detect_kbc_command() + if kbc_path is None: + raise ConfigError( + "kbc binary not found on PATH. Install with: brew install keboola-cli" + ) + + # Resolve the single project + projects = self.resolve_projects([alias] if alias else None) + if len(projects) != 1: + raise ConfigError( + "LLM export requires exactly one project. " + f"Use --project to specify one of: {', '.join(projects.keys())}" + ) + + resolved_alias, project = next(iter(projects.items())) + host = _extract_host_from_url(project.stack_url) + + # Create output directory + output_dir = Path.cwd() / resolved_alias + output_dir.mkdir(parents=True, exist_ok=True) + + # Build command + cmd = [ + kbc_path, + "llm", + "export", + "--storage-api-host", + host, + "--storage-api-token", + project.token, + "--force", + "--non-interactive", + "--version-check=false", + "--working-dir", + str(output_dir), + ] + + if with_samples: + cmd.append("--with-samples") + if sample_limit is not None: + cmd.extend(["--sample-limit", str(sample_limit)]) + if max_samples is not None: + cmd.extend(["--max-samples", str(max_samples)]) + + logger.debug("Running kbc command: %s", " ".join(cmd[:6]) + " ...") + + # Stream directly to terminal (no capture) + result = subprocess.run(cmd, check=False) + return result.returncode diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py new file mode 100644 index 00000000..216e2dd9 --- /dev/null +++ b/src/keboola_agent_cli/services/version_service.py @@ -0,0 +1,189 @@ +"""Version service - detect local versions and check for updates. + +Provides version information for kbagent and its dependencies: +- kbc (Go CLI) - local version via subprocess, latest via GitHub API +- keboola-mcp-server - always runs latest via 'uvx keboola_mcp_server@latest', + version resolved from PyPI +""" + +import logging +import re +import shutil +import subprocess +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import httpx +from packaging.version import InvalidVersion, Version + +from .. import __version__ +from ..constants import ( + KBC_GITHUB_RELEASES_URL, + KBC_SUBPROCESS_TIMEOUT, + MCP_PYPI_URL, + VERSION_CHECK_TIMEOUT, +) +from .kbc_service import detect_kbc_command + +logger = logging.getLogger(__name__) + + +def _get_kbc_local_version() -> str | None: + """Get locally installed kbc version by running 'kbc --version'. + + Returns: + Version string like '2.44.0', or None if not installed/detectable. + """ + kbc_path = detect_kbc_command() + if kbc_path is None: + return None + + try: + result = subprocess.run( + [kbc_path, "--version"], + capture_output=True, + text=True, + timeout=KBC_SUBPROCESS_TIMEOUT, + check=False, + ) + match = re.search(r"(\d+\.\d+\.\d+)", result.stdout) + return match.group(1) if match else None + except (subprocess.TimeoutExpired, OSError): + logger.debug("Failed to detect kbc version", exc_info=True) + return None + + +def _is_uvx_available() -> bool: + """Check if uvx is available on PATH.""" + return shutil.which("uvx") is not None + + +def _fetch_kbc_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None: + """Fetch latest kbc version from GitHub releases API. + + Args: + timeout: HTTP request timeout in seconds. + + Returns: + Version string like '2.44.2', or None on failure. + """ + try: + response = httpx.get( + KBC_GITHUB_RELEASES_URL, + timeout=timeout, + follow_redirects=True, + ) + response.raise_for_status() + data = response.json() + tag = data.get("tag_name", "") + # Strip 'v' prefix if present + version = tag.lstrip("v") + if re.match(r"\d+\.\d+\.\d+", version): + return version + return None + except (httpx.HTTPError, KeyError, ValueError): + logger.debug("Failed to fetch latest kbc version", exc_info=True) + return None + + +def _fetch_mcp_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None: + """Fetch latest keboola-mcp-server version from PyPI. + + Args: + timeout: HTTP request timeout in seconds. + + Returns: + Version string like '1.46.0', or None on failure. + """ + try: + response = httpx.get( + MCP_PYPI_URL, + timeout=timeout, + follow_redirects=True, + ) + response.raise_for_status() + data = response.json() + version = data.get("info", {}).get("version", "") + if re.match(r"\d+\.\d+\.\d+", version): + return version + return None + except (httpx.HTTPError, KeyError, ValueError): + logger.debug("Failed to fetch latest MCP server version", exc_info=True) + return None + + +def _is_up_to_date(local: str | None, latest: str | None) -> bool | None: + """Compare local and latest versions. + + Args: + local: Locally installed version string. + latest: Latest available version string. + + Returns: + True if up to date, False if update available, None if comparison not possible. + """ + if local is None or latest is None: + return None + try: + return Version(local) >= Version(latest) + except InvalidVersion: + return None + + +class VersionService: + """Business logic for version detection and update checks. + + Detects local versions of kbagent dependencies and checks + for available updates in parallel. + """ + + def get_versions(self) -> dict[str, Any]: + """Get version information for kbagent and all dependencies. + + kbc: local install checked against GitHub releases. + keboola-mcp-server: always runs latest via 'uvx ... @latest', + so we only need to check PyPI for the current latest version + and whether uvx is available. + + Returns: + Structured dict with kbagent version and dependency info. + """ + # Step 1: Detect local kbc version + check uvx availability + kbc_local = _get_kbc_local_version() + uvx_available = _is_uvx_available() + + # Step 2: Fetch latest versions in parallel + with ThreadPoolExecutor(max_workers=2) as executor: + kbc_future = executor.submit(_fetch_kbc_latest_version) + mcp_future = executor.submit(_fetch_mcp_latest_version) + + kbc_latest = kbc_future.result() + mcp_latest = mcp_future.result() + + # Step 3: Build result + # MCP server: runs via 'uvx keboola_mcp_server@latest' (always latest) + # so there's no local vs remote mismatch -- just show availability + mcp_entry: dict[str, Any] = { + "name": "keboola-mcp-server", + "description": "Keboola MCP Server (via uvx @latest)", + "uvx_available": uvx_available, + "latest_version": mcp_latest, + "auto_updates": True, + } + + return { + "kbagent": { + "version": __version__, + }, + "dependencies": [ + { + "name": "kbc", + "description": "Keboola CLI (Go)", + "local_version": kbc_local, + "latest_version": kbc_latest, + "up_to_date": _is_up_to_date(kbc_local, kbc_latest), + "upgrade_command": "brew upgrade keboola-cli", + }, + mcp_entry, + ], + } diff --git a/tests/test_kbc_service.py b/tests/test_kbc_service.py new file mode 100644 index 00000000..77c6cafc --- /dev/null +++ b/tests/test_kbc_service.py @@ -0,0 +1,235 @@ +"""Tests for KbcService - kbc CLI integration for LLM export.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from helpers import setup_single_project, setup_two_projects +from keboola_agent_cli.errors import ConfigError +from keboola_agent_cli.services.kbc_service import ( + KbcService, + _extract_host_from_url, + detect_kbc_command, +) + + +class TestDetectKbcCommand: + """Tests for detect_kbc_command().""" + + @patch("keboola_agent_cli.services.kbc_service.shutil.which") + def test_kbc_found(self, mock_which: MagicMock) -> None: + mock_which.return_value = "/usr/local/bin/kbc" + assert detect_kbc_command() == "/usr/local/bin/kbc" + mock_which.assert_called_once_with("kbc") + + @patch("keboola_agent_cli.services.kbc_service.shutil.which") + def test_kbc_not_found(self, mock_which: MagicMock) -> None: + mock_which.return_value = None + assert detect_kbc_command() is None + + +class TestExtractHostFromUrl: + """Tests for _extract_host_from_url().""" + + def test_standard_url(self) -> None: + assert _extract_host_from_url("https://connection.keboola.com") == "connection.keboola.com" + + def test_azure_url(self) -> None: + result = _extract_host_from_url("https://connection.north-europe.azure.keboola.com") + assert result == "connection.north-europe.azure.keboola.com" + + def test_url_with_path(self) -> None: + assert _extract_host_from_url("https://connection.keboola.com/v2/") == "connection.keboola.com" + + def test_empty_url_raises(self) -> None: + with pytest.raises(ValueError, match="Cannot extract hostname"): + _extract_host_from_url("") + + +class TestKbcServiceCheckAvailable: + """Tests for KbcService.check_kbc_available().""" + + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_kbc_not_installed(self, mock_detect: MagicMock, tmp_config_dir: Path) -> None: + mock_detect.return_value = None + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + result = svc.check_kbc_available() + assert result["status"] == "warn" + assert "not found" in result["message"] + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_kbc_installed_with_version( + self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path + ) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(stdout="Version: 2.44.0\nGit commit: abc123\n") + + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + result = svc.check_kbc_available() + assert result["status"] == "pass" + assert "v2.44.0" in result["message"] + + +class TestKbcServiceGetVersion: + """Tests for KbcService.get_kbc_version().""" + + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_not_installed(self, mock_detect: MagicMock) -> None: + mock_detect.return_value = None + assert KbcService.get_kbc_version() is None + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_version_parsed(self, mock_detect: MagicMock, mock_run: MagicMock) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(stdout="Version: 2.44.0\nGit commit: abc123\n") + assert KbcService.get_kbc_version() == "2.44.0" + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_version_unparseable(self, mock_detect: MagicMock, mock_run: MagicMock) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(stdout="unknown output") + assert KbcService.get_kbc_version() is None + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_timeout(self, mock_detect: MagicMock, mock_run: MagicMock) -> None: + import subprocess + + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="kbc", timeout=5) + assert KbcService.get_kbc_version() is None + + +class TestKbcServiceRunLlmExport: + """Tests for KbcService.run_llm_export().""" + + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_kbc_not_found_raises(self, mock_detect: MagicMock, tmp_config_dir: Path) -> None: + mock_detect.return_value = None + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + with pytest.raises(ConfigError, match="kbc binary not found"): + svc.run_llm_export(alias="prod") + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_basic_export( + self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path + ) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(returncode=0) + + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path): + exit_code = svc.run_llm_export(alias="prod") + + assert exit_code == 0 + # Verify the command was built correctly + cmd = mock_run.call_args[0][0] + assert cmd[0] == "/usr/local/bin/kbc" + assert cmd[1:3] == ["llm", "export"] + assert "--storage-api-host" in cmd + assert "connection.keboola.com" in cmd + assert "--force" in cmd + assert "--non-interactive" in cmd + assert "--version-check=false" in cmd + assert "--with-samples" not in cmd + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_export_with_samples( + self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path + ) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(returncode=0) + + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path): + exit_code = svc.run_llm_export( + alias="prod", with_samples=True, sample_limit=50, max_samples=10 + ) + + assert exit_code == 0 + cmd = mock_run.call_args[0][0] + assert "--with-samples" in cmd + assert "--sample-limit" in cmd + assert "50" in cmd + assert "--max-samples" in cmd + assert "10" in cmd + + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_multiple_projects_requires_alias( + self, mock_detect: MagicMock, tmp_config_dir: Path + ) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + store = setup_two_projects(tmp_config_dir) + svc = KbcService(config_store=store) + + with pytest.raises(ConfigError, match="exactly one project"): + svc.run_llm_export() + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_export_nonzero_exit_code( + self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path + ) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(returncode=1) + + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path): + exit_code = svc.run_llm_export(alias="prod") + + assert exit_code == 1 + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_output_dir_created( + self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path + ) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(returncode=0) + + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path): + svc.run_llm_export(alias="prod") + + # Verify working-dir was set to tmp_path/prod + cmd = mock_run.call_args[0][0] + working_dir_idx = cmd.index("--working-dir") + assert cmd[working_dir_idx + 1] == str(tmp_path / "prod") + assert (tmp_path / "prod").is_dir() + + @patch("keboola_agent_cli.services.kbc_service.subprocess.run") + @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command") + def test_single_project_auto_resolve( + self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """When only one project exists, alias=None should auto-resolve.""" + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(returncode=0) + + store = setup_single_project(tmp_config_dir) + svc = KbcService(config_store=store) + + with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path): + exit_code = svc.run_llm_export() # No alias + + assert exit_code == 0 diff --git a/tests/test_version_service.py b/tests/test_version_service.py new file mode 100644 index 00000000..aeaff5f9 --- /dev/null +++ b/tests/test_version_service.py @@ -0,0 +1,269 @@ +"""Tests for VersionService - version detection and update checks.""" + +from unittest.mock import MagicMock, patch + +from keboola_agent_cli.services.version_service import ( + VersionService, + _fetch_kbc_latest_version, + _fetch_mcp_latest_version, + _get_kbc_local_version, + _is_up_to_date, + _is_uvx_available, +) + + +class TestGetKbcLocalVersion: + """Tests for _get_kbc_local_version().""" + + @patch("keboola_agent_cli.services.version_service.detect_kbc_command") + def test_not_installed(self, mock_detect: MagicMock) -> None: + mock_detect.return_value = None + assert _get_kbc_local_version() is None + + @patch("keboola_agent_cli.services.version_service.subprocess.run") + @patch("keboola_agent_cli.services.version_service.detect_kbc_command") + def test_version_parsed(self, mock_detect: MagicMock, mock_run: MagicMock) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(stdout="Version: 2.44.0\nGit commit: abc\n") + assert _get_kbc_local_version() == "2.44.0" + + @patch("keboola_agent_cli.services.version_service.subprocess.run") + @patch("keboola_agent_cli.services.version_service.detect_kbc_command") + def test_unparseable_output(self, mock_detect: MagicMock, mock_run: MagicMock) -> None: + mock_detect.return_value = "/usr/local/bin/kbc" + mock_run.return_value = MagicMock(stdout="no version here") + assert _get_kbc_local_version() is None + + +class TestIsUvxAvailable: + """Tests for _is_uvx_available().""" + + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uvx_found(self, mock_which: MagicMock) -> None: + mock_which.return_value = "/usr/local/bin/uvx" + assert _is_uvx_available() is True + + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uvx_not_found(self, mock_which: MagicMock) -> None: + mock_which.return_value = None + assert _is_uvx_available() is False + + +class TestFetchKbcLatestVersion: + """Tests for _fetch_kbc_latest_version().""" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_success(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"tag_name": "v2.44.2"} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + assert _fetch_kbc_latest_version() == "2.44.2" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_no_v_prefix(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"tag_name": "2.44.2"} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + assert _fetch_kbc_latest_version() == "2.44.2" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_http_error(self, mock_get: MagicMock) -> None: + import httpx + + mock_get.side_effect = httpx.HTTPError("connection failed") + assert _fetch_kbc_latest_version() is None + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_invalid_tag(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"tag_name": "not-a-version"} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + assert _fetch_kbc_latest_version() is None + + +class TestFetchMcpLatestVersion: + """Tests for _fetch_mcp_latest_version().""" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_success(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"info": {"version": "1.46.0"}} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + assert _fetch_mcp_latest_version() == "1.46.0" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_http_error(self, mock_get: MagicMock) -> None: + import httpx + + mock_get.side_effect = httpx.HTTPError("connection failed") + assert _fetch_mcp_latest_version() is None + + +class TestIsUpToDate: + """Tests for _is_up_to_date().""" + + def test_same_version(self) -> None: + assert _is_up_to_date("2.44.0", "2.44.0") is True + + def test_newer_available(self) -> None: + assert _is_up_to_date("2.44.0", "2.44.2") is False + + def test_local_newer(self) -> None: + assert _is_up_to_date("2.45.0", "2.44.2") is True + + def test_local_none(self) -> None: + assert _is_up_to_date(None, "2.44.2") is None + + def test_latest_none(self) -> None: + assert _is_up_to_date("2.44.0", None) is None + + def test_both_none(self) -> None: + assert _is_up_to_date(None, None) is None + + def test_invalid_version(self) -> None: + assert _is_up_to_date("not-a-version", "2.44.0") is None + + +class TestVersionService: + """Tests for VersionService.get_versions().""" + + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version") + @patch("keboola_agent_cli.services.version_service._is_uvx_available") + @patch("keboola_agent_cli.services.version_service._get_kbc_local_version") + def test_kbc_update_available_mcp_auto( + self, + mock_kbc_local: MagicMock, + mock_uvx: MagicMock, + mock_kbc_latest: MagicMock, + mock_mcp_latest: MagicMock, + ) -> None: + mock_kbc_local.return_value = "2.44.0" + mock_uvx.return_value = True + mock_kbc_latest.return_value = "2.44.2" + mock_mcp_latest.return_value = "1.46.0" + + svc = VersionService() + result = svc.get_versions() + + assert result["kbagent"]["version"] is not None + deps = result["dependencies"] + assert len(deps) == 2 + + kbc_dep = deps[0] + assert kbc_dep["name"] == "kbc" + assert kbc_dep["local_version"] == "2.44.0" + assert kbc_dep["latest_version"] == "2.44.2" + assert kbc_dep["up_to_date"] is False + + mcp_dep = deps[1] + assert mcp_dep["name"] == "keboola-mcp-server" + assert mcp_dep["auto_updates"] is True + assert mcp_dep["uvx_available"] is True + assert mcp_dep["latest_version"] == "1.46.0" + + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version") + @patch("keboola_agent_cli.services.version_service._is_uvx_available") + @patch("keboola_agent_cli.services.version_service._get_kbc_local_version") + def test_all_up_to_date( + self, + mock_kbc_local: MagicMock, + mock_uvx: MagicMock, + mock_kbc_latest: MagicMock, + mock_mcp_latest: MagicMock, + ) -> None: + mock_kbc_local.return_value = "2.44.2" + mock_uvx.return_value = True + mock_kbc_latest.return_value = "2.44.2" + mock_mcp_latest.return_value = "1.46.0" + + svc = VersionService() + result = svc.get_versions() + + kbc_dep = result["dependencies"][0] + assert kbc_dep["up_to_date"] is True + + mcp_dep = result["dependencies"][1] + assert mcp_dep["auto_updates"] is True + assert mcp_dep["latest_version"] == "1.46.0" + + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version") + @patch("keboola_agent_cli.services.version_service._is_uvx_available") + @patch("keboola_agent_cli.services.version_service._get_kbc_local_version") + def test_kbc_not_installed( + self, + mock_kbc_local: MagicMock, + mock_uvx: MagicMock, + mock_kbc_latest: MagicMock, + mock_mcp_latest: MagicMock, + ) -> None: + mock_kbc_local.return_value = None + mock_uvx.return_value = True + mock_kbc_latest.return_value = "2.44.2" + mock_mcp_latest.return_value = "1.46.0" + + svc = VersionService() + result = svc.get_versions() + + kbc_dep = result["dependencies"][0] + assert kbc_dep["local_version"] is None + assert kbc_dep["up_to_date"] is None + + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version") + @patch("keboola_agent_cli.services.version_service._is_uvx_available") + @patch("keboola_agent_cli.services.version_service._get_kbc_local_version") + def test_uvx_not_available( + self, + mock_kbc_local: MagicMock, + mock_uvx: MagicMock, + mock_kbc_latest: MagicMock, + mock_mcp_latest: MagicMock, + ) -> None: + mock_kbc_local.return_value = "2.44.0" + mock_uvx.return_value = False + mock_kbc_latest.return_value = "2.44.0" + mock_mcp_latest.return_value = "1.46.0" + + svc = VersionService() + result = svc.get_versions() + + mcp_dep = result["dependencies"][1] + assert mcp_dep["uvx_available"] is False + assert mcp_dep["auto_updates"] is True + + @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version") + @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version") + @patch("keboola_agent_cli.services.version_service._is_uvx_available") + @patch("keboola_agent_cli.services.version_service._get_kbc_local_version") + def test_remote_check_fails( + self, + mock_kbc_local: MagicMock, + mock_uvx: MagicMock, + mock_kbc_latest: MagicMock, + mock_mcp_latest: MagicMock, + ) -> None: + mock_kbc_local.return_value = "2.44.0" + mock_uvx.return_value = True + mock_kbc_latest.return_value = None + mock_mcp_latest.return_value = None + + svc = VersionService() + result = svc.get_versions() + + kbc_dep = result["dependencies"][0] + assert kbc_dep["latest_version"] is None + assert kbc_dep["up_to_date"] is None + + mcp_dep = result["dependencies"][1] + assert mcp_dep["latest_version"] is None From ad5c9432508abc544337ac9eb91955ffeb8f56e4 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 3 Mar 2026 23:12:19 +0100 Subject: [PATCH 3/3] Add .audit/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 961a04a6..98321599 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ ENV/ *.swo *~ .claude/ +.audit/ # Testing .pytest_cache/