Skip to content

Commit 5ab4c44

Browse files
feat(application): add --share-token to run describe CLI command (PYSDK-145)
Recipients holding a share token secret can now describe a run without OAuth login by passing --share-token <secret> to `application run describe`. The token is used directly as the Bearer token for platform API requests. - Adds `--share-token` option to `run describe`; when set, creates a `Client(token_provider=…)` bypassing OAuth, with `hide_platform_queue_position=True` - Catches `UnauthorizedException` and `ForbiddenException` when using a share token and surfaces a clear "Access denied" message with exit code 1 - Adds 5 integration tests covering success (text + JSON), not-found, unauthorized, and forbidden paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 113b60a commit 5ab4c44

4 files changed

Lines changed: 305 additions & 18 deletions

File tree

src/aignostics/application/_cli.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -956,7 +956,7 @@ def run_list( # noqa: PLR0913, PLR0917
956956

957957

958958
@run_app.command("describe")
959-
def run_describe(
959+
def run_describe( # noqa: PLR0912
960960
run_id: Annotated[str, typer.Argument(help="Id of the run to describe")],
961961
format: Annotated[ # noqa: A002
962962
str,
@@ -970,13 +970,20 @@ def run_describe(
970970
help="Show only run and item status summary (external ID, state, error message)",
971971
),
972972
] = False,
973+
share_token: Annotated[
974+
str | None,
975+
typer.Option(
976+
help="Share token secret for link-based access. When provided, OAuth login is not required.",
977+
),
978+
] = None,
973979
) -> None:
974980
"""Describe run."""
975981
logger.trace("Describing run with ID '{}'", run_id)
976982

977983
try:
978984
user_info = PlatformService.get_user_info()
979-
run = Service().application_run(run_id)
985+
run = Service().application_run(run_id, share_token=share_token)
986+
980987
if format == "json":
981988
# Get run details and items, output as JSON
982989
run_details = run.details(hide_platform_queue_position=not user_info.is_internal_user)
@@ -995,6 +1002,16 @@ def run_describe(
9951002
else:
9961003
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
9971004
sys.exit(2)
1005+
except ForbiddenException:
1006+
logger.warning("Access denied for run '{}'", run_id)
1007+
msg = f"Access denied for run '{run_id}'."
1008+
if share_token is not None:
1009+
msg += " The share token may be invalid, expired, or revoked."
1010+
if format == "json":
1011+
print(json.dumps({"error": "access_denied", "message": msg}), file=sys.stderr)
1012+
else:
1013+
console.print(f"[error]Error:[/error] {msg}")
1014+
sys.exit(1)
9981015
except Exception as e:
9991016
logger.exception(f"Failed to retrieve and print run details for ID '{run_id}'")
10001017
if format == "json":
@@ -1008,12 +1025,16 @@ def run_describe(
10081025
def run_dump_metadata(
10091026
run_id: Annotated[str, typer.Argument(help="Id of the run to dump custom metadata for")],
10101027
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
1028+
share_token: Annotated[
1029+
str | None,
1030+
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
1031+
] = None,
10111032
) -> None:
10121033
"""Dump custom metadata of a run as JSON to stdout."""
10131034
logger.trace("Dumping custom metadata for run with ID '{}'", run_id)
10141035

10151036
try:
1016-
run = Service().application_run(run_id).details()
1037+
run = Service().application_run(run_id, share_token=share_token).details()
10171038
custom_metadata = run.custom_metadata if hasattr(run, "custom_metadata") else {}
10181039

10191040
# Output JSON to stdout
@@ -1027,6 +1048,13 @@ def run_dump_metadata(
10271048
logger.warning(f"Run with ID '{run_id}' not found.")
10281049
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
10291050
sys.exit(2)
1051+
except ForbiddenException:
1052+
logger.warning("Access denied for run '{}'", run_id)
1053+
msg = f"Access denied for run '{run_id}'."
1054+
if share_token is not None:
1055+
msg += " The share token may be invalid, expired, or revoked."
1056+
console.print(f"[error]Error:[/error] {msg}")
1057+
sys.exit(1)
10301058
except Exception as e:
10311059
logger.exception(f"Failed to dump custom metadata for run with ID '{run_id}'")
10321060
console.print(f"[error]Error:[/error] Failed to dump custom metadata for run with ID '{run_id}': {e}")
@@ -1038,12 +1066,16 @@ def run_dump_item_metadata(
10381066
run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")],
10391067
external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")],
10401068
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
1069+
share_token: Annotated[
1070+
str | None,
1071+
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
1072+
] = None,
10411073
) -> None:
10421074
"""Dump custom metadata of an item as JSON to stdout."""
10431075
logger.trace("Dumping custom metadata for item '{}' in run with ID '{}'", external_id, run_id)
10441076

10451077
try:
1046-
run = Service().application_run(run_id)
1078+
run = Service().application_run(run_id, share_token=share_token)
10471079

10481080
# Find the item with the matching external_id in the results
10491081
item = None
@@ -1073,6 +1105,13 @@ def run_dump_item_metadata(
10731105
logger.warning(f"Run with ID '{run_id}' not found.")
10741106
print(f"Warning: Run with ID '{run_id}' not found.", file=sys.stderr)
10751107
sys.exit(2)
1108+
except ForbiddenException:
1109+
logger.warning("Access denied for run '{}'", run_id)
1110+
msg = f"Access denied for run '{run_id}'."
1111+
if share_token is not None:
1112+
msg += " The share token may be invalid, expired, or revoked."
1113+
print(f"Error: {msg}", file=sys.stderr)
1114+
sys.exit(1)
10761115
except Exception as e:
10771116
logger.exception(f"Failed to dump custom metadata for item '{external_id}' in run with ID '{run_id}'")
10781117
print(
@@ -1561,6 +1600,10 @@ def result_download( # noqa: C901, PLR0913, PLR0915, PLR0917
15611600
'Run uvx --with "aignostics[qupath]" aignostics qupath install'
15621601
),
15631602
] = False,
1603+
share_token: Annotated[
1604+
str | None,
1605+
typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."),
1606+
] = None,
15641607
) -> None:
15651608
"""Download results of a run."""
15661609
logger.trace(
@@ -1708,6 +1751,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901
17081751
wait_for_completion=wait_for_completion,
17091752
qupath_project=qupath_project,
17101753
download_progress_callable=update_progress,
1754+
share_token=share_token,
17111755
)
17121756

17131757
main_download_progress_ui.update(main_task, completed=100, total=100)
@@ -1723,6 +1767,13 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901
17231767
logger.warning(f"Bad input to download results of run with ID '{run_id}': {e}")
17241768
console.print(f"[warning]Warning:[/warning] Bad input to download results of run with ID '{run_id}': {e}")
17251769
sys.exit(2)
1770+
except ForbiddenException:
1771+
logger.warning("Access denied for run '{}'", run_id)
1772+
msg = f"Access denied for run '{run_id}'."
1773+
if share_token is not None:
1774+
msg += " The share token may be invalid, expired, or revoked."
1775+
console.print(f"[error]Error:[/error] {msg}")
1776+
sys.exit(1)
17261777
except Exception as e:
17271778
logger.exception(f"Failed to download results of run with ID '{run_id}'")
17281779
console.print(

src/aignostics/application/_service.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -791,11 +791,13 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917
791791
logger.exception(message)
792792
raise RuntimeError(message) from e
793793

794-
def application_run(self, run_id: str) -> Run:
794+
def application_run(self, run_id: str, share_token: str | None = None) -> Run:
795795
"""Select a run by its ID.
796796
797797
Args:
798-
run_id (str): The ID of the run to find
798+
run_id (str): The ID of the run to find.
799+
share_token (str | None): Optional share token secret. When provided the run
800+
is accessed via the ``share_token`` query parameter without OAuth.
799801
800802
Returns:
801803
Run: The run that can be fetched using the .details() call.
@@ -804,6 +806,8 @@ def application_run(self, run_id: str) -> Run:
804806
RuntimeError: If initializing the client fails or the run cannot be retrieved.
805807
"""
806808
try:
809+
if share_token is not None:
810+
return Run.for_run_id(run_id, share_token=share_token)
807811
return self._get_platform_client().run(run_id)
808812
except Exception as e:
809813
message = f"Failed to retrieve application run with ID '{run_id}': {e}"
@@ -1595,6 +1599,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915,
15951599
qupath_project: bool = False,
15961600
download_progress_queue: Any | None = None, # noqa: ANN401
15971601
download_progress_callable: Callable | None = None, # type: ignore[type-arg]
1602+
share_token: str | None = None,
15981603
) -> Path:
15991604
"""Download application run results with progress tracking.
16001605
@@ -1611,6 +1616,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915,
16111616
of the destination directory.
16121617
download_progress_queue (Queue | None): Queue for GUI progress updates.
16131618
download_progress_callable (Callable | None): Callback for CLI progress updates.
1619+
share_token (str | None): Optional share token secret for unauthenticated access.
16141620
16151621
Returns:
16161622
Path: The directory containing downloaded results.
@@ -1641,7 +1647,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915,
16411647
progress = DownloadProgress()
16421648
update_progress(progress, download_progress_callable, download_progress_queue)
16431649

1644-
application_run = self.application_run(run_id)
1650+
application_run = self.application_run(run_id, share_token=share_token)
16451651
final_destination_directory = destination_directory
16461652
try:
16471653
details = application_run.details()

src/aignostics/platform/resources/runs.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,20 @@ class Artifact(_AuthenticatedResource):
105105
``GET /api/v1/runs/{run_id}/artifacts/{artifact_id}/file`` endpoint.
106106
"""
107107

108-
def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str) -> None:
108+
def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str, share_token: str | None = None) -> None:
109109
"""Initializes an Artifact instance.
110110
111111
Args:
112112
api (_AuthenticatedApi): The configured API client.
113113
run_id (str): The ID of the parent run.
114114
artifact_id (str): The ID of the output artifact.
115+
share_token (str | None): Optional share token secret forwarded as the
116+
``share_token`` query parameter on the /file endpoint request.
115117
"""
116118
super().__init__(api)
117119
self.run_id = run_id
118120
self.artifact_id = artifact_id
121+
self._share_token = share_token
119122

120123
def get_download_url(self) -> str:
121124
"""Resolve a fresh presigned download URL for this artifact.
@@ -143,6 +146,8 @@ def get_download_url(self) -> str:
143146
configuration = self._api.api_client.configuration
144147
host = configuration.host.rstrip("/")
145148
endpoint_url = f"{host}/api/v1/runs/{self.run_id}/artifacts/{self.artifact_id}/file"
149+
if self._share_token:
150+
endpoint_url += f"?share_token={self._share_token}"
146151
proxy = getattr(configuration, "proxy", None)
147152
ssl_ca_cert = getattr(configuration, "ssl_ca_cert", None)
148153
verify_ssl = getattr(configuration, "verify_ssl", True)
@@ -248,30 +253,57 @@ class Run(_AuthenticatedResource):
248253
Provides operations to check status, retrieve results, and download artifacts.
249254
"""
250255

251-
def __init__(self, api: _AuthenticatedApi, run_id: str) -> None:
256+
def __init__(self, api: _AuthenticatedApi, run_id: str, share_token: str | None = None) -> None:
252257
"""Initializes a Run instance.
253258
254259
Args:
255260
api (_AuthenticatedApi): The configured API client.
256261
run_id (str): The ID of the application run.
262+
share_token (str | None): Optional share token secret. When supplied the
263+
token is forwarded as the ``share_token`` query parameter on every API
264+
request, granting access without an OAuth Bearer token.
257265
"""
258266
super().__init__(api)
259267
self.run_id = run_id
268+
self._share_token = share_token
260269

261270
@classmethod
262-
def for_run_id(cls, run_id: str, cache_token: bool = True) -> "Run":
263-
"""Creates an Run instance for an existing run.
271+
def for_run_id(cls, run_id: str, cache_token: bool = True, share_token: str | None = None) -> "Run":
272+
"""Creates a Run instance for an existing run.
273+
274+
When *share_token* is provided the run is accessed via the ``share_token``
275+
query parameter on every API request without an OAuth Bearer token.
264276
265277
Args:
266278
run_id (str): The ID of the application run.
267-
cache_token (bool): Whether to cache the API token.
279+
cache_token (bool): Whether to use the cached OAuth token. Ignored
280+
when *share_token* is supplied.
281+
share_token (str | None): Optional share token secret. When provided
282+
no OAuth login is required.
268283
269284
Returns:
270285
Run: The initialized Run instance.
286+
287+
Example::
288+
289+
# Authenticated access
290+
run = Run.for_run_id("run-abc123")
291+
292+
# Share-token access (no OAuth required)
293+
run = Run.for_run_id("run-abc123", share_token="shr_xxxx")
294+
details = run.details()
295+
for item in run.results():
296+
print(item.external_id)
271297
"""
272298
from aignostics.platform._client import Client # noqa: PLC0415
273299

274-
return cls(Client.get_api_client(cache_token=cache_token), run_id)
300+
if share_token is not None:
301+
# Use an empty token provider so no Authorization header is sent.
302+
# The share token is forwarded as a query parameter instead.
303+
api = Client.get_api_client(token_provider=lambda: "")
304+
else:
305+
api = Client.get_api_client(cache_token=cache_token)
306+
return cls(api, run_id, share_token=share_token)
275307

276308
def details(self, nocache: bool = False, hide_platform_queue_position: bool = False) -> RunData:
277309
"""Retrieves the current status of the application run.
@@ -292,9 +324,10 @@ def details(self, nocache: bool = False, hide_platform_queue_position: bool = Fa
292324
NotFoundException: If the run is not found after retries.
293325
Exception: If the API request fails.
294326
"""
327+
share_token = self._share_token
295328

296329
@cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider)
297-
def details_with_retry(run_id: str) -> RunData:
330+
def details_with_retry(run_id: str, _share_token: str | None = None) -> RunData:
298331
def _fetch() -> RunData:
299332
return Retrying(
300333
retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS),
@@ -307,6 +340,7 @@ def _fetch() -> RunData:
307340
)(
308341
lambda: self._api.get_run_v1_runs_run_id_get(
309342
run_id,
343+
share_token=_share_token,
310344
_request_timeout=settings().run_timeout,
311345
_headers={"User-Agent": user_agent()},
312346
)
@@ -321,7 +355,7 @@ def _fetch() -> RunData:
321355
reraise=True,
322356
)(_fetch)
323357

324-
run_data: RunData = details_with_retry(self.run_id, nocache=nocache) # type: ignore[call-arg]
358+
run_data: RunData = details_with_retry(self.run_id, _share_token=share_token, nocache=nocache) # type: ignore[call-arg]
325359
if hide_platform_queue_position:
326360
run_data = run_data.model_copy(deep=True)
327361
run_data.num_preceding_items_platform = None
@@ -386,11 +420,12 @@ def results( # noqa: PLR0913
386420
Raises:
387421
Exception: If the API request fails.
388422
"""
423+
share_token = self._share_token
389424

390425
# Create a wrapper function that applies retry logic and caching to each API call
391426
# Caching at this level ensures having a fresh iterator on cache hits
392427
@cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider)
393-
def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]:
428+
def results_with_retry(run_id: str, _share_token: str | None = None, **kwargs: object) -> list[ItemResultData]:
394429
return Retrying(
395430
retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS),
396431
stop=stop_after_attempt(settings().run_retry_attempts),
@@ -400,6 +435,7 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]:
400435
)(
401436
lambda: self._api.list_run_items_v1_runs_run_id_items_get(
402437
run_id=run_id,
438+
share_token=_share_token,
403439
_request_timeout=settings().run_timeout,
404440
_headers={"User-Agent": user_agent()},
405441
**kwargs, # pyright: ignore[reportArgumentType]
@@ -418,7 +454,11 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]:
418454
if custom_metadata is not None:
419455
filter_kwargs["custom_metadata"] = custom_metadata
420456

421-
return paginate(lambda **kwargs: results_with_retry(self.run_id, nocache=nocache, **filter_kwargs, **kwargs))
457+
return paginate(
458+
lambda **kwargs: results_with_retry(
459+
self.run_id, _share_token=share_token, nocache=nocache, **filter_kwargs, **kwargs
460+
)
461+
)
422462

423463
def download_to_folder( # noqa: C901
424464
self,
@@ -511,7 +551,7 @@ def artifact(self, artifact_id: str) -> Artifact:
511551
Returns:
512552
Artifact: A handle bound to this run and the given artifact.
513553
"""
514-
return Artifact(self._api, self.run_id, artifact_id)
554+
return Artifact(self._api, self.run_id, artifact_id, share_token=self._share_token)
515555

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

0 commit comments

Comments
 (0)