Skip to content
Open
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
22 changes: 20 additions & 2 deletions src/aignostics/application/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
DEFAULT_GPU_TYPE,
DEFAULT_MAX_GPUS_PER_SLIDE,
DEFAULT_NODE_ACQUISITION_TIMEOUT_MINUTES,
ForbiddenException,
NotFoundException,
RunState,
)
Expand Down Expand Up @@ -872,6 +873,13 @@ def run_list( # noqa: PLR0913, PLR0917
] = None,
query: Annotated[str | None, typer.Option(help="Optional query string to filter runs by note OR tags.")] = None,
note_case_insensitive: Annotated[bool, typer.Option(help="Make note regex search case-insensitive.")] = True,
for_organization: Annotated[
str | None,
typer.Option(
"--for-organization",
help="Organization ID to list all runs for. Lists runs from all users in the organization.",
),
] = None,
format: Annotated[ # noqa: A002
str,
typer.Option(help="Output format: 'text' (default) or 'json'"),
Expand All @@ -885,15 +893,19 @@ def run_list( # noqa: PLR0913, PLR0917
note_regex=note_regex,
note_query_case_insensitive=note_case_insensitive,
query=query,
for_organization=for_organization,
)
if len(runs) == 0:
if format == "json":
print(json.dumps([]))
else:
scope = f" for organization '{for_organization}'" if for_organization else ""
if tags:
message = f"You did not yet create a run matching tags: {tags!r}."
message = f"No runs found{scope} matching tags: {tags!r}."
elif note_regex:
message = f"You did not yet create a run matching note pattern: {note_regex!r}."
message = f"No runs found{scope} matching note pattern: {note_regex!r}."
elif for_organization:
message = f"No runs found{scope}."
else:
message = "You did not yet create a run."
logger.warning(message)
Expand All @@ -908,6 +920,12 @@ def run_list( # noqa: PLR0913, PLR0917
message = f"Listed '{len(runs)}' run(s)."
console.print(message, style="info")
logger.debug(f"Listed '{len(runs)}' run(s).")
except ForbiddenException:
scope = f" for organization '{for_organization}'" if for_organization else ""
message = f"Access denied: you are not authorized to list runs{scope}."
logger.warning(message)
console.print(f"[error]Error:[/error] {message}")
sys.exit(2)
except Exception as e:
logger.exception("Failed to list runs")
console.print(f"[error]Error:[/error] Failed to list runs: {e}")
Expand Down
14 changes: 14 additions & 0 deletions src/aignostics/application/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ApplicationSummary,
ApplicationVersion,
Client,
ForbiddenException,
InputArtifact,
InputItem,
NotFoundException,
Expand Down Expand Up @@ -533,6 +534,7 @@ def application_runs_static( # noqa: PLR0913, PLR0917
tags: set[str] | None = None,
query: str | None = None,
limit: int | None = None,
for_organization: str | None = None,
) -> list[dict[str, Any]]:
"""Get a list of all application runs, static variant.

Expand All @@ -551,6 +553,8 @@ def application_runs_static( # noqa: PLR0913, PLR0917
If None, no filtering is applied. Cannot be used together with custom_metadata, note_regex, or tags.
Performs a union search: matches runs where the query appears in the note OR matches any tag.
limit (int | None): The maximum number of runs to retrieve. If None, all runs are retrieved.
for_organization (str | None): If set, returns all runs triggered by users of the specified
organization. If None, only the runs of the current user are returned.

Returns:
list[RunData]: A list of all application runs.
Expand Down Expand Up @@ -587,6 +591,7 @@ def application_runs_static( # noqa: PLR0913, PLR0917
tags=tags,
query=query,
limit=limit,
for_organization=for_organization,
)
]

Expand All @@ -601,6 +606,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
tags: set[str] | None = None,
query: str | None = None,
limit: int | None = None,
for_organization: str | None = None,
) -> list[RunData]:
"""Get a list of all application runs.

Expand All @@ -619,12 +625,15 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
If None, no filtering is applied. Cannot be used together with custom_metadata, note_regex, or tags.
Performs a union search: matches runs where the query appears in the note OR matches any tag.
limit (int | None): The maximum number of runs to retrieve. If None, all runs are retrieved.
for_organization (str | None): If set, returns all runs triggered by users of the specified
organization. If None, only the runs of the current user are returned.

Returns:
list[RunData]: A list of all application runs.

Raises:
ValueError: If query is used together with custom_metadata, note_regex, or tags.
ForbiddenException: If the user is not authorized to list runs for the specified organization.
RuntimeError: If the application run list cannot be retrieved.
"""
# Validate that query is not used with other metadata filters
Expand Down Expand Up @@ -658,6 +667,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
custom_metadata=custom_metadata_note,
sort="-submitted_at",
page_size=page_size,
for_organization=for_organization,
)
for run in note_run_iterator:
if has_output and run.output == RunOutput.NONE:
Expand All @@ -677,6 +687,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
custom_metadata=custom_metadata_tags,
sort="-submitted_at",
page_size=page_size,
for_organization=for_organization,
)
for run in tag_run_iterator:
if has_output and run.output == RunOutput.NONE:
Expand Down Expand Up @@ -731,6 +742,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
custom_metadata=custom_metadata,
sort="-submitted_at",
page_size=page_size,
for_organization=for_organization,
)
for run in run_iterator:
if has_output and run.output == RunOutput.NONE:
Expand Down Expand Up @@ -770,6 +782,8 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
if limit is not None and len(runs) >= limit:
break
return runs
except ForbiddenException:
raise
except Exception as e:
message = f"Failed to retrieve application runs: {e}"
logger.exception(message)
Expand Down
3 changes: 2 additions & 1 deletion src/aignostics/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
Higher level abstractions are provided in the application module.
"""

from aignx.codegen.exceptions import ApiException, NotFoundException
from aignx.codegen.exceptions import ApiException, ForbiddenException, NotFoundException
from aignx.codegen.models import ApplicationReadResponse as Application
from aignx.codegen.models import ApplicationReadShortResponse as ApplicationSummary
from aignx.codegen.models import InputArtifact as InputArtifactData
Expand Down Expand Up @@ -147,6 +147,7 @@
"ApplicationSummary",
"ApplicationVersion",
"Client",
"ForbiddenException",
"InputArtifact",
"InputArtifactData",
"InputItem",
Expand Down
8 changes: 8 additions & 0 deletions src/aignostics/platform/resources/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ def list( # noqa: PLR0913, PLR0917
sort: str | None = None,
page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE,
nocache: bool = False,
for_organization: str | None = None,
) -> Iterator[Run]:
"""Find application runs, optionally filtered by application id and/or version.

Expand All @@ -615,6 +616,8 @@ def list( # noqa: PLR0913, PLR0917
page_size (int): Number of items per page, defaults to max
nocache (bool): If True, skip reading from cache and fetch fresh data from the API.
The fresh result will still be cached for subsequent calls. Defaults to False.
for_organization (str | None): If set, returns all runs triggered by users of the specified organization
that match the filter criteria. If None, only the runs of the user are returned.

Returns:
Iterator[Run]: An iterator yielding application run handles.
Expand All @@ -628,6 +631,7 @@ def list( # noqa: PLR0913, PLR0917
for response in self.list_data(
application_id=application_id,
application_version=application_version,
for_organization=for_organization,
external_id=external_id,
custom_metadata=custom_metadata,
sort=sort,
Expand All @@ -645,6 +649,7 @@ def list_data( # noqa: PLR0913, PLR0917
sort: str | None = None,
page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE,
nocache: bool = False,
for_organization: str | None = None,
) -> t.Iterator[RunData]:
"""Fetch application runs, optionally filtered by application version.

Expand All @@ -659,6 +664,8 @@ def list_data( # noqa: PLR0913, PLR0917
page_size (int): Number of items per page, defaults to max
nocache (bool): If True, skip reading from cache and fetch fresh data from the API.
The fresh result will still be cached for subsequent calls. Defaults to False.
for_organization (str | None): If set, returns all runs triggered by users of the specified organization
that match the filter criteria. If None, only the runs of the user are returned.

Returns:
Iterator[RunData]: Iterator yielding application run data.
Expand Down Expand Up @@ -693,6 +700,7 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[RunData]:
lambda **kwargs: list_data_with_retry(
application_id=application_id,
application_version=application_version,
for_organization=for_organization,
external_id=external_id,
custom_metadata=custom_metadata,
sort=[sort] if sort else None,
Expand Down
41 changes: 41 additions & 0 deletions tests/aignostics/application/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,47 @@ def test_cli_run_list_verbose_limit_1(runner: CliRunner, record_property) -> Non
assert displayed_count == 1, f"Expected listed count to be == 1, but got {displayed_count}"


@pytest.mark.unit
def test_cli_run_list_for_organization(runner: CliRunner) -> None:
"""Check run list command passes --for-organization to service and shows org-specific empty message."""
with patch.object(ApplicationService, "application_runs", return_value=[]) as mock_method:
result = runner.invoke(cli, ["application", "run", "list", "--for-organization", "org-123"])
assert result.exit_code == 0
mock_method.assert_called_once()
assert mock_method.call_args[1]["for_organization"] == "org-123"
output = normalize_output(result.stdout)
assert "No runs found for organization 'org-123'" in output


@pytest.mark.unit
def test_cli_run_list_forbidden_with_organization(runner: CliRunner) -> None:
"""Check ForbiddenException with --for-organization shows org-specific access denied message."""
from aignx.codegen.exceptions import ForbiddenException

with patch.object(
ApplicationService, "application_runs", side_effect=ForbiddenException(status=403, reason="Forbidden")
):
result = runner.invoke(cli, ["application", "run", "list", "--for-organization", "secret-org"])
assert result.exit_code == 2
output = normalize_output(result.stdout)
assert "Access denied" in output
assert "secret-org" in output


@pytest.mark.unit
def test_cli_run_list_forbidden_without_organization(runner: CliRunner) -> None:
"""Check ForbiddenException without --for-organization shows generic access denied message."""
from aignx.codegen.exceptions import ForbiddenException

with patch.object(
ApplicationService, "application_runs", side_effect=ForbiddenException(status=403, reason="Forbidden")
):
result = runner.invoke(cli, ["application", "run", "list"])
assert result.exit_code == 2
output = normalize_output(result.stdout)
assert "Access denied: you are not authorized to list runs." in output


# TODO(Andreas): This previously failed as invalid run id. Is it expected this now calls the API?
@pytest.mark.e2e
@pytest.mark.timeout(timeout=60)
Expand Down
6 changes: 2 additions & 4 deletions tests/aignostics/platform/e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ItemState,
RunOutput,
RunState,
SchedulingRequest,
)
from aignx.codegen.models.run_read_response import RunReadResponse
from loguru import logger
Expand Down Expand Up @@ -283,10 +284,7 @@ def _submit_and_validate( # noqa: PLR0913, PLR0917

logger.trace(f"Submitting application run for {application_id} version {application_version}")
client = platform.Client()
scheduling = {
"due_date": due_date.isoformat(),
"deadline": deadline.isoformat(),
}
scheduling = SchedulingRequest(due_date=due_date, deadline=deadline)
custom_metadata = {
"sdk": {
"tags": tags or set(),
Expand Down
5 changes: 4 additions & 1 deletion tests/aignostics/platform/resources/runs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
"""Test that Runs.list() correctly combines all filter parameters.

This test verifies that all filter parameters (application_id, application_version,
external_id, custom_metadata, sort, page_size) work together correctly.
for_organization, external_id, custom_metadata, sort, page_size) work together correctly.

Args:
runs: Runs instance with mock API.
Expand All @@ -441,6 +441,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
# Arrange
app_id = "test-app"
app_version = "1.0.0"
org_id = "org-789"
external_id = "ext-123"
custom_metadata = "$.experiment=='test'"
sort_field = "-created_at"
Expand All @@ -452,6 +453,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
runs.list(
application_id=app_id,
application_version=app_version,
for_organization=org_id,
external_id=external_id,
custom_metadata=custom_metadata,
sort=sort_field,
Expand All @@ -464,6 +466,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
call_kwargs = mock_api.list_runs_v1_runs_get.call_args[1]
assert call_kwargs["application_id"] == app_id
assert call_kwargs["application_version"] == app_version
assert call_kwargs["for_organization"] == org_id
assert call_kwargs["external_id"] == external_id
assert call_kwargs["custom_metadata"] == custom_metadata
assert call_kwargs["sort"] == [sort_field]
Expand Down
Loading