Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
7 changes: 5 additions & 2 deletions src/zeep/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
185 changes: 185 additions & 0 deletions src/zeep/multipart_decoder.py
Original file line number Diff line number Diff line change
@@ -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)
49 changes: 36 additions & 13 deletions src/zeep/transports.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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"]


Expand All @@ -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()
)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/zeep/wsdl/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/zeep/wsdl/bindings/soap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_soap_xop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading