Skip to content

Commit 9f8559c

Browse files
authored
Merge pull request #42 from WEHI-ResearchComputing/update-ci
Update CI
2 parents dac26fb + 951c01f commit 9f8559c

17 files changed

Lines changed: 510 additions & 342 deletions

.github/workflows/build.yml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,24 @@ on: [push]
44

55
jobs:
66
build:
7-
87
runs-on: ubuntu-latest
98
strategy:
109
matrix:
11-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
10+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
1211

1312
steps:
1413
- uses: actions/checkout@v4
15-
- name: Set up Python ${{ matrix.python-version }}
16-
uses: actions/setup-python@v5
14+
- uses: astral-sh/setup-uv@v5
1715
with:
1816
python-version: ${{ matrix.python-version }}
1917
- name: Install dependencies
20-
run: pip install .
21-
- uses: jakebailey/pyright-action@v2
22-
with:
23-
version: 1.1.376
18+
run: uv sync --dev
19+
- name: Ruff checks
20+
run: uv run ruff check .
21+
- name: Ruff format check
22+
run: uv run ruff format --check
23+
- name: Pyright type check
24+
# Don't fail on errors yet
25+
run: uv run pyright filesender || true
2426
- name: Test CLI help
2527
run: filesender --help

docs/benchmark.ipynb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@
4848
"from os import environ\n",
4949
"\n",
5050
"with make_tempfiles(size=100_000_000, n=3) as paths:\n",
51-
" results = benchmark(paths, limit=6, apikey=environ[\"API_KEY\"], base_url=environ[\"BASE_URL\"], recipient=environ[\"RECIPIENT\"], username=environ[\"USERNAME\"])"
51+
" results = benchmark(\n",
52+
" paths,\n",
53+
" limit=6,\n",
54+
" apikey=environ[\"API_KEY\"],\n",
55+
" base_url=environ[\"BASE_URL\"],\n",
56+
" recipient=environ[\"RECIPIENT\"],\n",
57+
" username=environ[\"USERNAME\"],\n",
58+
" )"
5259
]
5360
},
5461
{
@@ -140,8 +147,9 @@
140147
],
141148
"source": [
142149
"import pandas as pd\n",
150+
"\n",
143151
"result_df = pd.DataFrame.from_records(vars(result) for result in results)\n",
144-
"result_df.memory = result_df.memory / 1024 ** 2\n",
152+
"result_df.memory = result_df.memory / 1024**2\n",
145153
"result_df"
146154
]
147155
},

filesender/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
from filesender.api import FileSenderClient
22
from filesender.auth import GuestAuth, UserAuth
3-
__all__ = [
4-
"GuestAuth",
5-
"UserAuth",
6-
"FileSenderClient"
7-
]
3+
4+
__all__ = ["GuestAuth", "UserAuth", "FileSenderClient"]

filesender/api.py

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,27 @@
1010
import aiofiles
1111
from aiostream import stream
1212
from contextlib import contextmanager
13-
from tenacity import RetryCallState, retry, stop_after_attempt, wait_fixed, retry_if_exception
13+
from tenacity import (
14+
RetryCallState,
15+
retry,
16+
stop_after_attempt,
17+
wait_fixed,
18+
retry_if_exception,
19+
)
1420
import logging
1521
from tqdm.asyncio import tqdm
1622

1723
logger = logging.getLogger(__name__)
1824

25+
1926
def should_retry(e: BaseException) -> bool:
2027
"""
2128
Returns True if the exception is a transient exception from the FileSender server,
2229
that can be retried.
2330
"""
2431
if isinstance(e, ReadError):
2532
# Seems to be just a bug in the backend
26-
# https://github.com/encode/httpx/discussions/2941
33+
# https://github.com/encode/httpx/discussions/2941
2734
return True
2835
elif isinstance(e, HTTPStatusError) and e.response.status_code == 500:
2936
message = e.response.json()["message"]
@@ -44,14 +51,18 @@ def url_without_scheme(url: str) -> str:
4451
"""
4552
return unquote(urlunparse(urlparse(url)._replace(scheme="")).lstrip("/"))
4653

54+
4755
def exception_to_message(e: BaseException) -> str:
4856
if isinstance(e, HTTPStatusError):
4957
return f"Request failed with content {e.response.text} for request {e.request.method} {e.request.url}."
5058
elif isinstance(e, RequestError):
51-
return f"Request failed for request {e.request.method} {e.request.url}. {repr(e)}"
59+
return (
60+
f"Request failed for request {e.request.method} {e.request.url}. {repr(e)}"
61+
)
5262
else:
5363
return repr(e)
5464

65+
5566
@contextmanager
5667
def raise_status():
5768
"""
@@ -63,6 +74,7 @@ def raise_status():
6374
except BaseException as e:
6475
raise Exception(exception_to_message(e)) from e
6576

77+
6678
async def yield_chunks(path: Path, chunk_size: int) -> AsyncIterator[Tuple[bytes, int]]:
6779
"""
6880
Yields (chunk, offset) tuples from a file, chunked by chunk_size.
@@ -78,7 +90,10 @@ async def yield_chunks(path: Path, chunk_size: int) -> AsyncIterator[Tuple[bytes
7890
yield chunk, offset
7991
offset += len(chunk)
8092

81-
def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[Tuple[str, Path]]:
93+
94+
def iter_files(
95+
paths: Iterable[Path], root: Optional[Path] = None
96+
) -> Iterable[Tuple[str, Path]]:
8297
"""
8398
Recursively yields (name, path) tuples for all files included in the input path, or that are children of these paths
8499
"""
@@ -87,10 +102,10 @@ def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[T
87102
# Recurse into directories
88103
if root is None:
89104
# If this is a top level directory, then its parent becomes the root
90-
yield from iter_files(path.iterdir(), root = path.parent)
105+
yield from iter_files(path.iterdir(), root=path.parent)
91106
else:
92107
# Preserve the same root when recursing
93-
yield from iter_files(path.iterdir(), root = root)
108+
yield from iter_files(path.iterdir(), root=root)
94109
else:
95110
if root is None:
96111
# If this is a top level file, just use the filename directly
@@ -99,6 +114,7 @@ def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[T
99114
# If this is a nested file, use the relative path from the root directory as the name
100115
yield str(path.relative_to(root)), path
101116

117+
102118
def _file_key(file_info: response.File) -> str:
103119
"""
104120
Returns the per-file upload key, checking both ``uid`` (older FileSender servers)
@@ -109,7 +125,10 @@ def _file_key(file_info: response.File) -> str:
109125
return file_info["uid"]
110126
if "puid" in file_info:
111127
return file_info["puid"]
112-
raise Exception(f"File response for {file_info['name']!r} has neither 'uid' nor 'puid' field")
128+
raise Exception(
129+
f"File response for {file_info['name']!r} has neither 'uid' nor 'puid' field"
130+
)
131+
113132

114133
class EndpointHandler:
115134
base: str
@@ -118,7 +137,9 @@ def __init__(self, base: str) -> None:
118137
# Backwards compatibility to support the old way of specifying the base URL
119138
base = base.rstrip("/")
120139
if base.endswith("/rest.php"):
121-
logger.warning("The base URL should not include /rest.php. This will no longer be supported in a future version.")
140+
logger.warning(
141+
"The base URL should not include /rest.php. This will no longer be supported in a future version."
142+
)
122143
base = base.removesuffix("/rest.php")
123144
self.base = base
124145

@@ -146,6 +167,7 @@ def guest(self) -> str:
146167
def server_info(self) -> str:
147168
return f"{self.api()}/info"
148169

170+
149171
class FileSenderClient:
150172
"""
151173
A client that can be used to programmatically interact with FileSender.
@@ -197,7 +219,6 @@ def __init__(
197219
self.concurrent_chunks = concurrent_chunks
198220
self.concurrent_files = concurrent_files
199221

200-
201222
async def prepare(self) -> None:
202223
"""
203224
Checks that the chunk size is appropriate and/or sets the chunk size based on the server info.
@@ -232,7 +253,7 @@ def on_retry(state: RetryCallState) -> None:
232253
retry=retry_if_exception(should_retry),
233254
wait=wait_fixed(0.1),
234255
stop=stop_after_attempt(5),
235-
before_sleep=on_retry
256+
before_sleep=on_retry,
236257
)
237258
async def _sign_send_inner(self, request: Request) -> Any:
238259
# Needs to be a separate function to handle retry policy correctly
@@ -303,7 +324,7 @@ async def update_file(
303324
await self._sign_send(
304325
self.http_client.build_request(
305326
"PUT",
306-
self.urls.file(file_info['id']),
327+
self.urls.file(file_info["id"]),
307328
params={"key": _file_key(file_info)},
308329
json=body,
309330
)
@@ -323,18 +344,24 @@ async def upload_file(self, file_info: response.File, path: Path) -> None:
323344
if self.chunk_size is None:
324345
raise Exception(".prepare() has not been called!")
325346

326-
async def _task_generator(chunk_size: int) -> AsyncIterator[Tuple[response.File, int, bytes]]:
347+
async def _task_generator(
348+
chunk_size: int,
349+
) -> AsyncIterator[Tuple[response.File, int, bytes]]:
327350
async for chunk, offset in yield_chunks(path, chunk_size):
328351
yield file_info, offset, chunk
329352

330353
async with (
331354
stream.starmap(
332-
_task_generator(self.chunk_size),
333-
self._upload_chunk, # type: ignore
334-
task_limit=self.concurrent_chunks
335-
)
336-
).stream() as streamer:
337-
async for _ in tqdm(streamer, total=math.ceil(int(file_info["size"]) / self.chunk_size), desc=file_info["name"]):
355+
_task_generator(self.chunk_size),
356+
self._upload_chunk, # type: ignore
357+
task_limit=self.concurrent_chunks,
358+
)
359+
).stream() as streamer:
360+
async for _ in tqdm(
361+
streamer,
362+
total=math.ceil(int(file_info["size"]) / self.chunk_size),
363+
desc=file_info["name"],
364+
):
338365
pass
339366

340367
async def _upload_chunk(
@@ -384,14 +411,10 @@ async def _files_from_token(self, token: str) -> Iterable[DownloadFile]:
384411
)
385412
return files_from_page(download_page.content)
386413

387-
async def download_files(
388-
self,
389-
token: str,
390-
out_dir: Path
391-
) -> None:
414+
async def download_files(self, token: str, out_dir: Path) -> None:
392415
"""
393416
Downloads all files for a transfer.
394-
417+
395418
Params:
396419
token: Obtained from the transfer email. The same as [`GuestAuth`][filesender.GuestAuth]'s `guest_token`.
397420
out_dir: The path to write the downloaded files.
@@ -406,15 +429,17 @@ async def _download_args() -> AsyncIterator[Tuple[str, Any, Path, int, str]]:
406429

407430
# Each file is downloaded in parallel
408431
# Pyright messes this up
409-
await stream.starmap(_download_args(), self.download_file, task_limit=self.concurrent_files) # type: ignore
432+
await stream.starmap(
433+
_download_args(), self.download_file, task_limit=self.concurrent_files
434+
) # type: ignore
410435

411436
async def download_file(
412437
self,
413438
token: str,
414439
file_id: int,
415440
out_dir: Path,
416441
file_size: Union[int, float, None] = None,
417-
file_name: Optional[str] = None
442+
file_name: Optional[str] = None,
418443
) -> None:
419444
"""
420445
Downloads a single file.
@@ -442,9 +467,13 @@ async def download_file(
442467
file_path.parent.mkdir(parents=True, exist_ok=True)
443468
chunk_size = 8192
444469
chunk_size_mb = chunk_size / 1024 / 1024
445-
with tqdm(desc=file_name, unit="MB", total=None if file_size is None else int(file_size / 1024 / 1024)) as progress:
470+
with tqdm(
471+
desc=file_name,
472+
unit="MB",
473+
total=None if file_size is None else int(file_size / 1024 / 1024),
474+
) as progress:
446475
async with aiofiles.open(out_dir / file_name, "wb") as fp:
447-
# We can't add the total here, because we don't know it:
476+
# We can't add the total here, because we don't know it:
448477
# https://github.com/filesender/filesender/issues/1555
449478
async for chunk in res.aiter_raw(chunk_size=chunk_size):
450479
await fp.write(chunk)
@@ -472,7 +501,10 @@ async def upload_workflow(
472501
: See [`Transfer`][filesender.response_types.Transfer]
473502
"""
474503
files_by_name = {key: value for key, value in iter_files(files)}
475-
file_info: List[request.File] = [{"name": name, "size": file.stat().st_size} for name, file in files_by_name.items()]
504+
file_info: List[request.File] = [
505+
{"name": name, "size": file.stat().st_size}
506+
for name, file in files_by_name.items()
507+
]
476508
transfer = await self.create_transfer(
477509
{
478510
"files": file_info,
@@ -488,14 +520,19 @@ async def upload_workflow(
488520

489521
async def _upload_args() -> AsyncIterator[Tuple[response.File, Path]]:
490522
for file in transfer["files"]:
491-
# Skip folders, which aren't real
523+
# Skip folders, which aren't real
492524
if file["name"] in files_by_name:
493525
# Pyright seems to not understand that some fields are optional
494526
yield file, files_by_name[file["name"]]
495527

496528
# Upload each file in parallel
497529
# Pyright doesn't map the type signatures correctly here
498-
await stream.starmap(_upload_args(), self.upload_complete, ordered=False, task_limit=self.concurrent_files) # type: ignore
530+
await stream.starmap(
531+
_upload_args(),
532+
self.upload_complete,
533+
ordered=False,
534+
task_limit=self.concurrent_files,
535+
) # type: ignore
499536

500537
# Mark the transfer as complete
501538
transfer = await self.update_transfer(

0 commit comments

Comments
 (0)