Skip to content

Commit 8a210f3

Browse files
✨ Add download-study-logs-fcn (#163)
* update workflow before publishing python package * fix dependency issue and bump version * point to website in project description * fix broken dependency * improve doc * add github token to download artifacts * ensure only read-access @wvangeit * yet another attempt at downloading artifacts * make sure to use repo that ran the trigger wf * another attempt at fixing * change owner * allow publishing to testpypi also when pr * minor change * revert minor (but breaking) change * minor fix * add debug messages * another debug message * hopefully the final version * final fix * minor fix * move master and tag to individual jobs * add debug messages * dev->post * add python script for determining semantic version * minor changes * minor changes * improve error handling and add version file to artifacts * check if release * minor fix * ensure to enter venv * also when tagging * source venv in publishin workflow * ensure only master * add script for testing 'pure' semver * adapt workflows to new python script * minor change * attempt to evaluate expressions correctly * several fixes to fix tests * ensure repo is checked out in publish workflow * several small fixes * cleanup * debug * minor cleanup * mionr changes * add debug message * minor change * minor change * yet another try * minor change * minor change * minor change * mionr change * minor changes * correct workflow run id * cosmetic change * avoid using gh * change to a single job for publishing * minor cleanup * swap loops in clean up jobs * correction * update server compatibility to new url * minor change to trigger ci * update openapi.json * update openapi.json * add ensure_unique_names fcn * bugfix and improve tsst * minor fixes * minor change * last fixes * add synchronous version of the method * @pcrespov private method and default limits+offsets * add type annotations * @pcrespov add progressbar when downloading logs * add more useful log * minor changes * add pylint exceptions * use pytest.fail @pcrespov * ensure unique file names directly in method * use Optional instead of | * remove superfluous file
1 parent 772a59f commit 8a210f3

File tree

11 files changed

+472
-37
lines changed

11 files changed

+472
-37
lines changed

api/openapi.json

Lines changed: 236 additions & 22 deletions
Large diffs are not rendered by default.

clients/python/client/osparc/_files_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ async def upload_file_async(
119119
configuration=self.api_client.configuration, timeout=timeout_seconds
120120
) as session:
121121
with logging_redirect_tqdm():
122-
_logger.info("Uploading %s in %i chunks", file.name, n_urls)
122+
_logger.info("Uploading %s in %i chunk(s)", file.name, n_urls)
123123
async for chunck, size in tqdm(
124124
file_chunk_generator(file, chunk_size),
125125
total=n_urls,
@@ -206,15 +206,15 @@ def _search_files(
206206
sha256_checksum: Optional[str] = None,
207207
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
208208
) -> PaginationGenerator:
209-
def pagination_method():
209+
def _pagination_method():
210210
return super(FilesApi, self).search_files_page(
211211
file_id=file_id,
212212
sha256_checksum=sha256_checksum,
213213
_request_timeout=timeout_seconds,
214214
)
215215

216216
return PaginationGenerator(
217-
first_page_callback=pagination_method,
217+
first_page_callback=_pagination_method,
218218
api_client=self.api_client,
219219
base_url=self.api_client.configuration.host,
220220
auth=self._auth,

clients/python/client/osparc/_http_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ async def delete(self, *args, **kwargs) -> httpx.Response:
8888
async def patch(self, *args, **kwargs) -> httpx.Response:
8989
return await self._request(self._client.patch, *args, **kwargs)
9090

91+
async def get(self, *args, **kwargs) -> httpx.Response:
92+
return await self._request(self._client.get, *args, **kwargs)
93+
9194
def _wait_callback(self, retry_state: tenacity.RetryCallState) -> int:
9295
assert retry_state.outcome is not None
9396
response: httpx.Response = retry_state.outcome.exception().response

clients/python/client/osparc/_solvers_api.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from typing import Any, List, Optional
22

33
import httpx
4-
from osparc_client import OnePageSolverPort, SolverPort
4+
from osparc_client import JobInputs, OnePageSolverPort, SolverPort
55
from osparc_client import SolversApi as _SolversApi
66

77
from . import ApiClient
88
from ._models import ParentProjectInfo
9-
from ._utils import PaginationGenerator, dev_feature, dev_features_enabled
9+
from ._utils import (
10+
_DEFAULT_PAGINATION_LIMIT,
11+
_DEFAULT_PAGINATION_OFFSET,
12+
PaginationGenerator,
13+
dev_feature,
14+
dev_features_enabled,
15+
)
1016

1117

1218
class SolversApi(_SolversApi):
@@ -60,18 +66,23 @@ def jobs(self, solver_key: str, version: str) -> PaginationGenerator:
6066
(its "length")
6167
"""
6268

63-
def pagination_method():
69+
def _pagination_method():
6470
return super(SolversApi, self).get_jobs_page(
65-
solver_key=solver_key, version=version, limit=20, offset=0
71+
solver_key=solver_key,
72+
version=version,
73+
limit=_DEFAULT_PAGINATION_LIMIT,
74+
offset=_DEFAULT_PAGINATION_OFFSET,
6675
)
6776

6877
return PaginationGenerator(
69-
first_page_callback=pagination_method,
78+
first_page_callback=_pagination_method,
7079
api_client=self.api_client,
7180
base_url=self.api_client.configuration.host,
7281
auth=self._auth,
7382
)
7483

75-
def create_job(self, solver_key, version, job_inputs, **kwargs):
84+
def create_job(
85+
self, solver_key: str, version: str, job_inputs: JobInputs, **kwargs
86+
):
7687
kwargs = {**kwargs, **ParentProjectInfo().model_dump(exclude_none=True)}
7788
return super().create_job(solver_key, version, job_inputs, **kwargs)

clients/python/client/osparc/_studies_api.py

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
1-
from typing import Any
1+
import asyncio
2+
import logging
3+
from pathlib import Path
4+
from tempfile import mkdtemp
5+
from typing import Any, Optional
26

7+
import httpx
8+
from osparc_client import ApiClient, JobInputs, JobLogsMap, PageStudy
39
from osparc_client import StudiesApi as _StudiesApi
10+
from tqdm.asyncio import tqdm_asyncio
411

12+
from ._http_client import AsyncHttpClient
513
from ._models import ParentProjectInfo
6-
from ._utils import dev_features_enabled
14+
from ._utils import (
15+
_DEFAULT_PAGINATION_LIMIT,
16+
_DEFAULT_PAGINATION_OFFSET,
17+
PaginationGenerator,
18+
dev_features_enabled,
19+
)
20+
21+
_logger = logging.getLogger(__name__)
722

823

924
class StudiesApi(_StudiesApi):
@@ -24,15 +39,96 @@ class StudiesApi(_StudiesApi):
2439
"stop_study_job",
2540
]
2641

42+
def __init__(self, api_client: Optional[ApiClient] = None):
43+
"""Construct object
44+
45+
Args:
46+
api_client (ApiClient, optinal): osparc.ApiClient object
47+
"""
48+
self._super: _StudiesApi = super()
49+
self._super.__init__(api_client)
50+
user: Optional[str] = self.api_client.configuration.username
51+
passwd: Optional[str] = self.api_client.configuration.password
52+
self._auth: Optional[httpx.BasicAuth] = (
53+
httpx.BasicAuth(username=user, password=passwd)
54+
if (user is not None and passwd is not None)
55+
else None
56+
)
57+
2758
def __getattribute__(self, name: str) -> Any:
2859
if (name in StudiesApi._dev_features) and (not dev_features_enabled()):
2960
raise NotImplementedError(f"StudiesApi.{name} is still under development")
3061
return super().__getattribute__(name)
3162

32-
def create_study_job(self, study_id, job_inputs, **kwargs):
63+
def create_study_job(self, study_id: str, job_inputs: JobInputs, **kwargs):
3364
kwargs = {**kwargs, **ParentProjectInfo().model_dump(exclude_none=True)}
3465
return super().create_study_job(study_id, job_inputs, **kwargs)
3566

36-
def clone_study(self, study_id, **kwargs):
67+
def clone_study(self, study_id: str, **kwargs):
3768
kwargs = {**kwargs, **ParentProjectInfo().model_dump(exclude_none=True)}
3869
return super().clone_study(study_id, **kwargs)
70+
71+
def studies(self) -> PaginationGenerator:
72+
def _pagination_method():
73+
page_study = super(StudiesApi, self).list_studies(
74+
limit=_DEFAULT_PAGINATION_LIMIT, offset=_DEFAULT_PAGINATION_OFFSET
75+
)
76+
assert isinstance(page_study, PageStudy) # nosec
77+
return page_study
78+
79+
return PaginationGenerator(
80+
first_page_callback=_pagination_method,
81+
api_client=self.api_client,
82+
base_url=self.api_client.configuration.host,
83+
auth=self._auth,
84+
)
85+
86+
def get_study_job_output_logfiles(self, study_id: str, job_id: str) -> Path:
87+
return asyncio.run(
88+
self.get_study_job_output_logfiles_async(study_id=study_id, job_id=job_id)
89+
)
90+
91+
async def get_study_job_output_logfiles_async(
92+
self, study_id: str, job_id: str, download_dir: Optional[Path] = None
93+
) -> Path:
94+
"""Download study logs. The log from each node will
95+
appear as a file with the node's name in the directory"""
96+
if download_dir is not None and not download_dir.is_dir():
97+
raise RuntimeError(f"{download_dir=} must be a valid directory")
98+
logs_map = super().get_study_job_output_logfile(study_id, job_id)
99+
assert isinstance(logs_map, JobLogsMap) # nosec
100+
log_links = logs_map.log_links
101+
assert log_links # nosec
102+
103+
folder = download_dir or Path(mkdtemp()).resolve()
104+
assert folder.is_dir() # nosec
105+
async with AsyncHttpClient(
106+
configuration=self.api_client.configuration
107+
) as client:
108+
109+
async def _download(unique_node_name: str, download_link: str) -> None:
110+
response = await client.get(download_link)
111+
response.raise_for_status()
112+
file = folder / unique_node_name
113+
ct = 1
114+
while file.exists():
115+
file = file.with_stem(f"{file.stem}({ct})")
116+
ct += 1
117+
file.touch()
118+
for chunk in response.iter_bytes():
119+
file.write_bytes(chunk)
120+
121+
tasks = [
122+
asyncio.create_task(_download(link.node_name, link.download_link))
123+
for link in log_links
124+
]
125+
_logger.info(
126+
"Downloading log files for study_id=%s and job_id=%s...",
127+
study_id,
128+
job_id,
129+
)
130+
await tqdm_asyncio.gather(
131+
*tasks, disable=(not _logger.isEnabledFor(logging.INFO))
132+
)
133+
134+
return folder

clients/python/client/osparc/_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
_MB = _KB * 1024 # in bytes
2525
_GB = _MB * 1024 # in bytes
2626

27+
_DEFAULT_PAGINATION_LIMIT: int = 20
28+
_DEFAULT_PAGINATION_OFFSET: int = 0
29+
2730
DEFAULT_TIMEOUT_SECONDS: int = 30 * 60
2831

2932
Page = Union[PageJob, PageFile, PageSolver, PageStudy]

clients/python/test/e2e/conftest.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
# pylint: disable=protected-access
12
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
24
# pylint: disable=unused-argument
35
# pylint: disable=unused-variable
4-
# pylint: disable=too-many-arguments
56

67
import logging
78
import os
89
from pathlib import Path
910
from typing import Iterable
11+
from uuid import UUID
1012

1113
import osparc
1214
import pytest
@@ -71,3 +73,28 @@ def sleeper(api_client: osparc.ApiClient) -> osparc.Solver:
7173
"simcore/services/comp/itis/sleeper", "2.0.2"
7274
) # type: ignore
7375
return sleeper
76+
77+
78+
@pytest.fixture
79+
def sleeper_study_id(api_client: osparc.ApiClient) -> UUID:
80+
"""Simple sleeper study template which takes
81+
as input a single file containing a single integer"""
82+
_test_study_title = "sleeper_test_study"
83+
study_api = osparc.StudiesApi(api_client=api_client)
84+
for study in study_api.studies():
85+
if study.title == _test_study_title:
86+
return UUID(study.uid)
87+
pytest.fail(f"Could not find {_test_study_title} study")
88+
89+
90+
@pytest.fixture
91+
def file_with_number(
92+
tmp_path: Path, api_client: osparc.ApiClient
93+
) -> Iterable[osparc.File]:
94+
files_api = osparc.FilesApi(api_client=api_client)
95+
file = tmp_path / "file_with_number.txt"
96+
file.write_text("1")
97+
server_file = files_api.upload_file(file)
98+
yield server_file
99+
100+
files_api.delete_file(server_file.id)

clients/python/test/e2e/test_files_api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
6+
17
import hashlib
28
from pathlib import Path
39

clients/python/test/e2e/test_notebooks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
6+
17
import shutil
28
import sys
39
from pathlib import Path

clients/python/test/e2e/test_solvers_api.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
6+
17
import json
28

39
import osparc
@@ -64,7 +70,7 @@ async def test_logstreaming(
6470
nloglines: int = 0
6571
url = f"/v0/solvers/{sleeper.id}/releases/{sleeper.version}/jobs/{job.id}/logstream"
6672
print(f"starting logstreaming from {url}...")
67-
73+
6874
async with async_client.stream(
6975
"GET",
7076
url,

0 commit comments

Comments
 (0)