Skip to content

Commit 308dd7f

Browse files
committed
merge master into 201-experiment-with-upgrading-openapi-generator
2 parents baf7bc1 + d05c0cd commit 308dd7f

17 files changed

+222
-158
lines changed

.github/workflows/publish-python-client.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ jobs:
3232
- name: Publish to PyPI
3333
uses: pypa/gh-action-pypi-publish@release/v1
3434
with:
35+
attestations: false
3536
verbose: true
3637
packages-dir: osparc_python_wheels/

clients/python/requirements/e2e-test.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
-r ../../../requirements.txt
22

33
black
4+
faker
45
ipykernel
56
ipython
67
jinja2
78
matplotlib
9+
memory_profiler
810
packaging
911
pandas
1012
papermill

clients/python/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"tqdm>=4.48.0",
3232
f"osparc_client=={VERSION}",
3333
"urllib3",
34+
"aiofiles",
3435
]
3536

3637
SETUP = dict(

clients/python/src/osparc/_api_files_api.py

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
import json
55
import logging
66
import math
7-
import random
8-
import shutil
9-
import string
107
from pathlib import Path
118
from typing import Any, Iterator, List, Optional, Tuple, Union
129
from tempfile import NamedTemporaryFile
@@ -29,11 +26,13 @@
2926
FileUploadData,
3027
UploadedPart,
3128
)
29+
from urllib.parse import urljoin
30+
import aiofiles
31+
import shutil
3232
from ._utils import (
3333
DEFAULT_TIMEOUT_SECONDS,
3434
PaginationGenerator,
3535
compute_sha256,
36-
dev_features_enabled,
3736
file_chunk_generator,
3837
)
3938

@@ -60,34 +59,58 @@ def __init__(self, api_client: ApiClient):
6059
else None
6160
)
6261

63-
def __getattr__(self, name: str) -> Any:
64-
if (name in FilesApi._dev_features) and (not dev_features_enabled()):
65-
raise NotImplementedError(f"FilesApi.{name} is still under development")
66-
return super().__getattribute__(name)
67-
6862
def download_file(
69-
self, file_id: str, *, destination_folder: Optional[Path] = None, **kwargs
63+
self,
64+
file_id: str,
65+
*,
66+
destination_folder: Optional[Path] = None,
67+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
68+
**kwargs,
69+
) -> str:
70+
return asyncio.run(
71+
self.download_file_async(
72+
file_id=file_id,
73+
destination_folder=destination_folder,
74+
timeout_seconds=timeout_seconds,
75+
**kwargs,
76+
)
77+
)
78+
79+
async def download_file_async(
80+
self,
81+
file_id: str,
82+
*,
83+
destination_folder: Optional[Path] = None,
84+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
85+
**kwargs,
7086
) -> str:
7187
if destination_folder is not None and not destination_folder.is_dir():
7288
raise RuntimeError(
7389
f"destination_folder: {destination_folder} must be a directory"
7490
)
75-
with NamedTemporaryFile(delete=False) as tmp_file:
76-
downloaded_file = Path(tmp_file.name)
77-
data = super().download_file(file_id, **kwargs)
78-
downloaded_file.write_bytes(data)
79-
if destination_folder is not None:
80-
dest_file: Path = destination_folder / downloaded_file.name
81-
while dest_file.is_file():
82-
new_name = (
83-
downloaded_file.stem
84-
+ "".join(random.choices(string.ascii_letters, k=8))
85-
+ downloaded_file.suffix
91+
async with aiofiles.tempfile.NamedTemporaryFile(
92+
mode="wb",
93+
delete=False,
94+
) as downloaded_file:
95+
async with AsyncHttpClient(
96+
configuration=self.api_client.configuration, timeout=timeout_seconds
97+
) as session:
98+
url = urljoin(
99+
self.api_client.configuration.host, f"/v0/files/{file_id}/content"
86100
)
87-
dest_file = destination_folder / new_name
88-
shutil.move(downloaded_file, dest_file)
89-
downloaded_file = dest_file
90-
return str(downloaded_file.resolve())
101+
async for response in await session.stream(
102+
"GET", url=url, auth=self._auth, follow_redirects=True
103+
):
104+
response.raise_for_status()
105+
async for chunk in response.aiter_bytes():
106+
await downloaded_file.write(chunk)
107+
dest_file = f"{downloaded_file.name}"
108+
if destination_folder is not None:
109+
dest_file = NamedTemporaryFile(dir=destination_folder, delete=False).name
110+
shutil.move(
111+
f"{downloaded_file.name}", dest_file
112+
) # aiofiles doesnt seem to have an async variant of this
113+
return dest_file
91114

92115
def upload_file(
93116
self,
@@ -109,7 +132,7 @@ async def upload_file_async(
109132
file = Path(file)
110133
if not file.is_file():
111134
raise RuntimeError(f"{file} is not a file")
112-
checksum: str = compute_sha256(file)
135+
checksum: str = await compute_sha256(file)
113136
for file_result in self._search_files(
114137
sha256_checksum=checksum, timeout_seconds=timeout_seconds
115138
):
@@ -163,7 +186,7 @@ async def upload_file_async(
163186
)
164187
async with AsyncHttpClient(
165188
configuration=self.api_client.configuration,
166-
request_type="post",
189+
method="post",
167190
url=links.abort_upload,
168191
body=abort_body.to_dict(),
169192
base_url=self.api_client.configuration.host,

clients/python/src/osparc/_api_solvers_api.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Wraps osparc_client.api.solvers_api
22

3-
from typing import Any, List, Optional
3+
from typing import List, Optional
44

55
import httpx
66
from osparc_client.api.solvers_api import SolversApi as _SolversApi
@@ -22,8 +22,6 @@
2222
_DEFAULT_PAGINATION_LIMIT,
2323
_DEFAULT_PAGINATION_OFFSET,
2424
PaginationGenerator,
25-
dev_feature,
26-
dev_features_enabled,
2725
)
2826
import warnings
2927
from tempfile import NamedTemporaryFile
@@ -54,11 +52,6 @@ def __init__(self, api_client: ApiClient):
5452
else None
5553
)
5654

57-
def __getattr__(self, name: str) -> Any:
58-
if (name in SolversApi._dev_features) and (not dev_features_enabled()):
59-
raise NotImplementedError(f"SolversApi.{name} is still under development")
60-
return super().__getattribute__(name)
61-
6255
def list_solver_ports(
6356
self, solver_key: str, version: str, **kwargs
6457
) -> List[SolverPort]:
@@ -67,7 +60,6 @@ def list_solver_ports(
6760
)
6861
return page.items if page.items else []
6962

70-
@dev_feature
7163
def iter_jobs(self, solver_key: str, version: str, **kwargs) -> PaginationGenerator:
7264
"""Returns an iterator through which one can iterate over
7365
all Jobs submitted to the solver
@@ -100,7 +92,6 @@ def _pagination_method():
10092
auth=self._auth,
10193
)
10294

103-
@dev_feature
10495
def jobs(self, solver_key: str, version: str, **kwargs) -> PaginationGenerator:
10596
warnings.warn(
10697
"The 'jobs' method is deprecated and will be removed in a future version. "

clients/python/src/osparc/_api_studies_api.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from pathlib import Path
66
from tempfile import mkdtemp
7-
from typing import Any, Optional
7+
from typing import Optional
88

99
import httpx
1010
from pydantic import StrictStr
@@ -29,7 +29,6 @@
2929
_DEFAULT_PAGINATION_LIMIT,
3030
_DEFAULT_PAGINATION_OFFSET,
3131
PaginationGenerator,
32-
dev_features_enabled,
3332
)
3433
import warnings
3534

@@ -69,11 +68,6 @@ def __init__(self, api_client: ApiClient):
6968
else None
7069
)
7170

72-
def __getattr__(self, name: str) -> Any:
73-
if (name in StudiesApi._dev_features) and (not dev_features_enabled()):
74-
raise NotImplementedError(f"StudiesApi.{name} is still under development")
75-
return super().__getattribute__(name)
76-
7771
def create_study_job(self, study_id: str, job_inputs: JobInputs, **kwargs):
7872
_job_inputs = _JobInputs.from_json(job_inputs.model_dump_json())
7973
assert _job_inputs is not None

clients/python/src/osparc/_http_client.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
from contextlib import suppress
22
from datetime import datetime
33
from email.utils import parsedate_to_datetime
4-
from typing import Any, Awaitable, Callable, Dict, Optional, Set
4+
from typing import (
5+
Any,
6+
Awaitable,
7+
Callable,
8+
Dict,
9+
Optional,
10+
Set,
11+
Literal,
12+
AsyncGenerator,
13+
)
514

615
import httpx
716
import tenacity
@@ -17,14 +26,14 @@ def __init__(
1726
self,
1827
*,
1928
configuration: Configuration,
20-
request_type: Optional[str] = None,
29+
method: Optional[str] = None,
2130
url: Optional[str] = None,
2231
body: Optional[Dict] = None,
2332
**httpx_async_client_kwargs,
2433
):
2534
self.configuration = configuration
2635
self._client = httpx.AsyncClient(**httpx_async_client_kwargs)
27-
self._callback = getattr(self._client, request_type) if request_type else None
36+
self._callback = getattr(self._client, method) if method else None
2837
self._url = url
2938
self._body = body
3039
if self._callback is not None:
@@ -77,6 +86,28 @@ async def _():
7786

7887
return await _()
7988

89+
async def _stream(
90+
self, method: Literal["GET"], url: str, *args, **kwargs
91+
) -> AsyncGenerator[httpx.Response, None]:
92+
n_attempts = self.configuration.retries.total
93+
assert isinstance(n_attempts, int)
94+
95+
@tenacity.retry(
96+
reraise=True,
97+
wait=self._wait_callback,
98+
stop=tenacity.stop_after_attempt(n_attempts),
99+
retry=tenacity.retry_if_exception_type(httpx.HTTPStatusError),
100+
)
101+
async def _() -> AsyncGenerator[httpx.Response, None]:
102+
async with self._client.stream(
103+
method=method, url=url, *args, **kwargs
104+
) as response:
105+
if response.status_code in self.configuration.retries.status_forcelist:
106+
response.raise_for_status()
107+
yield response
108+
109+
return _()
110+
80111
async def put(self, *args, **kwargs) -> httpx.Response:
81112
return await self._request(self._client.put, *args, **kwargs)
82113

@@ -92,6 +123,11 @@ async def patch(self, *args, **kwargs) -> httpx.Response:
92123
async def get(self, *args, **kwargs) -> httpx.Response:
93124
return await self._request(self._client.get, *args, **kwargs)
94125

126+
async def stream(
127+
self, method: Literal["GET"], url: str, *args, **kwargs
128+
) -> AsyncGenerator[httpx.Response, None]:
129+
return await self._stream(method=method, url=url, *args, **kwargs)
130+
95131
def _wait_callback(self, retry_state: tenacity.RetryCallState) -> int:
96132
assert retry_state.outcome is not None
97133
if retry_state.outcome and retry_state.outcome.exception():

clients/python/src/osparc/_utils.py

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import asyncio
22
import hashlib
3-
import os
4-
from functools import wraps
53
from pathlib import Path
64
from typing import AsyncGenerator, Callable, Generator, Optional, Tuple, TypeVar, Union
75

@@ -16,7 +14,7 @@
1614
Solver,
1715
Study,
1816
)
19-
17+
import aiofiles
2018
from ._exceptions import RequestError
2119

2220
_KB = 1024 # in bytes
@@ -87,15 +85,15 @@ async def file_chunk_generator(
8785
bytes_read: int = 0
8886
file_size: int = file.stat().st_size
8987
while bytes_read < file_size:
90-
with open(file, "rb") as f:
91-
f.seek(bytes_read)
88+
async with aiofiles.open(file, "rb") as f:
89+
await f.seek(bytes_read)
9290
nbytes = (
9391
chunk_size
9492
if (bytes_read + chunk_size <= file_size)
9593
else (file_size - bytes_read)
9694
)
9795
assert nbytes > 0
98-
chunk = await asyncio.get_event_loop().run_in_executor(None, f.read, nbytes)
96+
chunk = await f.read(nbytes)
9997
yield chunk, nbytes
10098
bytes_read += nbytes
10199

@@ -109,27 +107,13 @@ async def _fcn_to_coro(callback: Callable[..., S], *args) -> S:
109107
return result
110108

111109

112-
def compute_sha256(file: Path) -> str:
110+
async def compute_sha256(file: Path) -> str:
113111
assert file.is_file()
114112
sha256 = hashlib.sha256()
115-
with open(file, "rb") as f:
113+
async with aiofiles.open(file, "rb") as f:
116114
while True:
117-
data = f.read(100 * _KB)
115+
data = await f.read(100 * _KB)
118116
if not data:
119117
break
120118
sha256.update(data)
121-
return sha256.hexdigest()
122-
123-
124-
def dev_features_enabled() -> bool:
125-
return os.environ.get("OSPARC_DEV_FEATURES_ENABLED") == "1"
126-
127-
128-
def dev_feature(func: Callable):
129-
@wraps(func)
130-
def _wrapper(*args, **kwargs):
131-
if not dev_features_enabled():
132-
raise NotImplementedError(f"{func.__name__} is still under development")
133-
return func(*args, **kwargs)
134-
135-
return _wrapper
119+
return sha256.hexdigest()

clients/python/test/e2e/_utils.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
import subprocess
32
from pathlib import Path
43
from typing import Optional
@@ -11,10 +10,6 @@
1110
assert _clients_python_dir.is_dir()
1211

1312

14-
def osparc_dev_features_enabled() -> bool:
15-
return os.environ.get("OSPARC_DEV_FEATURES_ENABLED") == "1"
16-
17-
1813
def repo_version() -> Version:
1914
subprocess.run(
2015
"make VERSION", cwd=_clients_python_dir.resolve(), shell=True

0 commit comments

Comments
 (0)