Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
name: Publish

on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'cortexapps_cli/**'
- 'pyproject.toml'
- 'poetry.lock'

env:
CORTEX_API_KEY: ${{ secrets.CORTEX_API_KEY_PRODUCTION }}
Expand Down
21 changes: 21 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

<!-- insertion marker -->
## [1.19.0](https://github.com/cortexapps/cli/releases/tag/1.19.0) - 2026-06-01

<small>[Compare with 1.18.0](https://github.com/cortexapps/cli/compare/1.18.0...1.19.0)</small>

### Bug Fixes

- resolve Python 3.14 crash from builtin list shadowing in custom_events ([95e3f79](https://github.com/cortexapps/cli/commit/95e3f7960fc02ab6299cd765bafe39946f8fa847) by Jeff Schnitter).

## [1.18.0](https://github.com/cortexapps/cli/releases/tag/1.18.0) - 2026-06-01

<small>[Compare with 1.17.0](https://github.com/cortexapps/cli/compare/1.17.0...1.18.0)</small>

### Features

- add Python 3.14 support and update CI to test on 3.14 by default ([90c5654](https://github.com/cortexapps/cli/commit/90c56540c3034cb29212cbd1c06eb7a8eb4073b0) by Jeff Schnitter).

### Bug Fixes

- pin safety<3.8.0 to fix broken poetry audit in CI ([f706a6c](https://github.com/cortexapps/cli/commit/f706a6c0bb384f689eab7dbc14d1b0602cc62db2) by Jeff Schnitter).
- update idna to 3.17 to address CVE bypass (Dependabot #22) ([cb60c3c](https://github.com/cortexapps/cli/commit/cb60c3cbaf281b4b80e25e0d328209d300366d50) by Jeff Schnitter).

## [1.17.0](https://github.com/cortexapps/cli/releases/tag/1.17.0) - 2026-05-20

<small>[Compare with 1.16.0](https://github.com/cortexapps/cli/compare/1.16.0...1.17.0)</small>
Expand Down
1 change: 1 addition & 0 deletions cortexapps_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

5 changes: 3 additions & 2 deletions cortexapps_cli/commands/custom_events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections import defaultdict
from datetime import datetime
import json
from typing import List, Optional
from rich import print_json
import typer
from typing_extensions import Annotated
Expand Down Expand Up @@ -32,7 +33,7 @@ def _parse_key_value(values):
def update_by_uuid(
ctx: typer.Context,
file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help=" File containing custom event; can be passed as stdin with -, example: -f-")] = None,
custom_data: list[str] | None = typer.Option(None, "--custom", "-c", callback=_parse_key_value, help="List of optional custom metadata key=value pairs (only if file input not provided."),
custom_data: Optional[List[str]] = typer.Option(None, "--custom", "-c", callback=_parse_key_value, help="List of optional custom metadata key=value pairs (only if file input not provided."),
description: str = typer.Option(None, "--description", "-d", help="The description of the custom data key (only if file input not provided)."),
title: str = typer.Option(None, "--title", "-ti", help="The title of the custome event (only if file input not provided)."),
tag: str = typer.Option(..., "--tag", "-t", help="The tag (x-cortex-tag) or unique, auto-generated identifier for the entity."),
Expand Down Expand Up @@ -81,7 +82,7 @@ def update_by_uuid(
def create(
ctx: typer.Context,
file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help=" File containing custom event; can be passed as stdin with -, example: -f-")] = None,
custom_data: list[str] | None = typer.Option(None, "--custom", "-c", callback=_parse_key_value, help="List of optional custom metadata key=value pairs (only if file input not provided."),
custom_data: Optional[List[str]] = typer.Option(None, "--custom", "-c", callback=_parse_key_value, help="List of optional custom metadata key=value pairs (only if file input not provided."),
description: str = typer.Option(None, "--description", "-d", help="The description of the custom data key (only if file input not provided)."),
title: str = typer.Option(None, "--title", "-ti", help="The title of the custome event (only if file input not provided)."),
tag: str = typer.Option(..., "--tag", "-t", help="The tag (x-cortex-tag) or unique, auto-generated identifier for the entity."),
Expand Down
6 changes: 1 addition & 5 deletions cortexapps_cli/commands/entity_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ def update(
def get(
ctx: typer.Context,
entity_type: str = typer.Option(..., "--type", "-t", help="The entity type"),
_print: CommandOptions._print = True,
):
"""
Retrieve entity type
Expand All @@ -123,7 +122,4 @@ def get(
client = ctx.obj["client"]

r = client.get("api/v1/catalog/definitions/" + entity_type)
if _print:
print_json(data=r)
else:
return r
print_json(data=r)
2 changes: 1 addition & 1 deletion data/import/scorecards/cli-test-draft-scorecard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ rules:
weight: 1
level: You Made It
filter:
query: entity_descriptor.info.`x-cortex-tag` = "cli-test-service"
query: entity.tag() == "cli-test-service"
category: SERVICE
4 changes: 2 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ requests = "^2.33.0"
pyyaml = ">= 6.0.1, < 7"
urllib3 = ">= 2.7.0"
typer = ">=0.15,<1.0"
typing_extensions = ">=3.7.4.3"

[tool.poetry.scripts]
cortex = "cortexapps_cli.cli:app"
Expand Down
233 changes: 233 additions & 0 deletions tests/test_cortex_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import io
import logging
import pytest
import responses
import typer

from cortexapps_cli.cortex_client import CortexClient

BASE_URL = "https://api.test.example.com"


def make_client():
return CortexClient(
api_key="test-key",
tenant="default",
numeric_level=logging.WARNING,
base_url=BASE_URL,
rate_limit=60000,
)


# ---------------------------------------------------------------------------
# Task 6: Error handling paths (Lines 77-78, 162-173, 182-188)
# ---------------------------------------------------------------------------

def test_version_fallback_when_package_not_found():
from unittest.mock import patch
import importlib.metadata
with patch("importlib.metadata.version", side_effect=importlib.metadata.PackageNotFoundError):
client = CortexClient(
api_key="test-key",
tenant="default",
numeric_level=logging.WARNING,
base_url=BASE_URL,
rate_limit=60000,
)
assert client.version == "unknown"


@responses.activate
def test_request_error_with_violations():
client = make_client()
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
json={
"violations": [
{
"title": "Bad Field",
"description": "Field is invalid",
"violationType": "CONSTRAINT",
"pointer": "/field",
},
{
"title": "Missing Field",
"description": "Required field missing",
},
]
},
status=400,
)
with pytest.raises(typer.Exit):
client.get("api/v1/test")


@responses.activate
def test_request_error_with_violations_minimal_fields():
client = make_client()
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
json={
"violations": [
{}
]
},
status=400,
)
with pytest.raises(typer.Exit):
client.get("api/v1/test")


@responses.activate
def test_request_error_non_json_response():
client = make_client()
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
body="<html>502 Bad Gateway</html>",
status=502,
content_type="text/html",
)
with pytest.raises(typer.Exit):
client.get("api/v1/test")


# ---------------------------------------------------------------------------
# Task 7: Response fallbacks and read_file (Lines 198-201, 310)
# ---------------------------------------------------------------------------

@responses.activate
def test_request_ok_non_json_returns_text():
client = make_client()
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
body="plain text response",
status=200,
content_type="text/plain",
)
result = client.get("api/v1/test")
assert result == "plain text response"


def test_read_file():
client = make_client()
f = io.StringIO("file contents")
assert client.read_file(f) == "file contents"


# ---------------------------------------------------------------------------
# Task 8: `fetch` edge cases (Lines 229, 238-240, 250)
# ---------------------------------------------------------------------------

@responses.activate
def test_fetch_non_dict_non_list_response_breaks():
client = make_client()
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
body="not json at all",
status=200,
content_type="text/plain",
)
result = client.fetch("api/v1/test")
# When response is not dict/list, fetch breaks immediately with data_key still None.
# The return statement at line 252-257 creates {"total": 0, "page": 0, "totalPages": 0, None: []}
assert result["total"] == 0
assert result["page"] == 0
assert result["totalPages"] == 0
assert None in result
assert result[None] == []


@responses.activate
def test_fetch_list_response_pagination():
client = make_client()
# First page returns items
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
json=[{"id": 1}, {"id": 2}],
status=200,
)
# Second page returns empty list (signals end)
responses.add(
responses.GET,
f"{BASE_URL}/api/v1/test",
json=[],
status=200,
)
result = client.fetch("api/v1/test")
assert result == [{"id": 1}, {"id": 2}]


# ---------------------------------------------------------------------------
# Task 9: Entity helper methods with team type
# (Lines 270, 274-280, 283-289, 292-298, 301-307)
# ---------------------------------------------------------------------------

@responses.activate
def test_get_entity_team_type():
client = make_client()
responses.add(responses.GET, f"{BASE_URL}/api/v1/teams/my-team", json={"tag": "my-team"}, status=200)
result = client.get_entity("my-team", entity_type="team")
assert result["tag"] == "my-team"
assert "/teams/" in responses.calls[0].request.url


@responses.activate
def test_get_entity_catalog_type():
client = make_client()
responses.add(responses.GET, f"{BASE_URL}/api/v1/catalog/my-service", json={"tag": "my-service"}, status=200)
result = client.get_entity("my-service", entity_type="service")
assert "/catalog/" in responses.calls[0].request.url


@responses.activate
def test_delete_entity_team_type():
client = make_client()
responses.add(responses.DELETE, f"{BASE_URL}/api/v1/teams/my-team", json={}, status=200)
client.delete_entity("my-team", entity_type="teams")
assert "/teams/" in responses.calls[0].request.url


@responses.activate
def test_delete_entity_catalog_type():
client = make_client()
responses.add(responses.DELETE, f"{BASE_URL}/api/v1/catalog/my-service", json={}, status=200)
client.delete_entity("my-service", entity_type="service")
assert "/catalog/" in responses.calls[0].request.url


@responses.activate
def test_archive_entity_team_type():
client = make_client()
responses.add(responses.PUT, f"{BASE_URL}/api/v1/teams/my-team/archive", json={}, status=200)
client.archive_entity("my-team", entity_type="team")
assert "/teams/" in responses.calls[0].request.url


@responses.activate
def test_archive_entity_catalog_type():
client = make_client()
responses.add(responses.PUT, f"{BASE_URL}/api/v1/catalog/my-service/archive", json={}, status=200)
client.archive_entity("my-service", entity_type="service")
assert "/catalog/" in responses.calls[0].request.url


@responses.activate
def test_unarchive_entity_team_type():
client = make_client()
responses.add(responses.PUT, f"{BASE_URL}/api/v1/teams/my-team/unarchive", json={}, status=200)
client.unarchive_entity("my-team", entity_type="team")
assert "/teams/" in responses.calls[0].request.url


@responses.activate
def test_unarchive_entity_catalog_type():
client = make_client()
responses.add(responses.PUT, f"{BASE_URL}/api/v1/catalog/my-service/unarchive", json={}, status=200)
client.unarchive_entity("my-service", entity_type="service")
assert "/catalog/" in responses.calls[0].request.url
3 changes: 3 additions & 0 deletions tests/test_entity_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ def test_resource_definitions(capsys):
response = cli(["entity-types", "get", "-t", "cli-test"])
assert response.get('iconTag') == "Cortex-builtin::Basketball", "iconTag should be set to Cortex-builtin::Basketball"

# Verify default columns are set when using --table output
cli(["entity-types", "list", "--table"], return_type=ReturnType.STDOUT)

cli(["entity-types", "update", "-t", "cli-test", "-f", "data/run-time/entity-type-update.json"])


Expand Down
13 changes: 13 additions & 0 deletions tests/test_integrations_apiiro.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ def test_integrations_apiiro_validate():
def test_integrations_apiiro_validate_all():
responses.add(responses.POST, os.getenv("CORTEX_BASE_URL") + "/api/v1/apiiro/configuration/validate", json={}, status=200)
cli(["integrations", "apiiro", "validate-all"])

@responses.activate
def test_integrations_apiiro_add_file_with_flags_error(tmp_path):
f = _dummy_file(tmp_path)
result = cli(["integrations", "apiiro", "add", "-a", "test", "--api-key", "key", "-f", str(f)], return_type=ReturnType.RAW)
assert result.exit_code != 0

@responses.activate
def test_integrations_apiiro_add_multiple_valid(tmp_path):
f = tmp_path / "valid.json"
f.write_text('{"configurations": []}')
responses.add(responses.PUT, os.getenv("CORTEX_BASE_URL") + "/api/v1/apiiro/configurations", json={}, status=200)
cli(["integrations", "apiiro", "add-multiple", "-f", str(f)])
13 changes: 13 additions & 0 deletions tests/test_integrations_argocd.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ def test_integrations_argocd_validate():
def test_integrations_argocd_validate_all():
responses.add(responses.POST, os.getenv("CORTEX_BASE_URL") + "/api/v1/argocd/configuration/validate", json={}, status=200)
cli(["integrations", "argocd", "validate-all"])

@responses.activate
def test_integrations_argocd_add_file_with_flags_error(tmp_path):
f = _dummy_file(tmp_path)
result = cli(["integrations", "argocd", "add", "-a", "test", "-h", "host", "-u", "user", "-p", "pass", "-f", str(f)], return_type=ReturnType.RAW)
assert result.exit_code != 0

@responses.activate
def test_integrations_argocd_add_multiple_valid(tmp_path):
f = tmp_path / "valid.json"
f.write_text('{"configurations": []}')
responses.add(responses.PUT, os.getenv("CORTEX_BASE_URL") + "/api/v1/argocd/configurations", json={}, status=200)
cli(["integrations", "argocd", "add-multiple", "-f", str(f)])
Loading