diff --git a/LICENSE b/LICENSE index db6c7c6a..0a5c7c3a 100644 --- a/LICENSE +++ b/LICENSE @@ -90,3 +90,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +-- + +The multipart decoding is from requests-toolbelt, see: +https://github.com/requests/toolbelt + +Copyright 2014 Ian Cordasco, Cory Benfield + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/pyproject.toml b/pyproject.toml index cd8794a4..6362181e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "lxml>=4.6.0", "platformdirs>=1.4.0", "requests>=2.7.0", - "requests-toolbelt>=0.7.1", "requests-file>=1.5.1", ] diff --git a/src/zeep/__main__.py b/src/zeep/__main__.py index d06ee7d6..c491a977 100644 --- a/src/zeep/__main__.py +++ b/src/zeep/__main__.py @@ -4,7 +4,10 @@ import time from urllib.parse import urlparse -import requests +try: + from requests import Session +except ImportError: + from httpx import Client as Session from zeep.cache import SqliteCache from zeep.client import Client @@ -63,7 +66,7 @@ def main(args): profile.enable() cache = SqliteCache() if args.cache else None - session = requests.Session() + session = Session() if args.no_verify: session.verify = False diff --git a/src/zeep/multipart_decoder.py b/src/zeep/multipart_decoder.py new file mode 100644 index 00000000..9a54af35 --- /dev/null +++ b/src/zeep/multipart_decoder.py @@ -0,0 +1,185 @@ +# This is code from requests-toolbelt modified to work with both requests and httpx. +# Here is the original license statement for requests-toolbelt: +# +# Copyright 2014 Ian Cordasco, Cory Benfield +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +import email.parser +import sys +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + import httpx + import requests + +try: + from requests.structures import CaseInsensitiveDict as Headers +except ImportError: + from httpx import Headers + + +def encode_with(string: Union[str, bytes], encoding: str) -> bytes: + """Encoding ``string`` with ``encoding`` if necessary. + + :param str string: If string is a bytes object, it will not encode it. + Otherwise, this function will encode it with the provided encoding. + :param str encoding: The encoding with which to encode string. + :returns: encoded bytes object + """ + if not (string is None or isinstance(string, bytes)): + return string.encode(encoding) + return string + + +def _split_on_find(content, bound): + point = content.find(bound) + return content[:point], content[point + len(bound) :] + + +class ImproperBodyPartContentException(Exception): + pass + + +class NonMultipartContentTypeException(Exception): + pass + + +def _header_parser(string, encoding): + major = sys.version_info[0] + if major == 3: + string = string.decode(encoding) + headers = email.parser.HeaderParser().parsestr(string).items() + return ((encode_with(k, encoding), encode_with(v, encoding)) for k, v in headers) + + +class BodyPart(object): + """ + + The ``BodyPart`` object is a ``Response``-like interface to an individual + subpart of a multipart response. It is expected that these will + generally be created by objects of the ``MultipartDecoder`` class. + + Like ``Response``, there is a ``CaseInsensitiveDict`` object named headers, + ``content`` to access bytes, ``text`` to access unicode, and ``encoding`` + to access the unicode codec. + + """ + + def __init__(self, content: bytes, encoding: str): + self.encoding = encoding + headers = {} + # Split into header section (if any) and the content + if b"\r\n\r\n" in content: + first, self.content = _split_on_find(content, b"\r\n\r\n") + if first != b"": + headers = _header_parser(first.lstrip(), encoding) + else: + raise ImproperBodyPartContentException( + "content does not contain CR-LF-CR-LF" + ) + self.headers = Headers(headers) + + @property + def text(self) -> str: + """Content of the ``BodyPart`` in unicode.""" + return self.content.decode(self.encoding) + + +class MultipartDecoder(object): + """ + + The ``MultipartDecoder`` object parses the multipart payload of + a bytestring into a tuple of ``Response``-like ``BodyPart`` objects. + + The basic usage is:: + + import requests + from requests_toolbelt import MultipartDecoder + + response = requests.get(url) + decoder = MultipartDecoder.from_response(response) + for part in decoder.parts: + print(part.headers['content-type']) + + If the multipart content is not from a response, basic usage is:: + + from requests_toolbelt import MultipartDecoder + + decoder = MultipartDecoder(content, content_type) + for part in decoder.parts: + print(part.headers['content-type']) + + For both these usages, there is an optional ``encoding`` parameter. This is + a string, which is the name of the unicode codec to use (default is + ``'utf-8'``). + + """ + + def __init__(self, content, content_type, encoding: str = "utf-8"): + #: Original Content-Type header + self.content_type = content_type + #: Response body encoding + self.encoding = encoding + #: Parsed parts of the multipart response body + self.parts = tuple() + self._find_boundary() + self._parse_body(content) + + def _find_boundary(self): + ct_info = tuple(x.strip() for x in self.content_type.split(";")) + mimetype = ct_info[0] + if mimetype.split("/")[0].lower() != "multipart": + raise NonMultipartContentTypeException( + "Unexpected mimetype in content-type: '{}'".format(mimetype) + ) + for item in ct_info[1:]: + attr, value = _split_on_find(item, "=") + if attr.lower() == "boundary": + self.boundary = encode_with(value.strip('"'), self.encoding) + + @staticmethod + def _fix_first_part(part, boundary_marker): + bm_len = len(boundary_marker) + if boundary_marker == part[:bm_len]: + return part[bm_len:] + else: + return part + + def _parse_body(self, content): + boundary = b"".join((b"--", self.boundary)) + + def body_part(part): + fixed = MultipartDecoder._fix_first_part(part, boundary) + return BodyPart(fixed, self.encoding) + + def test_part(part): + return ( + part != b"" + and part != b"\r\n" + and part[:4] != b"--\r\n" + and part != b"--" + ) + + parts = content.split(b"".join((b"\r\n", boundary))) + self.parts = tuple(body_part(x) for x in parts if test_part(x)) + + @classmethod + def from_response( + cls, response: Union[requests.Response, httpx.Response], encoding: str = "utf-8" + ): + content = response.content + content_type = response.headers.get("content-type", None) + return cls(content, content_type, encoding) diff --git a/src/zeep/transports.py b/src/zeep/transports.py index 99e28bb8..9c090582 100644 --- a/src/zeep/transports.py +++ b/src/zeep/transports.py @@ -1,16 +1,25 @@ +from __future__ import annotations + import logging import os from contextlib import closing, contextmanager +from typing import TYPE_CHECKING, Union from urllib.parse import urlparse -import requests -from requests import Response -from requests_file import FileAdapter - from zeep.exceptions import TransportError from zeep.utils import get_media_type, get_version from zeep.wsdl.utils import etree_to_string +if TYPE_CHECKING: + from httpx import Client + from requests import Session + +try: + import requests + from requests_file import FileAdapter +except ImportError: + requests = None + try: import httpx except ImportError: @@ -27,6 +36,9 @@ Version = None HTTPX_PROXY_KWARG_NAME = None +if requests is None and httpx is None: + raise RuntimeError("Either `requests` or `httpx` must be installed.") + __all__ = ["AsyncTransport", "Transport"] @@ -37,19 +49,27 @@ class Transport: :param timeout: The timeout for loading wsdl and xsd documents. :param operation_timeout: The timeout for operations (POST/GET). By default this is None (no timeout). - :param session: A :py:class:`request.Session()` object (optional) + :param session: A :py:class:`request.Session()` or :py:class:`httpx.Client()` object (optional) """ - def __init__(self, cache=None, timeout=300, operation_timeout=None, session=None): + def __init__( + self, + cache=None, + timeout=300, + operation_timeout=None, + session: Union[Session, Client, None] = None, + ): self.cache = cache self.load_timeout = timeout self.operation_timeout = operation_timeout self.logger = logging.getLogger(__name__) self._close_session = not session - self.session = session or requests.Session() - self.session.mount("file://", FileAdapter()) + self.session = session or (requests.Session() if requests else httpx.Client()) + if requests and isinstance(self.session, requests.Session): + self.session = session or requests.Session() + self.session.mount("file://", FileAdapter()) self.session.headers["User-Agent"] = "Zeep/%s (www.python-zeep.org)" % ( get_version() ) @@ -246,22 +266,25 @@ async def post(self, address, message, headers): async def post_xml(self, address, envelope, headers): message = etree_to_string(envelope) - response = await self.post(address, message, headers) - return self.new_response(response) + return await self.post(address, message, headers) async def get(self, address, params, headers): - response = await self.client.get( + return await self.client.get( address, params=params, headers=headers, ) - return self.new_response(response) def new_response(self, response): """Convert an aiohttp.Response object to a requests.Response object""" + if requests is None: + raise RuntimeError( + "`requests` must be installed to use the `new_response` function." + ) + body = response.read() - new = Response() + new = requests.Response() new._content = body new.status_code = response.status_code new.headers = response.headers diff --git a/src/zeep/wsdl/attachments.py b/src/zeep/wsdl/attachments.py index 44e09900..b37f0206 100644 --- a/src/zeep/wsdl/attachments.py +++ b/src/zeep/wsdl/attachments.py @@ -7,7 +7,10 @@ import base64 from functools import cached_property -from requests.structures import CaseInsensitiveDict +try: + from requests.structures import CaseInsensitiveDict as Headers +except ImportError: + from httpx import Headers class MessagePack: @@ -51,7 +54,7 @@ def get_by_content_id(self, content_id): class Attachment: def __init__(self, part): encoding = part.encoding or "utf-8" - self.headers = CaseInsensitiveDict( + self.headers = Headers( {k.decode(encoding): v.decode(encoding) for k, v in part.headers.items()} ) self.content_type = self.headers.get("Content-Type", None) diff --git a/src/zeep/wsdl/bindings/soap.py b/src/zeep/wsdl/bindings/soap.py index 3a5e5433..af3fd9ed 100644 --- a/src/zeep/wsdl/bindings/soap.py +++ b/src/zeep/wsdl/bindings/soap.py @@ -2,11 +2,11 @@ import typing from lxml import etree -from requests_toolbelt.multipart.decoder import MultipartDecoder from zeep import ns, plugins, wsa from zeep.exceptions import Fault, TransportError, XMLSyntaxError from zeep.loader import parse_xml +from zeep.multipart_decoder import MultipartDecoder from zeep.utils import as_qname, get_media_type, qname_attr from zeep.wsdl.attachments import MessagePack from zeep.wsdl.definitions import Binding, Operation diff --git a/tests/test_soap_xop.py b/tests/test_soap_xop.py index 9c010114..26d5f3c2 100644 --- a/tests/test_soap_xop.py +++ b/tests/test_soap_xop.py @@ -3,10 +3,10 @@ import requests_mock from lxml import etree from pretend import stub -from requests_toolbelt.multipart.decoder import MultipartDecoder from tests.utils import assert_nodes_equal from zeep import Client +from zeep.multipart_decoder import MultipartDecoder from zeep.transports import Transport from zeep.wsdl.attachments import MessagePack from zeep.wsdl.messages import xop diff --git a/uv.lock b/uv.lock index cd380086..f84dd7d4 100644 --- a/uv.lock +++ b/uv.lock @@ -790,18 +790,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, ] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - [[package]] name = "roman-numerals" version = "4.1.0" @@ -1140,7 +1128,6 @@ dependencies = [ { name = "platformdirs" }, { name = "requests" }, { name = "requests-file" }, - { name = "requests-toolbelt" }, ] [package.optional-dependencies] @@ -1180,7 +1167,6 @@ requires-dist = [ { name = "platformdirs", specifier = ">=1.4.0" }, { name = "requests", specifier = ">=2.7.0" }, { name = "requests-file", specifier = ">=1.5.1" }, - { name = "requests-toolbelt", specifier = ">=0.7.1" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=1.4.0" }, { name = "xmlsec", marker = "extra == 'xmlsec'", specifier = ">=0.6.1" }, ]