Skip to content

Commit b3d3e55

Browse files
committed
fixup! fixup! fixup! fixup! chore: Use SAMIA run artifact file endpoint to download artifacts
1 parent 8e8f59a commit b3d3e55

5 files changed

Lines changed: 424 additions & 188 deletions

File tree

src/aignostics/application/_download.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,44 @@ def update_progress(
146146
download_progress_queue.put_nowait(progress)
147147

148148

149+
def _resolve_artifact_url(
150+
artifact: Any, # noqa: ANN401
151+
run_id: str,
152+
get_artifact_download_url: Callable[[str, str], str] | None,
153+
) -> str | None:
154+
"""Resolve the download URL for an artifact.
155+
156+
Tries the new file endpoint first (via get_artifact_download_url), then falls back to
157+
the deprecated download_url field on the artifact if the endpoint fails.
158+
159+
Args:
160+
artifact: The artifact object with optional output_artifact_id and download_url.
161+
run_id: The run ID, passed to get_artifact_download_url.
162+
get_artifact_download_url: Callable for the new endpoint, or None to skip.
163+
164+
Returns:
165+
str | None: The resolved download URL, or None if unavailable.
166+
167+
Raises:
168+
Exception: Re-raises if the new endpoint fails and no fallback download_url exists.
169+
"""
170+
if get_artifact_download_url and artifact.output_artifact_id:
171+
try:
172+
return get_artifact_download_url(run_id, artifact.output_artifact_id)
173+
except Exception as e:
174+
fallback_url: str | None = getattr(artifact, "download_url", None)
175+
if fallback_url:
176+
logger.warning(
177+
"Failed to resolve download URL via file endpoint for artifact {} ({}). "
178+
"Falling back to deprecated download_url field.",
179+
artifact.output_artifact_id,
180+
e,
181+
)
182+
return fallback_url
183+
raise
184+
return getattr(artifact, "download_url", None)
185+
186+
149187
def download_available_items( # noqa: PLR0913, PLR0917
150188
progress: DownloadProgress,
151189
application_run: Run,
@@ -205,12 +243,9 @@ def download_available_items( # noqa: PLR0913, PLR0917
205243
progress.artifact = artifact
206244
update_progress(progress, download_progress_callable, download_progress_queue)
207245

208-
# Resolve artifact download URL via the new file endpoint
209-
if get_artifact_download_url and artifact.output_artifact_id:
210-
artifact_url = get_artifact_download_url(application_run.run_id, artifact.output_artifact_id)
211-
elif download_url := getattr(artifact, "download_url", None):
212-
artifact_url = download_url # Deprecated fallback
213-
else:
246+
# Resolve artifact download URL via the new file endpoint, with fallback
247+
artifact_url = _resolve_artifact_url(artifact, application_run.run_id, get_artifact_download_url)
248+
if not artifact_url:
214249
logger.warning("No download URL available for artifact {}", artifact.output_artifact_id)
215250
continue
216251

src/aignostics/application/_gui/_page_application_run_describe.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -339,9 +339,9 @@ async def start_download() -> None:
339339

340340
# Activate the timer now that download is starting
341341
progress_timer.activate()
342+
download_button.disable()
343+
download_button.props(add="loading")
342344
try:
343-
download_button.disable()
344-
download_button.props(add="loading")
345345
results_folder = await nicegui_run.cpu_bound(
346346
Service.application_run_download_static,
347347
run_id=run.run_id,
@@ -365,19 +365,24 @@ async def start_download() -> None:
365365
else:
366366
ui.notify("Download completed.", type="positive")
367367
show_in_file_manager(str(results_folder))
368-
except ValueError as e:
368+
except Exception as e:
369+
logger.exception(
370+
"Download failed for run {} (qupath_project={}, marimo={}, folder={})",
371+
run.run_id,
372+
current_qupath_project,
373+
current_marimo,
374+
current_folder,
375+
)
369376
ui.notify(f"Download failed: {e}", type="negative", multi_line=True)
377+
finally:
370378
progress_timer.deactivate()
371379
progress_state["queue"] = None
372-
return
373-
progress_timer.deactivate()
374-
progress_state["queue"] = None
375-
download_button.props(remove="loading")
376-
download_button.enable()
377-
download_item_status.set_visibility(False)
378-
download_item_progress.set_visibility(False)
379-
download_artifact_status.set_visibility(False)
380-
download_artifact_progress.set_visibility(False)
380+
download_button.props(remove="loading")
381+
download_button.enable()
382+
download_item_status.set_visibility(False)
383+
download_item_progress.set_visibility(False)
384+
download_artifact_status.set_visibility(False)
385+
download_artifact_progress.set_visibility(False)
381386

382387
ui.separator()
383388
with ui.row(align_items="end").classes("w-full justify-end"):
@@ -840,7 +845,7 @@ async def _preview_csv(
840845

841846
preview_button.on_click(_preview_csv)
842847

843-
download_button = ui.button(
848+
artifact_dl_button = ui.button(
844849
text="Download",
845850
icon="cloud_download",
846851
)
@@ -849,7 +854,7 @@ async def _download_artifact(
849854
_: ClickEventArguments,
850855
aid: str = artifact_id,
851856
_run: Run = run,
852-
_btn: ui.button = download_button,
857+
_btn: ui.button = artifact_dl_button,
853858
) -> None:
854859
try:
855860
_btn.props(add="loading")
@@ -860,7 +865,7 @@ async def _download_artifact(
860865
finally:
861866
_btn.props(remove="loading")
862867

863-
download_button.on_click(_download_artifact)
868+
artifact_dl_button.on_click(_download_artifact)
864869
if metadata:
865870
ui.button(
866871
text="Schema",

src/aignostics/platform/resources/runs.py

Lines changed: 86 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import requests
1717
from aignx.codegen.api.public_api import PublicApi
18-
from aignx.codegen.exceptions import NotFoundException, ServiceException
18+
from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException
1919
from aignx.codegen.models import (
2020
CustomMetadataUpdateRequest,
2121
ItemCreationRequest,
@@ -348,10 +348,72 @@ def download_to_folder( # noqa: C901
348348
msg = f"Download operation failed unexpectedly for run {self.run_id}: {e}"
349349
raise RuntimeError(msg) from e
350350

351+
@staticmethod
352+
def _fetch_artifact_redirect_url(
353+
endpoint_url: str,
354+
token: str,
355+
proxy: str | None,
356+
ssl_verify: bool | str,
357+
artifact_id: str,
358+
) -> str:
359+
"""Execute the HTTP request to the artifact file endpoint and return the redirect URL.
360+
361+
Args:
362+
endpoint_url (str): Full URL of the artifact file endpoint.
363+
token (str): Bearer token for authorization.
364+
proxy (str | None): Optional proxy URL.
365+
ssl_verify (bool | str): SSL verification setting (True/False or CA bundle path).
366+
artifact_id (str): Artifact ID used in error messages.
367+
368+
Returns:
369+
str: The presigned URL from the redirect Location header.
370+
371+
Raises:
372+
NotFoundException: If the artifact is not found (404).
373+
ServiceException: On network errors or 5xx responses.
374+
ApiException: On 4xx client errors.
375+
RuntimeError: If the redirect has no Location header or returns an unexpected status.
376+
"""
377+
try:
378+
response = requests.get(
379+
endpoint_url,
380+
headers={"Authorization": f"Bearer {token}", "User-Agent": user_agent()},
381+
allow_redirects=False,
382+
timeout=settings().run_timeout,
383+
proxies={"http": proxy, "https": proxy} if proxy else None,
384+
verify=ssl_verify,
385+
)
386+
except requests.Timeout as e:
387+
raise ServiceException(status=HTTPStatus.SERVICE_UNAVAILABLE, reason="Request timed out") from e
388+
except requests.ConnectionError as e:
389+
raise ServiceException(status=HTTPStatus.SERVICE_UNAVAILABLE, reason="Connection failed") from e
390+
except requests.RequestException as e:
391+
raise ServiceException(status=HTTPStatus.SERVICE_UNAVAILABLE, reason=f"Request failed: {e}") from e
392+
393+
if response.status_code in {
394+
HTTPStatus.MOVED_PERMANENTLY,
395+
HTTPStatus.FOUND,
396+
HTTPStatus.TEMPORARY_REDIRECT,
397+
HTTPStatus.PERMANENT_REDIRECT,
398+
}:
399+
location = response.headers.get("Location")
400+
if not location:
401+
msg = f"Redirect response {response.status_code} missing Location header for artifact {artifact_id}"
402+
raise RuntimeError(msg)
403+
return location
404+
if response.status_code == HTTPStatus.NOT_FOUND:
405+
raise NotFoundException(status=HTTPStatus.NOT_FOUND, reason="Artifact not found")
406+
if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
407+
raise ServiceException(status=response.status_code, reason=response.reason)
408+
if response.status_code >= HTTPStatus.BAD_REQUEST:
409+
raise ApiException(status=response.status_code, reason=response.reason)
410+
msg = f"Unexpected status {response.status_code} from artifact file endpoint for artifact {artifact_id}"
411+
raise RuntimeError(msg)
412+
351413
def get_artifact_download_url(self, artifact_id: str) -> str:
352414
"""Get a presigned download URL for an artifact via the file endpoint.
353415
354-
Calls GET /v1/runs/{run_id}/artifacts/{artifact_id}/file with
416+
Calls GET /api/v1/runs/{run_id}/artifacts/{artifact_id}/file with
355417
allow_redirects=False and extracts the presigned URL from the Location
356418
header of the 307 redirect response. Using the codegen method is not
357419
viable here because urllib3 follows the redirect automatically, fetching
@@ -365,45 +427,28 @@ def get_artifact_download_url(self, artifact_id: str) -> str:
365427
366428
Raises:
367429
NotFoundException: If the artifact is not found (404).
368-
ServiceException: On 5xx server errors (retried automatically).
430+
ServiceException: On 5xx or other unexpected HTTP errors (retried automatically where transient).
369431
RuntimeError: If the redirect Location header is missing.
370432
"""
371-
endpoint_url = f"{self._api.api_client.configuration.host}/v1/runs/{self.run_id}/artifacts/{artifact_id}/file"
372-
373-
def _resolve() -> str:
374-
token = get_token()
375-
response = requests.get(
376-
endpoint_url,
377-
headers={"Authorization": f"Bearer {token}", "User-Agent": user_agent()},
378-
allow_redirects=False,
379-
timeout=settings().run_timeout,
380-
)
381-
if response.status_code in {
382-
HTTPStatus.MOVED_PERMANENTLY,
383-
HTTPStatus.FOUND,
384-
HTTPStatus.TEMPORARY_REDIRECT,
385-
HTTPStatus.PERMANENT_REDIRECT,
386-
}:
387-
location = response.headers.get("Location")
388-
if not location:
389-
msg = f"Redirect response {response.status_code} missing Location header for artifact {artifact_id}"
390-
raise RuntimeError(msg)
391-
return location
392-
if response.status_code == HTTPStatus.NOT_FOUND:
393-
raise NotFoundException(status=HTTPStatus.NOT_FOUND, reason="Artifact not found")
394-
if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
395-
raise ServiceException(status=response.status_code, reason=response.reason)
396-
response.raise_for_status()
397-
msg = f"Unexpected status {response.status_code} from artifact file endpoint for artifact {artifact_id}"
398-
raise RuntimeError(msg)
399-
400-
return Retrying(
433+
configuration = self._api.api_client.configuration
434+
host = configuration.host.rstrip("/")
435+
endpoint_url = f"{host}/api/v1/runs/{self.run_id}/artifacts/{artifact_id}/file"
436+
token_provider = getattr(configuration, "token_provider", None) or get_token
437+
proxy = getattr(configuration, "proxy", None)
438+
ssl_ca_cert = getattr(configuration, "ssl_ca_cert", None)
439+
verify_ssl = getattr(configuration, "verify_ssl", True)
440+
ssl_verify: bool | str = ssl_ca_cert or verify_ssl
441+
logger.trace("Resolving download URL for artifact {} via {}", artifact_id, endpoint_url)
442+
443+
url = Retrying(
401444
retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS),
402445
stop=stop_after_attempt(settings().run_retry_attempts),
403446
wait=wait_exponential_jitter(initial=settings().run_retry_wait_min, max=settings().run_retry_wait_max),
404447
before_sleep=_log_retry_attempt,
405448
reraise=True,
406-
)(_resolve)
449+
)(lambda: self._fetch_artifact_redirect_url(endpoint_url, token_provider(), proxy, ssl_verify, artifact_id))
450+
logger.trace("Resolved download URL for artifact {}", artifact_id)
451+
return url
407452

408453
def ensure_artifacts_downloaded(
409454
self,
@@ -431,6 +476,13 @@ def ensure_artifacts_downloaded(
431476
downloaded_at_least_one_artifact = False
432477
for artifact in item.output_artifacts:
433478
if not artifact.output_artifact_id:
479+
logger.warning(
480+
"Skipping artifact {} for item {}: missing output_artifact_id", artifact.name, item.external_id
481+
)
482+
if print_status:
483+
print(
484+
f"> Skipping artifact {artifact.name} for item {item.external_id}: missing output_artifact_id"
485+
)
434486
continue
435487
item_dir.mkdir(exist_ok=True, parents=True)
436488
file_ending = mime_type_to_file_ending(get_mime_type_for_artifact(artifact))

0 commit comments

Comments
 (0)