From 17df4cc1b4d7a506d92051ac08b7c65dbe067653 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 24 Mar 2026 18:34:49 +0100 Subject: [PATCH 1/3] Add folder name to config list output Resolves #36. Fetches KBC.configuration.folderName metadata via search/component-configurations endpoint and adds 'folder' field to config list output (JSON and human table). Graceful fallback: if metadata search fails, folder is empty string. --- src/keboola_agent_cli/client.py | 33 +++++++++++++++++++ src/keboola_agent_cli/output.py | 2 ++ .../services/config_service.py | 13 +++++++- tests/test_cli.py | 10 +++--- tests/test_output.py | 6 ++-- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index 9b1d405f..1a8bfaab 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -233,6 +233,39 @@ def get_config_detail(self, component_id: str, config_id: str) -> dict[str, Any] ) return response.json() + def list_config_folder_metadata(self, branch_id: int | None = None) -> dict[str, str]: + """Fetch folder names for all configurations via metadata search. + + Uses the search/component-configurations endpoint to find configs + with ``KBC.configuration.folderName`` metadata. + + Args: + branch_id: If set, search in a specific dev branch. + + Returns: + Dict mapping ``"{component_id}/{config_id}"`` to folder name. + """ + prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage" + resp = self._request( + "GET", + f"{prefix}/search/component-configurations", + params={ + "metadataKeys[]": "KBC.configuration.folderName", + "include": "filteredMetadata", + }, + ) + folder_map: dict[str, str] = {} + for item in resp.json(): + comp_id = item.get("idComponent", "") + config_id = str(item.get("configurationId", "")) + meta = next( + (m for m in item.get("metadata", []) if m["key"] == "KBC.configuration.folderName"), + None, + ) + if meta: + folder_map[f"{comp_id}/{config_id}"] = meta["value"] + return folder_map + def create_config( self, component_id: str, diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py index 29aceafe..347aac14 100644 --- a/src/keboola_agent_cli/output.py +++ b/src/keboola_agent_cli/output.py @@ -163,6 +163,7 @@ def format_configs_table(console: Console, data: dict[str, Any]) -> None: table.add_column("Type", style="dim") table.add_column("Config ID", justify="right") table.add_column("Config Name") + table.add_column("Folder", style="dim") table.add_column("Last Modified", style="dim") table.add_column("Modified By", style="dim") @@ -177,6 +178,7 @@ def format_configs_table(console: Console, data: dict[str, Any]) -> None: cfg["component_type"], cfg["config_id"], cfg["config_name"], + cfg.get("folder", ""), last_mod, cfg.get("last_modified_by", ""), ) diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 0079cda5..cc1bcc32 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -64,6 +64,15 @@ def _fetch_project_configs( client = self._client_factory(project.stack_url, project.token) try: components = client.list_components(component_type=component_type) + + # Fetch folder metadata in one call (maps "comp_id/config_id" -> folder name) + branch_id = project.active_branch_id + try: + result = client.list_config_folder_metadata(branch_id=branch_id) + folder_map = result if isinstance(result, dict) else {} + except Exception: + folder_map = {} # graceful fallback if search endpoint unavailable + configs: list[dict[str, Any]] = [] for component in components: comp_id = component.get("id", "") @@ -79,6 +88,7 @@ def _fetch_project_configs( # Extract last-modified info from currentVersion current_version = cfg.get("currentVersion", {}) creator_token = current_version.get("creatorToken", {}) + cfg_id = str(cfg.get("id", "")) configs.append( { @@ -86,12 +96,13 @@ def _fetch_project_configs( "component_id": comp_id, "component_name": comp_name, "component_type": comp_type, - "config_id": str(cfg.get("id", "")), + "config_id": cfg_id, "config_name": cfg.get("name", ""), "config_description": cfg.get("description", ""), "last_modified": current_version.get("created", ""), "last_modified_by": creator_token.get("description", ""), "last_change_description": current_version.get("changeDescription", ""), + "folder": folder_map.get(f"{comp_id}/{cfg_id}", ""), } ) return (alias, configs, True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e3fd45e..90af5717 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -727,9 +727,9 @@ def test_config_list_human_output(self, tmp_path: Path) -> None: # Should show project-grouped table assert "prod" in result.output assert "Configurations" in result.output - # Rich may truncate long names in narrow terminals, check for prefix - assert "Production" in result.output - assert "keboola.ex" in result.output + # Rich may truncate long names in narrow terminals + assert "Product" in result.output + assert "keboola" in result.output def test_config_list_project_filter(self, tmp_path: Path) -> None: """config list --project X returns configs only from that project.""" @@ -1065,8 +1065,8 @@ def factory(url, token): assert result.exit_code == 0 # Should show configs from good project assert "Configurations" in result.output - # Rich may truncate long names in narrow terminals, check for prefix - assert "Production" in result.output + # Rich may truncate long names in narrow terminals + assert "Product" in result.output # Should show warning about bad project assert "bad" in result.output assert "Token expired" in result.output diff --git a/tests/test_output.py b/tests/test_output.py index ebea69e1..53ae8f99 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -891,9 +891,9 @@ def test_configs_table_grouped_by_project(self) -> None: assert "Configurations" in output assert "prod" in output - # Rich may word-wrap long names across lines in narrow console - assert "Production" in output - assert "Write to" in output + # Rich may truncate long names in narrow console (7 columns) + assert "Product" in output + assert "Write" in output def test_configs_table_empty(self) -> None: """format_configs_table shows helpful message when no configs found.""" From 2ba130e8988aff6ee82c68ea50beee533916ce0a Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 24 Mar 2026 18:49:49 +0100 Subject: [PATCH 2/3] Fix folder metadata: require branch ID for search endpoint The /search/component-configurations endpoint is branch-only (returns 404 without branch prefix). Now fetches default branch ID from dev-branches API when no active branch is set. Also makes branch_id required in list_config_folder_metadata() to prevent silent failures. --- src/keboola_agent_cli/client.py | 8 ++++--- .../services/config_service.py | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index 1a8bfaab..4fa817d0 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -233,19 +233,21 @@ def get_config_detail(self, component_id: str, config_id: str) -> dict[str, Any] ) return response.json() - def list_config_folder_metadata(self, branch_id: int | None = None) -> dict[str, str]: + def list_config_folder_metadata(self, branch_id: int) -> dict[str, str]: """Fetch folder names for all configurations via metadata search. Uses the search/component-configurations endpoint to find configs with ``KBC.configuration.folderName`` metadata. + Note: This endpoint requires a branch ID (branch-only route). + Args: - branch_id: If set, search in a specific dev branch. + branch_id: Branch ID (required — use default branch for production). Returns: Dict mapping ``"{component_id}/{config_id}"`` to folder name. """ - prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage" + prefix = f"/v2/storage/branch/{branch_id}" resp = self._request( "GET", f"{prefix}/search/component-configurations", diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index cc1bcc32..905b4d28 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -65,13 +65,26 @@ def _fetch_project_configs( try: components = client.list_components(component_type=component_type) - # Fetch folder metadata in one call (maps "comp_id/config_id" -> folder name) - branch_id = project.active_branch_id + # Fetch folder metadata (requires branch ID — search endpoint is branch-only) + folder_map: dict[str, str] = {} try: - result = client.list_config_folder_metadata(branch_id=branch_id) - folder_map = result if isinstance(result, dict) else {} + # Use active branch or find the default branch ID + branch_id = project.active_branch_id + if not branch_id: + default_branch = next( + (c for c in components if True), # just need any component to get branch + None, + ) + # Fetch default branch ID from dev-branches endpoint + branches = client.list_dev_branches() + default = next((b for b in branches if b.get("isDefault")), None) + if default: + branch_id = default["id"] + if branch_id: + result = client.list_config_folder_metadata(branch_id=branch_id) + folder_map = result if isinstance(result, dict) else {} except Exception: - folder_map = {} # graceful fallback if search endpoint unavailable + pass # graceful fallback if search endpoint unavailable configs: list[dict[str, Any]] = [] for component in components: From 6a8e90d5039bcd03f973003bf72844dc77afadd9 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 24 Mar 2026 18:56:34 +0100 Subject: [PATCH 3/3] Remove unused variable flagged by ruff --- src/keboola_agent_cli/services/config_service.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 905b4d28..5f381016 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -71,10 +71,6 @@ def _fetch_project_configs( # Use active branch or find the default branch ID branch_id = project.active_branch_id if not branch_id: - default_branch = next( - (c for c in components if True), # just need any component to get branch - None, - ) # Fetch default branch ID from dev-branches endpoint branches = client.list_dev_branches() default = next((b for b in branches if b.get("isDefault")), None)