From 987cc1701f740835cb2b3c7f9c970471d709a37e Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 14:06:28 -0700 Subject: [PATCH 01/14] first commit --- pyproject.toml | 2 +- src/groundlight/cli.py | 97 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7d2b03af..39d3da11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [ {include = "**/*.py", from = "src"}, ] readme = "README.md" -version = "0.27.0" +version = "0.28.0" [tool.poetry.dependencies] # For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index de33e741..f81bcda3 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -1,17 +1,33 @@ +import logging +import sys +from enum import Enum from functools import wraps -from typing import Union +from typing import Any, Union import typer from typing_extensions import get_origin -from groundlight import Groundlight +from groundlight import ExperimentalApi, Groundlight from groundlight.client import ApiTokenError +logger = logging.getLogger("groundlight.sdk") + cli_app = typer.Typer( no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, ) +experimental_app = typer.Typer( + no_args_is_help=True, + help="Experimental commands — may change or be removed without notice.", + context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, +) +cli_app.add_typer(experimental_app, name="experimental") +cli_app.add_typer(experimental_app, name="exp") + + +_CLI_PRIMITIVE_TYPES = (str, int, float, bool) + def is_cli_supported_type(annotation): """ @@ -21,15 +37,45 @@ def is_cli_supported_type(annotation): return annotation in (int, float, bool) -def class_func_to_cli(method): +def is_cli_representable(annotation) -> bool: + """Returns True if the annotation is a type Typer can natively represent as a CLI argument. + + Primitive scalar types, Enum subclasses, and Union types (handled separately) are considered + representable. Complex types like dict, list, bytes, and custom model classes are not. + """ + if annotation in _CLI_PRIMITIVE_TYPES: + return True + if isinstance(annotation, type) and issubclass(annotation, Enum): + return True + if get_origin(annotation) is Union: + return True + return False + + +def _format_result(result: Any) -> str: + """Format a method return value for CLI output. + + Pydantic models are serialized to indented JSON. Everything else falls back to str(). + """ + try: + return result.model_dump_json(indent=2) + except AttributeError: + return str(result) + + +def class_func_to_cli(method, is_experimental: bool = False): """ - Given the class method, create a method with the identical signature to provide the help documentation and - but only instantiates the class when the method is actually called. + Given a class method, return a wrapper function with the same signature that Typer can + register as a CLI command. The wrapper instantiates ExperimentalApi at call time (which + also provides all stable Groundlight methods via inheritance), so a single instantiation + path serves both stable and experimental commands. + + If is_experimental is True, a warning is printed to stderr before the method runs. """ - # We create a fake class and fake method so we have the correct annotations for typer to use - # When we wrap the fake method, we only use the fake method's name to access the real method - # and attach it to a Groundlight instance that we create at function call time + # We create a fake class and fake method so we have the correct annotations for typer to use. + # When we wrap the fake method, we only use the fake method's name to look up and call the + # real method on an ExperimentalApi instance created at call time. class FakeClass: pass @@ -38,14 +84,22 @@ class FakeClass: @wraps(fake_method) def wrapper(*args, **kwargs): - gl = Groundlight() - gl_method = vars(Groundlight)[fake_method.__name__] - gl_bound_method = gl_method.__get__(gl, Groundlight) # pylint: disable=all - print(gl_bound_method(*args, **kwargs)) # this is where we output to the console + if is_experimental: + print( + f"Warning: '{fake_method.__name__}' is an experimental command and may change without notice.", + file=sys.stderr, + ) + gl = ExperimentalApi() + bound_method = getattr(gl, fake_method.__name__) + result = bound_method(*args, **kwargs) + if result is not None: + print(_format_result(result)) # not recommended practice to directly change annotations, but gets around Typer not supporting Union types cli_unsupported_params = [] for name, annotation in method.__annotations__.items(): + if name == "return": + continue if get_origin(annotation) is Union: # If we can submit a string, we take the string from the cli if str in annotation.__args__: @@ -60,6 +114,11 @@ def wrapper(*args, **kwargs): break if not found_supported_type: cli_unsupported_params.append(name) + elif is_experimental and not is_cli_representable(annotation): + # For experimental methods only: proactively flag non-Union types that Typer cannot + # represent (e.g. dict, list, custom models) so the caller can skip them gracefully + # before Typer raises a deferred RuntimeError at cli_app() invocation time. + cli_unsupported_params.append(name) # Ideally we could just not list the unsupported params, but it doesn't seem natively supported by Typer # and requires more metaprogamming than makes sense at the moment. For now, we require methods to support str for param in cli_unsupported_params: @@ -72,12 +131,24 @@ def wrapper(*args, **kwargs): def groundlight(): + """Entry point for the groundlight CLI.""" try: - # For each method in the Groundlight class, create a function that can be called from the command line + stable_names = {n for n, m in vars(Groundlight).items() if callable(m) and not n.startswith("_")} + for name, method in vars(Groundlight).items(): if callable(method) and not name.startswith("_"): cli_func = class_func_to_cli(method) cli_app.command()(cli_func) + + for name, method in vars(ExperimentalApi).items(): + if not callable(method) or name.startswith("_") or name in stable_names: + continue + try: + cli_func = class_func_to_cli(method, is_experimental=True) + experimental_app.command()(cli_func) + except Exception as e: # pylint: disable=broad-except + logger.debug("Skipping experimental CLI command '%s': %s", name, e) + cli_app() except ApiTokenError as e: print(e) From de74ef74e6aa08e41abaef075450fa4fcc7cabc3 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 15:05:15 -0700 Subject: [PATCH 02/14] adding pretty print for return values --- src/groundlight/cli.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index f81bcda3..602c58ed 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -1,9 +1,14 @@ +import json import logging import sys +from datetime import datetime from enum import Enum from functools import wraps from typing import Any, Union +from groundlight_openapi_client.model_utils import OpenApiModel +from pydantic import BaseModel + import typer from typing_extensions import get_origin @@ -52,15 +57,26 @@ def is_cli_representable(annotation) -> bool: return False +def _json_default(obj: Any) -> Any: + """Fallback serializer for json.dumps — handles datetime values.""" + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + def _format_result(result: Any) -> str: - """Format a method return value for CLI output. + """Format a CLI result value as a human-readable, jq-compatible string. - Pydantic models are serialized to indented JSON. Everything else falls back to str(). + Pydantic models and OpenAPI client objects are serialized to indented JSON. + Plain dicts and lists are also JSON. Everything else falls back to str(). """ - try: + if isinstance(result, BaseModel): return result.model_dump_json(indent=2) - except AttributeError: - return str(result) + if isinstance(result, OpenApiModel): + return json.dumps(result.to_dict(), indent=2, default=_json_default) + if isinstance(result, (dict, list)): + return json.dumps(result, indent=2, default=_json_default) + return str(result) def class_func_to_cli(method, is_experimental: bool = False): From a56e831983a0b5072d7dda0de8660769a3143a4e Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 27 Apr 2026 22:05:56 +0000 Subject: [PATCH 03/14] Automatically reformatting code --- src/groundlight/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 602c58ed..6185f86e 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -6,10 +6,9 @@ from functools import wraps from typing import Any, Union +import typer from groundlight_openapi_client.model_utils import OpenApiModel from pydantic import BaseModel - -import typer from typing_extensions import get_origin from groundlight import ExperimentalApi, Groundlight From e0d97342b4b3303dcff22d9dbf048d117d6673e7 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 16:01:19 -0700 Subject: [PATCH 04/14] organizing commands into groupings --- src/groundlight/cli.py | 114 ++++++++++++++++++++++++++++++++++++----- test/unit/test_cli.py | 25 +++++---- 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 602c58ed..787951fe 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -27,11 +27,8 @@ help="Experimental commands — may change or be removed without notice.", context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, ) -cli_app.add_typer(experimental_app, name="experimental") -cli_app.add_typer(experimental_app, name="exp") - - -_CLI_PRIMITIVE_TYPES = (str, int, float, bool) +cli_app.add_typer(experimental_app, name="exp", rich_help_panel="Subcommands") +cli_app.add_typer(experimental_app, name="experimental", hidden=True) def is_cli_supported_type(annotation): @@ -48,7 +45,7 @@ def is_cli_representable(annotation) -> bool: Primitive scalar types, Enum subclasses, and Union types (handled separately) are considered representable. Complex types like dict, list, bytes, and custom model classes are not. """ - if annotation in _CLI_PRIMITIVE_TYPES: + if annotation in (str, int, float, bool): return True if isinstance(annotation, type) and issubclass(annotation, Enum): return True @@ -146,22 +143,115 @@ def wrapper(*args, **kwargs): return wrapper +# Methods to exclude from the CLI entirely. These may be too complex to express +# as CLI commands, deprecated, or otherwise not useful from a shell context. +_CLI_EXCLUDED_METHODS = { + "make_action", + "create_rule", + "get_rule", + "delete_rule", + "list_rules", + "delete_all_rules", + "start_inspection", + "update_inspection_metadata", + "stop_inspection", +} + +# Desired display order of command groups in the CLI help output. +# Groups not listed here appear after the listed ones. +_GROUP_ORDER = [ + "Account", + "Detectors", + "Image Queries", + "ML Pipelines & Priming", + "Notes", + "Utilities", +] + +# Maps method names to their rich_help_panel group label for the CLI help output. +# Applies to both stable and experimental commands. Methods not listed here fall +# into the default "Commands" panel. +_COMMAND_GROUPS: dict = { + # Account + "whoami": "Account", + "get_month_to_date_usage": "Account", + # Detectors + "get_detector": "Detectors", + "get_detector_by_name": "Detectors", + "list_detectors": "Detectors", + "create_detector": "Detectors", + "get_or_create_detector": "Detectors", + "delete_detector": "Detectors", + "create_binary_detector": "Detectors", + "create_counting_detector": "Detectors", + "create_multiclass_detector": "Detectors", + "create_bounding_box_detector": "Detectors", + "create_detector_group": "Detectors", + "list_detector_groups": "Detectors", + "create_roi": "Detectors", + "update_detector_confidence_threshold": "Detectors", + "update_detector_status": "Detectors", + "update_detector_escalation_type": "Detectors", + "reset_detector": "Detectors", + "update_detector_name": "Detectors", + "create_text_recognition_detector": "Detectors", + "get_detector_evaluation": "Detectors", + "get_detector_metrics": "Detectors", + "download_mlbinary": "Detectors", + # Image Queries + "get_image_query": "Image Queries", + "list_image_queries": "Image Queries", + "submit_image_query": "Image Queries", + "ask_confident": "Image Queries", + "ask_ml": "Image Queries", + "ask_async": "Image Queries", + "wait_for_confident_result": "Image Queries", + "wait_for_ml_result": "Image Queries", + "get_image": "Image Queries", + "add_label": "Image Queries", + # Notes + "get_notes": "Notes", + "create_note": "Notes", + # ML Pipelines & Priming + "list_detector_pipelines": "ML Pipelines & Priming", + "list_priming_groups": "ML Pipelines & Priming", + "create_priming_group": "ML Pipelines & Priming", + "get_priming_group": "ML Pipelines & Priming", + "delete_priming_group": "ML Pipelines & Priming", + # Utilities + "edge_base_url": "Utilities", + "get_raw_headers": "Utilities", +} + + +def _cli_sort_key(item: tuple) -> tuple: + """Sort key for CLI command registration that controls group and within-group ordering. + + Commands are ordered first by their group's position in _GROUP_ORDER (ungrouped last), + then alphabetically by method name within each group. + """ + name, _ = item + group = _COMMAND_GROUPS.get(name) + group_rank = _GROUP_ORDER.index(group) if group in _GROUP_ORDER else len(_GROUP_ORDER) + return (group_rank, name) + + def groundlight(): """Entry point for the groundlight CLI.""" try: stable_names = {n for n, m in vars(Groundlight).items() if callable(m) and not n.startswith("_")} - for name, method in vars(Groundlight).items(): - if callable(method) and not name.startswith("_"): + for name, method in sorted(vars(Groundlight).items(), key=_cli_sort_key): + if callable(method) and not name.startswith("_") and name not in _CLI_EXCLUDED_METHODS: cli_func = class_func_to_cli(method) - cli_app.command()(cli_func) + cli_app.command(rich_help_panel=_COMMAND_GROUPS.get(name))(cli_func) - for name, method in vars(ExperimentalApi).items(): - if not callable(method) or name.startswith("_") or name in stable_names: + for name, method in sorted(vars(ExperimentalApi).items(), key=_cli_sort_key): + if not callable(method) or name.startswith("_") or name in stable_names or name in _CLI_EXCLUDED_METHODS: continue try: cli_func = class_func_to_cli(method, is_experimental=True) - experimental_app.command()(cli_func) + experimental_app.command(rich_help_panel=_COMMAND_GROUPS.get(name))(cli_func) except Exception as e: # pylint: disable=broad-except logger.debug("Skipping experimental CLI command '%s': %s", name, e) diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py index 47911b70..f5ea9d07 100644 --- a/test/unit/test_cli.py +++ b/test/unit/test_cli.py @@ -41,16 +41,9 @@ def test_detector_and_image_queries(detector_name: Callable): check=False, ) assert completed_process.returncode == 0 - match = re.search("id='([^']+)'", completed_process.stdout) + match = re.search(r'"id":\s*"([^"]+)"', completed_process.stdout) assert match is not None det_id_on_create = match.group(1) - # The output of the create-detector command looks something like: - # id='det_abc123' - # type= - # created_at=datetime.datetime(2023, 8, 30, 18, 3, 9, 489794, - # tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))) - # name='testdetector 2023-08-31 01:03:09.039448' query='testdetector' - # group_name='__DEFAULT' confidence_threshold=0.9 # test getting detectors completed_process = subprocess.run( @@ -61,7 +54,7 @@ def test_detector_and_image_queries(detector_name: Callable): check=False, ) assert completed_process.returncode == 0 - match = re.search("id='([^']+)'", completed_process.stdout) + match = re.search(r'"id":\s*"([^"]+)"', completed_process.stdout) assert match is not None det_id_on_get = match.group(1) assert det_id_on_create == det_id_on_get @@ -110,6 +103,20 @@ def test_help(): assert completed_process.returncode == 0 +def test_experimental_subcommand(): + # Both 'experimental' and 'exp' should resolve to the same subcommand group + for alias in ("experimental", "exp"): + completed_process = subprocess.run( + ["groundlight", alias, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert completed_process.returncode == 0 + assert "list-priming-groups" in completed_process.stdout + + def test_bad_commands(): completed_process = subprocess.run( ["groundlight", "wat"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False From fbe190a8db5c08d5287305710ac40f4ddc50f416 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 16:08:20 -0700 Subject: [PATCH 05/14] adding --version --- src/groundlight/cli.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 33702072..32c29379 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -4,6 +4,7 @@ from datetime import datetime from enum import Enum from functools import wraps +from importlib.metadata import version as importlib_version from typing import Any, Union import typer @@ -21,6 +22,18 @@ context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, ) + +@cli_app.callback(invoke_without_command=True) +def _main( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", "-v", is_eager=True, help="Show the SDK version and exit."), +): + if version: + print(importlib_version("groundlight")) + raise typer.Exit() + if ctx.invoked_subcommand is None: + print(ctx.get_help()) + experimental_app = typer.Typer( no_args_is_help=True, help="Experimental commands — may change or be removed without notice.", From 518b6085882d623a4741d2eaa5f8674557667eb5 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 27 Apr 2026 23:09:05 +0000 Subject: [PATCH 06/14] Automatically reformatting code --- src/groundlight/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 32c29379..9c24cdf9 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -34,6 +34,7 @@ def _main( if ctx.invoked_subcommand is None: print(ctx.get_help()) + experimental_app = typer.Typer( no_args_is_help=True, help="Experimental commands — may change or be removed without notice.", From ef71903edf6717562a3c2ae27fc37bb7fb86d75c Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 16:11:41 -0700 Subject: [PATCH 07/14] code cleanup --- src/groundlight/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 32c29379..8c9a2c0e 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -18,7 +18,6 @@ logger = logging.getLogger("groundlight.sdk") cli_app = typer.Typer( - no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, ) @@ -34,6 +33,7 @@ def _main( if ctx.invoked_subcommand is None: print(ctx.get_help()) + experimental_app = typer.Typer( no_args_is_help=True, help="Experimental commands — may change or be removed without notice.", @@ -183,7 +183,7 @@ def wrapper(*args, **kwargs): # Maps method names to their rich_help_panel group label for the CLI help output. # Applies to both stable and experimental commands. Methods not listed here fall # into the default "Commands" panel. -_COMMAND_GROUPS: dict = { +_COMMAND_GROUPS: dict[str, str] = { # Account "whoami": "Account", "get_month_to_date_usage": "Account", From cccccd322098b53bdcc3730b3472ea5cef338ae4 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 16:27:17 -0700 Subject: [PATCH 08/14] respondign to PR feedback --- src/groundlight/cli.py | 28 ++++++++++++++++++++-------- test/unit/test_cli.py | 13 +++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 8c9a2c0e..2776cb4c 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -1,11 +1,13 @@ import json import logging import sys -from datetime import datetime +from datetime import date, datetime +from decimal import Decimal from enum import Enum from functools import wraps from importlib.metadata import version as importlib_version from typing import Any, Union +from uuid import UUID import typer from groundlight_openapi_client.model_utils import OpenApiModel @@ -15,7 +17,7 @@ from groundlight import ExperimentalApi, Groundlight from groundlight.client import ApiTokenError -logger = logging.getLogger("groundlight.sdk") +logger = logging.getLogger(__name__) cli_app = typer.Typer( context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, @@ -40,7 +42,6 @@ def _main( context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, ) cli_app.add_typer(experimental_app, name="exp", rich_help_panel="Subcommands") -cli_app.add_typer(experimental_app, name="experimental", hidden=True) def is_cli_supported_type(annotation): @@ -67,10 +68,20 @@ def is_cli_representable(annotation) -> bool: def _json_default(obj: Any) -> Any: - """Fallback serializer for json.dumps — handles datetime values.""" - if isinstance(obj, datetime): + """Fallback serializer for json.dumps for types the stdlib encoder doesn't handle. + + Covers common types that appear in OpenAPI client to_dict() output. Unknown types + fall back to str() rather than raising, so CLI output is always usable. + """ + if isinstance(obj, (datetime, date)): return obj.isoformat() - raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + if isinstance(obj, Decimal): + return float(obj) + if isinstance(obj, UUID): + return str(obj) + if isinstance(obj, Enum): + return obj.value + return str(obj) def _format_result(result: Any) -> str: @@ -178,6 +189,7 @@ def wrapper(*args, **kwargs): "ML Pipelines & Priming", "Notes", "Utilities", + "Other", ] # Maps method names to their rich_help_panel group label for the CLI help output. @@ -256,14 +268,14 @@ def groundlight(): for name, method in sorted(vars(Groundlight).items(), key=_cli_sort_key): if callable(method) and not name.startswith("_") and name not in _CLI_EXCLUDED_METHODS: cli_func = class_func_to_cli(method) - cli_app.command(rich_help_panel=_COMMAND_GROUPS.get(name))(cli_func) + cli_app.command(rich_help_panel=_COMMAND_GROUPS.get(name, "Other"))(cli_func) for name, method in sorted(vars(ExperimentalApi).items(), key=_cli_sort_key): if not callable(method) or name.startswith("_") or name in stable_names or name in _CLI_EXCLUDED_METHODS: continue try: cli_func = class_func_to_cli(method, is_experimental=True) - experimental_app.command(rich_help_panel=_COMMAND_GROUPS.get(name))(cli_func) + experimental_app.command(rich_help_panel=_COMMAND_GROUPS.get(name, "Other"))(cli_func) except Exception as e: # pylint: disable=broad-except logger.debug("Skipping experimental CLI command '%s': %s", name, e) diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py index f5ea9d07..85e208cd 100644 --- a/test/unit/test_cli.py +++ b/test/unit/test_cli.py @@ -103,6 +103,19 @@ def test_help(): assert completed_process.returncode == 0 +def test_version(): + for flag in ("--version", "-v"): + completed_process = subprocess.run( + ["groundlight", flag], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert completed_process.returncode == 0 + assert re.match(r"\d+\.\d+\.\d+", completed_process.stdout.strip()) + + def test_experimental_subcommand(): # Both 'experimental' and 'exp' should resolve to the same subcommand group for alias in ("experimental", "exp"): From 75e97f13dff0ce18f742427448213343343afdb3 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 16:31:07 -0700 Subject: [PATCH 09/14] fixing a broken test --- test/unit/test_cli.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py index 85e208cd..4783c749 100644 --- a/test/unit/test_cli.py +++ b/test/unit/test_cli.py @@ -117,17 +117,15 @@ def test_version(): def test_experimental_subcommand(): - # Both 'experimental' and 'exp' should resolve to the same subcommand group - for alias in ("experimental", "exp"): - completed_process = subprocess.run( - ["groundlight", alias, "--help"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - assert completed_process.returncode == 0 - assert "list-priming-groups" in completed_process.stdout + completed_process = subprocess.run( + ["groundlight", "exp", "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert completed_process.returncode == 0 + assert "list-priming-groups" in completed_process.stdout def test_bad_commands(): From 88cabec6cfed294cdb70603229305ca656242581 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 27 Apr 2026 16:35:40 -0700 Subject: [PATCH 10/14] removing unnecessary comments --- src/groundlight/cli.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 2776cb4c..d2416973 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -166,8 +166,7 @@ def wrapper(*args, **kwargs): return wrapper -# Methods to exclude from the CLI entirely. These may be too complex to express -# as CLI commands, deprecated, or otherwise not useful from a shell context. +# Methods to exclude from the CLI entirely _CLI_EXCLUDED_METHODS = { "make_action", "create_rule", @@ -181,7 +180,6 @@ def wrapper(*args, **kwargs): } # Desired display order of command groups in the CLI help output. -# Groups not listed here appear after the listed ones. _GROUP_ORDER = [ "Account", "Detectors", @@ -193,8 +191,7 @@ def wrapper(*args, **kwargs): ] # Maps method names to their rich_help_panel group label for the CLI help output. -# Applies to both stable and experimental commands. Methods not listed here fall -# into the default "Commands" panel. +# Applies to both stable and experimental commands. _COMMAND_GROUPS: dict[str, str] = { # Account "whoami": "Account", From 85705e48ae9015631148adbe6f221a0a50fa0755 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 4 May 2026 12:02:53 -0700 Subject: [PATCH 11/14] removing deprecated functions and responding to PR feedback --- src/groundlight/cli.py | 32 ++-- src/groundlight/client.py | 36 ---- src/groundlight/experimental_api.py | 258 +-------------------------- src/groundlight/internalapi.py | 110 ------------ test/integration/test_groundlight.py | 111 +----------- test/unit/test_actions.py | 56 +----- test/unit/test_cli.py | 39 ++++ 7 files changed, 58 insertions(+), 584 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index d2416973..5a858ad5 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -85,7 +85,7 @@ def _json_default(obj: Any) -> Any: def _format_result(result: Any) -> str: - """Format a CLI result value as a human-readable, jq-compatible string. + """Format a CLI result value as a human-readable string. Pydantic models and OpenAPI client objects are serialized to indented JSON. Plain dicts and lists are also JSON. Everything else falls back to str(). @@ -126,6 +126,10 @@ def wrapper(*args, **kwargs): file=sys.stderr, ) gl = ExperimentalApi() + # Typer sees the fake method's annotations (for correct CLI argument types), but the + # actual call goes to the real method on a live ExperimentalApi instance. The fake + # method's name is identical to the real one, so getattr resolves to the correct + # implementation, including inherited Groundlight methods. bound_method = getattr(gl, fake_method.__name__) result = bound_method(*args, **kwargs) if result is not None: @@ -166,19 +170,6 @@ def wrapper(*args, **kwargs): return wrapper -# Methods to exclude from the CLI entirely -_CLI_EXCLUDED_METHODS = { - "make_action", - "create_rule", - "get_rule", - "delete_rule", - "list_rules", - "delete_all_rules", - "start_inspection", - "update_inspection_metadata", - "stop_inspection", -} - # Desired display order of command groups in the CLI help output. _GROUP_ORDER = [ "Account", @@ -187,7 +178,6 @@ def wrapper(*args, **kwargs): "ML Pipelines & Priming", "Notes", "Utilities", - "Other", ] # Maps method names to their rich_help_panel group label for the CLI help output. @@ -248,8 +238,8 @@ def wrapper(*args, **kwargs): def _cli_sort_key(item: tuple) -> tuple: """Sort key for CLI command registration that controls group and within-group ordering. - Commands are ordered first by their group's position in _GROUP_ORDER (ungrouped last), - then alphabetically by method name within each group. + Commands are ordered first by their group's position in _GROUP_ORDER, then alphabetically + by method name within each group. """ name, _ = item group = _COMMAND_GROUPS.get(name) @@ -263,16 +253,16 @@ def groundlight(): stable_names = {n for n, m in vars(Groundlight).items() if callable(m) and not n.startswith("_")} for name, method in sorted(vars(Groundlight).items(), key=_cli_sort_key): - if callable(method) and not name.startswith("_") and name not in _CLI_EXCLUDED_METHODS: + if callable(method) and not name.startswith("_"): cli_func = class_func_to_cli(method) - cli_app.command(rich_help_panel=_COMMAND_GROUPS.get(name, "Other"))(cli_func) + cli_app.command(rich_help_panel=_COMMAND_GROUPS[name])(cli_func) for name, method in sorted(vars(ExperimentalApi).items(), key=_cli_sort_key): - if not callable(method) or name.startswith("_") or name in stable_names or name in _CLI_EXCLUDED_METHODS: + if not callable(method) or name.startswith("_") or name in stable_names: continue try: cli_func = class_func_to_cli(method, is_experimental=True) - experimental_app.command(rich_help_panel=_COMMAND_GROUPS.get(name, "Other"))(cli_func) + experimental_app.command(rich_help_panel=_COMMAND_GROUPS[name])(cli_func) except Exception as e: # pylint: disable=broad-except logger.debug("Skipping experimental CLI command '%s': %s", name, e) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 7ec89a54..c7931f8c 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -1277,42 +1277,6 @@ def add_label( request_params = LabelValueRequest(label=label, image_query_id=image_query_id, rois=roi_requests) self.labels_api.create_label(request_params) - def start_inspection(self) -> str: - """ - **NOTE:** For users with Inspection Reports enabled only. - Starts an inspection report and returns the id of the inspection. - - :return: The unique identifier of the inspection. - """ - return self.api_client.start_inspection() - - def update_inspection_metadata(self, inspection_id: str, user_provided_key: str, user_provided_value: str) -> None: - """ - **NOTE:** For users with Inspection Reports enabled only. - Add/update inspection metadata with the user_provided_key and user_provided_value. - - :param inspection_id: The unique identifier of the inspection. - - :param user_provided_key: the key in the key/value pair for the inspection metadata. - - :param user_provided_value: the value in the key/value pair for the inspection metadata. - - :return: None - """ - self.api_client.update_inspection_metadata(inspection_id, user_provided_key, user_provided_value) - - def stop_inspection(self, inspection_id: str) -> str: - """ - **NOTE:** For users with Inspection Reports enabled only. - Stops an inspection and raises an exception if the response from the server - indicates that the inspection was not successfully stopped. - - :param inspection_id: The unique identifier of the inspection. - - :return: "PASS" or "FAIL" depending on the result of the inspection. - """ - return self.api_client.stop_inspection(inspection_id) - def update_detector_confidence_threshold(self, detector: Union[str, Detector], confidence_threshold: float) -> None: """ Updates the confidence threshold for the given detector diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index c4bf9c98..40c55fab 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -40,7 +40,6 @@ ModeEnum, PaginatedMLPipelineList, PaginatedPrimingGroupList, - PaginatedRuleList, PayloadTemplate, PrimingGroup, Rule, @@ -76,17 +75,6 @@ def __init__( # Create an experimental API client gl = ExperimentalApi() - # Create a notification rule - rule = gl.create_rule( - detector="door_detector", - rule_name="Door Open Alert", - channel="EMAIL", - recipient="alerts@company.com", - alert_on="CHANGED_TO", - include_image=True, - condition_parameters={"label": "YES"} - ) - # Create a detector group group = gl.create_detector_group( name="Security Detectors", @@ -144,34 +132,6 @@ def make_condition(self, verb: str, parameters: dict) -> Condition: """ return Condition(verb=verb, parameters=parameters) - def make_action( - self, - channel: str, - recipient: str, - include_image: bool, - ) -> Action: - """ - Creates an Action object for use in creating alerts - - This function serves as a convenience method; Action objects can also be created directly. - - **Example usage**:: - - gl = ExperimentalApi() - - # Create an action for an alert - action = gl.make_action("EMAIL", "example@example.com", include_image=True) - - :param channel: The notification channel to use. One of "EMAIL" or "TEXT" - :param recipient: The email address or phone number to send notifications to - :param include_image: Whether to include the triggering image in action message - """ - return Action( - channel=channel, - recipient=recipient, - include_image=include_image, - ) - def make_webhook_action( self, url: str, include_image: bool, payload_template: Optional[PayloadTemplate] = None ) -> WebhookAction: @@ -227,21 +187,13 @@ def create_alert( # pylint: disable=too-many-locals, too-many-arguments # noqa gl = ExperimentalApi() - # Create a rule to send email alerts when door is detected as open + # Create an alert to send emails when door is detected as open condition = gl.make_condition( verb="CHANGED_TO", parameters={"label": "YES"} ) - action1 = gl.make_action( - "EMAIL", - "alerts@company.com", - include_image=True - ) - action2 = gl.make_action( - "TEXT", - "+1234567890", - include_image=False - ) + action1 = Action(channel="EMAIL", recipient="alerts@company.com", include_image=True) + action2 = Action(channel="TEXT", recipient="+1234567890", include_image=False) alert = gl.create_alert( detector="det_idhere", name="Door Open Alert", @@ -315,210 +267,6 @@ def create_alert( # pylint: disable=too-many-locals, too-many-arguments # noqa ) return Rule.model_validate(self.actions_api.create_rule(detector, rule_input).to_dict()) - def create_rule( # pylint: disable=too-many-locals # noqa: PLR0913 - self, - detector: Union[str, Detector], - rule_name: str, - channel: Union[str, ChannelEnum], - recipient: str, - *, - alert_on: str = "CHANGED_TO", - enabled: bool = True, - include_image: bool = False, - condition_parameters: Union[str, dict, None] = None, - snooze_time_enabled: bool = False, - snooze_time_value: int = 3600, - snooze_time_unit: str = "SECONDS", - human_review_required: bool = False, - ) -> Rule: - """ - DEPRECATED: Use create_alert instead. - - Creates a notification rule for a detector that will send alerts based on specified conditions. - - A notification rule allows you to configure automated alerts when certain conditions are met, - such as when a detector's prediction changes or maintains a particular state. - - .. note:: - Currently, only binary mode detectors (YES/NO answers) are supported for notification rules. - - **Example usage**:: - - gl = ExperimentalApi() - - # Create a rule to send email alerts when door is detected as open - rule = gl.create_rule( - detector="door_detector", - rule_name="Door Open Alert", - channel="EMAIL", - recipient="alerts@company.com", - alert_on="CHANGED_TO", - condition_parameters={"label": "YES"}, - include_image=True - ) - - # Create a rule for consecutive motion detections via SMS - rule = gl.create_rule( - detector="motion_detector", - rule_name="Repeated Motion Alert", - channel="TEXT", - recipient="+1234567890", - alert_on="ANSWERED_CONSECUTIVELY", - condition_parameters={ - "num_consecutive_labels": 3, - "label": "YES" - }, - snooze_time_enabled=True, - snooze_time_value=1, - snooze_time_unit="HOURS" - ) - - :param detector: The detector ID or Detector object to add the rule to - :param rule_name: A unique name to identify this rule - :param channel: Notification channel - either "EMAIL" or "TEXT" - :param recipient: Email address or phone number to receive notifications - :param alert_on: what to alert on. One of ANSWERED_CONSECUTIVELY, ANSWERED_WITHIN_TIME, - CHANGED_TO, NO_CHANGE, NO_QUERIES - :param enabled: Whether the rule should be active when created (default True) - :param include_image: Whether to attach the triggering image to notifications (default False) - :param condition_parameters: Additional parameters for the alert condition: - - For ANSWERED_CONSECUTIVELY: {"num_consecutive_labels": N, "label": "YES/NO"} - - For CHANGED_TO: {"label": "YES/NO"} - - For time-based conditions: {"time_value": N, "time_unit": "MINUTES/HOURS/DAYS"} - :param snooze_time_enabled: Enable notification snoozing to prevent alert spam (default False) - :param snooze_time_value: Duration of snooze period (default 3600) - :param snooze_time_unit: Unit for snooze duration - "SECONDS", "MINUTES", "HOURS", or "DAYS" (default "SECONDS") - :param human_review_required: Require human verification before sending alerts (default False) - - :return: The created Rule object - """ - - logger.warning("create_rule is no longer supported. Please use create_alert instead.") - - if condition_parameters is None: - condition_parameters = {} - if isinstance(channel, str): - channel = ChannelEnum(channel.upper()) - if isinstance(condition_parameters, str): - condition_parameters = json.loads(condition_parameters) # type: ignore - action = ActionRequest( - channel=channel, # type: ignore - recipient=recipient, - include_image=include_image, - ) - condition = ConditionRequest(verb=alert_on, parameters=condition_parameters) # type: ignore - det_id = detector.id if isinstance(detector, Detector) else detector - rule_input = RuleRequest( - detector_id=det_id, - name=rule_name, - enabled=enabled, - action=action, - condition=condition, - snooze_time_enabled=snooze_time_enabled, - snooze_time_value=snooze_time_value, - snooze_time_unit=snooze_time_unit, - human_review_required=human_review_required, - ) - return Rule.model_validate(self.actions_api.create_rule(det_id, rule_input).to_dict()) - - def get_rule(self, action_id: int) -> Rule: - """ - Gets the rule with the given id. - - **Example usage**:: - - gl = ExperimentalApi() - - # Get an existing rule by ID - rule = gl.get_rule(action_id=123) - print(f"Rule name: {rule.name}") - print(f"Rule enabled: {rule.enabled}") - - :param action_id: the id of the rule to get - :return: the Rule object with the given id - """ - return Rule.model_validate(self.actions_api.get_rule(action_id).to_dict()) - - def delete_rule(self, action_id: int) -> None: - """ - Deletes the rule with the given id. - - **Example usage**:: - - gl = ExperimentalApi() - - # Delete a specific rule - gl.delete_rule(action_id=123) - - :param action_id: the id of the rule to delete - """ - self.actions_api.delete_rule(action_id) - - def list_rules(self, page=1, page_size=10) -> PaginatedRuleList: - """ - Gets a paginated list of all rules. - - **Example usage**:: - - gl = ExperimentalApi() - - # Get first page of rules - rules = gl.list_rules(page=1, page_size=10) - print(f"Total rules: {rules.count}") - - # Iterate through rules on current page - for rule in rules.results: - print(f"Rule {rule.id}: {rule.name}") - - # Get next page - next_page = gl.list_rules(page=2, page_size=10) - - :param page: Page number to retrieve (default: 1) - :param page_size: Number of rules per page (default: 10) - :return: PaginatedRuleList containing the rules and pagination info - """ - obj = self.actions_api.list_rules(page=page, page_size=page_size) - return PaginatedRuleList.model_validate(obj.to_dict()) - - def delete_all_rules(self, detector: Union[None, str, Detector] = None) -> int: - """ - Deletes all rules associated with the given detector. If no detector is specified, - deletes all rules in the account. - - WARNING: If no detector is specified, this will delete ALL rules in your account. - This action cannot be undone. Use with caution. - - **Example usage**:: - - gl = ExperimentalApi() - - # Delete all rules for a specific detector - detector = gl.get_detector("my_detector") - num_deleted = gl.delete_all_rules(detector) - print(f"Deleted {num_deleted} rules") - - # Delete all rules in the account - num_deleted = gl.delete_all_rules() - print(f"Deleted {num_deleted} rules") - - :param detector: the detector to delete the rules from. If None, deletes all rules. - - :return: the number of rules deleted - """ - det_id = detector.id if isinstance(detector, Detector) else detector - # we collect a list of all the rules to delete, then delete them - ids_to_delete = [] - num_rules = self.list_rules().count - for page in range(1, (num_rules // self.ITEMS_PER_PAGE) + 2): - for rule in self.list_rules(page=page, page_size=self.ITEMS_PER_PAGE).results: - if det_id is None: - ids_to_delete.append(rule.id) - elif rule.detector_id == det_id: - ids_to_delete.append(rule.id) - for rule_id in ids_to_delete: - self.delete_rule(rule_id) - return num_rules - def get_notes(self, detector: Union[str, Detector]) -> Dict[str, Any]: """ Retrieves all notes associated with a detector. diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index 40a7dffe..ef98f740 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -249,113 +249,3 @@ def _get_detector_by_name(self, name: str) -> Detector: f"We found multiple ({parsed['count']}) detectors with the same name. This shouldn't happen.", ) return Detector.model_validate(parsed["results"][0]) - - @RequestsRetryDecorator() - def start_inspection(self) -> str: - """Starts an inspection, returns the ID.""" - url = f"{self.configuration.host}/inspections" - - headers = self._headers() - - response = requests.request("POST", url, headers=headers, json={}, verify=self.configuration.verify_ssl) - - if not is_ok(response.status_code): - raise InternalApiError( - status=response.status_code, - reason="Error starting inspection.", - http_resp=response, - ) - - return response.json()["id"] - - @RequestsRetryDecorator() - def update_inspection_metadata(self, inspection_id: str, user_provided_key: str, user_provided_value: str) -> None: - """Add/update inspection metadata with the user_provided_key and user_provided_value. - - The API stores inspections metadata in two ways: - 1) At the top level of the inspection with user_provided_id_key and user_provided_id_value. This is a - kind of "primary" piece of metadata for the inspection. Only one key/value pair is allowed at this level. - 2) In the user_metadata field as a dictionary. Multiple key/value pairs are allowed at this level. - - The first piece of metadata presented to an inspection will be assumed to be the user_provided_id_key and - user_provided_id_value. All subsequent pieces metadata will be stored in the user_metadata field. - - """ - url = f"{self.configuration.host}/inspections/{inspection_id}" - - headers = self._headers() - - # Get inspection in order to find out: - # 1) if user_provided_id_key has been set - # 2) if the inspection is closed - response = requests.request("GET", url, headers=headers, verify=self.configuration.verify_ssl) - - if not is_ok(response.status_code): - raise InternalApiError( - status=response.status_code, - reason=f"Error getting inspection details for inspection {inspection_id}.", - http_resp=response, - ) - if response.json()["status"] == "COMPLETE": - raise ValueError(f"Inspection {inspection_id} is closed. Metadata cannot be added.") - - payload = {} - - # Set the user_provided_id_key and user_provided_id_value if they were not previously set. - response_json = response.json() - if not response_json.get("user_provided_id_key"): - payload["user_provided_id_key"] = user_provided_key - payload["user_provided_id_value"] = user_provided_value - - # Get the existing keys and values in user_metadata (if any) so that we don't overwrite them. - metadata = response_json["user_metadata"] - if not metadata: - metadata = {} - - # Submit the new metadata - metadata[user_provided_key] = user_provided_value - payload["user_metadata_json"] = json.dumps(metadata) - response = requests.request("PATCH", url, headers=headers, json=payload, verify=self.configuration.verify_ssl) - - if not is_ok(response.status_code): - raise InternalApiError( - status=response.status_code, - reason=f"Error updating inspection metadata on inspection {inspection_id}.", - http_resp=response, - ) - - @RequestsRetryDecorator() - def stop_inspection(self, inspection_id: str) -> str: - """Stops an inspection and raises an exception if the response from the server does not indicate success. - Returns a string that indicates the result (either PASS or FAIL). The URCap requires this. - """ - url = f"{self.configuration.host}/inspections/{inspection_id}" - - headers = self._headers() - - # Closing an inspection generates a new inspection PDF. Therefore, if the inspection - # is already closed, just return "COMPLETE" to avoid unnecessarily generating a new PDF. - response = requests.request("GET", url, headers=headers, verify=self.configuration.verify_ssl) - - if not is_ok(response.status_code): - raise InternalApiError( - status=response.status_code, - reason=f"Error checking the status of {inspection_id}.", - http_resp=response, - ) - - if response.json().get("status") == "COMPLETE": - return "COMPLETE" - - payload = {"status": "COMPLETE"} - - response = requests.request("PATCH", url, headers=headers, json=payload, verify=self.configuration.verify_ssl) - - if not is_ok(response.status_code): - raise InternalApiError( - status=response.status_code, - reason=f"Error stopping inspection {inspection_id}.", - http_resp=response, - ) - - return response.json()["result"] diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index 9dbeceaa..5a22fe75 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -10,7 +10,7 @@ import pytest from groundlight import Groundlight from groundlight.binary_labels import VALID_DISPLAY_LABELS, Label, convert_internal_label_to_display -from groundlight.internalapi import ApiException, InternalApiError, NotFoundError +from groundlight.internalapi import ApiException, NotFoundError from groundlight.optional_imports import * from groundlight.status_codes import is_user_error from groundlight_openapi_client.exceptions import NotFoundException @@ -739,93 +739,6 @@ def test_submit_numpy_image(gl: Groundlight, detector: Detector): assert is_valid_display_result(_image_query.result) -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -def test_start_inspection(gl: Groundlight): - inspection_id = gl.start_inspection() - - assert isinstance(inspection_id, str) - assert "inspect_" in inspection_id - - -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -def test_update_inspection_metadata_success(gl: Groundlight): - """Starts an inspection and adds a couple pieces of metadata to it. - This should succeed. If there are any errors, an exception will be raised. - """ - inspection_id = gl.start_inspection() - - user_provided_key = "Inspector" - user_provided_value = "Bob" - gl.update_inspection_metadata(inspection_id, user_provided_key, user_provided_value) - - user_provided_key = "Engine ID" - user_provided_value = "1234" - gl.update_inspection_metadata(inspection_id, user_provided_key, user_provided_value) - - -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -def test_update_inspection_metadata_failure(gl: Groundlight): - """Attempts to add metadata to an inspection after it is closed. - Should raise an exception. - """ - inspection_id = gl.start_inspection() - - _ = gl.stop_inspection(inspection_id) - - with pytest.raises(ValueError): - user_provided_key = "Inspector" - user_provided_value = "Bob" - gl.update_inspection_metadata(inspection_id, user_provided_key, user_provided_value) - - -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -def test_update_inspection_metadata_invalid_inspection_id(gl: Groundlight): - """Attempt to update metadata for an inspection that doesn't exist. - Should raise an InternalApiError. - """ - - inspection_id = "some_invalid_inspection_id" - user_provided_key = "Operator" - user_provided_value = "Bob" - - with pytest.raises(InternalApiError): - gl.update_inspection_metadata(inspection_id, user_provided_key, user_provided_value) - - -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -@retry_on_failure() -def test_stop_inspection_pass(gl: Groundlight, detector: Detector): - """Starts an inspection, submits a query with the inspection ID that should pass, stops - the inspection, checks the result. - """ - inspection_id = gl.start_inspection() - - _ = gl.submit_image_query(detector=detector, image="test/assets/dog.jpeg", inspection_id=inspection_id) - - assert gl.stop_inspection(inspection_id) == "PASS" - - -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -def test_stop_inspection_fail(gl: Groundlight, detector: Detector): - """Starts an inspection, submits a query that should fail, stops - the inspection, checks the result. - """ - inspection_id = gl.start_inspection() - - iq = gl.submit_image_query(detector=detector, image="test/assets/cat.jpeg", inspection_id=inspection_id) - gl.add_label(iq, Label.NO) # labeling it NO just to be sure the inspection fails - - assert gl.stop_inspection(inspection_id) == "FAIL" - - -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint doesn't support inspection_id") -def test_stop_inspection_with_invalid_id(gl: Groundlight): - inspection_id = "some_invalid_inspection_id" - - with pytest.raises(InternalApiError): - gl.stop_inspection(inspection_id) - - def test_update_detector_confidence_threshold_success(gl: Groundlight, detector: Detector): """Updates the confidence threshold for a detector. This should succeed.""" gl.update_detector_confidence_threshold(detector.id, 0.77) @@ -842,28 +755,6 @@ def test_update_detector_confidence_threshold_failure(gl: Groundlight, detector: gl.update_detector_confidence_threshold(detector.id, -1) # too low -@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint does not support passing detector metadata.") -@retry_on_failure() -def test_submit_image_query_with_inspection_id_metadata_and_want_async(gl: Groundlight, detector: Detector, image: str): - inspection_id = gl.start_inspection() - metadata = {"key": "value"} - iq = gl.submit_image_query( - detector=detector.id, - image=image, - human_review="NEVER", - inspection_id=inspection_id, - metadata=metadata, - want_async=True, - wait=0, - ) - - iq = gl.get_image_query(iq.id) - iq = gl.wait_for_confident_result(iq.id) - - assert iq.metadata == metadata - assert iq.result.label == Label.YES - - def test_submit_image_query_with_empty_inspection_id(gl: Groundlight, detector: Detector, image: str): """The URCap submits the inspection_id as an empty string when there is no active inspection. This test ensures that this behavior is allowed and does not raise an exception. diff --git a/test/unit/test_actions.py b/test/unit/test_actions.py index 22ba0edb..f33fd86b 100644 --- a/test/unit/test_actions.py +++ b/test/unit/test_actions.py @@ -2,63 +2,15 @@ import pytest from groundlight import ApiException, ExperimentalApi -from groundlight_openapi_client.exceptions import NotFoundException - - -def test_create_action(gl_experimental: ExperimentalApi): - # We first clear out any rules in case the account has any left over from a previous test - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - rule = gl_experimental.create_rule(det, f"test_rule_{name}", "EMAIL", "test@example.com") - rule2 = gl_experimental.get_rule(rule.id) - assert rule == rule2 - - -@pytest.mark.skip(reason="actions are global on account, the test matrix collides with itself") # type: ignore -def test_get_all_actions(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - num_test_rules = 13 # needs to be larger than the default page size - gl_experimental.ITEMS_PER_PAGE = 10 - assert gl_experimental.ITEMS_PER_PAGE < num_test_rules - det = gl_experimental.get_or_create_detector(name, "test_query") - gl_experimental.delete_all_rules() - for i in range(num_test_rules): - _ = gl_experimental.create_rule(det, f"test_rule_{i}", "EMAIL", "test@example.com") - rules = gl_experimental.list_rules(page_size=gl_experimental.ITEMS_PER_PAGE) - assert rules.count == num_test_rules - assert len(rules.results) == gl_experimental.ITEMS_PER_PAGE - num_deleted = gl_experimental.delete_all_rules() - assert num_deleted == num_test_rules - rules = gl_experimental.list_rules() - assert rules.count == 0 - - -def test_create_action_with_human_review(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - rule = gl_experimental.create_rule( - det, f"test_rule_{name}", "EMAIL", "test@example.com", human_review_required=True - ) - rule2 = gl_experimental.get_rule(rule.id) - assert rule == rule2 - - -@pytest.mark.skip(reason="actions are global on account, the test matrix collides with itself") # type: ignore -def test_delete_action(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - rule = gl_experimental.create_rule(det, f"test_rule_{name}", "EMAIL", "test@example.com") - gl_experimental.delete_rule(rule.id) - with pytest.raises(NotFoundException) as _: - gl_experimental.get_rule(rule.id) +from model import Action def test_create_alert_multiple_actions(gl_experimental: ExperimentalApi): name = f"Test {datetime.utcnow()}" det = gl_experimental.get_or_create_detector(name, "test_query") condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) - action1 = gl_experimental.make_action("EMAIL", "test@groundlight.ai", False) - action2 = gl_experimental.make_action("EMAIL", "test@groundlight.ai", False) + action1 = Action(channel="EMAIL", recipient="test@groundlight.ai", include_image=False) + action2 = Action(channel="EMAIL", recipient="test@groundlight.ai", include_image=False) actions = [action1, action2] alert = gl_experimental.create_alert( det, @@ -102,7 +54,7 @@ def test_create_alert_webhook_action_and_other_action(gl_experimental: Experimen det = gl_experimental.get_or_create_detector(name, "test_query") condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) webhook_action = gl_experimental.make_webhook_action(url="https://groundlight.ai", include_image=True) - email_action = gl_experimental.make_action("EMAIL", "test@groundlight.ai", False) + email_action = Action(channel="EMAIL", recipient="test@groundlight.ai", include_image=False) alert = gl_experimental.create_alert( det, f"test_alert_{name}", condition, webhook_actions=webhook_action, actions=email_action ) diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py index 4783c749..33baec54 100644 --- a/test/unit/test_cli.py +++ b/test/unit/test_cli.py @@ -4,6 +4,9 @@ from typing import Callable from unittest.mock import patch +from groundlight import ExperimentalApi, Groundlight +from groundlight.cli import _COMMAND_GROUPS, class_func_to_cli + def test_whoami(): completed_process = subprocess.run( @@ -138,3 +141,39 @@ def test_bad_commands(): ["groundlight", "list_detectors"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False ) assert completed_process.returncode != 0 + + +def test_all_cli_commands_have_group(): + """Enforce that every method registered in the CLI has an entry in _COMMAND_GROUPS. + + Stable methods are always registered, so all of them must have a group. Experimental + methods are only checked if they are CLI-representable (i.e., class_func_to_cli does not + raise) — methods that get silently skipped at registration time don't appear in the CLI + and therefore don't need a group. + + This test is the enforcement mechanism for _COMMAND_GROUPS being a complete, up-to-date + table. If a new method is added to Groundlight or ExperimentalApi, this test will fail + until a group is assigned in _COMMAND_GROUPS. + """ + stable_names = {n for n, m in vars(Groundlight).items() if callable(m) and not n.startswith("_")} + + missing = [] + + for name in stable_names: + if name not in _COMMAND_GROUPS: + missing.append(f"stable: {name}") + + for name, method in vars(ExperimentalApi).items(): + if not callable(method) or name.startswith("_") or name in stable_names: + continue + try: + class_func_to_cli(method, is_experimental=True) + # Method is CLI-representable and will be registered — it needs a group. + if name not in _COMMAND_GROUPS: + missing.append(f"experimental: {name}") + except Exception: + pass # Method will be silently skipped at registration; no group needed. + + assert not missing, f"Methods registered in CLI but missing from _COMMAND_GROUPS:\n" + "\n".join( + f" {m}" for m in sorted(missing) + ) From 9b4472f4f38e0323f72ece383b33cf2afe3be9bb Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 4 May 2026 19:03:51 +0000 Subject: [PATCH 12/14] Automatically reformatting code --- src/groundlight/experimental_api.py | 3 +-- src/groundlight/internalapi.py | 1 - test/unit/test_cli.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 40c55fab..a75af648 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -7,7 +7,6 @@ modifications or potentially be removed in future releases, which could lead to breaking changes in your applications. """ -import json from http import HTTPStatus from io import BufferedReader, BytesIO from pathlib import Path @@ -52,7 +51,7 @@ from groundlight.internalapi import NotFoundError, _generate_request_id from groundlight.optional_imports import Image, np -from .client import DEFAULT_REQUEST_TIMEOUT, Groundlight, GroundlightClientError, logger +from .client import DEFAULT_REQUEST_TIMEOUT, Groundlight, GroundlightClientError class ExperimentalApi(Groundlight): # pylint: disable=too-many-public-methods,too-many-instance-attributes diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index ef98f740..fd4304cb 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -1,4 +1,3 @@ -import json import logging import os import platform diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py index 33baec54..eec6e5a4 100644 --- a/test/unit/test_cli.py +++ b/test/unit/test_cli.py @@ -174,6 +174,6 @@ def test_all_cli_commands_have_group(): except Exception: pass # Method will be silently skipped at registration; no group needed. - assert not missing, f"Methods registered in CLI but missing from _COMMAND_GROUPS:\n" + "\n".join( + assert not missing, "Methods registered in CLI but missing from _COMMAND_GROUPS:\n" + "\n".join( f" {m}" for m in sorted(missing) ) From 37d43b6974dc519ba1cbce37cc8394a0d1edf137 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 4 May 2026 12:17:23 -0700 Subject: [PATCH 13/14] removing more deprecated functions --- src/groundlight/experimental_api.py | 175 ---------------------------- test/unit/test_actions.py | 146 ----------------------- 2 files changed, 321 deletions(-) delete mode 100644 test/unit/test_actions.py diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 40c55fab..688f7bb7 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -15,35 +15,22 @@ from urllib.parse import urlparse, urlunparse import requests -from groundlight_openapi_client.api.actions_api import ActionsApi from groundlight_openapi_client.api.detector_groups_api import DetectorGroupsApi from groundlight_openapi_client.api.detector_reset_api import DetectorResetApi from groundlight_openapi_client.api.edge_api import EdgeApi from groundlight_openapi_client.api.notes_api import NotesApi from groundlight_openapi_client.api.priming_groups_api import PrimingGroupsApi from groundlight_openapi_client.exceptions import ApiException, NotFoundException -from groundlight_openapi_client.model.action_request import ActionRequest -from groundlight_openapi_client.model.channel_enum import ChannelEnum -from groundlight_openapi_client.model.condition_request import ConditionRequest from groundlight_openapi_client.model.patched_detector_request import PatchedDetectorRequest -from groundlight_openapi_client.model.payload_template_request import PayloadTemplateRequest from groundlight_openapi_client.model.priming_group_creation_input_request import PrimingGroupCreationInputRequest -from groundlight_openapi_client.model.rule_request import RuleRequest from groundlight_openapi_client.model.text_mode_configuration import TextModeConfiguration -from groundlight_openapi_client.model.webhook_action_request import WebhookActionRequest from model import ( - Action, - ActionList, - Condition, Detector, EdgeModelInfo, ModeEnum, PaginatedMLPipelineList, PaginatedPrimingGroupList, - PayloadTemplate, PrimingGroup, - Rule, - WebhookAction, ) from urllib3.response import HTTPResponse @@ -94,7 +81,6 @@ def __init__( Groundlight cloud service. """ super().__init__(endpoint=endpoint, api_token=api_token, disable_tls_verification=disable_tls_verification) - self.actions_api = ActionsApi(self.api_client) self.notes_api = NotesApi(self.api_client) self.detector_group_api = DetectorGroupsApi(self.api_client) self.detector_reset_api = DetectorResetApi(self.api_client) @@ -106,167 +92,6 @@ def __init__( # API client for interacting with the EdgeEndpoint (getting/setting configuration, etc.) self.edge = EdgeEndpointApi(self) - ITEMS_PER_PAGE = 100 - - def make_condition(self, verb: str, parameters: dict) -> Condition: - """ - Creates a Condition object for use in creating alerts - - This function serves as a convenience method; Condition objects can also be created directly. - - **Example usage**:: - - gl = ExperimentalApi() - - # Create a condition for a rule - condition = gl.make_condition("CHANGED_TO", {"label": "YES"}) - - :param verb: The condition verb to use. One of "ANSWERED_CONSECUTIVELY", "ANSWERED_WITHIN_TIME", - "CHANGED_TO", "NO_CHANGE", "NO_QUERIES" - :param condition_parameters: Additional parameters for the condition, dependant on the verb: - - For ANSWERED_CONSECUTIVELY: {"num_consecutive_labels": N, "label": "YES/NO"} - - For CHANGED_TO: {"label": "YES/NO"} - - For ANSWERED_WITHIN_TIME: {"time_value": N, "time_unit": "MINUTES/HOURS/DAYS"} - - :return: The created Condition object - """ - return Condition(verb=verb, parameters=parameters) - - def make_webhook_action( - self, url: str, include_image: bool, payload_template: Optional[PayloadTemplate] = None - ) -> WebhookAction: - """ - Creates a WebhookAction object for use in creating alerts - This function serves as a convenience method; WebhookAction objects can also be created directly. - **Example usage**:: - gl = ExperimentalApi() - # Create a webhook action for an alert - action = gl.make_webhook_action("https://example.com/webhook", include_image=True) - :param url: The URL to send the webhook to - :param include_image: Whether to include the triggering image in the webhook payload - :param payload_template: Optional custom template for the webhook payload. The template will be rendered with - the alert data. The template must be a valid Jinja2 template which produces valid JSON when rendered. If no - template is provided, the default template designed for Slack will be used. - """ - return WebhookAction( - url=str(url), - include_image=include_image, - payload_template=payload_template, - ) - - def make_payload_template(self, template: str, headers: Optional[Dict[str, str]] = None) -> PayloadTemplate: - """ - Creates a PayloadTemplate object for use in creating alerts - """ - return PayloadTemplate(template=template, headers=headers) - - def create_alert( # pylint: disable=too-many-locals, too-many-arguments # noqa: PLR0913 - self, - detector: Union[str, Detector], - name, - condition: Condition, - actions: Optional[Union[Action, List[Action], ActionList]] = None, - webhook_actions: Optional[Union[WebhookAction, List[WebhookAction]]] = None, - *, - enabled: bool = True, - snooze_time_enabled: bool = False, - snooze_time_value: int = 3600, - snooze_time_unit: str = "SECONDS", - human_review_required: bool = False, - ) -> Rule: - """ - Creates an alert for a detector that will trigger actions based on specified conditions. - - An alert allows you to configure automated actions when certain conditions are met, - such as when a detector's prediction changes or maintains a particular state. - - .. note:: - Currently, only binary mode detectors (YES/NO answers) are supported for alerts. - - **Example usage**:: - - gl = ExperimentalApi() - - # Create an alert to send emails when door is detected as open - condition = gl.make_condition( - verb="CHANGED_TO", - parameters={"label": "YES"} - ) - action1 = Action(channel="EMAIL", recipient="alerts@company.com", include_image=True) - action2 = Action(channel="TEXT", recipient="+1234567890", include_image=False) - alert = gl.create_alert( - detector="det_idhere", - name="Door Open Alert", - condition=condition, - actions=[action1, action2] - ) - - :param detector: The detector ID or Detector object to add the alert to - :param name: A unique name to identify this alert - :param condition: The condition to use for the alert - :param actions: The actions to use for the alert. Optional if webhook_actions are provided (default None) - :param webhook_actions: The webhook actions to use for the alert. Optional if actions are provided (default - None) - :param enabled: Whether the alert should be active when created (default True) - :param snooze_time_enabled: Enable notification snoozing to prevent alert spam (default False) - :param snooze_time_value: Duration of snooze period (default 3600) - :param snooze_time_unit: Unit for snooze duration - "SECONDS", "MINUTES", "HOURS", or "DAYS" (default "SECONDS") - :param human_review_required: Require human verification before sending alerts (default False) - - :return: The created Alert object - """ - if isinstance(actions, Action): - actions = [actions] - elif isinstance(actions, ActionList): - actions = actions.root - if isinstance(detector, Detector): - detector = detector.id - if isinstance(webhook_actions, WebhookAction): - webhook_actions = [webhook_actions] - # translate pydantic type to the openapi type - actions = ( - [ - ActionRequest( - channel=ChannelEnum(action.channel), recipient=action.recipient, include_image=action.include_image - ) - for action in actions - ] - if actions - else [] - ) - webhook_actions = ( - [ - WebhookActionRequest( - url=str(webhook_action.url), - include_image=webhook_action.include_image, - payload_template=( - PayloadTemplateRequest( - template=webhook_action.payload_template.template, - headers=webhook_action.payload_template.headers, - ) - if webhook_action.payload_template - else None - ), - ) - for webhook_action in webhook_actions - ] - if webhook_actions - else [] - ) - rule_input = RuleRequest( - detector_id=detector, - name=name, - enabled=enabled, - action=actions, - condition=ConditionRequest(verb=condition.verb, parameters=condition.parameters), - snooze_time_enabled=snooze_time_enabled, - snooze_time_value=snooze_time_value, - snooze_time_unit=snooze_time_unit, - human_review_required=human_review_required, - webhook_action=webhook_actions, - ) - return Rule.model_validate(self.actions_api.create_rule(detector, rule_input).to_dict()) - def get_notes(self, detector: Union[str, Detector]) -> Dict[str, Any]: """ Retrieves all notes associated with a detector. diff --git a/test/unit/test_actions.py b/test/unit/test_actions.py deleted file mode 100644 index f33fd86b..00000000 --- a/test/unit/test_actions.py +++ /dev/null @@ -1,146 +0,0 @@ -from datetime import datetime - -import pytest -from groundlight import ApiException, ExperimentalApi -from model import Action - - -def test_create_alert_multiple_actions(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) - action1 = Action(channel="EMAIL", recipient="test@groundlight.ai", include_image=False) - action2 = Action(channel="EMAIL", recipient="test@groundlight.ai", include_image=False) - actions = [action1, action2] - alert = gl_experimental.create_alert( - det, - f"test_alert_{name}", - condition, - actions, - ) - assert len(alert.action.root) == len(actions) - - -def test_create_alert_webhook_action(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("ANSWERED_CONSECUTIVELY", {"num_consecutive_labels": 1, "label": "YES"}) - webhook_url = "https://hooks.slack.com/services/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" - webhook_action = gl_experimental.make_webhook_action(webhook_url, include_image=False) - alert = gl_experimental.create_alert( - det, - f"test_alert_{name}", - condition, - webhook_actions=webhook_action, - ) - assert len(alert.webhook_action) == 1 - assert len(alert.action.root) == 0 - - -def test_create_alert_multiple_webhook_actions(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) - webhook_action_1 = gl_experimental.make_webhook_action(url="https://groundlight.ai", include_image=True) - webhook_action_2 = gl_experimental.make_webhook_action(url="https://example.com/webhook", include_image=False) - webhook_actions = [webhook_action_1, webhook_action_2] - alert = gl_experimental.create_alert(det, f"test_alert_{name}", condition, webhook_actions=webhook_actions) - assert len(alert.webhook_action) == len(webhook_actions) - assert len(alert.action.root) == 0 - - -def test_create_alert_webhook_action_and_other_action(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) - webhook_action = gl_experimental.make_webhook_action(url="https://groundlight.ai", include_image=True) - email_action = Action(channel="EMAIL", recipient="test@groundlight.ai", include_image=False) - alert = gl_experimental.create_alert( - det, f"test_alert_{name}", condition, webhook_actions=webhook_action, actions=email_action - ) - assert len(alert.webhook_action) == 1 - assert len(alert.action.root) == 1 - - -def test_create_alert_webhook_action_with_payload_template(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) - payload_template = gl_experimental.make_payload_template('{"text": "This should be a valid payload"}') - webhook_action = gl_experimental.make_webhook_action( - url="https://hooks.slack.com/services/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", - include_image=True, - payload_template=payload_template, - ) - alert = gl_experimental.create_alert(det, f"test_alert_{name}", condition, webhook_actions=webhook_action) - - assert len(alert.webhook_action) == 1 - assert alert.webhook_action[0].payload_template.template == '{"text": "This should be a valid payload"}' - - -def test_create_alert_webhook_action_with_invalid_payload_template(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) - payload_template = gl_experimental.make_payload_template( - '{"text": "This should not be a valid payload, jinja brackets are not closed properly {{detector_id}"}' - ) - webhook_action = gl_experimental.make_webhook_action( - url="https://groundlight.ai", include_image=True, payload_template=payload_template - ) - - bad_request_exception_status_code = 400 - - with pytest.raises(ApiException) as e: - gl_experimental.create_alert(det, f"test_alert_{name}", condition, webhook_actions=webhook_action) - assert e.value.status == bad_request_exception_status_code - - payload_template = gl_experimental.make_payload_template( - "This should not be a valid payload, it's valid jinja but won't produce valid json" - ) - webhook_action = gl_experimental.make_webhook_action( - url="https://groundlight.ai", include_image=True, payload_template=payload_template - ) - - with pytest.raises(ApiException) as e: - gl_experimental.create_alert(det, f"test_alert_{name}", condition, webhook_actions=webhook_action) - assert e.value.status == bad_request_exception_status_code - - -def test_create_alert_webhook_action_headers(gl_experimental: ExperimentalApi): - name = f"Test {datetime.utcnow()}" - det = gl_experimental.get_or_create_detector(name, "test_query") - condition = gl_experimental.make_condition("ANSWERED_CONSECUTIVELY", {"num_consecutive_labels": 1, "label": "YES"}) - - test_api_key = "test_api_key" - url = "https://example.com/webhook" - headers = { - "Authorization": f"Bearer {test_api_key}", - } - - template = """{"records": [{"fields": {"detector_id": "{{ detector_id }}" } }]}""" - - payload_template = {"template": template, "headers": headers} - webhook_action = gl_experimental.make_webhook_action( - url=url, include_image=False, payload_template=payload_template - ) - - alert = gl_experimental.create_alert( - det, - f"test_alert_{name}", - condition, - webhook_actions=webhook_action, - ) - - assert len(alert.webhook_action) == 1 - assert alert.webhook_action[0].payload_template.template == template - assert alert.webhook_action[0].payload_template.headers == headers - - -def test_create_invalid_payload_template_headers(gl_experimental: ExperimentalApi): - with pytest.raises(Exception) as e: - gl_experimental.make_payload_template( - '{"template": "This is a fine template"}', headers="bad headers" # type: ignore - ) - assert e.typename == "ValidationError" - assert "Input should be a valid dictionary" in str(e.value) From e9f4e284d9724bc679f3cca5b31c76a7dcf5b10d Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 4 May 2026 19:18:19 +0000 Subject: [PATCH 14/14] Automatically reformatting code --- src/groundlight/experimental_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 6171f2ea..57619b9c 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -10,7 +10,7 @@ from http import HTTPStatus from io import BufferedReader, BytesIO from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Optional, Union from urllib.parse import urlparse, urlunparse import requests