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
33 changes: 30 additions & 3 deletions src/aignostics/application/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
print_runs_verbose,
read_metadata_csv_to_dict,
retrieve_and_print_run_details,
share_token_access_denied_message,
validate_mappings,
write_metadata_dict_to_csv,
)
Expand Down Expand Up @@ -121,6 +122,10 @@
int,
typer.Option(help="Timeout for acquiring compute nodes in minutes (1-3600).", min=1, max=3600),
]
ShareTokenOption = Annotated[
str | None,
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
]


cli = typer.Typer(name="application", help="List and inspect applications on Aignostics Platform.")
Expand Down Expand Up @@ -970,13 +975,15 @@ def run_describe(
help="Show only run and item status summary (external ID, state, error message)",
),
] = False,
share_token: ShareTokenOption = None,
) -> None:
"""Describe run."""
logger.trace("Describing run with ID '{}'", run_id)

try:
user_info = PlatformService.get_user_info()
run = Service().application_run(run_id)
run = Service().application_run(run_id, share_token=share_token)

if format == "json":
# Get run details and items, output as JSON
run_details = run.details(hide_platform_queue_position=not user_info.is_internal_user)
Expand All @@ -995,6 +1002,13 @@ def run_describe(
else:
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
sys.exit(2)
except ForbiddenException:
msg = share_token_access_denied_message(run_id, share_token)
if format == "json":
print(json.dumps({"error": "access_denied", "message": msg}), file=sys.stderr)
else:
console.print(f"[error]Error:[/error] {msg}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to retrieve and print run details for ID '{run_id}'")
if format == "json":
Expand All @@ -1008,6 +1022,7 @@ def run_describe(
def run_dump_metadata(
run_id: Annotated[str, typer.Argument(help="Id of the run to dump custom metadata for")],
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
share_token: ShareTokenOption = None,
show_checksum: Annotated[
bool,
typer.Option(
Expand All @@ -1023,7 +1038,7 @@ def run_dump_metadata(
logger.trace("Dumping custom metadata for run with ID '{}'", run_id)

try:
run = Service().application_run(run_id).details()
run = Service().application_run(run_id, share_token=share_token).details()
custom_metadata = run.custom_metadata if hasattr(run, "custom_metadata") else {}
output: dict[str, Any] | Any = custom_metadata
if show_checksum:
Expand All @@ -1043,6 +1058,9 @@ def run_dump_metadata(
logger.warning(f"Run with ID '{run_id}' not found.")
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
sys.exit(2)
except ForbiddenException:
console.print(f"[error]Error:[/error] {share_token_access_denied_message(run_id, share_token)}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to dump custom metadata for run with ID '{run_id}'")
console.print(f"[error]Error:[/error] Failed to dump custom metadata for run with ID '{run_id}': {e}")
Expand All @@ -1054,6 +1072,7 @@ def run_dump_item_metadata(
run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")],
external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")],
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
share_token: ShareTokenOption = None,
show_checksum: Annotated[
bool,
typer.Option(
Expand All @@ -1070,7 +1089,7 @@ def run_dump_item_metadata(
logger.trace("Dumping custom metadata for item '{}' in run with ID '{}'", external_id, run_id)

try:
run = Service().application_run(run_id)
run = Service().application_run(run_id, share_token=share_token)

# Find the item with the matching external_id in the results
item = None
Expand Down Expand Up @@ -1106,6 +1125,9 @@ def run_dump_item_metadata(
logger.warning(f"Run with ID '{run_id}' not found.")
print(f"Warning: Run with ID '{run_id}' not found.", file=sys.stderr)
sys.exit(2)
except ForbiddenException:
print(f"Error: {share_token_access_denied_message(run_id, share_token)}", file=sys.stderr)
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to dump custom metadata for item '{external_id}' in run with ID '{run_id}'")
print(
Expand Down Expand Up @@ -1661,6 +1683,7 @@ def result_download( # noqa: C901, PLR0913, PLR0915
'Run uvx --with "aignostics[qupath]" aignostics qupath install'
),
] = False,
share_token: ShareTokenOption = None,
) -> None:
"""Download results of a run."""
logger.trace(
Expand Down Expand Up @@ -1808,6 +1831,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901
wait_for_completion=wait_for_completion,
qupath_project=qupath_project,
download_progress_callable=update_progress,
share_token=share_token,
)

main_download_progress_ui.update(main_task, completed=100, total=100)
Expand All @@ -1823,6 +1847,9 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901
logger.warning(f"Bad input to download results of run with ID '{run_id}': {e}")
console.print(f"[warning]Warning:[/warning] Bad input to download results of run with ID '{run_id}': {e}")
sys.exit(2)
except ForbiddenException:
console.print(f"[error]Error:[/error] {share_token_access_denied_message(run_id, share_token)}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to download results of run with ID '{run_id}'")
console.print(
Expand Down
16 changes: 13 additions & 3 deletions src/aignostics/application/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -800,11 +800,13 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0915
logger.exception(message)
raise RuntimeError(message) from e

def application_run(self, run_id: str) -> Run:
def application_run(self, run_id: str, share_token: str | None = None) -> Run:
"""Select a run by its ID.

Args:
run_id (str): The ID of the run to find
run_id (str): The ID of the run to find.
share_token (str | None): Optional share token secret. When provided the run
is accessed via the ``share_token`` query parameter without OAuth.

Returns:
Run: The run that can be fetched using the .details() call.
Expand All @@ -813,6 +815,8 @@ def application_run(self, run_id: str) -> Run:
RuntimeError: If initializing the client fails or the run cannot be retrieved.
"""
try:
if share_token is not None:
return Run.for_run_id(run_id, share_token=share_token)
return self._get_platform_client().run(run_id)
except Exception as e:
message = f"Failed to retrieve application run with ID '{run_id}': {e}"
Expand Down Expand Up @@ -1675,6 +1679,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915
qupath_project: bool = False,
download_progress_queue: Any | None = None, # noqa: ANN401
download_progress_callable: Callable | None = None, # type: ignore[type-arg]
share_token: str | None = None,
) -> Path:
"""Download application run results with progress tracking.

Expand All @@ -1691,6 +1696,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915
of the destination directory.
download_progress_queue (Queue | None): Queue for GUI progress updates.
download_progress_callable (Callable | None): Callback for CLI progress updates.
share_token (str | None): Optional share token secret for unauthenticated access.

Returns:
Path: The directory containing downloaded results.
Expand Down Expand Up @@ -1721,14 +1727,18 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915
progress = DownloadProgress()
update_progress(progress, download_progress_callable, download_progress_queue)

application_run = self.application_run(run_id)
application_run = self.application_run(run_id, share_token=share_token)
final_destination_directory = destination_directory
try:
details = application_run.details()
except NotFoundException as e:
message = f"Application run with ID '{run_id}' not found: {e}"
logger.warning(message)
raise NotFoundException(message) from e
except ForbiddenException:
# Propagate 403 unchanged so the CLI can surface a share-token-specific
# "access denied" message; do not wrap it into RuntimeError below.
raise
except ApiException as e:
if e.status == HTTPStatus.UNPROCESSABLE_ENTITY:
message = f"Run ID '{run_id}' invalid: {e!s}."
Expand Down
22 changes: 22 additions & 0 deletions src/aignostics/application/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,3 +599,25 @@ def get_supported_extensions_for_application(application_id: str) -> set[str]:
message = f"Unsupported application {application_id}"
logger.critical(message)
raise RuntimeError(message)


def share_token_access_denied_message(run_id: str, share_token: str | None) -> str:
"""Compose the operator-facing "access denied" message for a run and log a warning.

Centralizes the wording, share-token hint, and warning log shared by the run CLI
commands that support ``--share-token``. The caller owns the output sink (console,
stderr, or JSON) and the exit code.

Args:
run_id (str): The run access was denied for.
share_token (str | None): The share token supplied, if any. When set, a hint
that the token may be invalid, expired, or revoked is appended.

Returns:
str: The composed message.
"""
logger.warning("Access denied for run '{}'", run_id)
message = f"Access denied for run '{run_id}'."
if share_token is not None:
message += " The share token may be invalid, expired, or revoked."
return message
Loading
Loading