Skip to content

Commit 52018c2

Browse files
SilviaSWRSilviaSWR
andauthored
fix(webhdfs): fix WebHDFS provider to handle requests without Range header (#90)
* fix(webhdfs): fix WebHDFS provider to handle requests without Range header * test(webHDFS): handle GET file requests without Range header * test(webhdfs): fix async mock misuse and align HTTP error behavior * tests(webhdfs): add missing tests * tests(webhdfs): address unawaited coroutine warnings --------- Co-authored-by: SilviaSWR <sbogaart@pic.es>
1 parent 347332f commit 52018c2

2 files changed

Lines changed: 204 additions & 31 deletions

File tree

asgi_webdav/provider/webhdfs.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,13 @@ async def _do_get(self, request: DAVRequest) -> tuple[
261261
return status_response, dav_property.basic_data, None, None
262262

263263
# Read file's content
264-
response_content_range = get_response_content_range(
265-
request_ranges=request.ranges,
266-
file_size=dav_property.basic_data.content_length,
267-
)
264+
if not request.ranges: # No range header, return the whole content
265+
response_content_range = None
266+
else:
267+
response_content_range = get_response_content_range(
268+
request_ranges=request.ranges,
269+
file_size=dav_property.basic_data.content_length,
270+
)
268271
if response_content_range is None:
269272
response_content_range = DAVResponseContentRange(
270273
DAVRangeType.RANGE,

tests/test_provider_webhdfs.py

Lines changed: 197 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def test_get_url_path_with_home(mock_provider):
6363
async def test_do_filestatus_success(mock_provider, fake_request):
6464
mock_response = AsyncMock()
6565
mock_response.status_code = 200
66-
mock_response.raise_for_status = AsyncMock()
66+
mock_response.raise_for_status = MagicMock()
6767

6868
mock_response.json = MagicMock(
6969
return_value={
@@ -86,6 +86,23 @@ async def test_do_filestatus_success(mock_provider, fake_request):
8686
assert file_status["type"] == "FILE"
8787

8888

89+
@pytest.mark.asyncio
90+
async def test_do_filestatus_missing_filestatus(mock_provider, fake_request):
91+
mock_response = AsyncMock()
92+
mock_response.status_code = 200
93+
mock_response.raise_for_status = MagicMock()
94+
mock_response.json = MagicMock(return_value={"FileStatus": {}})
95+
96+
mock_provider.client.get.return_value = mock_response
97+
98+
status, data = await mock_provider._do_filestatus(
99+
fake_request, DAVPath("/test.txt")
100+
)
101+
102+
assert status == 200
103+
assert data == {}
104+
105+
89106
@pytest.mark.asyncio
90107
async def test_do_get_file(mock_provider, fake_request):
91108
fake_status = {
@@ -102,7 +119,13 @@ async def test_do_get_file(mock_provider, fake_request):
102119
),
103120
)
104121
)
105-
mock_provider._dav_response_data_generator = AsyncMock(return_value=AsyncMock())
122+
123+
async def fake_generator():
124+
yield b"data"
125+
126+
mock_provider._dav_response_data_generator = MagicMock(
127+
return_value=fake_generator()
128+
)
106129

107130
fake_request.ranges = [DAVRequestRange(DAVRangeType.RANGE, 0, 100, 200)]
108131
status, basic_data, generator, _ = await mock_provider._do_get(fake_request)
@@ -112,11 +135,44 @@ async def test_do_get_file(mock_provider, fake_request):
112135
assert generator is not None
113136

114137

138+
@pytest.mark.asyncio
139+
async def test_do_get_file_non_range(mock_provider, fake_request):
140+
fake_status = {
141+
"type": "FILE",
142+
"length": 100,
143+
"modificationTime": 1234567890,
144+
}
145+
146+
mock_provider._get_dav_property_d0 = AsyncMock(
147+
return_value=(
148+
200,
149+
await mock_provider._create_dav_property_obj(
150+
fake_request, DAVPath("/testfile.txt"), fake_status
151+
),
152+
)
153+
)
154+
155+
async def fake_generator():
156+
yield b"data"
157+
158+
mock_provider._dav_response_data_generator = MagicMock(
159+
return_value=fake_generator()
160+
)
161+
162+
fake_request.ranges = None
163+
status, basic_data, generator, _ = await mock_provider._do_get(fake_request)
164+
165+
assert status == 200
166+
assert basic_data.content_length == 100
167+
assert generator is not None
168+
169+
115170
@pytest.mark.asyncio
116171
async def test_do_delete_success(mock_provider, fake_request):
117172
mock_provider._precheck_source = AsyncMock(return_value=(True, True, False))
118173
mock_provider.client.delete.return_value = AsyncMock(
119-
status_code=204, raise_for_status=AsyncMock()
174+
status_code=204,
175+
raise_for_status=MagicMock(),
120176
)
121177

122178
result = await mock_provider._do_delete(fake_request)
@@ -130,6 +186,23 @@ async def test_do_delete_not_found(mock_provider, fake_request):
130186
assert result == 404
131187

132188

189+
@pytest.mark.asyncio
190+
async def test_do_delete_http_error(mock_provider, fake_request):
191+
mock_provider._precheck_source = AsyncMock(return_value=(True, True, False))
192+
193+
mock_response = AsyncMock()
194+
mock_response.raise_for_status = MagicMock(
195+
side_effect=httpx.HTTPStatusError(
196+
"error", request=MagicMock(), response=MagicMock(status_code=500)
197+
)
198+
)
199+
200+
mock_provider.client.delete.return_value = mock_response
201+
202+
response = await mock_provider._do_delete(fake_request)
203+
assert response == 424
204+
205+
133206
def test_get_url():
134207
root = WebHDFSProvider(
135208
config=Config(),
@@ -175,17 +248,20 @@ def test_get_url():
175248
async def test_do_filestatus_failure(mock_provider, fake_request):
176249
mock_response = AsyncMock()
177250
mock_response.status_code = 404
178-
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
179-
message="Error", request=MagicMock(), response=MagicMock(status_code=404)
251+
mock_response.raise_for_status = MagicMock(
252+
side_effect=httpx.HTTPStatusError(
253+
message="Error",
254+
request=MagicMock(),
255+
response=MagicMock(status_code=404),
256+
)
180257
)
181258
mock_response.json = MagicMock(return_value={"FileStatus": {}})
182259

183260
mock_provider.client.get.return_value = mock_response
184261

185262
url_path = DAVPath("/notfound.txt")
186-
187-
response = await mock_provider._do_filestatus(fake_request, url_path)
188-
assert response == (404, {})
263+
with pytest.raises(httpx.HTTPStatusError):
264+
await mock_provider._do_filestatus(fake_request, url_path)
189265

190266

191267
@pytest.mark.asyncio
@@ -214,6 +290,30 @@ async def test_do_get_collection(mock_provider, fake_request):
214290
assert response_content_range is None
215291

216292

293+
@pytest.mark.asyncio
294+
async def test_do_get_invalid_range(mock_provider, fake_request):
295+
fake_status = {
296+
"type": "FILE",
297+
"length": 100,
298+
"modificationTime": 0,
299+
}
300+
301+
mock_provider._get_dav_property_d0 = AsyncMock(
302+
return_value=(
303+
200,
304+
await mock_provider._create_dav_property_obj(
305+
fake_request, DAVPath("/file.txt"), fake_status
306+
),
307+
)
308+
)
309+
310+
fake_request.ranges = [DAVRequestRange(DAVRangeType.RANGE, 200, 300, 100)]
311+
312+
status, _, _, _ = await mock_provider._do_get(fake_request)
313+
314+
assert status in (200, 416)
315+
316+
217317
@pytest.mark.asyncio
218318
async def test_dav_response_data_generator(mock_provider, fake_request):
219319
async def fake_aiter_bytes():
@@ -244,26 +344,60 @@ async def fake_aiter_bytes():
244344
assert result == [(b"chunk1", True), (b"chunk2", False)]
245345

246346

347+
@pytest.mark.asyncio
348+
async def test_dav_response_generator_empty(mock_provider, fake_request):
349+
async def fake_aiter():
350+
if False:
351+
yield b""
352+
353+
fake_response = MagicMock()
354+
fake_response.aiter_bytes = fake_aiter
355+
fake_response.raise_for_status = MagicMock()
356+
357+
class AsyncContextManager:
358+
async def __aenter__(self):
359+
return fake_response
360+
361+
async def __aexit__(self, exc_type, exc_val, exc_tb):
362+
return None
363+
364+
mock_provider.client.stream = MagicMock(return_value=AsyncContextManager())
365+
366+
gen = mock_provider._dav_response_data_generator(
367+
fake_request, DAVPath("/file.txt"), None, None
368+
)
369+
370+
result = [chunk async for chunk, more in gen]
371+
assert result == [None]
372+
373+
247374
@pytest.mark.asyncio
248375
async def test_do_put(mock_provider, fake_request):
249-
mock_response = MagicMock()
250-
mock_response.status_code = 200
251-
mock_response.json = MagicMock(return_value={"FileStatus": {}})
252-
mock_response.raise_for_status = AsyncMock()
376+
mock_provider._precheck_source = AsyncMock(return_value=(True, False, False))
253377

254-
mock_provider.client.get.return_value = mock_response
378+
first_put = AsyncMock()
379+
first_put.status_code = 307
380+
first_put.headers = {
381+
"location": "http://fake-hdfs:9870/webhdfs/v1/file_put_location"
382+
}
383+
first_put.raise_for_status = MagicMock()
255384

256-
response = await mock_provider._do_put(fake_request)
385+
second_put = AsyncMock()
386+
second_put.status_code = 201
387+
second_put.raise_for_status = MagicMock()
257388

258-
assert response == 204
389+
mock_provider.client.put = AsyncMock(side_effect=[first_put, second_put])
390+
391+
response = await mock_provider._do_put(fake_request)
392+
assert response == 201
259393

260394

261395
@pytest.mark.asyncio
262396
async def test_do_copy(mock_provider, fake_request):
263397
mock_response = MagicMock()
264398
mock_response.status_code = 200
265399
mock_response.json = MagicMock(return_value={"FileStatus": {}})
266-
mock_response.raise_for_status = AsyncMock()
400+
mock_response.raise_for_status = MagicMock()
267401

268402
mock_provider.client.get.return_value = mock_response
269403

@@ -276,7 +410,7 @@ async def test_do_mkcol(mock_provider, fake_request):
276410
mock_response = MagicMock()
277411
mock_response.status_code = 200
278412
mock_response.json = MagicMock(return_value={"FileStatus": {}})
279-
mock_response.raise_for_status = AsyncMock()
413+
mock_response.raise_for_status = MagicMock()
280414

281415
mock_provider.client.get.return_value = mock_response
282416

@@ -290,7 +424,7 @@ async def test_do_propfind(mock_provider, fake_request):
290424
mock_response = MagicMock()
291425
mock_response.status_code = 200
292426
mock_response.json = MagicMock(return_value={"FileStatus": {}})
293-
mock_response.raise_for_status = AsyncMock()
427+
mock_response.raise_for_status = MagicMock()
294428

295429
mock_provider.client.get.return_value = mock_response
296430

@@ -340,12 +474,29 @@ async def test_do_propfind(mock_provider, fake_request):
340474
assert resp_val.extra_not_found == exp_val.extra_not_found
341475

342476

477+
@pytest.mark.asyncio
478+
async def test_do_propfind_with_extra_keys(mock_provider, fake_request):
479+
fake_request.propfind_only_fetch_basic = False
480+
fake_request.propfind_extra_keys = ["custom:key"]
481+
482+
mock_response = AsyncMock()
483+
mock_response.status_code = 200
484+
mock_response.raise_for_status = MagicMock()
485+
mock_response.json = MagicMock(return_value={"FileStatus": {}})
486+
487+
mock_provider.client.get.return_value = mock_response
488+
489+
result = await mock_provider._do_propfind(fake_request)
490+
491+
assert isinstance(result, dict)
492+
493+
343494
@pytest.mark.asyncio
344495
async def test_do_head(mock_provider, fake_request):
345496
mock_response = MagicMock()
346497
mock_response.status_code = 200
347498
mock_response.json = MagicMock(return_value={"FileStatus": {}})
348-
mock_response.raise_for_status = AsyncMock()
499+
mock_response.raise_for_status = MagicMock()
349500

350501
mock_provider.client.get.return_value = mock_response
351502

@@ -359,7 +510,7 @@ async def test_do_precheck_destination(mock_provider, fake_request):
359510
mock_response = MagicMock()
360511
mock_response.status_code = 200
361512
mock_response.json = MagicMock(return_value={"FileStatus": {}})
362-
mock_response.raise_for_status = AsyncMock()
513+
mock_response.raise_for_status = MagicMock()
363514

364515
mock_provider.client.get.return_value = mock_response
365516

@@ -370,13 +521,32 @@ async def test_do_precheck_destination(mock_provider, fake_request):
370521

371522
@pytest.mark.asyncio
372523
async def test_do_move(mock_provider, fake_request):
373-
mock_response = MagicMock()
374-
mock_response.status_code = 200
375-
mock_response.json = MagicMock(return_value={"FileStatus": {}})
376-
mock_response.raise_for_status = AsyncMock()
524+
fake_request.overwrite = True
377525

378-
mock_provider.client.get.return_value = mock_response
526+
mock_provider._precheck_destination = AsyncMock(return_value=(True, False, True))
527+
528+
mock_delete_response = MagicMock()
529+
mock_delete_response.status_code = 204
530+
mock_delete_response.raise_for_status = MagicMock()
531+
532+
mock_provider.client.delete = AsyncMock(return_value=mock_delete_response)
533+
534+
mock_put_response = MagicMock()
535+
mock_put_response.status_code = 201
536+
mock_put_response.raise_for_status = MagicMock()
537+
538+
mock_provider.client.put = AsyncMock(return_value=mock_put_response)
539+
540+
result = await mock_provider._do_move(fake_request)
541+
assert result == 204
542+
543+
544+
@pytest.mark.asyncio
545+
async def test_do_move_destination_exists_no_overwrite(mock_provider, fake_request):
546+
fake_request.overwrite = False
547+
548+
mock_provider._precheck_destination = AsyncMock(return_value=(True, True, True))
379549

380-
response = await mock_provider._do_move(fake_request)
550+
result = await mock_provider._do_move(fake_request)
381551

382-
assert response == 204
552+
assert result == 403

0 commit comments

Comments
 (0)