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
69 changes: 62 additions & 7 deletions src/aignostics/application/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ def run_list( # noqa: PLR0913, PLR0917


@run_app.command("describe")
def run_describe(
def run_describe( # noqa: PLR0912
run_id: Annotated[str, typer.Argument(help="Id of the run to describe")],
format: Annotated[ # noqa: A002
str,
Expand All @@ -970,22 +970,33 @@ def run_describe(
help="Show only run and item status summary (external ID, state, error message)",
),
] = False,
share_token: Annotated[
str | None,
typer.Option(
help="Share token secret for link-based access. When provided, OAuth login is not required.",
),
] = 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)
hide_platform_queue_position = False
if share_token is None:
user_info = PlatformService.get_user_info()
hide_platform_queue_position = not user_info.is_internal_user

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)
run_details = run.details(hide_platform_queue_position=hide_platform_queue_position)
run_data = run_details.model_dump(mode="json")
run_data["items"] = [item.model_dump(mode="json") for item in run.results()]
print(json.dumps(run_data, indent=2, default=str))
else:
retrieve_and_print_run_details(
run, hide_platform_queue_position=not user_info.is_internal_user, summarize=summarize
run, hide_platform_queue_position=hide_platform_queue_position, summarize=summarize
)
logger.debug("Described run with ID '{}'", run_id)
except NotFoundException:
Expand All @@ -995,6 +1006,16 @@ def run_describe(
else:
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
sys.exit(2)
except ForbiddenException:
logger.warning("Access denied for run '{}'", run_id)
msg = f"Access denied for run '{run_id}'."
if share_token is not None:
msg += " The share token may be invalid, expired, or revoked."
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,12 +1029,16 @@ 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: Annotated[
str | None,
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
] = None,
) -> None:
"""Dump custom metadata of a run as JSON to stdout."""
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 JSON to stdout
Expand All @@ -1027,6 +1052,13 @@ 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:
logger.warning("Access denied for run '{}'", run_id)
msg = f"Access denied for run '{run_id}'."
if share_token is not None:
msg += " The share token may be invalid, expired, or revoked."
console.print(f"[error]Error:[/error] {msg}")
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 @@ -1038,12 +1070,16 @@ 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: Annotated[
str | None,
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
] = None,
) -> None:
"""Dump custom metadata of an item as JSON to stdout."""
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 @@ -1073,6 +1109,13 @@ 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:
logger.warning("Access denied for run '{}'", run_id)
msg = f"Access denied for run '{run_id}'."
if share_token is not None:
msg += " The share token may be invalid, expired, or revoked."
print(f"Error: {msg}", 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 @@ -1561,6 +1604,10 @@ def result_download( # noqa: C901, PLR0913, PLR0915, PLR0917
'Run uvx --with "aignostics[qupath]" aignostics qupath install'
),
] = False,
share_token: Annotated[
str | None,
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
] = None,
) -> None:
"""Download results of a run."""
logger.trace(
Expand Down Expand Up @@ -1708,6 +1755,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 @@ -1723,6 +1771,13 @@ 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:
logger.warning("Access denied for run '{}'", run_id)
msg = f"Access denied for run '{run_id}'."
if share_token is not None:
msg += " The share token may be invalid, expired, or revoked."
console.print(f"[error]Error:[/error] {msg}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to download results of run with ID '{run_id}'")
console.print(
Expand Down
12 changes: 9 additions & 3 deletions src/aignostics/application/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,11 +791,13 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
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 @@ -804,6 +806,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_share_token(run_id, 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 @@ -1595,6 +1599,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, 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 @@ -1611,6 +1616,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, 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 @@ -1641,7 +1647,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, 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()
Expand Down
59 changes: 52 additions & 7 deletions src/aignostics/platform/resources/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,20 @@ class Artifact(_AuthenticatedResource):
``GET /api/v1/runs/{run_id}/artifacts/{artifact_id}/file`` endpoint.
"""

def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str) -> None:
def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str, share_token: str | None = None) -> None:
"""Initializes an Artifact instance.

Args:
api (_AuthenticatedApi): The configured API client.
run_id (str): The ID of the parent run.
artifact_id (str): The ID of the output artifact.
share_token (str | None): Optional share token secret forwarded as the
``share_token`` query parameter on the /file endpoint request.
"""
super().__init__(api)
self.run_id = run_id
self.artifact_id = artifact_id
self._share_token = share_token

def get_download_url(self) -> str:
"""Resolve a fresh presigned download URL for this artifact.
Expand Down Expand Up @@ -143,6 +146,8 @@ def get_download_url(self) -> str:
configuration = self._api.api_client.configuration
host = configuration.host.rstrip("/")
endpoint_url = f"{host}/api/v1/runs/{self.run_id}/artifacts/{self.artifact_id}/file"
if self._share_token:
endpoint_url += f"?share_token={self._share_token}"
proxy = getattr(configuration, "proxy", None)
ssl_ca_cert = getattr(configuration, "ssl_ca_cert", None)
verify_ssl = getattr(configuration, "verify_ssl", True)
Expand Down Expand Up @@ -248,15 +253,19 @@ class Run(_AuthenticatedResource):
Provides operations to check status, retrieve results, and download artifacts.
"""

def __init__(self, api: _AuthenticatedApi, run_id: str) -> None:
def __init__(self, api: _AuthenticatedApi, run_id: str, share_token: str | None = None) -> None:
"""Initializes a Run instance.

Args:
api (_AuthenticatedApi): The configured API client.
run_id (str): The ID of the application run.
share_token (str | None): Optional share token secret. When supplied the
token is forwarded as the ``share_token`` query parameter on every API
request, granting access without an OAuth Bearer token.
"""
super().__init__(api)
self.run_id = run_id
self._share_token = share_token

@classmethod
def for_run_id(cls, run_id: str, cache_token: bool = True) -> "Run":
Expand All @@ -273,6 +282,34 @@ def for_run_id(cls, run_id: str, cache_token: bool = True) -> "Run":

return cls(Client.get_api_client(cache_token=cache_token), run_id)

@classmethod
def for_share_token(cls, run_id: str, share_token: str) -> "Run":
"""Creates a Run instance accessible via share token without OAuth login.

The share token secret is forwarded as the ``share_token`` query parameter on
every API request. No Bearer token is required.

Args:
run_id (str): The ID of the application run to access.
share_token (str): The share token secret obtained from a run owner.

Returns:
Run: The initialized Run instance bound to the share token.

Example::

run = Run.for_share_token("run-abc123", "shr_xxxx")
details = run.details()
for item in run.results():
print(item.external_id)
"""
from aignostics.platform._client import Client # noqa: PLC0415

# Use an empty token provider so no Authorization header is sent.
# The share token is forwarded as a query parameter instead.
api = Client.get_api_client(token_provider=lambda: "")
return cls(api, run_id, share_token=share_token)

def details(self, nocache: bool = False, hide_platform_queue_position: bool = False) -> RunData:
"""Retrieves the current status of the application run.

Expand All @@ -292,9 +329,10 @@ def details(self, nocache: bool = False, hide_platform_queue_position: bool = Fa
NotFoundException: If the run is not found after retries.
Exception: If the API request fails.
"""
share_token = self._share_token

@cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider)
def details_with_retry(run_id: str) -> RunData:
def details_with_retry(run_id: str, _share_token: str | None = None) -> RunData:
def _fetch() -> RunData:
return Retrying(
retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS),
Expand All @@ -307,6 +345,7 @@ def _fetch() -> RunData:
)(
lambda: self._api.get_run_v1_runs_run_id_get(
run_id,
share_token=_share_token,
_request_timeout=settings().run_timeout,
_headers={"User-Agent": user_agent()},
)
Expand All @@ -321,7 +360,7 @@ def _fetch() -> RunData:
reraise=True,
)(_fetch)

run_data: RunData = details_with_retry(self.run_id, nocache=nocache) # type: ignore[call-arg]
run_data: RunData = details_with_retry(self.run_id, _share_token=share_token, nocache=nocache) # type: ignore[call-arg]
if hide_platform_queue_position:
run_data = run_data.model_copy(deep=True)
run_data.num_preceding_items_platform = None
Expand Down Expand Up @@ -386,11 +425,12 @@ def results( # noqa: PLR0913
Raises:
Exception: If the API request fails.
"""
share_token = self._share_token

# Create a wrapper function that applies retry logic and caching to each API call
# Caching at this level ensures having a fresh iterator on cache hits
@cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider)
def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]:
def results_with_retry(run_id: str, _share_token: str | None = None, **kwargs: object) -> list[ItemResultData]:
return Retrying(
retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS),
stop=stop_after_attempt(settings().run_retry_attempts),
Expand All @@ -400,6 +440,7 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]:
)(
lambda: self._api.list_run_items_v1_runs_run_id_items_get(
run_id=run_id,
share_token=_share_token,
_request_timeout=settings().run_timeout,
_headers={"User-Agent": user_agent()},
**kwargs, # pyright: ignore[reportArgumentType]
Expand All @@ -418,7 +459,11 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]:
if custom_metadata is not None:
filter_kwargs["custom_metadata"] = custom_metadata

return paginate(lambda **kwargs: results_with_retry(self.run_id, nocache=nocache, **filter_kwargs, **kwargs))
return paginate(
lambda **kwargs: results_with_retry(
self.run_id, _share_token=share_token, nocache=nocache, **filter_kwargs, **kwargs
)
)

def download_to_folder( # noqa: C901
self,
Expand Down Expand Up @@ -511,7 +556,7 @@ def artifact(self, artifact_id: str) -> Artifact:
Returns:
Artifact: A handle bound to this run and the given artifact.
"""
return Artifact(self._api, self.run_id, artifact_id)
return Artifact(self._api, self.run_id, artifact_id, share_token=self._share_token)

def get_artifact_download_url(self, artifact_id: str) -> str:
"""Resolve a fresh presigned download URL for an artifact of this run.
Expand Down
Loading
Loading