diff --git a/setup.cfg b/setup.cfg index 580c0f6..e1e5440 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ tests = typing_extensions==3.10.0.2 pytest==6.2.5 pytest-cov==2.12.1 + respx==0.17.1 cheroot==8.5.2 WsgiDAV==3.1.1 colorama==0.4.4 diff --git a/src/webdav4/client.py b/src/webdav4/client.py index 8651466..4b5fdb3 100644 --- a/src/webdav4/client.py +++ b/src/webdav4/client.py @@ -311,7 +311,12 @@ def propfind( headers=headers, ) http_resp = self.with_retry(call) - return parse_multistatus_response(http_resp) + msr = parse_multistatus_response(http_resp) + if not data and len(msr.responses) == 1: + _, response = next(iter(msr.responses.items())) + if response.has_propstat and not response.properties.status_ok(): + raise ResourceNotFound(path) + return msr def get_props( self, @@ -547,11 +552,17 @@ def exists(self, path: str) -> bool: def isdir(self, path: str) -> bool: """Checks whether the resource with the given path is a directory.""" - return bool(self.get_props(path).collection) + try: + return bool(self.get_props(path).collection) + except ResourceNotFound: + return False def isfile(self, path: str) -> bool: """Checks whether the resource with the given path is a file.""" - return not self.isdir(path) + try: + return not self.get_props(path).collection + except ResourceNotFound: + return False def content_length(self, path: str) -> Optional[int]: """Returns content-length of the resource with the given path.""" @@ -586,7 +597,8 @@ def open( chunk_size: int = None, ) -> Iterator[Union[TextIO, BinaryIO]]: """Returns file-like object to a resource.""" - if self.isdir(path): + props = self.get_props(path) + if props.collection: raise IsACollectionError(path, "Cannot open a collection") assert mode in {"r", "rt", "rb"} diff --git a/src/webdav4/multistatus.py b/src/webdav4/multistatus.py index d8eddde..0e496a2 100644 --- a/src/webdav4/multistatus.py +++ b/src/webdav4/multistatus.py @@ -1,5 +1,6 @@ """Parsing propfind response.""" +from contextlib import suppress from http.client import responses from typing import TYPE_CHECKING, Any, Dict, Optional, Union from xml.etree.ElementTree import Element, ElementTree, SubElement @@ -88,6 +89,27 @@ def extract_text(prop_name: str) -> Optional[str]: self.display_name = extract_text("display_name") + status_line = ( + prop(response_xml, "status", relative=True) + if response_xml + else None + ) + self.status_code: Optional[int] = None + if status_line: + _, code_str, *_ = status_line.split() + with suppress(ValueError): + self.status_code = int(code_str) + + self.reason_phrase = ( + responses[self.status_code] if self.status_code else None + ) + + def status_ok(self) -> bool: + """Check if the propstat:status is ok.""" + if not self.status_code: + return True + return 200 <= self.status_code < 300 + def as_dict(self, raw: bool = False) -> Dict[str, Any]: """Returns all properties that it supports parsing. @@ -144,7 +166,8 @@ def __init__(self, response_xml: Element) -> None: code = None if status_line: _, code_str, *_ = status_line.split() - code = int(code_str) + with suppress(ValueError): + code = int(code_str) self.status_code = code self.reason_phrase = ( diff --git a/tests/test_client.py b/tests/test_client.py index 0a4fbc4..2a1f0a5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ """Tests for webdav client.""" +import textwrap from datetime import datetime, timezone from http import HTTPStatus from io import DEFAULT_BUFFER_SIZE, BytesIO @@ -8,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest +from respx import MockRouter from webdav4.client import ( BadGatewayError, @@ -792,23 +794,8 @@ def test_isdir_isfile(storage_dir: TmpDir, client: Client): assert client.isfile("/data/foo") assert not client.isdir("/data/foo") - with pytest.raises(ResourceNotFound) as exc_info: - client.isdir("/data/file") - - assert exc_info.value.path == "/data/file" - assert ( - str(exc_info.value) - == "The resource /data/file could not be found in the server" - ) - - with pytest.raises(ResourceNotFound) as exc_info: - client.isdir("/data/file") - - assert exc_info.value.path == "/data/file" - assert ( - str(exc_info.value) - == "The resource /data/file could not be found in the server" - ) + assert not client.isfile("/data/file") + assert not client.isdir("/data/file") def test_exists(storage_dir: TmpDir, client: Client): @@ -969,3 +956,43 @@ def test_client_retries(client: Client, server_address: URL): ) client.copy("/container1", "/container2") assert func.call_count == 3 + + +def test_handle_nginx_propfind_responses_correctly(respx_mock: MockRouter): + """Add support for Propfind response from nginx.""" + from httpx import Response + + path = "not-existing-file" + content = textwrap.dedent( + f"""\ + + + + /{path} + + + HTTP/1.1 404 Not Found + + + """ + ) + base_url = "https://example.org" + client = Client(base_url) + route = respx_mock.request(Method.PROPFIND, f"{base_url}/{path}").mock( + return_value=Response(HTTPStatus.MULTI_STATUS, content=content) + ) + + assert not client.exists(path) + with pytest.raises(ResourceNotFound): + client.propfind(path) + + assert not client.isdir(path) + assert not client.isfile(path) + + with pytest.raises(ResourceNotFound): + client.info(path) + + with pytest.raises(ResourceNotFound): + assert client.ls(path) + + assert route.called diff --git a/tests/test_multistatus.py b/tests/test_multistatus.py index 00cf2db..f5d72df 100644 --- a/tests/test_multistatus.py +++ b/tests/test_multistatus.py @@ -71,6 +71,9 @@ def test_dav_properties_empty(args: Tuple[Element]): assert props.display_name is None assert props.collection is None assert props.resource_type is None + assert props.status_code is None + assert props.reason_phrase is None + assert props.status_ok() def test_dav_properties(): @@ -88,6 +91,7 @@ def test_dav_properties(): "8db748065bfed5c0731e9c7ee5f9bf4c" text/plain + HTTP/1.1 200 OK """ elem = fromstring(content) @@ -116,6 +120,8 @@ def test_dav_properties(): assert props.display_name == "foo" assert props.collection is False assert props.resource_type == "file" + assert props.status_code == 200 + assert props.reason_phrase == "OK" assert props.as_dict() == { "created": datetime(2020, 1, 2, 3, 4, 5, tzinfo=tzutc()), @@ -127,6 +133,7 @@ def test_dav_properties(): "display_name": "foo", "type": "file", } + assert props.status_ok() def test_dav_properties_partial(): @@ -166,6 +173,10 @@ def test_dav_properties_partial(): assert props.collection is True assert props.resource_type == "directory" + assert props.status_code is None + assert props.reason_phrase is None + assert props.status_ok() + @pytest.mark.parametrize( "href, absolute",