Skip to content

Commit dac26fb

Browse files
authored
Merge pull request #40 from rdenham/type_error
Handle server API differences in file response fields
2 parents 3ccff71 + 8d8100a commit dac26fb

6 files changed

Lines changed: 165 additions & 25 deletions

File tree

filesender/api.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[T
9999
# If this is a nested file, use the relative path from the root directory as the name
100100
yield str(path.relative_to(root)), path
101101

102+
def _file_key(file_info: response.File) -> str:
103+
"""
104+
Returns the per-file upload key, checking both ``uid`` (older FileSender servers)
105+
and ``puid`` (newer FileSender servers). The field was renamed in
106+
https://github.com/filesender/filesender/pull/2429.
107+
"""
108+
if "uid" in file_info:
109+
return file_info["uid"]
110+
if "puid" in file_info:
111+
return file_info["puid"]
112+
raise Exception(f"File response for {file_info['name']!r} has neither 'uid' nor 'puid' field")
113+
102114
class EndpointHandler:
103115
base: str
104116

@@ -292,7 +304,7 @@ async def update_file(
292304
self.http_client.build_request(
293305
"PUT",
294306
self.urls.file(file_info['id']),
295-
params={"key": file_info["uid"]},
307+
params={"key": _file_key(file_info)},
296308
json=body,
297309
)
298310
)
@@ -322,7 +334,7 @@ async def _task_generator(chunk_size: int) -> AsyncIterator[Tuple[response.File,
322334
task_limit=self.concurrent_chunks
323335
)
324336
).stream() as streamer:
325-
async for _ in tqdm(streamer, total=math.ceil(file_info["size"] / self.chunk_size), desc=file_info["name"]): # type: ignore
337+
async for _ in tqdm(streamer, total=math.ceil(int(file_info["size"]) / self.chunk_size), desc=file_info["name"]):
326338
pass
327339

328340
async def _upload_chunk(
@@ -338,7 +350,7 @@ async def _upload_chunk(
338350
self.http_client.build_request(
339351
"PUT",
340352
self.urls.chunk(file_info["id"], offset),
341-
params={"key": file_info["uid"]},
353+
params={"key": _file_key(file_info)},
342354
content=chunk,
343355
headers={
344356
"Content-Type": "application/octet-stream",

filesender/download.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,53 @@
1-
from typing import Iterable, TypedDict
1+
from typing import Iterable, Optional, TypedDict
22

33
from bs4 import BeautifulSoup
44

55

66
class DownloadFile(TypedDict):
77
client_entropy: str
88
encrypted: str
9-
encrypted_size: int
9+
encrypted_size: Optional[int]
1010
fileaead: str
1111
fileiv: str
1212
id: int
1313
key_salt: str
14-
key_version: int
14+
key_version: Optional[int]
1515
mime: str
1616
#: filename
1717
name: str
1818
password_encoding: str
19-
password_hash_iterations: int
20-
password_version: int
19+
password_hash_iterations: Optional[int]
20+
password_version: Optional[int]
2121
size: int
2222
transferid: int
2323

24+
def _opt_int(value: str) -> Optional[int]:
25+
return int(value) if value else None
26+
2427
def files_from_page(content: bytes) -> Iterable[DownloadFile]:
2528
"""
2629
Yields dictionaries describing the files listed on a FileSender web page
2730
2831
Params:
29-
content: The HTML content of the FileSender download page
32+
content: The HTML content of the FileSender download page
3033
"""
3134
for file in BeautifulSoup(content, "html.parser").find_all(
3235
class_="file"
3336
):
3437
yield {
3538
"client_entropy": file.attrs[f"data-client-entropy"],
3639
"encrypted": file.attrs["data-encrypted"],
37-
"encrypted_size": int(file.attrs["data-encrypted-size"]),
40+
"encrypted_size": _opt_int(file.attrs["data-encrypted-size"]),
3841
"fileaead": file.attrs["data-fileaead"],
3942
"fileiv": file.attrs["data-fileiv"],
4043
"id": int(file.attrs["data-id"]),
4144
"key_salt": file.attrs["data-key-salt"],
42-
"key_version": int(file.attrs["data-key-version"]),
45+
"key_version": _opt_int(file.attrs["data-key-version"]),
4346
"mime": file.attrs["data-mime"],
4447
"name": file.attrs["data-name"],
4548
"password_encoding": file.attrs["data-password-encoding"],
46-
"password_hash_iterations": int(file.attrs["data-password-hash-iterations"]),
47-
"password_version": int(file.attrs["data-password-version"]),
49+
"password_hash_iterations": _opt_int(file.attrs["data-password-hash-iterations"]),
50+
"password_version": _opt_int(file.attrs["data-password-version"]),
4851
"size": int(file.attrs["data-size"]),
4952
"transferid": int(file.attrs["data-transferid"]),
5053
}

filesender/response_types.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1+
from __future__ import annotations
12
from typing import List
23
from typing_extensions import TypedDict
34

4-
class File(TypedDict):
5+
class _FileBase(TypedDict):
56
id: int
67
transfer_id: int
7-
uid: str
88
name: str
9-
size: int
9+
size: int | str # some FileSender servers return this as a string
1010
sha1: str
1111

12+
class _FileWithUid(_FileBase):
13+
uid: str
14+
15+
class _FileWithPuid(_FileBase):
16+
puid: str
17+
18+
File = _FileWithUid | _FileWithPuid
19+
1220
class Date(TypedDict):
1321
raw: int
1422
formatted: str

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ dependencies = [
2525
"cryptography",
2626
"typing_extensions",
2727
"tenacity",
28-
"tqdm"
28+
"tqdm",
29+
"click>=8.1.8",
2930
]
3031

3132
[tool.setuptools.packages.find]

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)