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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ dependencies = [
"httpx>=0.28.1,<1",
"idc-index-data==24.0.3",
"ijson>=3.4.0.post0,<4",
"joserfc>=1.6.7",
"jsf>=0.11.2,<1",
"jsonschema[format-nongpl]>=4.25.1,<5",
"loguru>=0.7.3,<1",
Expand All @@ -114,6 +115,8 @@ dependencies = [
"pyarrow>=23.0.1,<24; python_version >= '3.14'",
"pyjwt[crypto]>=2.13.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475)
"python-dateutil>=2.9.0.post0,<3",
"python-engineio>=4.13.3",
"python-socketio>=5.16.3",
# "pywebview[qt6]>=5.4,<6; sys_platform == 'linux'",
"requests>=2.33.0,<3", # CVE-2026-25645 requires >= 2.33.0
"requests-oauthlib>=2.0.0,<3",
Expand Down
59 changes: 55 additions & 4 deletions src/aignostics/application/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@


@run_app.command("describe")
def run_describe(
def run_describe( # noqa: PLR0912

Check failure on line 959 in src/aignostics/application/_cli.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=aignostics_python-sdk&issues=AZ8Y-OAj7H8VEn_-7GIM&open=AZ8Y-OAj7H8VEn_-7GIM&pullRequest=688
run_id: Annotated[str, typer.Argument(help="Id of the run to describe")],
format: Annotated[ # noqa: A002
str,
Expand All @@ -970,13 +970,20 @@
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)
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,16 @@
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)

Check failure on line 1006 in src/aignostics/application/_cli.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Access denied for run '{}'" 4 times.

See more on https://sonarcloud.io/project/issues?id=aignostics_python-sdk&issues=AZ8Y-OAj7H8VEn_-7GIL&open=AZ8Y-OAj7H8VEn_-7GIL&pullRequest=688
msg = f"Access denied for run '{run_id}'."
if share_token is not None:
msg += " The share token may be invalid, expired, or revoked."

Check failure on line 1009 in src/aignostics/application/_cli.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal " The share token may be invalid, expired, or revoked." 4 times.

See more on https://sonarcloud.io/project/issues?id=aignostics_python-sdk&issues=AZ8Y-OAj7H8VEn_-7GIK&open=AZ8Y-OAj7H8VEn_-7GIK&pullRequest=688
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 +1025,16 @@
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 +1048,13 @@
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 +1066,16 @@
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 +1105,13 @@
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 +1600,10 @@
'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 +1751,7 @@
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 +1767,13 @@
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_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 @@ -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
57 changes: 46 additions & 11 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,30 +253,52 @@ 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":
"""Creates an Run instance for an existing run.
def for_run_id(cls, run_id: str, cache_token: bool = True, share_token: str | None = None) -> "Run":
"""Creates a Run instance for an existing run.

When *share_token* is provided the run is accessed via the ``share_token``
query parameter on every API request without an OAuth Bearer token.

Args:
run_id (str): The ID of the application run.
cache_token (bool): Whether to cache the API token.
cache_token (bool): Whether to use the cached OAuth token. Ignored
when *share_token* is supplied.
share_token (str | None): Optional share token secret. When provided
no OAuth login is required.

Returns:
Run: The initialized Run instance.

Example::

# Authenticated access
run = Run.for_run_id("run-abc123")

# Share-token access (no OAuth required)
run = Run.for_run_id("run-abc123", share_token="shr_xxxx")
details = run.details()
for item in run.results():
print(item.external_id)
"""
from aignostics.platform._client import Client # noqa: PLC0415

return cls(Client.get_api_client(cache_token=cache_token), run_id)
api = Client.get_api_client(cache_token=cache_token)
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 +319,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 +335,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 +350,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 +415,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 +430,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 +449,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 +546,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