Skip to content

Commit 3f06055

Browse files
authored
feat(platform): add 'for_organization' to list all runs of an org (#510)
1 parent 4d61ff0 commit 3f06055

7 files changed

Lines changed: 91 additions & 8 deletions

File tree

src/aignostics/application/_cli.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DEFAULT_GPU_TYPE,
2020
DEFAULT_MAX_GPUS_PER_SLIDE,
2121
DEFAULT_NODE_ACQUISITION_TIMEOUT_MINUTES,
22+
ForbiddenException,
2223
NotFoundException,
2324
RunState,
2425
)
@@ -872,6 +873,13 @@ def run_list( # noqa: PLR0913, PLR0917
872873
] = None,
873874
query: Annotated[str | None, typer.Option(help="Optional query string to filter runs by note OR tags.")] = None,
874875
note_case_insensitive: Annotated[bool, typer.Option(help="Make note regex search case-insensitive.")] = True,
876+
for_organization: Annotated[
877+
str | None,
878+
typer.Option(
879+
"--for-organization",
880+
help="Organization ID to list all runs for. Lists runs from all users in the organization.",
881+
),
882+
] = None,
875883
format: Annotated[ # noqa: A002
876884
str,
877885
typer.Option(help="Output format: 'text' (default) or 'json'"),
@@ -885,15 +893,19 @@ def run_list( # noqa: PLR0913, PLR0917
885893
note_regex=note_regex,
886894
note_query_case_insensitive=note_case_insensitive,
887895
query=query,
896+
for_organization=for_organization,
888897
)
889898
if len(runs) == 0:
890899
if format == "json":
891900
print(json.dumps([]))
892901
else:
902+
scope = f" for organization '{for_organization}'" if for_organization else ""
893903
if tags:
894-
message = f"You did not yet create a run matching tags: {tags!r}."
904+
message = f"No runs found{scope} matching tags: {tags!r}."
895905
elif note_regex:
896-
message = f"You did not yet create a run matching note pattern: {note_regex!r}."
906+
message = f"No runs found{scope} matching note pattern: {note_regex!r}."
907+
elif for_organization:
908+
message = f"No runs found{scope}."
897909
else:
898910
message = "You did not yet create a run."
899911
logger.warning(message)
@@ -908,6 +920,12 @@ def run_list( # noqa: PLR0913, PLR0917
908920
message = f"Listed '{len(runs)}' run(s)."
909921
console.print(message, style="info")
910922
logger.debug(f"Listed '{len(runs)}' run(s).")
923+
except ForbiddenException:
924+
scope = f" for organization '{for_organization}'" if for_organization else ""
925+
message = f"Access denied: you are not authorized to list runs{scope}."
926+
logger.warning(message)
927+
console.print(f"[error]Error:[/error] {message}")
928+
sys.exit(2)
911929
except Exception as e:
912930
logger.exception("Failed to list runs")
913931
console.print(f"[error]Error:[/error] Failed to list runs: {e}")

src/aignostics/application/_service.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ApplicationSummary,
2424
ApplicationVersion,
2525
Client,
26+
ForbiddenException,
2627
InputArtifact,
2728
InputItem,
2829
NotFoundException,
@@ -533,6 +534,7 @@ def application_runs_static( # noqa: PLR0913, PLR0917
533534
tags: set[str] | None = None,
534535
query: str | None = None,
535536
limit: int | None = None,
537+
for_organization: str | None = None,
536538
) -> list[dict[str, Any]]:
537539
"""Get a list of all application runs, static variant.
538540
@@ -551,6 +553,8 @@ def application_runs_static( # noqa: PLR0913, PLR0917
551553
If None, no filtering is applied. Cannot be used together with custom_metadata, note_regex, or tags.
552554
Performs a union search: matches runs where the query appears in the note OR matches any tag.
553555
limit (int | None): The maximum number of runs to retrieve. If None, all runs are retrieved.
556+
for_organization (str | None): If set, returns all runs triggered by users of the specified
557+
organization. If None, only the runs of the current user are returned.
554558
555559
Returns:
556560
list[RunData]: A list of all application runs.
@@ -587,6 +591,7 @@ def application_runs_static( # noqa: PLR0913, PLR0917
587591
tags=tags,
588592
query=query,
589593
limit=limit,
594+
for_organization=for_organization,
590595
)
591596
]
592597

@@ -601,6 +606,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
601606
tags: set[str] | None = None,
602607
query: str | None = None,
603608
limit: int | None = None,
609+
for_organization: str | None = None,
604610
) -> list[RunData]:
605611
"""Get a list of all application runs.
606612
@@ -619,12 +625,15 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
619625
If None, no filtering is applied. Cannot be used together with custom_metadata, note_regex, or tags.
620626
Performs a union search: matches runs where the query appears in the note OR matches any tag.
621627
limit (int | None): The maximum number of runs to retrieve. If None, all runs are retrieved.
628+
for_organization (str | None): If set, returns all runs triggered by users of the specified
629+
organization. If None, only the runs of the current user are returned.
622630
623631
Returns:
624632
list[RunData]: A list of all application runs.
625633
626634
Raises:
627635
ValueError: If query is used together with custom_metadata, note_regex, or tags.
636+
ForbiddenException: If the user is not authorized to list runs for the specified organization.
628637
RuntimeError: If the application run list cannot be retrieved.
629638
"""
630639
# Validate that query is not used with other metadata filters
@@ -658,6 +667,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
658667
custom_metadata=custom_metadata_note,
659668
sort="-submitted_at",
660669
page_size=page_size,
670+
for_organization=for_organization,
661671
)
662672
for run in note_run_iterator:
663673
if has_output and run.output == RunOutput.NONE:
@@ -677,6 +687,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
677687
custom_metadata=custom_metadata_tags,
678688
sort="-submitted_at",
679689
page_size=page_size,
690+
for_organization=for_organization,
680691
)
681692
for run in tag_run_iterator:
682693
if has_output and run.output == RunOutput.NONE:
@@ -731,6 +742,7 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
731742
custom_metadata=custom_metadata,
732743
sort="-submitted_at",
733744
page_size=page_size,
745+
for_organization=for_organization,
734746
)
735747
for run in run_iterator:
736748
if has_output and run.output == RunOutput.NONE:
@@ -770,6 +782,8 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
770782
if limit is not None and len(runs) >= limit:
771783
break
772784
return runs
785+
except ForbiddenException:
786+
raise
773787
except Exception as e:
774788
message = f"Failed to retrieve application runs: {e}"
775789
logger.exception(message)

src/aignostics/platform/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
Higher level abstractions are provided in the application module.
1111
"""
1212

13-
from aignx.codegen.exceptions import ApiException, NotFoundException
13+
from aignx.codegen.exceptions import ApiException, ForbiddenException, NotFoundException
1414
from aignx.codegen.models import ApplicationReadResponse as Application
1515
from aignx.codegen.models import ApplicationReadShortResponse as ApplicationSummary
1616
from aignx.codegen.models import InputArtifact as InputArtifactData
@@ -147,6 +147,7 @@
147147
"ApplicationSummary",
148148
"ApplicationVersion",
149149
"Client",
150+
"ForbiddenException",
150151
"InputArtifact",
151152
"InputArtifactData",
152153
"InputItem",

src/aignostics/platform/resources/runs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ def list( # noqa: PLR0913, PLR0917
601601
sort: str | None = None,
602602
page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE,
603603
nocache: bool = False,
604+
for_organization: str | None = None,
604605
) -> Iterator[Run]:
605606
"""Find application runs, optionally filtered by application id and/or version.
606607
@@ -615,6 +616,8 @@ def list( # noqa: PLR0913, PLR0917
615616
page_size (int): Number of items per page, defaults to max
616617
nocache (bool): If True, skip reading from cache and fetch fresh data from the API.
617618
The fresh result will still be cached for subsequent calls. Defaults to False.
619+
for_organization (str | None): If set, returns all runs triggered by users of the specified organization
620+
that match the filter criteria. If None, only the runs of the user are returned.
618621
619622
Returns:
620623
Iterator[Run]: An iterator yielding application run handles.
@@ -628,6 +631,7 @@ def list( # noqa: PLR0913, PLR0917
628631
for response in self.list_data(
629632
application_id=application_id,
630633
application_version=application_version,
634+
for_organization=for_organization,
631635
external_id=external_id,
632636
custom_metadata=custom_metadata,
633637
sort=sort,
@@ -645,6 +649,7 @@ def list_data( # noqa: PLR0913, PLR0917
645649
sort: str | None = None,
646650
page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE,
647651
nocache: bool = False,
652+
for_organization: str | None = None,
648653
) -> t.Iterator[RunData]:
649654
"""Fetch application runs, optionally filtered by application version.
650655
@@ -659,6 +664,8 @@ def list_data( # noqa: PLR0913, PLR0917
659664
page_size (int): Number of items per page, defaults to max
660665
nocache (bool): If True, skip reading from cache and fetch fresh data from the API.
661666
The fresh result will still be cached for subsequent calls. Defaults to False.
667+
for_organization (str | None): If set, returns all runs triggered by users of the specified organization
668+
that match the filter criteria. If None, only the runs of the user are returned.
662669
663670
Returns:
664671
Iterator[RunData]: Iterator yielding application run data.
@@ -693,6 +700,7 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[RunData]:
693700
lambda **kwargs: list_data_with_retry(
694701
application_id=application_id,
695702
application_version=application_version,
703+
for_organization=for_organization,
696704
external_id=external_id,
697705
custom_metadata=custom_metadata,
698706
sort=[sort] if sort else None,

tests/aignostics/application/cli_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,47 @@ def test_cli_run_list_verbose_limit_1(runner: CliRunner, record_property) -> Non
807807
assert displayed_count == 1, f"Expected listed count to be == 1, but got {displayed_count}"
808808

809809

810+
@pytest.mark.unit
811+
def test_cli_run_list_for_organization(runner: CliRunner) -> None:
812+
"""Check run list command passes --for-organization to service and shows org-specific empty message."""
813+
with patch.object(ApplicationService, "application_runs", return_value=[]) as mock_method:
814+
result = runner.invoke(cli, ["application", "run", "list", "--for-organization", "org-123"])
815+
assert result.exit_code == 0
816+
mock_method.assert_called_once()
817+
assert mock_method.call_args[1]["for_organization"] == "org-123"
818+
output = normalize_output(result.stdout)
819+
assert "No runs found for organization 'org-123'" in output
820+
821+
822+
@pytest.mark.unit
823+
def test_cli_run_list_forbidden_with_organization(runner: CliRunner) -> None:
824+
"""Check ForbiddenException with --for-organization shows org-specific access denied message."""
825+
from aignx.codegen.exceptions import ForbiddenException
826+
827+
with patch.object(
828+
ApplicationService, "application_runs", side_effect=ForbiddenException(status=403, reason="Forbidden")
829+
):
830+
result = runner.invoke(cli, ["application", "run", "list", "--for-organization", "secret-org"])
831+
assert result.exit_code == 2
832+
output = normalize_output(result.stdout)
833+
assert "Access denied" in output
834+
assert "secret-org" in output
835+
836+
837+
@pytest.mark.unit
838+
def test_cli_run_list_forbidden_without_organization(runner: CliRunner) -> None:
839+
"""Check ForbiddenException without --for-organization shows generic access denied message."""
840+
from aignx.codegen.exceptions import ForbiddenException
841+
842+
with patch.object(
843+
ApplicationService, "application_runs", side_effect=ForbiddenException(status=403, reason="Forbidden")
844+
):
845+
result = runner.invoke(cli, ["application", "run", "list"])
846+
assert result.exit_code == 2
847+
output = normalize_output(result.stdout)
848+
assert "Access denied: you are not authorized to list runs." in output
849+
850+
810851
# TODO(Andreas): This previously failed as invalid run id. Is it expected this now calls the API?
811852
@pytest.mark.e2e
812853
@pytest.mark.timeout(timeout=60)

tests/aignostics/platform/e2e_test.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ItemState,
2020
RunOutput,
2121
RunState,
22+
SchedulingRequest,
2223
)
2324
from aignx.codegen.models.run_read_response import RunReadResponse
2425
from loguru import logger
@@ -283,10 +284,7 @@ def _submit_and_validate( # noqa: PLR0913, PLR0917
283284

284285
logger.trace(f"Submitting application run for {application_id} version {application_version}")
285286
client = platform.Client()
286-
scheduling = {
287-
"due_date": due_date.isoformat(),
288-
"deadline": deadline.isoformat(),
289-
}
287+
scheduling = SchedulingRequest(due_date=due_date, deadline=deadline)
290288
custom_metadata = {
291289
"sdk": {
292290
"tags": tags or set(),

tests/aignostics/platform/resources/runs_test.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
432432
"""Test that Runs.list() correctly combines all filter parameters.
433433
434434
This test verifies that all filter parameters (application_id, application_version,
435-
external_id, custom_metadata, sort, page_size) work together correctly.
435+
for_organization, external_id, custom_metadata, sort, page_size) work together correctly.
436436
437437
Args:
438438
runs: Runs instance with mock API.
@@ -441,6 +441,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
441441
# Arrange
442442
app_id = "test-app"
443443
app_version = "1.0.0"
444+
org_id = "org-789"
444445
external_id = "ext-123"
445446
custom_metadata = "$.experiment=='test'"
446447
sort_field = "-created_at"
@@ -452,6 +453,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
452453
runs.list(
453454
application_id=app_id,
454455
application_version=app_version,
456+
for_organization=org_id,
455457
external_id=external_id,
456458
custom_metadata=custom_metadata,
457459
sort=sort_field,
@@ -464,6 +466,7 @@ def test_runs_list_with_all_filters_combined(runs, mock_api) -> None:
464466
call_kwargs = mock_api.list_runs_v1_runs_get.call_args[1]
465467
assert call_kwargs["application_id"] == app_id
466468
assert call_kwargs["application_version"] == app_version
469+
assert call_kwargs["for_organization"] == org_id
467470
assert call_kwargs["external_id"] == external_id
468471
assert call_kwargs["custom_metadata"] == custom_metadata
469472
assert call_kwargs["sort"] == [sort_field]

0 commit comments

Comments
 (0)