diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index 475a6da8e..a7d4132d5 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -19,6 +19,7 @@ DEFAULT_GPU_TYPE, DEFAULT_MAX_GPUS_PER_SLIDE, DEFAULT_NODE_ACQUISITION_TIMEOUT_MINUTES, + ForbiddenException, NotFoundException, RunState, ) @@ -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'"), @@ -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) @@ -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}") diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 4212a6853..315c674f6 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -23,6 +23,7 @@ ApplicationSummary, ApplicationVersion, Client, + ForbiddenException, InputArtifact, InputItem, NotFoundException, @@ -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. @@ -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. @@ -587,6 +591,7 @@ def application_runs_static( # noqa: PLR0913, PLR0917 tags=tags, query=query, limit=limit, + for_organization=for_organization, ) ] @@ -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. @@ -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 @@ -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: @@ -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: @@ -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: @@ -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) diff --git a/src/aignostics/platform/__init__.py b/src/aignostics/platform/__init__.py index 9e4cc47fc..8036b6b98 100644 --- a/src/aignostics/platform/__init__.py +++ b/src/aignostics/platform/__init__.py @@ -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 @@ -147,6 +147,7 @@ "ApplicationSummary", "ApplicationVersion", "Client", + "ForbiddenException", "InputArtifact", "InputArtifactData", "InputItem", diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index dc6dca005..f2b7a49b9 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -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. @@ -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. @@ -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, @@ -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. @@ -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. @@ -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, diff --git a/tests/aignostics/application/cli_test.py b/tests/aignostics/application/cli_test.py index 1b5491f22..aa5ed5d4f 100644 --- a/tests/aignostics/application/cli_test.py +++ b/tests/aignostics/application/cli_test.py @@ -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) diff --git a/tests/aignostics/platform/e2e_test.py b/tests/aignostics/platform/e2e_test.py index 683cbe861..ac2cf8924 100644 --- a/tests/aignostics/platform/e2e_test.py +++ b/tests/aignostics/platform/e2e_test.py @@ -19,6 +19,7 @@ ItemState, RunOutput, RunState, + SchedulingRequest, ) from aignx.codegen.models.run_read_response import RunReadResponse from loguru import logger @@ -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(), diff --git a/tests/aignostics/platform/resources/runs_test.py b/tests/aignostics/platform/resources/runs_test.py index c8367196e..cb4749eda 100644 --- a/tests/aignostics/platform/resources/runs_test.py +++ b/tests/aignostics/platform/resources/runs_test.py @@ -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. @@ -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" @@ -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, @@ -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]