Skip to content

Commit 4814d00

Browse files
author
Robert Denham
committed
Address review feedback: types, tests, remove type: ignore
- Use _FileBase + Union[_FileWithUid, _FileWithPuid] instead of NotRequired on both uid/puid fields - Type size as Union[int, str] to reflect actual server responses - Replace # type: ignore with cast(AsyncIterator[None], streamer) - Add unit tests for _file_key and files_from_page - Make conftest server args optional so unit tests run without a server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3023f93 commit 4814d00

4 files changed

Lines changed: 141 additions & 18 deletions

File tree

filesender/api.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator, Union
1+
from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator, Union, cast
22
from filesender.download import files_from_page, DownloadFile
33
import filesender.response_types as response
44
import filesender.request_types as request
@@ -102,7 +102,8 @@ def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[T
102102
def _file_key(file_info: response.File) -> str:
103103
"""
104104
Returns the per-file upload key, checking both ``uid`` (older FileSender servers)
105-
and ``puid`` (newer FileSender servers).
105+
and ``puid`` (newer FileSender servers). The field was renamed in
106+
https://github.com/filesender/filesender/pull/2429.
106107
"""
107108
if "uid" in file_info:
108109
return file_info["uid"]
@@ -333,7 +334,7 @@ async def _task_generator(chunk_size: int) -> AsyncIterator[Tuple[response.File,
333334
task_limit=self.concurrent_chunks
334335
)
335336
).stream() as streamer:
336-
async for _ in tqdm(streamer, total=math.ceil(int(file_info["size"]) / self.chunk_size), desc=file_info["name"]): # type: ignore
337+
async for _ in tqdm(cast(AsyncIterator[None], streamer), total=math.ceil(int(file_info["size"]) / self.chunk_size), desc=file_info["name"]):
337338
pass
338339

339340
async def _upload_chunk(
@@ -441,7 +442,7 @@ async def download_file(
441442
file_path.parent.mkdir(parents=True, exist_ok=True)
442443
chunk_size = 8192
443444
chunk_size_mb = chunk_size / 1024 / 1024
444-
with tqdm(desc=file_name, unit="MB", total=None if file_size is None else int(int(file_size) / 1024 / 1024)) as progress:
445+
with tqdm(desc=file_name, unit="MB", total=None if file_size is None else int(file_size / 1024 / 1024)) as progress:
445446
async with aiofiles.open(out_dir / file_name, "wb") as fp:
446447
# We can't add the total here, because we don't know it:
447448
# https://github.com/filesender/filesender/issues/1555

filesender/response_types.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
from typing import List
2-
from typing_extensions import TypedDict, NotRequired
1+
from typing import List, Union
2+
from typing_extensions import TypedDict
33

4-
class File(TypedDict):
4+
class _FileBase(TypedDict):
55
id: int
66
transfer_id: int
7-
uid: NotRequired[str] # older FileSender servers
8-
puid: NotRequired[str] # newer FileSender servers
97
name: str
10-
size: int
8+
size: Union[int, str] # some FileSender servers return this as a string
119
sha1: str
1210

11+
class _FileWithUid(_FileBase):
12+
uid: str
13+
14+
class _FileWithPuid(_FileBase):
15+
puid: str
16+
17+
File = Union[_FileWithUid, _FileWithPuid]
18+
1319
class Date(TypedDict):
1420
raw: int
1521
formatted: str

test/conftest.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1-
from pytest import Parser, Metafunc
1+
from pytest import Parser, Metafunc, skip
22

33
def pytest_addoption(parser: Parser):
4-
# Require the user to provide these arguments
5-
parser.addoption("--base-url", required=True)
6-
parser.addoption("--apikey", required=True)
7-
parser.addoption("--username", required=True)
4+
parser.addoption("--base-url", required=False)
5+
parser.addoption("--apikey", required=False)
6+
parser.addoption("--username", required=False)
87
parser.addoption("--delay", required=False, default="0")
9-
parser.addoption("--recipient", help="Email address that will be used as the recipient of the invitations", required=True)
8+
parser.addoption("--recipient", help="Email address that will be used as the recipient of the invitations", required=False)
109

1110
def pytest_generate_tests(metafunc: Metafunc):
1211
argnames = []
1312
argvalues = []
1413

1514
for fixture in metafunc.fixturenames:
1615
if hasattr(metafunc.config.option, fixture):
16+
value = getattr(metafunc.config.option, fixture)
17+
if value is None:
18+
skip(f"--{fixture} not provided")
1719
argnames.append(fixture)
18-
argvalues.append(getattr(metafunc.config.option, fixture))
20+
argvalues.append(value)
1921

2022
metafunc.parametrize(argnames, [argvalues])

test/test_utils.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,120 @@
1-
from filesender.api import iter_files
1+
from filesender.api import iter_files, _file_key
2+
from filesender.response_types import _FileWithUid, _FileWithPuid
3+
from filesender.download import files_from_page
24
from pathlib import Path
35
import tempfile
6+
import pytest
7+
8+
9+
# Minimal file dicts matching the structure of the real API response
10+
11+
FILE_WITH_UID: _FileWithUid = {
12+
"id": 28000001,
13+
"transfer_id": 3500001,
14+
"uid": "abc123def456",
15+
"name": "example.txt",
16+
"size": 1024,
17+
"sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
18+
}
19+
20+
FILE_WITH_PUID: _FileWithPuid = {
21+
"id": 28622026,
22+
"transfer_id": 3573766,
23+
"puid": "91dc452a-7d5c-4e34-9bee-6bc44fa5e012",
24+
"name": "final_train.h5",
25+
"size": "5243079740", # AARNet server returns size as a string
26+
"sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
27+
}
28+
29+
# Minimal HTML matching the structure of the FileSender download page
30+
31+
DOWNLOAD_PAGE_ENCRYPTED = b"""
32+
<html><body>
33+
<div class="file"
34+
data-id="1"
35+
data-transfer-id="1"
36+
data-name="secret.txt"
37+
data-size="1024"
38+
data-mime="text/plain"
39+
data-encrypted="1"
40+
data-encrypted-size="1040"
41+
data-fileiv="aabbccdd"
42+
data-fileaead="eeff0011"
43+
data-key-version="1"
44+
data-key-salt="saltsalt"
45+
data-client-entropy="entropydata"
46+
data-password-version="1"
47+
data-password-encoding="base64"
48+
data-password-hash-iterations="10000"
49+
data-transferid="1">
50+
</div>
51+
</body></html>
52+
"""
53+
54+
DOWNLOAD_PAGE_UNENCRYPTED = b"""
55+
<html><body>
56+
<div class="file"
57+
data-id="2"
58+
data-transfer-id="2"
59+
data-name="plain.txt"
60+
data-size="2048"
61+
data-mime="text/plain"
62+
data-encrypted="0"
63+
data-encrypted-size=""
64+
data-fileiv=""
65+
data-fileaead=""
66+
data-key-version=""
67+
data-key-salt=""
68+
data-client-entropy=""
69+
data-password-version=""
70+
data-password-encoding=""
71+
data-password-hash-iterations=""
72+
data-transferid="2">
73+
</div>
74+
</body></html>
75+
"""
76+
77+
78+
def test_file_key_uid():
79+
assert _file_key(FILE_WITH_UID) == "abc123def456"
80+
81+
82+
def test_file_key_puid():
83+
assert _file_key(FILE_WITH_PUID) == "91dc452a-7d5c-4e34-9bee-6bc44fa5e012"
84+
85+
86+
def test_file_key_neither():
87+
file: _FileWithUid = {
88+
"id": 1,
89+
"transfer_id": 1,
90+
"uid": "x",
91+
"name": "test.txt",
92+
"size": 0,
93+
"sha1": "",
94+
}
95+
# Strip the uid at runtime to simulate a response with neither field
96+
d = dict(file)
97+
del d["uid"]
98+
with pytest.raises(Exception, match="neither 'uid' nor 'puid'"):
99+
_file_key(d) # type: ignore[arg-type]
100+
101+
102+
def test_files_from_page_encrypted():
103+
(file,) = files_from_page(DOWNLOAD_PAGE_ENCRYPTED)
104+
assert file["name"] == "secret.txt"
105+
assert file["size"] == 1024
106+
assert file["encrypted_size"] == 1040
107+
assert file["key_version"] == 1
108+
assert file["password_hash_iterations"] == 10000
109+
110+
111+
def test_files_from_page_unencrypted():
112+
(file,) = files_from_page(DOWNLOAD_PAGE_UNENCRYPTED)
113+
assert file["name"] == "plain.txt"
114+
assert file["size"] == 2048
115+
assert file["encrypted_size"] is None
116+
assert file["key_version"] is None
117+
assert file["password_hash_iterations"] is None
4118

5119
def test_iter_files():
6120
with tempfile.TemporaryDirectory() as _tempdir:

0 commit comments

Comments
 (0)