From 0cc8358b1b8521578b0e2a3ef71244fa556ea2a2 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 25 Jun 2026 16:34:25 -0700 Subject: [PATCH 1/2] Add catalogs CLI command Add list, get, create, and delete commands for the catalogs public API. Co-Authored-By: Claude Opus 4.6 --- cortexapps_cli/cli.py | 2 + cortexapps_cli/commands/catalogs.py | 84 ++++++++++++++++++++++ data/import/catalogs/cli-test-catalog.json | 7 ++ tests/test_catalogs.py | 24 +++++++ 4 files changed, 117 insertions(+) create mode 100644 cortexapps_cli/commands/catalogs.py create mode 100644 data/import/catalogs/cli-test-catalog.json create mode 100644 tests/test_catalogs.py diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index 5664c5c..5315ff0 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -16,6 +16,7 @@ import cortexapps_cli.commands.audit_logs as audit_logs import cortexapps_cli.commands.backup as backup import cortexapps_cli.commands.catalog as catalog +import cortexapps_cli.commands.catalogs as catalogs import cortexapps_cli.commands.custom_data as custom_data import cortexapps_cli.commands.custom_events as custom_events import cortexapps_cli.commands.custom_metrics as custom_metrics @@ -54,6 +55,7 @@ app.add_typer(audit_logs.app, name="audit-logs") app.add_typer(backup.app, name="backup") app.add_typer(catalog.app, name="catalog") +app.add_typer(catalogs.app, name="catalogs") app.add_typer(custom_data.app, name="custom-data") app.add_typer(custom_events.app, name="custom-events") app.add_typer(custom_metrics.app, name="custom-metrics") diff --git a/cortexapps_cli/commands/catalogs.py b/cortexapps_cli/commands/catalogs.py new file mode 100644 index 0000000..fd5446a --- /dev/null +++ b/cortexapps_cli/commands/catalogs.py @@ -0,0 +1,84 @@ +import json +from rich import print_json +import typer +from typing_extensions import Annotated +from cortexapps_cli.command_options import CommandOptions +from cortexapps_cli.command_options import ListCommandOptions +from cortexapps_cli.utils import print_output_with_context + +app = typer.Typer( + help="Catalogs commands — manage catalog pages (distinct from 'catalog' which manages entities)", + no_args_is_help=True +) + +@app.command(name="list") +def catalogs_list( + ctx: typer.Context, + _print: CommandOptions._print = True, + table_output: ListCommandOptions.table_output = False, + csv_output: ListCommandOptions.csv_output = False, + columns: ListCommandOptions.columns = [], + no_headers: ListCommandOptions.no_headers = False, + filters: ListCommandOptions.filters = [], + sort: ListCommandOptions.sort = [], +): + """ + List all catalogs. + """ + client = ctx.obj["client"] + + if (table_output or csv_output) and not ctx.params.get('columns'): + ctx.params['columns'] = [ + "Name=name", + "Slug=slug", + "Description=description", + "Icon=iconTag", + "IsDraft=isDraft", + "Type=catalogType", + ] + + result = client.get("api/v1/catalogs") + + if _print: + print_output_with_context(ctx, result) + else: + return result + +@app.command() +def get( + ctx: typer.Context, + tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="The slug or unique ID of the catalog"), +): + """ + Retrieve a catalog by its slug or ID. + """ + client = ctx.obj["client"] + + result = client.get("api/v1/catalogs/" + tag_or_id) + print_json(data=result) + +@app.command() +def create( + ctx: typer.Context, + file_input: Annotated[typer.FileText, typer.Option(..., "--file", "-f", help="File containing JSON body of the catalog request, can be passed as stdin with -, example: -f-")] = None, +): + """ + Create a catalog. The JSON body should include: name, slug, iconTag, and optionally description, isDraft, filter, relationshipTypeId, catalogType. + """ + client = ctx.obj["client"] + data = json.loads("".join([line for line in file_input])) + + result = client.post("api/v1/catalogs", data=data) + print_json(data=result) + +@app.command() +def delete( + ctx: typer.Context, + tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="The slug or unique ID of the catalog"), +): + """ + Delete a catalog by its slug or ID. + """ + client = ctx.obj["client"] + + client.delete("api/v1/catalogs/" + tag_or_id) diff --git a/data/import/catalogs/cli-test-catalog.json b/data/import/catalogs/cli-test-catalog.json new file mode 100644 index 0000000..37a5144 --- /dev/null +++ b/data/import/catalogs/cli-test-catalog.json @@ -0,0 +1,7 @@ +{ + "name": "CLI Test Catalog", + "slug": "cli-test-catalog", + "description": "Catalog created by CLI integration tests", + "iconTag": "service", + "isDraft": false +} diff --git a/tests/test_catalogs.py b/tests/test_catalogs.py new file mode 100644 index 0000000..048dc4e --- /dev/null +++ b/tests/test_catalogs.py @@ -0,0 +1,24 @@ +from tests.helpers.utils import * + +def test_catalogs_crud(): + """Test full lifecycle: create, list, get, delete for catalogs.""" + + # Create a catalog from a JSON file + cli(["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json"]) + + # List catalogs and verify the new one exists + response = cli(["catalogs", "list"]) + assert any(catalog['slug'] == 'cli-test-catalog' for catalog in response['catalogs']), \ + "Should find catalog with slug cli-test-catalog" + + # Get the specific catalog by slug + response = cli(["catalogs", "get", "-t", "cli-test-catalog"]) + assert response['name'] == "CLI Test Catalog", "Catalog name should match" + assert response['slug'] == "cli-test-catalog", "Catalog slug should match" + + # Delete the catalog + cli(["catalogs", "delete", "-t", "cli-test-catalog"]) + + # Verify deletion - get should return 404 + result = cli(["catalogs", "get", "-t", "cli-test-catalog"], ReturnType.RAW) + assert result.exit_code == 1, "Getting a deleted catalog should fail" From 022224dd6ce027fe16a24a8eff660a05069de9cd Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 6 Jul 2026 12:56:37 -0700 Subject: [PATCH 2/2] feat: add catalogs CLI commands (list, get, create, delete) with UPSERT/CREATE mode support --- .github/workflows/test-pr.yml | 4 ++ cortexapps_cli/commands/catalogs.py | 63 ++++++++++++++++++++++------- tests/test_catalogs.py | 35 ++++++++++++++-- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml index 7dd933a..ebf265a 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test-pr.yml @@ -21,6 +21,10 @@ on: - 'cortexapps_cli/**' - 'tests/**' +concurrency: + group: test-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + env: AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} CORTEX_API_KEY: ${{ secrets.CORTEX_API_KEY }} diff --git a/cortexapps_cli/commands/catalogs.py b/cortexapps_cli/commands/catalogs.py index fd5446a..f746126 100644 --- a/cortexapps_cli/commands/catalogs.py +++ b/cortexapps_cli/commands/catalogs.py @@ -1,5 +1,5 @@ import json -from rich import print_json +from typing import Optional import typer from typing_extensions import Annotated from cortexapps_cli.command_options import CommandOptions @@ -14,6 +14,8 @@ @app.command(name="list") def catalogs_list( ctx: typer.Context, + page: ListCommandOptions.page = None, + page_size: ListCommandOptions.page_size = 250, _print: CommandOptions._print = True, table_output: ListCommandOptions.table_output = False, csv_output: ListCommandOptions.csv_output = False, @@ -23,7 +25,7 @@ def catalogs_list( sort: ListCommandOptions.sort = [], ): """ - List all catalogs. + List all catalogs. API key must have the View catalogs permission. """ client = ctx.obj["client"] @@ -37,48 +39,79 @@ def catalogs_list( "Type=catalogType", ] - result = client.get("api/v1/catalogs") + params = {k: v for k, v in {"page": page, "pageSize": page_size}.items() if v is not None} + + result = client.fetch("api/v1/catalogs", params=params) if page is None else client.get("api/v1/catalogs", params=params) if _print: print_output_with_context(ctx, result) else: return result + @app.command() def get( ctx: typer.Context, - tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="The slug or unique ID of the catalog"), + slug: str = typer.Option(..., "--slug", "-s", help="The slug of the catalog"), + _print: CommandOptions._print = True, ): """ - Retrieve a catalog by its slug or ID. + Retrieve a catalog by its slug. API key must have the View catalogs permission. """ client = ctx.obj["client"] - result = client.get("api/v1/catalogs/" + tag_or_id) - print_json(data=result) + result = client.get("api/v1/catalogs/" + slug) + + if _print: + print_output_with_context(ctx, result) + else: + return result + @app.command() def create( ctx: typer.Context, - file_input: Annotated[typer.FileText, typer.Option(..., "--file", "-f", help="File containing JSON body of the catalog request, can be passed as stdin with -, example: -f-")] = None, + file_input: Annotated[ + typer.FileText, + typer.Option(..., "--file", "-f", help="File containing JSON catalog definition; use - for stdin, e.g. -f-"), + ], + mode: Optional[str] = typer.Option( + None, + "--mode", + "-m", + help="UPSERT (default): create or replace existing catalog. CREATE: fail if slug already exists.", + ), + _print: CommandOptions._print = True, ): """ - Create a catalog. The JSON body should include: name, slug, iconTag, and optionally description, isDraft, filter, relationshipTypeId, catalogType. + Create or replace a catalog. API key must have the Edit catalogs permission. + + JSON fields: name (required), slug (required), iconTag (required), description, + isDraft, filter, relationshipTypeTag, catalogType (FILTER|RELATIONSHIP_TYPE|DOMAIN). """ client = ctx.obj["client"] - data = json.loads("".join([line for line in file_input])) + data = json.loads(file_input.read()) + + params = {} + if mode: + params["mode"] = mode.upper() + + result = client.post("api/v1/catalogs", data=data, params=params if params else None) + + if _print: + print_output_with_context(ctx, result) + else: + return result - result = client.post("api/v1/catalogs", data=data) - print_json(data=result) @app.command() def delete( ctx: typer.Context, - tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="The slug or unique ID of the catalog"), + slug: str = typer.Option(..., "--slug", "-s", help="The slug of the catalog to delete"), ): """ - Delete a catalog by its slug or ID. + Delete a catalog by its slug. API key must have the Edit catalogs permission. """ client = ctx.obj["client"] - client.delete("api/v1/catalogs/" + tag_or_id) + client.delete("api/v1/catalogs/" + slug) diff --git a/tests/test_catalogs.py b/tests/test_catalogs.py index 048dc4e..2f2f98e 100644 --- a/tests/test_catalogs.py +++ b/tests/test_catalogs.py @@ -1,5 +1,6 @@ from tests.helpers.utils import * + def test_catalogs_crud(): """Test full lifecycle: create, list, get, delete for catalogs.""" @@ -12,13 +13,41 @@ def test_catalogs_crud(): "Should find catalog with slug cli-test-catalog" # Get the specific catalog by slug - response = cli(["catalogs", "get", "-t", "cli-test-catalog"]) + response = cli(["catalogs", "get", "-s", "cli-test-catalog"]) assert response['name'] == "CLI Test Catalog", "Catalog name should match" assert response['slug'] == "cli-test-catalog", "Catalog slug should match" # Delete the catalog - cli(["catalogs", "delete", "-t", "cli-test-catalog"]) + cli(["catalogs", "delete", "-s", "cli-test-catalog"]) # Verify deletion - get should return 404 - result = cli(["catalogs", "get", "-t", "cli-test-catalog"], ReturnType.RAW) + result = cli(["catalogs", "get", "-s", "cli-test-catalog"], ReturnType.RAW) assert result.exit_code == 1, "Getting a deleted catalog should fail" + + +def test_catalogs_upsert_replaces_existing(): + """UPSERT mode (default) should replace an existing catalog without error.""" + + cli(["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json"]) + + try: + # Second create with same slug should succeed (upsert replaces) + response = cli(["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json"]) + assert response['slug'] == 'cli-test-catalog', "Upserted catalog should be returned" + finally: + cli(["catalogs", "delete", "-s", "cli-test-catalog"]) + + +def test_catalogs_create_mode_fails_on_duplicate(): + """mode=CREATE should fail with an error if the slug already exists.""" + + cli(["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json"]) + + try: + result = cli( + ["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json", "--mode", "CREATE"], + ReturnType.RAW, + ) + assert result.exit_code == 1, "CREATE mode should fail if slug already exists" + finally: + cli(["catalogs", "delete", "-s", "cli-test-catalog"])