commit 90d4bf5939ccd16e530a6d10f79adeacffeb654a Author: Mathias Behrle Date: Tue Dec 6 17:32:03 2016 +0100 Adding debian version 0.23.0-1. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4926c2f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*.py] +line_length = 79 +multi_line_output = 4 +balanced_wrapping = true +known_first_party = zeep,tests +use_parentheses = true +indent_style = space +indent_size = 4 +tab_width = 4 + +[*.yml] +indent_size = 2 +shift_width = 2 + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..166db7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +*.egg-info +*.pyc +.tox +.coverage +.eggs +.cache +.python-version +.venv +.idea/ +/build/ +/dist/ +/test_clients/ +/docs/_build/ +/frutsels/ +/server/ +/htmlcov/ + + +# Editors +.idea/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d2de223 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,40 @@ +--- +sudo: false +language: python + +python: + - '2.7' + - '3.3' + - '3.4' + - '3.5' + - 'pypy' + +install: + - | + if [ "$TRAVIS_PYTHON_VERSION" = "pypy" ]; then + export PYENV_ROOT="$HOME/.pyenv" + if [ -f "$PYENV_ROOT/bin/pyenv" ]; then + pushd "$PYENV_ROOT" && git pull && popd + else + rm -rf "$PYENV_ROOT" && git clone --depth 1 https://github.com/yyuu/pyenv.git "$PYENV_ROOT" + fi + export PYPY_VERSION="5.4" + "$PYENV_ROOT/bin/pyenv" install --skip-existing "pypy-$PYPY_VERSION" + virtualenv --python="$PYENV_ROOT/versions/pypy-$PYPY_VERSION/bin/python" "$HOME/virtualenvs/pypy-$PYPY_VERSION" + source "$HOME/virtualenvs/pypy-$PYPY_VERSION/bin/activate" + fi + - pip install codecov + - pip install -e .[test] + +script: + - py.test --cov=zeep --cov-report=term-missing + +after_success: + - codecov + +before_cache: + - rm -rf $HOME/.cache/pip/log + +cache: + directories: + - $HOME/.cache/pip diff --git a/CHANGES b/CHANGES new file mode 100644 index 0000000..468f461 --- /dev/null +++ b/CHANGES @@ -0,0 +1,355 @@ +0.23.0 (2016-11-24) +------------------- + - Add Client.set_default_soapheaders() to set soapheaders which are to be used + on all operations done via the client object. + - Add basic support for asyncio using aiohttp. Many thanks to chrisimcevoy + for the initial implementation! Please see + https://github.com/mvantellingen/python-zeep/pull/207 and + https://github.com/mvantellingen/python-zeep/pull/251 for more information + - Fix recursion error when generating the call signature (jaceksnet, #264) + + +0.22.1 (2016-11-22) +------------------- + - Fix reversed() error (jaceksnet) (#260) + - Better error message when unexpected xml elements are encountered in + sequences. + + +0.22.0 (2016-11-13) +------------------- + - Force the soap:address / http:address to HTTPS when the wsdl is loaded from + a https url (#228) + - Improvements to the xsd:union handling. The matching base class is now used + for serializing/deserializing the values. If there is no matching base class + then the raw value is returned. (#195) + - Fix handling of xsd:any with maxOccurs > 1 in xsd:choice elements (#253) + - Add workaround for schema's importing the xsd from + http://www.w3.org/XML/1998/namespace (#220) + - Add new Client.type_factory(namespace) method which returns a factory to + simplify creation of types. + + + +0.21.0 (2016-11-02) +------------------- + - Don't error on empty xml namespaces declarations in inline schema's (#186) + - Wrap importing of sqlite3 in try..except for Google App Engine (#243) + - Don't use pkg_resources to determine the zeep version, use __version__ + instead (#243). + - Fix SOAP arrays by wrapping children in the appropriate element + (joeribekker, #236) + - Add ``operation_timeout`` kwarg to the Transport class to set timeouts for + operations. The default is still no timeout (#140) + - Introduce client.options context manager to temporarily override various + options (only timeout for now) (#140) + - Wrap the parsing of xml values in a try..except block and log an error + instead of throwing an exception (#137) + - Fix xsd:choice xml rendering with nested choice/sequence structure (#221) + - Correctly resolve header elements of which the message part defines the + type instead of element. (#199) + + +0.20.0 (2016-10-24) +------------------- + - Major performance improvements / lower memory usage. Zeep now no longer + copies data and alters it in place but instead uses a set to keep track of + modified data. + - Fix parsing empty soap response (#223) + - Major refactor of the xsd:extension / xsd:restriction implementation. + - Better support for xsd:anyType, by re-using the xsd.AnyObject (#229) + - Deserialize SOAP response without message elements correctly (#237) + + +0.19.0 (2016-10-18) +------------------- + - **backwards-incompatible**: If the WSDL defines that the endpoint returns + soap:header elements and/or multple soap:body messages then the return + signature of the operation is changed. You can now explcitly access the + body and header elements. + - Fix parsing HTTP bindings when there are no message elements (#185) + - Fix deserializing RPC responses (#219 + - Add support for SOAP 1.2 Fault subcodes (#210, vashek) + - Don't alter the _soapheaders elements during rendering, instead create a + deepcopy first. (#188) + - Add the SOAPAction to the Content-Type header in SOAP 1.2 bindings (#211) + - Fix issue when mixing elements and any elements in a choice type (#192) + - Improving parsing of results for union types (#192) + - Make ws-addressing work with lxml < 3.5 (#209) + - Fix recursion error when xsi:type='anyType' is given. (#198) + + +0.18.1 (2016-09-23) +------------------- + - PyPi release error + + +0.18.0 (2016-09-23) +------------------- + - Fix parsing Any elements by using the namespace map of the response node + instead of the namespace map of the wsdl. (#184, #164) + - Improve handling of nested choice elements (choice>sequence>choice) + + +0.17.0 (2016-09-12) +------------------- + - Add support for xsd:notation (#183) + - Add improvements to resolving phase so that all objects are resolved. + - Improve implementation of xsd.attributeGroup and xsd.UniqueType + - Create a deepcopy of the args and kwargs passed to objects so that the + original are unmodified. + - Improve handling of wsdl:arrayType + + +0.16.0 (2016-09-06) +------------------- + - Fix error when rendering choice elements with have sequences as children, + see #150 + - Re-use credentials passed to python -mzeep (#130) + - Workaround invalid usage of qualified vs non-qualified element tags in the + response handling (#176) + - Fix regression when importing xsd:schema's via wsdl:import statements (#179) + + +0.15.0 (2016-09-04) +------------------- + - All wsdl documents and xsd schemas are now globally available for eachother. + While this is not correct according to the (messy) soap specifications, it + does make zeep more compatible with all the invalid wsdl documents out + there. (#159) + - Implement support for attributeGroup (#160) + - Add experimental support for ws-addressing (#92) + - Fix handling of Mime messages with no parts (#168) + - Workaround an issue where soap servers don't qualify references (#170) + - Correctly process attributes which are passed as a dictionary. (#125) + - Add support for plugins, see documentation for examples. + - Fix helpers.serialize_object for lists of objects (#123). + - Add HistoryPlugin which ofers last_sent and last_received properties (#93). + + +0.14.0 (2016-08-03) +------------------- + - Global attributes are now always correctly handled as qualified. (#129) + - Fix parsing xml data containing simpleContent types (#136). + - Set xsi:nil attribute when serializing objects to xml (#141) + - Fix rendering choice elements when the element is mixed with other elements + in a sequence (#150) + - Fix maximum recursion error for recursive xsd:include elements + - Make wsdl:import statements transitive. (#149) + - Merge xsd:schema's which are spread around imported wsdl objects. (#146) + - Don't raise exception when no value is given for AnyAttribute (#152) + + +0.13.0 (2016-07-17) +------------------- + - Use warnings.warn() for duplicate target namespaces instead of raising an + exception. This better matches with what lxml does. + - **backwards-incompatible**: The ``persistent`` kwarg is removed from the + SqliteCache.__init__() call. Use the new InMemoryCache() instead when you + don't want to persist data. This was required to make the SqliteCache + backend thread-safe since we now open/close the db when writing/reading + from it (with an additional lock). + - Fix zeep.helpers.serialize_object() for nested objects (#123) + - Remove fallback between soap 1.1 and soap 1.2 namespaces during the parsing + of the wsdl. This should not be required. + + +0.12.0 (2016-07-09) +------------------- + - **backwards-incompatible**: Choice elements are now unwrapped if + maxOccurs=1. This results in easier operation definitions when choices are + used. + - **backwards-incompatible**: The _soapheader kwarg is renamed to _soapheaders + and now requires a nested dictionary with the header name as key or a list + of values (value object or lxml.etree.Element object). Please see the + call signature of the function using ``python -mzeep ``. + - Support the element ref's to xsd:schema elements. + - Improve the signature() output of element and type definitions + - Accept lxml.etree.Element objects as value for Any elements. + - And various other fixes + + +0.11.0 (2016-07-03) +------------------- + - **backwards-incompatible**: The kwarg name for Any and Choice elements are + renamed to generic ``_value_N`` names. + - **backwards-incompatible**: Client.set_address() is replaced with the + Client.create_service() call + - Auto-load the http://schemas.xmlsoap.org/soap/encoding/ schema if it is + referenced but not imported. Too many XSD's assume that the schema is always + available. + - Major refactoring of the XSD handling to correctly support nested + xsd:sequence elements. + - Add ``logger.debug()`` calls around Transport.post() to allow capturing the + content send/received from the server + - Add proper support for default values on attributes and elements. + + +0.10.0 (2016-06-22) +------------------- + - Make global elements / types truly global by refactoring the Schema + parsing. Previously the lookups where non-transitive, but this should only + be the case during parsing of the xml schema. + - Properly unwrap XML responses in soap.DocumentMessage when a choice is the + root element. (#80) + - Update exceptions structure, all zeep exceptions are now using + zeep.exceptions.Error() as base class. + + +0.9.1 (2016-06-17) +------------------ + - Quote the SOAPAction header value (Derek Harland) + - Undo fallback for SOAPAction if it is empty (#83) + + +0.9.0 (2016-06-14) +------------------ + - Use the appdirs module to retrieve the OS cache path. Note that this results + in an other default cache path then previous releases! See + https://github.com/ActiveState/appdirs for more information. + - Fix regression when initializing soap objects with invalid kwargs. + - Update wsse.UsernameToken to set encoding type on nonce (Antonio Cuni) + - Remove assert statement in soap error handling (Eric Waller) + - Add '--no-verify' to the command line interface. (#63) + - Correctly xsi:type attributes on unbounded elements. (nicholjy) (#68) + - Re-implement xsd:list handling + - Refactor logic to open files from filesystem. + - Refactor the xsd:choice implementation (serializing/deserializing) + - Implement parsing of xsd:any elements. + + +0.8.1 (2016-06-08) +------------------ + - Use the operation name for the xml element which wraps the parameters in + for soap RPC messages (#60) + + +0.8.0 (2016-06-07) +------------------ + - Add ability to override the soap endpoint via `Client.set_address()` + - Fix parsing ComplexTypes which have no child elements (#50) + - Handle xsi:type attributes on anyType's correctly when deserializing + responses (#17) + - Fix xsd:restriction on xsd:simpleType's when the base type wasn't defined + yet. (#59) + - Add xml declaration to the generate xml strings (#60) + - Fix xsd:import statements without schemaLocation (#58) + + +0.7.1 (2016-06-01) +------------------ + - Fix regression with handling wsdl:import statements for messages (#47) + + +0.7.0 (2016-05-31) +------------------ + - Add support HTTP authentication (mcordes). This adds a new attribute to the + Transport client() which passes the http_auth value to requests. (#31) + - Fix issue where setting cache=None to Transport class didn't disable + caching. + - Refactor handling of wsdl:imports, don't merge definitions but instead + lookup values in child definitions. (#40) + - Remove unused namespace declarations from the generated SOAP messages. + - Update requirement of six>=1.0.0 to six>=1.9.0 (#39) + - Fix handling of xsd:choice, xsd:group and xsd:attribute (#30) + - Improve error messages + - Fix generating soap messages when sub types are used via xsd extensions (#36) + - Improve handling of custom soap headers (#33) + + +0.6.0 (2016-05-21) +------------------ + - Add missing `name` attributes to xsd.QName and xsd.NOTATION (#15) + - Various fixes related to the Choice element + - Support xsd:include + - Experimental support for HTTP bindings + - Removed `Client.get_port()`, use `Client.bind()`. + + +0.5.0 (2015-05-08) +------------------ + - Handle attributes during parsing of the response values> + - Don't create empty soap objects when the root element is empty. + - Implement support for WSSE usernameToken profile including + passwordText/passwordDigest. + - Improve XSD date/time related builtins. + - Various minor XSD handling fixes + - Use the correct soap-envelope XML namespace for the Soap 1.2 binding + - Use `application/soap+xml` as content-type in the Soap 1.2 binding + - **backwards incompatible**: Make cache part of the transport object + instead of the client. This changes the call signature of the Client() + class. (Marek Wywiał) + - Add the `verify` kwarg to the Transport object to disable ssl certificate + verification. (Marek Wywiał) + + +0.4.0 (2016-04-17) +------------------ + - Add defusedxml module for XML security issues + - Add support for choice elements + - Fix documentation example for complex types (Falk Schuetzenmeister) + + +0.3.0 (2016-04-10) +------------------ + - Correctly handle recursion in WSDL and XSD files + - Add support for the XSD Any element + - Allow usage of shorthand prefixes when creating elements and types + - And more various improvements + + +0.2.5 (2016-04-05) +------------------ + - Temporarily disable the HTTP binding support until it works properly + - Fix an issue with parsing SOAP responses with optional elements + + +0.2.4 (2016-04-03) +------------------ + - Improve xsd.DateTime, xsd.Date and xsd.Time implementations by using the + isodate module. + - Implement xsd.Duration + + +0.2.3 (2016-04-03) +------------------ + - Fix xsd.DateTime, xsd.Date and xsd.Time implementations + - Handle NIL values correctly for simpletypes + + +0.2.2 (2016-04-03) +------------------ + - Fix issue with initializing value objects (ListElements) + - Add new `zeep.helpers.serialize_object()` method + - Rename type attribute on value objects to `_xsd_type` to remove potential + attribute conflicts + + +0.2.1 (2016-04-03) +------------------ + - Support minOccurs 0 (optional elements) + - Automatically convert python datastructures to zeep objects for requests. + - Set default values for new zeep objects to None / [] (Element, ListElement) + - Add `Client.get_element()` to create custom objects + + +0.2.0 (2016-04-03) +------------------ + - Proper support for XSD element and attribute forms (qualified/unqualified) + - Improved XSD handling + - Separate bindings for Soap 1.1 and Soap 1.2 + - And again various other fixes + + +0.1.1 (2016-03-20) +------------------ + - Various fixes to make the HttpBinding not throw errors during parsing + - More built-in xsd types + - Add support for `python -mzeep ` + - Various other fixes + + +0.1.0 (2016-03-20) +------------------ + +Preview / Proof-of-concept release. Probably not suitable for production use :) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9310f1f --- /dev/null +++ b/LICENSE @@ -0,0 +1,53 @@ +The MIT License (MIT) + +Copyright (c) 2016 Michael van Tellingen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Parts of the XSD handling are heavily inspired by soapfish, see: +https://github.com/FlightDataServices/soapfish + +Copyright (c) 2011-2014, soapfish contributors +All rights reserved. +For the exact contribution history, see the git revision log. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3244331 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +.PHONY: install clean test retest coverage docs + +install: + pip install -e .[docs,test] + pip install bumpversion twine wheel + +lint: + flake8 src/ tests/ + isort --recursive --check-only --diff src tests + +clean: + find . -name '*.pyc' -delete + +test: + py.test -vvv + +retest: + py.test -vvv --lf + +coverage: + py.test --cov=zeep --cov-report=term-missing --cov-report=html + +docs: + $(MAKE) -C docs html + +release: + pip install twine wheel + rm -rf dist/* + python setup.py sdist bdist_wheel + twine upload -s dist/* diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..0810ebd --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,87 @@ +Metadata-Version: 1.1 +Name: zeep +Version: 0.23.0 +Summary: A modern/fast Python SOAP client based on lxml / requests +Home-page: http://docs.python-zeep.org +Author: Michael van Tellingen +Author-email: michaelvantellingen@gmail.com +License: MIT +Description: ======================== + Zeep: Python SOAP client + ======================== + + A fast and modern Python SOAP client + + | Website: http://docs.python-zeep.org/ + | IRC: #python-zeep on Freenode + + Highlights: + * Modern codebase compatible with Python 2.7, 3.3, 3.4, 3.5 and PyPy + * Build on top of lxml and requests + * Supports recursive WSDL and XSD documents. + * Supports the xsd:choice and xsd:any elements. + * Uses the defusedxml module for handling potential XML security issues + * Support for WSSE (UsernameToken only for now) + * Experimental support for HTTP bindings + * Experimental support for WS-Addressing headers + * Experimental support for asyncio via aiohttp (Python 3.5+) + + Features still in development include: + * WSSE x.509 support (BinarySecurityToken) + * WS Policy support + + Please see for more information the documentation at + http://docs.python-zeep.org/ + + + + + Installation + ------------ + + .. code-block:: bash + + pip install zeep + + + Usage + ----- + .. code-block:: python + + from zeep import Client + + client = Client('tests/wsdl_files/example.rst') + client.service.ping() + + + To quickly inspect a WSDL file use:: + + python -mzeep + + + Please see the documentation at http://docs.python-zeep.org for more + information. + + + Support + ======= + + If you encounter bugs then please `let me know`_ . A copy of the WSDL file if + possible would be most helpful. + + I'm also able to offer commercial support. Please contact me at + info@mvantellingen.nl for more information. + + .. _let me know: https://github.com/mvantellingen/python-zeep/issues + +Platform: UNKNOWN +Classifier: Development Status :: 4 - Beta +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..81d782f --- /dev/null +++ b/README.rst @@ -0,0 +1,90 @@ +======================== +Zeep: Python SOAP client +======================== + +A fast and modern Python SOAP client + +| Website: http://docs.python-zeep.org/ +| IRC: #python-zeep on Freenode + +Highlights: + * Modern codebase compatible with Python 2.7, 3.3, 3.4, 3.5 and PyPy + * Build on top of lxml and requests + * Supports recursive WSDL and XSD documents. + * Supports the xsd:choice and xsd:any elements. + * Uses the defusedxml module for handling potential XML security issues + * Support for WSSE (UsernameToken only for now) + * Experimental support for HTTP bindings + * Experimental support for WS-Addressing headers + * Experimental support for asyncio via aiohttp (Python 3.5+) + +Features still in development include: + * WSSE x.509 support (BinarySecurityToken) + * WS Policy support + +Please see for more information the documentation at +http://docs.python-zeep.org/ + + +.. start-no-pypi + +Status +------ + +.. image:: https://readthedocs.org/projects/python-zeep/badge/?version=latest + :target: https://readthedocs.org/projects/python-zeep/ + +.. image:: https://travis-ci.org/mvantellingen/python-zeep.svg?branch=master + :target: https://travis-ci.org/mvantellingen/python-zeep + +.. image:: https://ci.appveyor.com/api/projects/status/im609ng9h29vt89r?svg=true + :target: https://ci.appveyor.com/project/mvantellingen/python-zeep + +.. image:: http://codecov.io/github/mvantellingen/python-zeep/coverage.svg?branch=master + :target: http://codecov.io/github/mvantellingen/python-zeep?branch=master + +.. image:: https://img.shields.io/pypi/v/zeep.svg + :target: https://pypi.python.org/pypi/zeep/ + +.. image:: https://requires.io/github/mvantellingen/python-zeep/requirements.svg?branch=master + :target: https://requires.io/github/mvantellingen/python-zeep/requirements/?branch=master + +.. end-no-pypi + +Installation +------------ + +.. code-block:: bash + + pip install zeep + + +Usage +----- +.. code-block:: python + + from zeep import Client + + client = Client('tests/wsdl_files/example.rst') + client.service.ping() + + +To quickly inspect a WSDL file use:: + + python -mzeep + + +Please see the documentation at http://docs.python-zeep.org for more +information. + + +Support +======= + +If you encounter bugs then please `let me know`_ . A copy of the WSDL file if +possible would be most helpful. + +I'm also able to offer commercial support. Please contact me at +info@mvantellingen.nl for more information. + +.. _let me know: https://github.com/mvantellingen/python-zeep/issues diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..64d8067 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +python-zeep (0.23.0-1) unstable; urgency=medium + + * Initial commit (Closes: #834485). + + -- Mathias Behrle Tue, 06 Dec 2016 19:07:51 +0100 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..5163435 --- /dev/null +++ b/debian/control @@ -0,0 +1,126 @@ +Source: python-zeep +Section: python +Priority: optional +Maintainer: Debian Tryton Maintainers +Uploaders: + Mathias Behrle , +Build-Depends: + debhelper (>= 9), + dh-python (>= 1.20130901-1~), + python, + python-appdirs, + python-cached-property, + python-defusedxml, + python-flake8, + python-freezegun, + python-isodate, + python-isort, + python-lxml, + python-mock, + python-pretend, + python-pytest, + python-pytest-cov, + python-requests, + python-requests-mock, + python-setuptools, + python-six, + python-tz, + python3, + python3-appdirs, + python3-cached-property, + python3-defusedxml, + python3-flake8, + python3-freezegun, + python3-isodate, + python3-isort, + python3-lxml, + python3-mock, + python3-pretend, + python3-pytest, + python3-pytest-cov, + python3-requests, + python3-requests-mock, + python3-setuptools, + python3-six, + python3-tz, +Standards-Version: 3.9.8 +Homepage: https://github.com/mvantellingen/python-zeep +Vcs-Browser: https://anonscm.debian.org/cgit/tryton/python-zeep.git +Vcs-Git: https://anonscm.debian.org/cgit/tryton/python-zeep.git + +Package: python-zeep +Architecture: all +Depends: + python-appdirs, + python-cached-property, + python-defusedxml, + python-freezegun, + python-isodate, + python-lxml, + python-pkg-resources, + python-requests, + python-setuptools, + python-six, + python-tz, + ${misc:Depends}, + ${python:Depends}, +Description: Modern SOAP client library (Python 2) + A fast and modern Python SOAP client + . + Highlights: + * Modern codebase compatible with Python 2.7, 3.3, 3.4, 3.5 and PyPy + * Build on top of lxml and requests + * Supports recursive WSDL and XSD documents. + * Supports the xsd:choice and xsd:any elements. + * Uses the defusedxml module for handling potential XML security issues + * Support for WSSE (UsernameToken only for now) + * Experimental support for HTTP bindings + * Experimental support for WS-Addressing headers + * Experimental support for asyncio via aiohttp (Python 3.5+) + . + Features still in development include: + * WSSE x.509 support (BinarySecurityToken) + * WS Policy support + . + Please see for more information the documentation at + http://docs.python-zeep.org/ + . + This package is targeting Python version 2. + +Package: python3-zeep +Architecture: all +Depends: + python3-appdirs, + python3-cached-property, + python3-defusedxml, + python3-freezegun, + python3-isodate, + python3-lxml, + python3-pkg-resources, + python3-requests, + python3-six, + python3-tz, + ${misc:Depends}, + ${python3:Depends}, +Description: Modern SOAP client library (Python 3) + A fast and modern Python SOAP client + . + Highlights: + * Modern codebase compatible with Python 2.7, 3.3, 3.4, 3.5 and PyPy + * Build on top of lxml and requests + * Supports recursive WSDL and XSD documents. + * Supports the xsd:choice and xsd:any elements. + * Uses the defusedxml module for handling potential XML security issues + * Support for WSSE (UsernameToken only for now) + * Experimental support for HTTP bindings + * Experimental support for WS-Addressing headers + * Experimental support for asyncio via aiohttp (Python 3.5+) + . + Features still in development include: + * WSSE x.509 support (BinarySecurityToken) + * WS Policy support + . + Please see for more information the documentation at + http://docs.python-zeep.org/ + . + This package is targeting Python version 3. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..e60eafc --- /dev/null +++ b/debian/copyright @@ -0,0 +1,125 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: * +Copyright: 2016 Michael van Tellingen +License: Expat + +Files: src/zeep/xsd/* +Copyright: 2011-2014 soapfish contributors + 2016 Michael van Tellingen +License: BSD-3-clause or Expat +Comments: + Parts of the XSD handling are heavily inspired by soapfish, see: + https://github.com/FlightDataServices/soapfish + +Files: tests/integration/hello_world_recursive.wdsl + tests/integration/hello_world_recursive_import.wdsl +Copyright: not applicable +License: Apache + +Files: tests/wsdl_files/soap-enc.xsd + tests/wsdl_files/xmldsig-core-schema.xsd +Copyright: 2001 DevelopMentor + 2001 W3C (Massachusetts Institute of Technology, Institut National de Recherche en Informatique et en Automatique, Keio University) +License: W3C +Comments: + Original W3C files; http://www.w3.org/2001/06/soap-encoding + +Files: debian/* +Copyright: 2016 Mathias Behrle +License: Expat + +License: Expat + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +License: BSD-3-clause + Copyright (c) 2011-2014, soapfish contributors + All rights reserved. + For the exact contribution history, see the git revision log. + . + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + . + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 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. + +License: Apache + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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 + . + http://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. + +License: W3C + Portions © 2001 DevelopMentor. + © 2001 W3C (Massachusetts Institute of Technology, Institut National de Recherche en Informatique et en Automatique, Keio University). All Rights Reserved. + . + This document is governed by the W3C Software License [1] as described in the FAQ [2]. + [1] http://www.w3.org/Consortium/Legal/copyright-software-19980720 + [2] http://www.w3.org/Consortium/Legal/IPR-FAQ-20000620.html#DTD + By obtaining, using and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions: + . + Permission to use, copy, modify, and distribute this software and its documentation, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the software and documentation or portions thereof, including modifications, that you make: + . + 1. The full text of this NOTICE in a location viewable to users of the redistributed or derivative work. + . + 2. Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, a short notice of the following form (hypertext is preferred, text is permitted) should be used within the body of any redistributed or derivative code: "Copyright © 2001 World Wide Web Consortium, (Massachusetts Institute of Technology, Institut National de Recherche en Informatique et en Automatique, Keio University). All Rights Reserved. http://www.w3.org/Consortium/Legal/" + . + 3. Notice of any changes or modifications to the W3C files, including the date changes were made. (We recommend you provide URIs to the location from which the code is derived.) + . + Original W3C files; http://www.w3.org/2001/06/soap-encoding + Changes made: + - reverted namespace to http://schemas.xmlsoap.org/soap/encoding/ + - reverted root to only allow 0 and 1 as lexical values + - removed default value from root attribute declaration + . + THIS SOFTWARE AND DOCUMENTATION IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. + . + COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENTATION. + . + The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the software without specific, written prior permission. Title to copyright in this software and any associated documentation will at all times remain with copyright holders. diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 0000000..477d35e --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,12 @@ +# Settings for Debian Tryton Maintainer repositories +# for usage with git-buildpackage + +[DEFAULT] +debian-branch = debian +pristine-tar = True + +[buildpackage] +ignore-new = True +# Use export-dir at your discretion to avoid git-buildpackage messing +# your git repeository +#export-dir = ../build-area/ diff --git a/debian/patches/01-add-missing-xsd-files.patch b/debian/patches/01-add-missing-xsd-files.patch new file mode 100644 index 0000000..c615cae --- /dev/null +++ b/debian/patches/01-add-missing-xsd-files.patch @@ -0,0 +1,1007 @@ +Description: Add missing test files + Those files needed to run the tests are in the git repository, + but not in the tarball on pypi. +Author: Mathias Behrle +Bug-Debian: https://bugs.debian.org/834485 +Bug: https://github.com/mvantellingen/python-zeep/issues/27 +Last-Update: 2016-12-06 + +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ python-zeep/tests/wsdl_files/soap-enc.xsd 2016-12-06 18:34:15.926748461 +0100 +@@ -0,0 +1,535 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ 'root' can be used to distinguish serialization roots from other ++ elements that are present in a serialization but are not roots of ++ a serialized value graph ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ Attributes common to all elements that function as accessors or ++ represent independent (multi-ref) values. The href attribute is ++ intended to be used in a manner like CONREF. That is, the element ++ content should be empty iff the href attribute appears ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ 'Array' is a complex type for accessors identified by position ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ python-zeep/tests/wsdl_files/test_import_2.xsd 2016-12-06 18:34:15.926748461 +0100 +@@ -0,0 +1,60 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ python-zeep/tests/wsdl_files/xmldsig-core-schema.xsd 2016-12-06 18:34:15.926748461 +0100 +@@ -0,0 +1,309 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ python-zeep/tests/wsdl_files/soap_import_2.wsdl 2016-12-06 18:24:05.000000000 +0100 +@@ -0,0 +1,42 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ python-zeep/tests/wsdl_files/soap_import_main.wsdl 2016-12-06 18:24:05.000000000 +0100 +@@ -0,0 +1,38 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ My first service ++ ++ ++ ++ ++ ++ diff --git a/debian/patches/series b/debian/patches/series new file mode 100644 index 0000000..3b984e6 --- /dev/null +++ b/debian/patches/series @@ -0,0 +1 @@ +01-add-missing-xsd-files.patch diff --git a/debian/python-zeep.docs b/debian/python-zeep.docs new file mode 100644 index 0000000..d838da9 --- /dev/null +++ b/debian/python-zeep.docs @@ -0,0 +1 @@ +examples/ diff --git a/debian/python3-zeep.docs b/debian/python3-zeep.docs new file mode 100644 index 0000000..d838da9 --- /dev/null +++ b/debian/python3-zeep.docs @@ -0,0 +1 @@ +examples/ diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..b736038 --- /dev/null +++ b/debian/rules @@ -0,0 +1,16 @@ +#!/usr/bin/make -f + +export PYBUILD_NAME := zeep + +%: + dh $@ --with python2,python3 --buildsystem=pybuild + +override_dh_auto_clean: + rm -rf $(PACKAGE_NAME).egg-info + rm -rf PKG-INFO + dh_auto_clean + +override_dh_install: + dh_install + # asyncio is Python3 only + rm -rf debian/python-zeep/usr/lib/python2.7/dist-packages/zeep/asyncio diff --git a/debian/source.lintian-overrides b/debian/source.lintian-overrides new file mode 100644 index 0000000..18be748 --- /dev/null +++ b/debian/source.lintian-overrides @@ -0,0 +1,4 @@ +# Missing test files in the tarball on pypi, but present in the +# github repository need to be mentioned in copyright. +python-zeep source: wildcard-matches-nothing-in-dep5-copyright +python-zeep source: unused-file-paragraph-in-dep5-copyright diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/upstream/signing-key.asc b/debian/upstream/signing-key.asc new file mode 100644 index 0000000..d00a350 --- /dev/null +++ b/debian/upstream/signing-key.asc @@ -0,0 +1,48 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: SKS 1.1.5 +Comment: Hostname: pgp.mit.edu + +mQINBFcEG3kBEADGwYCsUz9ZtnelD/rVIN/cH+sLWydYCNRIFjO2Bw/7Bno2lzNyyzW3J+rG +dNuGnyLAts1S6Ofey13j4L7iWD6InKGOr6chOtKOBGHhDnbTaZNPR8AZOIr0wY/C1c+mIM/X +trPYuKYcaaTlFpVTGXCvXLnzJmrzsli1zLTEVgMTWhlbyIno3yZ+GOv7GLTAkEfUwl+9x2Gr +3sN87VgCcz0xrebNbY7OJSnR2BteljMQsot1oP/lKiRZgz51i0Bi37uHsybgHalbUljfZqYT +EEZ/C5t7NNh6H4qP/Zbt7vckXlHOLmYEW5nO5pL+T3dd3spZW04dwem/JMyOl+4aUNvAJQD0 +l0i+Fk2CGGHyYpfNgq9zyEkEVASihBnXoUHwe9XbKtDhRQQDg56N7xJ2yvmKrK3B1aFhdpUb +xuSKWHeR3Fd6gE8UqEOpS/ZmhijYUuXG1NK1q4YbIv6I7VowxSx18KTetZgRc0dzUBU4Zh9R ++x6SmssPe7qAYUYWwv89hLTiTMScb8ov6yOHEangXneg81v+8IuKi258qALLr+nPVaFOpdIL +AekNvyWa1TMDhy5utpFcXDzTp8LFmX4ZWgJ7/ZLISPA7bBrXjf8cj76lx2YxpxJw4jBELfj6 +lJVg1KVKJOAGRhEtX2Ref7vMtzexye38rABQH+YWM+U5GDKM+wARAQABtDBNaWNoYWVsIHZh +biBUZWxsaW5nZW4gPG1pY2hhZWxAbXZhbnRlbGxpbmdlbi5ubD6JAjgEEwECACIFAlcEG3kC +GwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEJa2cEKrxE93wZ8QALPQjj3FjTQvw35B +0Sjuw08OQBPchh5q1SnlOA09Y0mxrRcC/UfXJsJ63OE0G8ITzipMj8GQskIARAAqIQSTiOLI +csCXDVpPesVEYgKqKAck8ic2vWqQdyE6jUaNGDAzvFzvtV5O/1tibm49s+G6r2rpSNvl8AYI +Oq6zR/sa8T4JlyetICF+5iC5eyza5DyGzBvIt0Ji0JasiWAV9GHNe3Z+P3lfglzQzQD4mz8Z +CeSEokoxKGZSp4b9cbs80Cj9Kc4ASaUNqC7yYo3vpGrlEF4MzeGGuauwhOzKo7A4SHtutAJb +98aGNSrb2wI7ReykaXbMIqKgB8i3UkwL8piMUhUHSKjdRJY7+suS01+cbntotGHhbp7vfp/a +FcKM9eo8kGdblvpdDd0MQDBPREuU3wV3Ldnuzl33/AYGHR811DkqGjsfSuiASy7IkzGirLxU +igflORRxdnp0K9h9zrStgF0LpnQFmfQRALfSHj0HGpxcHNKbg1aHkZGSr4+SSO00TJO5t7Gi +ioCYLooShB906PJd1W8ARbieH+tesY2ZZkL+WdsxWdBsvzMcHJcbq7zynn4P3KxNt4HouzEw +q0j/0UyoHNMeqyMvC6wTQ09Zih8s3bRfcBVVujv/RqSpHGN4F6dCtG/yibpaV72wXSgfCJag ++PfYWLqOJ9bsXCzkIAAiuQINBFcEG3kBEAC9BTDICB0cS9HOOzYsOAigV9i9Yw6Jc9KvcurI +U39cs/mbs5Fx+kC/q00+uitKMFgN9MFhdXxbHHZCDwEXv3N/+KBHiU1M15/ieuBdmm8KRvkj +6ztJQlZBVX3LXUxTAr5wJZQWXdnP6xNa74aVGCgPQemPfYpW6kdOv+sXxoKTEjJ5ME7AWQe2 +K5sg6c4NmybECdnAlxdb0KGIpWQC9BeSaf5zNGOROpuQdh6TqHvG511KASHEWWzqXSxs6wXI +bbOCkqWvx7R5Pd3yoAhA8WZ2+YH4JTCv4KTiP8yWsuvaESxt3vPspG/q6EhyXU1t/hXeU0st +/lB0tcf/tYnltoeJvyF4dlbtqZm57F2c+FGmZnuWKpHQLRGfG4DedmhnUnLJRYLIGrCUuhG6 +ExpVqPvZ1j2914TpkU+BELb4vRpIgFawjqcm+7dkEXynnYZrcXE1+T7+vnrV3cSFIEwGhv6N +hZ0X5j8b/iacGEhA5YqrpCiH+0Kj4h4MQmpKLPkFhB2fpMWij6bby0UaNOLY3q/li3zhonOh +m+WM8MkFPYMCg3p4fNlzsWCUjZNOqXVl5+8WxcBP3CT55cYN19BjmqbMW0z2QE3b9VNfzFMA +c548Lqmjqo3JgJkdZv81ZScn1XuKX56Lckz0iEcMfHSQWr0pDiN43SBn3iRgAvN0jWu2awAR +AQABiQIfBBgBAgAJBQJXBBt5AhsMAAoJEJa2cEKrxE93WVgQALtPxFRXhgdidE0apFT3AIQG +El45GBnItmDXVGcnx4X1mImWa01af0/Y3Znsg8NfrV+ud47adKysYDhuPeN5f7jaWa6RLdfL +KeKlD6j+8mnAvrXg+WV+/9nvuCrlfX7KIHlIGlWINDNjfvzBPZf15YVBLMO41s1L5bZMs/8A +Q5O8Ij3ct4SYSW6/2ZfP9oVhHbVeapkZtJAG2tnc9vwq2BHoOz7p8pj2T9qLxbYWiFVKI16g +GhknUydyqNk3FJz4T1GlAMKujGypfTCh/+3Kw6jDST9vGs6hybb4UA1u9Lvh4LgsNSjiBzI2 ++wR3dsqkgUCh3ONHGdwNFdCz9SFP6cFpm39x6gucDkJQfTm/mDkDTLr/c8w3QMg3QGokIgYR +2Csli7DASaOSuOtW+fHudVUnN4wsSjzT0P4xGxj3IM0fSbyrSq5VlQxLHb97Qhh8WOvgAzFQ +NRZzkNVWG8tUhj66Dpoh+c0m04JhN/CMbLRZ7tZFYqH+GzFcNS75TKmQpfVSyNMAmV+4rP1a +odNHJpe67VglkA6ix4Bpv+TN+E9hHpvcKYEFxV2uOVfFpEltb4dmerDo/iCA6Wy2qyrLrqgu +0OwiZtYDJ92VjHFX8h0w4q8cV2QSnI0miGrTxovzubdVTn7Um/UZ4PoF1jIahXiwlRpOdn7n +mbtC2StQre7+ +=gI+u +-----END PGP PUBLIC KEY BLOCK----- diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..751e001 --- /dev/null +++ b/debian/watch @@ -0,0 +1,3 @@ +version=3 +opts=uversionmangle=s/(rc|a|b|c)/~$1/,pgpsigurlmangle=s/$/.asc/ \ +https://pypi.debian.net/zeep/zeep-(.+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz))) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..49c4227 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,216 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Zeep.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Zeep.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Zeep" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Zeep" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/_templates/sidebar-intro.html b/docs/_templates/sidebar-intro.html new file mode 100644 index 0000000..af3fb9e --- /dev/null +++ b/docs/_templates/sidebar-intro.html @@ -0,0 +1,18 @@ + + + + + +

Zeep is a modern SOAP client for Python

+

+ +

+ +

Links

+ + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..b233225 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +# +# Zeep documentation build configuration file, created by +# sphinx-quickstart on Fri Mar 4 16:51:06 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import pkg_resources + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Zeep' +copyright = u'2016, Michael van Tellingen' +author = u'Michael van Tellingen' + + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.23.0' +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +autodoc_default_flags = [':members:'] + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = { + 'github_user': 'mvantellingen', + 'github_banner': True, + 'github_repo': 'python-zeep', + 'travis_button': True, + 'codecov_button': True, + 'analytics_id': 'UA-75907833-1', +} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +html_sidebars = { + '*': [ + 'sidebar-intro.html', 'globaltoc.html', 'sourcelink.html', + 'searchbox.html' + ] +} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Zeepdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Zeep.tex', u'Zeep Documentation', + u'Michael van Tellingen', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'zeep', u'Zeep Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Zeep', u'Zeep Documentation', + author, 'Zeep', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..2aeb016 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,131 @@ +======================== +Zeep: Python SOAP client +======================== + +A fast and modern Python SOAP client + +Highlights: + * Modern codebase compatible with Python 2.7, 3.3, 3.4, 3.5 and PyPy + * Build on top of lxml and requests + * Supports recursive WSDL and XSD documents. + * Supports the xsd:choice and xsd:any elements. + * Uses the defusedxml module for handling potential XML security issues + * Support for WSSE (UsernameToken only for now) + * Experimental support for HTTP bindings + * Experimental support for WS-Addressing headers + * Experimental support for asyncio via aiohttp (Python 3.5+) + +Features still in development include: + * WSSE x.509 support (BinarySecurityToken) + * WS Policy support + + +A simple example: + +.. code-block:: python + + from zeep import Client + + client = Client('http://www.webservicex.net/ConvertSpeed.asmx?WSDL') + result = client.service.ConvertSpeed( + 100, 'kilometersPerhour', 'milesPerhour') + + assert result == 62.137 + + +Quick Introduction +================== + +Zeep inspects the wsdl document and generates the corresponding bindings. This +provides an easy to use programmatic interface to a soap server. + +The emphasis is on Soap 1.1 and Soap 1.2, however Zeep also offers experimental +support for HTTP Get and Post bindings. + +Parsing the XML documents is done by using the lxml library. This is the most +performant and compliant Python XML library currently available. This results +in major speed benefits when retrieving large soap responses. + +The SOAP specifications are unfortunately really vague and leave a lot of +things open for interpretation. Due to this there are a lot of WSDL documents +available which are invalid or SOAP servers which contain bugs. Zeep tries to +be as compatible as possible but there might be cases where you run into +problems. Don't hesitate to submit an issue in this case (please see +:ref:`reporting_bugs`). + + +Getting started +=============== + +You can install the latest version of zeep using pip:: + + pip install zeep + +The first thing you generally want to do is inspect the wsdl file you need to +implement. This can be done with:: + + python -mzeep + + +See ``python -mzeep --help`` for more information about this command. + + +.. note:: Since this module hasn't reached 1.0.0 yet their might be minor + releases which introduce backwards compatible changes. While I try + to keep this to a minimum it can still happen. So as always pin the + version of zeep you used (e.g. ``zeep==0.14.0``'). + + + +A simple use-case +----------------- + +To give you an idea how zeep works a basic example. + +.. code-block:: python + + import zeep + + wsdl = 'http://www.soapclient.com/xml/soapresponder.wsdl' + client = zeep.Client(wsdl=wsdl) + print(client.service.Method1('Zeep', 'is cool')) + +The WSDL used above only defines one simple function (``Method1``) which is +made available by zeep via ``client.service.Method1``. It takes two arguments +and returns a string. To get an overview of the services available on the +endpoint you can run the following command in your terminal. + +.. code-block:: bash + + python -mzeep http://www.soapclient.com/xml/soapresponder.wsdl + + +More information +================ + +.. toctree:: + :maxdepth: 2 + :name: mastertoc + + in_depth + datastructures + transport + wsa + wsse + plugins + helpers + reporting_bugs + changes + + +Support +======= + +If you encounter bugs then please `let me know`_ . Please see :doc:`reporting_bugs` +for information how to best report them. + +I'm also able to offer commercial support. Please contact me at +info@mvantellingen.nl for more information. + + +.. _let me know: https://github.com/mvantellingen/python-zeep/issues diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..bb121e7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,263 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Zeep.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Zeep.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/examples/code39.py b/examples/code39.py new file mode 100644 index 0000000..eda664f --- /dev/null +++ b/examples/code39.py @@ -0,0 +1,8 @@ +from __future__ import print_function +import zeep + + +client = zeep.Client( + wsdl='http://www.webservicex.net/barcode.asmx?WSDL') +response = client.service.Code39('1234', 20, ShowCodeString=True, Title='ZEEP') +print(repr(response)) diff --git a/examples/echo_services.py b/examples/echo_services.py new file mode 100644 index 0000000..5270f95 --- /dev/null +++ b/examples/echo_services.py @@ -0,0 +1,5 @@ +from zeep.client import Client + +# RPC style soap service +client = Client('http://www.soapclient.com/xml/soapresponder.wsdl') +print(client.service.Method1('zeep', 'soap')) diff --git a/examples/eu_vat_service.py b/examples/eu_vat_service.py new file mode 100644 index 0000000..e5ba15e --- /dev/null +++ b/examples/eu_vat_service.py @@ -0,0 +1,7 @@ +from __future__ import print_function +import zeep + + +client = zeep.Client( + wsdl='http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl') +print(client.service.checkVat('NL', '170944128B01')) diff --git a/examples/km_to_miles.py b/examples/km_to_miles.py new file mode 100644 index 0000000..d8821df --- /dev/null +++ b/examples/km_to_miles.py @@ -0,0 +1,16 @@ +from __future__ import print_function +import zeep + + +client = zeep.Client( + wsdl='http://www.webservicex.net/ConvertSpeed.asmx?WSDL') + +client.wsdl.dump() + +print (client.service.ConvertSpeed(100, 'kilometersPerhour', 'milesPerhour')) + +http_get = client.bind('ConvertSpeeds', 'ConvertSpeedsHttpGet') +http_post = client.bind('ConvertSpeeds', 'ConvertSpeedsHttpPost') + +print(http_get.ConvertSpeed(100, 'kilometersPerhour', 'milesPerhour')) +print(http_post.ConvertSpeed(100, 'kilometersPerhour', 'milesPerhour')) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3cbf7f8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +[wheel] +universal = 1 + +[flake8] +max-line-length = 99 + +[egg_info] +tag_build = +tag_date = 0 +tag_svn_revision = 0 + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..8ba07bf --- /dev/null +++ b/setup.py @@ -0,0 +1,72 @@ +import re +from setuptools import find_packages, setup + +install_requires = [ + 'appdirs>=1.4.0', + 'cached-property>=1.0.0', + 'defusedxml>=0.4.1', + 'isodate>=0.5.4', + 'lxml>=3.0.0', + 'requests>=2.7.0', + 'six>=1.9.0', + 'pytz', +] + +docs_require = [ + 'sphinx>=1.4.0', +] + +tests_require = [ + 'freezegun==0.3.7', + 'mock==2.0.0', + 'pretend==1.0.8', + 'pytest-cov==2.3.1', + 'pytest==3.0.2', + 'requests_mock>=0.7.0', + + # Linting + 'isort==4.2.5', + 'flake8==3.0.3', + 'flake8-blind-except==0.1.1', + 'flake8-debugger==1.4.0', +] + +with open('README.rst') as fh: + long_description = re.sub( + '^.. start-no-pypi.*^.. end-no-pypi', '', fh.read(), flags=re.M | re.S) + +setup( + name='zeep', + version='0.23.0', + description='A modern/fast Python SOAP client based on lxml / requests', + long_description=long_description, + author="Michael van Tellingen", + author_email="michaelvantellingen@gmail.com", + url='http://docs.python-zeep.org', + + install_requires=install_requires, + tests_require=tests_require, + extras_require={ + 'docs': docs_require, + 'test': tests_require, + }, + entry_points={}, + package_dir={'': 'src'}, + packages=find_packages('src'), + include_package_data=True, + + license='MIT', + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + ], + zip_safe=False, +) diff --git a/src/zeep/__init__.py b/src/zeep/__init__.py new file mode 100644 index 0000000..606d054 --- /dev/null +++ b/src/zeep/__init__.py @@ -0,0 +1,5 @@ +from zeep.client import Client # noqa +from zeep.transports import Transport # noqa +from zeep.plugins import Plugin # noqa + +__version__ = '0.23.0' diff --git a/src/zeep/__main__.py b/src/zeep/__main__.py new file mode 100644 index 0000000..ae2c3af --- /dev/null +++ b/src/zeep/__main__.py @@ -0,0 +1,85 @@ +from __future__ import absolute_import, print_function + +import argparse +import logging +import logging.config +import time + +from six.moves.urllib.parse import urlparse +from zeep.cache import InMemoryCache, SqliteCache +from zeep.client import Client +from zeep.transports import Transport + +logger = logging.getLogger('zeep') + + +def parse_arguments(args=None): + parser = argparse.ArgumentParser(description='Zeep: The SOAP client') + parser.add_argument( + 'wsdl_file', type=str, help='Path or URL to the WSDL file', + default=None) + parser.add_argument( + '--cache', action='store_true', help='Enable cache') + parser.add_argument( + '--no-verify', action='store_true', help='Disable SSL verification') + parser.add_argument( + '--verbose', action='store_true', help='Enable verbose output') + parser.add_argument( + '--profile', help="Enable profiling and save output to given file") + return parser.parse_args(args) + + +def main(args): + if args.verbose: + logging.config.dictConfig({ + 'version': 1, + 'formatters': { + 'verbose': { + 'format': '%(name)20s: %(message)s' + } + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'zeep': { + 'level': 'DEBUG', + 'propagate': True, + 'handlers': ['console'], + }, + } + }) + + if args.profile: + import cProfile + profile = cProfile.Profile() + profile.enable() + + cache = SqliteCache() if args.cache else InMemoryCache() + transport_kwargs = {'cache': cache} + + if args.no_verify: + transport_kwargs['verify'] = False + + result = urlparse(args.wsdl_file) + if result.username or result.password: + transport_kwargs['http_auth'] = (result.username, result.password) + + transport = Transport(**transport_kwargs) + st = time.time() + client = Client(args.wsdl_file, transport=transport) + logger.debug("Loading WSDL took %sms", (time.time() - st) * 1000) + + if args.profile: + profile.disable() + profile.dump_stats(args.profile) + client.wsdl.dump() + + +if __name__ == '__main__': + args = parse_arguments() + main(args) diff --git a/src/zeep/asyncio/__init__.py b/src/zeep/asyncio/__init__.py new file mode 100644 index 0000000..3011239 --- /dev/null +++ b/src/zeep/asyncio/__init__.py @@ -0,0 +1,2 @@ +from .transport import * # noqa +from .bindings import * # noqa diff --git a/src/zeep/asyncio/bindings.py b/src/zeep/asyncio/bindings.py new file mode 100644 index 0000000..63c8f21 --- /dev/null +++ b/src/zeep/asyncio/bindings.py @@ -0,0 +1,26 @@ +from zeep.wsdl import bindings + +__all__ = ['AsyncSoap11Binding', 'AsyncSoap12Binding'] + + +class AsyncSoapBinding(object): + + async def send(self, client, options, operation, args, kwargs): + envelope, http_headers = self._create( + operation, args, kwargs, + client=client, + options=options) + + response = await client.transport.post_xml( + options['address'], envelope, http_headers) + + operation_obj = self.get(operation) + return self.process_reply(client, operation_obj, response) + + +class AsyncSoap11Binding(AsyncSoapBinding, bindings.Soap11Binding): + pass + + +class AsyncSoap12Binding(AsyncSoapBinding, bindings.Soap12Binding): + pass diff --git a/src/zeep/asyncio/transport.py b/src/zeep/asyncio/transport.py new file mode 100644 index 0000000..c487811 --- /dev/null +++ b/src/zeep/asyncio/transport.py @@ -0,0 +1,73 @@ +""" +Adds asyncio support to Zeep. Contains Python 3.5+ only syntax! + +""" +import asyncio + +import aiohttp + +from zeep.transports import Transport +from zeep.wsdl.utils import etree_to_string + +__all__ = ['AsyncTransport'] + + +class AsyncTransport(Transport): + """Asynchronous Transport class using aiohttp.""" + supports_async = True + + def __init__(self, loop, *args, **kwargs): + self.loop = loop if loop else asyncio.get_event_loop() + super().__init__(*args, **kwargs) + + def create_session(self): + connector = aiohttp.TCPConnector(verify_ssl=self.http_verify) + + return aiohttp.ClientSession( + connector=connector, + loop=self.loop, + headers=self.http_headers, + auth=self.http_auth) + + def _load_remote_data(self, url): + result = None + async def _load_remote_data_async(): + nonlocal result + with aiohttp.Timeout(self.load_timeout): + response = await self.session.get(url) + result = await response.read() + + # Block until we have the data + self.loop.run_until_complete(_load_remote_data_async()) + return result + + async def post(self, address, message, headers): + self.logger.debug("HTTP Post to %s:\n%s", address, message) + with aiohttp.Timeout(self.operation_timeout): + response = await self.session.post( + address, data=message, headers=headers) + self.logger.debug( + "HTTP Response from %s (status: %d):\n%s", + address, response.status, await response.read()) + return response + + async def post_xml(self, address, envelope, headers): + message = etree_to_string(envelope) + response = await self.post(address, message, headers) + + from pretend import stub + return stub( + content=await response.read(), + status_code=response.status, + headers=response.headers) + + async def get(self, address, params, headers): + with aiohttp.Timeout(self.operation_timeout): + response = await self.session.get( + address, params=params, headers=headers) + + from pretend import stub + return await stub( + content=await response.read(), + status_code=response.status, + headers=response.headers) diff --git a/src/zeep/cache.py b/src/zeep/cache.py new file mode 100644 index 0000000..27e8d2c --- /dev/null +++ b/src/zeep/cache.py @@ -0,0 +1,158 @@ +import base64 +import datetime +import errno +import logging +import os +import threading +from contextlib import contextmanager + +import appdirs +import pytz +import six + +# The sqlite3 is not available on Google App Engine so we handle the +# ImportError here and set the sqlite3 var to None. +# See https://github.com/mvantellingen/python-zeep/issues/243 +try: + import sqlite3 +except ImportError: + sqlite3 = None + +logger = logging.getLogger(__name__) + + +class Base(object): + + def add(self, url, content): + raise NotImplemented() + + def get(self, url): + raise NotImplemented() + + +class InMemoryCache(Base): + """Simple in-memory caching using dict lookup with support for timeouts""" + _cache = {} # global cache, thread-safe by default + + def __init__(self, timeout=3600): + self._timeout = timeout + + def add(self, url, content): + logger.debug("Caching contents of %s", url) + self._cache[url] = (datetime.datetime.utcnow(), content) + + def get(self, url): + try: + created, content = self._cache[url] + except KeyError: + pass + else: + if not _is_expired(created, self._timeout): + logger.debug("Cache HIT for %s", url) + return content + logger.debug("Cache MISS for %s", url) + return None + + +class SqliteCache(Base): + """Cache contents via an sqlite database on the filesystem""" + _version = '1' + + def __init__(self, path=None, timeout=3600): + + if sqlite3 is None: + raise RuntimeError("sqlite3 module is required for the SqliteCache") + + # No way we can support this when we want to achieve thread safety + if path == ':memory:': + raise ValueError( + "The SqliteCache doesn't support :memory: since it is not " + + "thread-safe. Please use zeep.cache.InMemoryCache()") + + self._lock = threading.RLock() + self._timeout = timeout + self._db_path = path if path else _get_default_cache_path() + + # Initialize db + with self.db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS request + (created timestamp, url text, content text) + """) + conn.commit() + + @contextmanager + def db_connection(self): + with self._lock: + connection = sqlite3.connect( + self._db_path, detect_types=sqlite3.PARSE_DECLTYPES) + yield connection + connection.close() + + def add(self, url, content): + logger.debug("Caching contents of %s", url) + data = self._encode_data(content) + + with self.db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM request WHERE url = ?", (url,)) + cursor.execute( + "INSERT INTO request (created, url, content) VALUES (?, ?, ?)", + (datetime.datetime.utcnow(), url, data)) + conn.commit() + + def get(self, url): + with self.db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT created, content FROM request WHERE url=?", (url, )) + rows = cursor.fetchall() + + if rows: + created, data = rows[0] + if not _is_expired(created, self._timeout): + logger.debug("Cache HIT for %s", url) + return self._decode_data(data) + logger.debug("Cache MISS for %s", url) + + def _encode_data(self, data): + data = base64.b64encode(data) + if six.PY2: + return buffer(self._version_string + data) # noqa + return self._version_string + data + + def _decode_data(self, data): + if six.PY2: + data = str(data) + if data.startswith(self._version_string): + return base64.b64decode(data[len(self._version_string):]) + + @property + def _version_string(self): + prefix = u'$ZEEP:%s$' % self._version + return bytes(prefix.encode('ascii')) + + +def _is_expired(value, timeout): + """Return boolean if the value is expired""" + if timeout is None: + return False + + now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + max_age = value.replace(tzinfo=pytz.utc) + max_age += datetime.timedelta(seconds=timeout) + return now > max_age + + +def _get_default_cache_path(): + path = appdirs.user_cache_dir('zeep', False) + try: + os.makedirs(path) + except OSError as exc: + if exc.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise + return os.path.join(path, 'cache.db') diff --git a/src/zeep/client.py b/src/zeep/client.py new file mode 100644 index 0000000..62bfcec --- /dev/null +++ b/src/zeep/client.py @@ -0,0 +1,188 @@ +import copy +import logging +from contextlib import contextmanager + +from zeep.transports import Transport +from zeep.wsdl import Document + +NSMAP = { + 'xsd': 'http://www.w3.org/2001/XMLSchema', + 'soap': 'http://schemas.xmlsoap.org/wsdl/soap/', + 'soap-env': 'http://schemas.xmlsoap.org/soap/envelope/', +} + + +logger = logging.getLogger(__name__) + + +class OperationProxy(object): + def __init__(self, service_proxy, operation_name): + self._proxy = service_proxy + self._op_name = operation_name + + def __call__(self, *args, **kwargs): + if self._proxy._client._default_soapheaders: + op_soapheaders = kwargs.get('_soapheaders') + if op_soapheaders: + soapheaders = copy.deepcopy(self._proxy._client._default_soapheaders) + if type(op_soapheaders) != type(soapheaders): + raise ValueError("Incompatible soapheaders definition") + + if isinstance(soapheaders, list): + soapheaders.extend(op_soapheaders) + else: + soapheaders.update(op_soapheaders) + else: + soapheaders = self._proxy._client._default_soapheaders + kwargs['_soapheaders'] = soapheaders + + return self._proxy._binding.send( + self._proxy._client, self._proxy._binding_options, + self._op_name, args, kwargs) + + +class ServiceProxy(object): + def __init__(self, client, binding, **binding_options): + self._client = client + self._binding_options = binding_options + self._binding = binding + + def __getattr__(self, key): + return self[key] + + def __getitem__(self, key): + try: + self._binding.get(key) + except ValueError: + raise AttributeError('Service has no operation %r' % key) + return OperationProxy(self, key) + + +class Factory(object): + def __init__(self, types, kind, namespace): + self._method = getattr(types, 'get_%s' % kind) + + if namespace in types.namespaces: + self._ns = namespace + else: + self._ns = types.get_ns_prefix(namespace) + + def __getattr__(self, key): + return self[key] + + def __getitem__(self, key): + return self._method('{%s}%s' % (self._ns, key)) + + +class Client(object): + + def __init__(self, wsdl, wsse=None, transport=None, + service_name=None, port_name=None, plugins=None): + if not wsdl: + raise ValueError("No URL given for the wsdl") + + self.transport = transport or Transport() + self.wsdl = Document(wsdl, self.transport) + self.wsse = wsse + self.plugins = plugins if plugins is not None else [] + + self._default_service = None + self._default_service_name = service_name + self._default_port_name = port_name + self._default_soapheaders = None + + @property + def service(self): + """The default ServiceProxy instance""" + if self._default_service: + return self._default_service + + self._default_service = self.bind( + service_name=self._default_service_name, + port_name=self._default_port_name) + if not self._default_service: + raise ValueError( + "There is no default service defined. This is usually due to " + "missing wsdl:service definitions in the WSDL") + return self._default_service + + @contextmanager + def options(self, timeout): + """Context manager to temporarily overrule various options. + + Example:: + + client = zeep.Client('foo.wsdl') + with client.options(timeout=10): + client.service.fast_call() + + :param timeout: Set the timeout for POST/GET operations (not used for + loading external WSDL or XSD documents) + + """ + with self.transport._options(timeout=timeout): + yield + + def bind(self, service_name=None, port_name=None): + """Create a new ServiceProxy for the given service_name and port_name. + + The default ServiceProxy instance (`self.service`) always referes to + the first service/port in the wsdl Document. Use this when a specific + port is required. + + """ + if not self.wsdl.services: + return + + if service_name: + service = self.wsdl.services.get(service_name) + if not service: + raise ValueError("Service not found") + else: + service = next(iter(self.wsdl.services.values()), None) + + if port_name: + port = service.ports.get(port_name) + if not port: + raise ValueError("Port not found") + else: + port = list(service.ports.values())[0] + return ServiceProxy(self, port.binding, **port.binding_options) + + def create_service(self, binding_name, address): + """Create a new ServiceProxy for the given binding name and address. + + :param binding_name: The QName of the binding + :param address: The address of the endpoint + + """ + try: + binding = self.wsdl.bindings[binding_name] + except KeyError: + raise ValueError( + "No binding found with the given QName. Available bindings " + "are: %s" % (', '.join(self.wsdl.bindings.keys()))) + return ServiceProxy(self, binding, address=address) + + def type_factory(self, namespace): + return Factory(self.wsdl.types, 'type', namespace) + + def get_type(self, name): + return self.wsdl.types.get_type(name) + + def get_element(self, name): + return self.wsdl.types.get_element(name) + + def set_ns_prefix(self, prefix, namespace): + self.wsdl.types.set_ns_prefix(prefix, namespace) + + def set_default_soapheaders(self, headers): + """Set the default soap headers which will be automatically used on + all calls. + + Note that if you pass custom soapheaders using a list then you will + also need to use that during the operations. Since mixing these use + cases isn't supported (yet). + + """ + self._default_soapheaders = headers diff --git a/src/zeep/exceptions.py b/src/zeep/exceptions.py new file mode 100644 index 0000000..bdd2334 --- /dev/null +++ b/src/zeep/exceptions.py @@ -0,0 +1,49 @@ +class Error(Exception): + def __init__(self, message): + super(Exception, self).__init__(message) + self.message = message + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.message) + + +class XMLSyntaxError(Error): + pass + + +class XMLParseError(Error): + pass + + +class UnexpectedElementError(Error): + pass + + +class WsdlSyntaxError(Error): + pass + + +class TransportError(Error): + pass + + +class LookupError(Error): + pass + + +class NamespaceError(Error): + pass + + +class Fault(Error): + def __init__(self, message, code=None, actor=None, detail=None, subcodes=None): + super(Fault, self).__init__(message) + self.message = message + self.code = code + self.actor = actor + self.detail = detail + self.subcodes = subcodes + + +class ZeepWarning(RuntimeWarning): + pass diff --git a/src/zeep/helpers.py b/src/zeep/helpers.py new file mode 100644 index 0000000..27df819 --- /dev/null +++ b/src/zeep/helpers.py @@ -0,0 +1,20 @@ +from collections import OrderedDict + +from zeep.xsd.valueobjects import CompoundValue + + +def serialize_object(obj): + """Serialize zeep objects to native python data structures""" + if obj is None: + return obj + + if isinstance(obj, list): + return [serialize_object(sub) for sub in obj] + + result = OrderedDict() + for key in obj: + value = obj[key] + if isinstance(value, (list, CompoundValue)): + value = serialize_object(value) + result[key] = value + return result diff --git a/src/zeep/parser.py b/src/zeep/parser.py new file mode 100644 index 0000000..4589def --- /dev/null +++ b/src/zeep/parser.py @@ -0,0 +1,40 @@ +import os + +from defusedxml.lxml import fromstring +from lxml import etree + +from six.moves.urllib.parse import urljoin, urlparse +from zeep.exceptions import XMLSyntaxError + + +def parse_xml(content, base_url=None, recover=False): + parser = etree.XMLParser(remove_comments=True, recover=recover) + try: + return fromstring(content, parser=parser, base_url=base_url) + except etree.XMLSyntaxError as exc: + raise XMLSyntaxError("Invalid XML content received (%s)" % exc) + + +def load_external(url, transport, base_url=None): + if base_url: + url = absolute_location(url, base_url) + + response = transport.load(url) + return parse_xml(response, base_url) + + +def absolute_location(location, base): + if location == base or location.startswith('intschema'): + return location + + if urlparse(location).scheme in ('http', 'https'): + return location + + if base and urlparse(base).scheme in ('http', 'https'): + return urljoin(base, location) + else: + if os.path.isabs(location): + return location + if base: + return os.path.join(os.path.dirname(base), location) + return location diff --git a/src/zeep/plugins.py b/src/zeep/plugins.py new file mode 100644 index 0000000..5ddd953 --- /dev/null +++ b/src/zeep/plugins.py @@ -0,0 +1,63 @@ +from collections import deque + + +class Plugin(object): + """Base plugin""" + + def ingress(self, envelope, http_headers, operation): + return envelope, http_headers + + def egress(self, envelope, http_headers, operation, binding_options): + return envelope, http_headers + + +def apply_egress(client, envelope, http_headers, operation, binding_options): + for plugin in client.plugins: + result = plugin.egress( + envelope, http_headers, operation, binding_options) + if result is not None: + envelope, http_headers = result + + return envelope, http_headers + + +def apply_ingress(client, envelope, http_headers, operation): + for plugin in client.plugins: + result = plugin.ingress(envelope, http_headers, operation) + if result is not None: + envelope, http_headers = result + + return envelope, http_headers + + +class HistoryPlugin(object): + def __init__(self, maxlen=1): + self._buffer = deque([], maxlen) + + @property + def last_sent(self): + last_tx = self._buffer[-1] + if last_tx: + return last_tx['sent'] + + @property + def last_received(self): + last_tx = self._buffer[-1] + if last_tx: + return last_tx['received'] + + def ingress(self, envelope, http_headers, operation): + last_tx = self._buffer[-1] + last_tx['received'] = { + 'envelope': envelope, + 'http_headers': http_headers, + } + + def egress(self, envelope, http_headers, operation, binding_options): + self._buffer.append({ + 'received': None, + 'sent': { + 'envelope': envelope, + 'http_headers': http_headers, + }, + }) diff --git a/src/zeep/transports.py b/src/zeep/transports.py new file mode 100644 index 0000000..a4d71ed --- /dev/null +++ b/src/zeep/transports.py @@ -0,0 +1,154 @@ +import logging +import os +from contextlib import contextmanager + +import requests + +from six.moves.urllib.parse import urlparse +from zeep.cache import SqliteCache +from zeep.utils import NotSet, get_version +from zeep.wsdl.utils import etree_to_string + + +class Transport(object): + supports_async = False + + def __init__(self, cache=NotSet, timeout=300, operation_timeout=None, + verify=True, http_auth=None): + """The transport object handles all communication to the SOAP server. + + :param cache: The cache object to be used to cache GET requests + :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 verify: Boolean to indicate if the SSL certificate needs to be + verified. + :param http_auth: HTTP authentication, passed to requests. + + """ + self.cache = SqliteCache() if cache is NotSet else cache + self.load_timeout = timeout + self.operation_timeout = operation_timeout + self.logger = logging.getLogger(__name__) + + self.http_verify = verify + self.http_auth = http_auth + self.http_headers = { + 'User-Agent': 'Zeep/%s (www.python-zeep.org)' % (get_version()) + } + self.session = self.create_session() + + def create_session(self): + session = requests.Session() + session.verify = self.http_verify + session.auth = self.http_auth + session.headers = self.http_headers + return session + + def get(self, address, params, headers): + """Proxy to requests.get() + + :param address: The URL for the request + :param params: The query parameters + :param headers: a dictionary with the HTTP headers. + + """ + response = self.session.get( + address, + params=params, + headers=headers, + timeout=self.operation_timeout) + return response + + def post(self, address, message, headers): + """Proxy to requests.posts() + + :param address: The URL for the request + :param message: The content for the body + :param headers: a dictionary with the HTTP headers. + + """ + if self.logger.isEnabledFor(logging.DEBUG): + log_message = message + if isinstance(log_message, bytes): + log_message = log_message.decode('utf-8') + self.logger.debug("HTTP Post to %s:\n%s", address, log_message) + + response = self.session.post( + address, + data=message, + headers=headers, + timeout=self.operation_timeout) + + if self.logger.isEnabledFor(logging.DEBUG): + log_message = response.content + if isinstance(log_message, bytes): + log_message = log_message.decode('utf-8') + + self.logger.debug( + "HTTP Response from %s (status: %d):\n%s", + address, response.status_code, log_message) + + return response + + def post_xml(self, address, envelope, headers): + """Post the envelope xml element to the given address with the headers. + + This method is intended to be overriden if you want to customize the + serialization of the xml element. By default the body is formatted + and encoded as utf-8. See ``zeep.wsdl.utils.etree_to_string``. + + """ + message = etree_to_string(envelope) + return self.post(address, message, headers) + + def load(self, url): + """Load the content from the given URL""" + if not url: + raise ValueError("No url given to load") + + scheme = urlparse(url).scheme + if scheme in ('http', 'https'): + + if self.cache: + response = self.cache.get(url) + if response: + return bytes(response) + + content = self._load_remote_data(url) + + if self.cache: + self.cache.add(url, content) + + return content + + elif scheme == 'file': + if url.startswith('file://'): + url = url[7:] + + with open(os.path.expanduser(url), 'rb') as fh: + return fh.read() + + def _load_remote_data(self, url): + response = self.session.get(url, timeout=self.load_timeout) + response.raise_for_status() + return response.content + + @contextmanager + def _options(self, timeout=None): + """Context manager to temporarily overrule options. + + Example:: + + client = zeep.Client('foo.wsdl') + with client.options(timeout=10): + client.service.fast_call() + + :param timeout: Set the timeout for POST/GET operations (not used for + loading external WSDL or XSD documents) + + """ + old_timeout = self.operation_timeout + self.operation_timeout = timeout + yield + self.operation_timeout = old_timeout diff --git a/src/zeep/utils.py b/src/zeep/utils.py new file mode 100644 index 0000000..531c7f2 --- /dev/null +++ b/src/zeep/utils.py @@ -0,0 +1,67 @@ +import inspect + + +from lxml import etree + + +class _NotSetClass(object): + def __repr__(self): + return 'NotSet' + + +NotSet = _NotSetClass() + + +def qname_attr(node, attr_name, target_namespace=None): + value = node.get(attr_name) + if value is not None: + return as_qname(value, node.nsmap, target_namespace) + + +def as_qname(value, nsmap, target_namespace): + """Convert the given value to a QName""" + if ':' in value: + prefix, local = value.split(':') + namespace = nsmap.get(prefix, prefix) + return etree.QName(namespace, local) + + if target_namespace: + return etree.QName(target_namespace, value) + + if nsmap.get(None): + return etree.QName(nsmap[None], value) + return etree.QName(value) + + +def findall_multiple_ns(node, name, namespace_sets): + result = [] + for nsmap in namespace_sets: + result.extend(node.findall(name, namespaces=nsmap)) + return result + + +def get_version(): + from zeep import __version__ # cyclic import + + return __version__ + + +def get_base_class(objects): + """Return the best base class for multiple objects. + + Implementation is quick and dirty, might be done better.. ;-) + + """ + bases = [inspect.getmro(obj.__class__)[::-1] for obj in objects] + num_objects = len(objects) + max_mro = max(len(mro) for mro in bases) + + base_class = None + for i in range(max_mro): + try: + if len({bases[j][i] for j in range(num_objects)}) > 1: + break + except IndexError: + break + base_class = bases[0][i] + return base_class diff --git a/src/zeep/wsa.py b/src/zeep/wsa.py new file mode 100644 index 0000000..f406934 --- /dev/null +++ b/src/zeep/wsa.py @@ -0,0 +1,42 @@ +import uuid + +from lxml import etree +from lxml.builder import ElementMaker + +from zeep.plugins import Plugin +from zeep.wsdl.utils import get_or_create_header + +WSA = ElementMaker(namespace='http://www.w3.org/2005/08/addressing') + + +class WsAddressingPlugin(Plugin): + nsmap = { + 'wsa': 'http://www.w3.org/2005/08/addressing' + } + + def egress(self, envelope, http_headers, operation, binding_options): + """Apply the ws-addressing headers to the given envelope.""" + + wsa_action = operation.input.abstract.wsa_action + if not wsa_action: + wsa_action = operation.soapaction + + header = get_or_create_header(envelope) + headers = [ + WSA.Action(wsa_action), + WSA.MessageID('urn:uuid:' + str(uuid.uuid4())), + WSA.To(binding_options['address']), + ] + header.extend(headers) + + # the top_nsmap kwarg was added in lxml 3.5.0 + if etree.LXML_VERSION[:2] >= (3, 5): + etree.cleanup_namespaces( + header, + keep_ns_prefixes=header.nsmap, + top_nsmap=self.nsmap) + else: + etree.cleanup_namespaces( + header, + keep_ns_prefixes=header.nsmap) + return envelope, http_headers diff --git a/src/zeep/wsdl/__init__.py b/src/zeep/wsdl/__init__.py new file mode 100644 index 0000000..2dbac73 --- /dev/null +++ b/src/zeep/wsdl/__init__.py @@ -0,0 +1 @@ +from zeep.wsdl.wsdl import Document # noqa diff --git a/src/zeep/wsdl/bindings/__init__.py b/src/zeep/wsdl/bindings/__init__.py new file mode 100644 index 0000000..ad34dde --- /dev/null +++ b/src/zeep/wsdl/bindings/__init__.py @@ -0,0 +1,2 @@ +from .soap import Soap11Binding, Soap12Binding # noqa +from .http import HttpGetBinding, HttpPostBinding # noqa diff --git a/src/zeep/wsdl/bindings/http.py b/src/zeep/wsdl/bindings/http.py new file mode 100644 index 0000000..e5dc8f8 --- /dev/null +++ b/src/zeep/wsdl/bindings/http.py @@ -0,0 +1,181 @@ +import logging + +import six +from lxml import etree + +from zeep.exceptions import Fault +from zeep.utils import qname_attr +from zeep.wsdl import messages +from zeep.wsdl.definitions import Binding, Operation + +logger = logging.getLogger(__name__) + +NSMAP = { + 'http': 'http://schemas.xmlsoap.org/wsdl/http/', + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', + 'mime': 'http://schemas.xmlsoap.org/wsdl/mime/', +} + + +class HttpBinding(Binding): + + def create_message(self, operation, *args, **kwargs): + if isinstance(operation, six.string_types): + operation = self.get(operation) + if not operation: + raise ValueError("Operation not found") + return operation.create(*args, **kwargs) + + def process_service_port(self, xmlelement, force_https=False): + address_node = xmlelement.find('http:address', namespaces=NSMAP) + if address_node is None: + raise ValueError("No `http:address` node found") + + # Force the usage of HTTPS when the force_https boolean is true + location = address_node.get('location') + if force_https and location and location.startswith('http://'): + logger.warning("Forcing http:address location to HTTPS") + location = 'https://' + location[8:] + + return { + 'address': location + } + + @classmethod + def parse(cls, definitions, xmlelement): + name = qname_attr(xmlelement, 'name', definitions.target_namespace) + port_name = qname_attr(xmlelement, 'type', definitions.target_namespace) + + obj = cls(definitions.wsdl, name, port_name) + for node in xmlelement.findall('wsdl:operation', namespaces=NSMAP): + operation = HttpOperation.parse(definitions, node, obj) + obj._operation_add(operation) + return obj + + def process_reply(self, client, operation, response): + if response.status_code != 200: + return self.process_error(response.content) + raise NotImplementedError("No error handling yet!") + return operation.process_reply(response.content) + + def process_error(self, doc): + raise Fault(message=doc) + + +class HttpPostBinding(HttpBinding): + + def send(self, client, options, operation, args, kwargs): + """Called from the service""" + operation_obj = self.get(operation) + if not operation_obj: + raise ValueError("Operation %r not found" % operation) + + serialized = operation_obj.create(*args, **kwargs) + + url = options['address'] + serialized.path + response = client.transport.post( + url, serialized.content, headers=serialized.headers) + return self.process_reply(client, operation_obj, response) + + @classmethod + def match(cls, node): + """Check if this binding instance should be used to parse the given + node. + + :param node: The node to match against + :type node: lxml.etree._Element + + """ + http_node = node.find(etree.QName(NSMAP['http'], 'binding')) + return http_node is not None and http_node.get('verb') == 'POST' + + +class HttpGetBinding(HttpBinding): + + def send(self, client, options, operation, args, kwargs): + """Called from the service""" + operation_obj = self.get(operation) + if not operation_obj: + raise ValueError("Operation %r not found" % operation) + + serialized = operation_obj.create(*args, **kwargs) + + url = options['address'] + serialized.path + response = client.transport.get( + url, serialized.content, headers=serialized.headers) + return self.process_reply(client, operation_obj, response) + + @classmethod + def match(cls, node): + """Check if this binding instance should be used to parse the given + node. + + :param node: The node to match against + :type node: lxml.etree._Element + + """ + http_node = node.find(etree.QName(NSMAP['http'], 'binding')) + return http_node is not None and http_node.get('verb') == 'GET' + + +class HttpOperation(Operation): + def __init__(self, name, binding, location): + super(HttpOperation, self).__init__(name, binding) + self.location = location + + def process_reply(self, envelope): + return self.output.deserialize(envelope) + + @classmethod + def parse(cls, definitions, xmlelement, binding): + """ + + + + + + + + + + + + """ + name = xmlelement.get('name') + + http_operation = xmlelement.find('http:operation', namespaces=NSMAP) + location = http_operation.get('location') + obj = cls(name, binding, location) + + for node in xmlelement.getchildren(): + tag_name = etree.QName(node.tag).localname + if tag_name not in ('input', 'output'): + continue + + # XXX Multiple mime types may be declared as alternatives + message_node = None + if len(node.getchildren()) > 0: + message_node = node.getchildren()[0] + message_class = None + if message_node is not None: + if message_node.tag == etree.QName(NSMAP['http'], 'urlEncoded'): + message_class = messages.UrlEncoded + elif message_node.tag == etree.QName(NSMAP['http'], 'urlReplacement'): + message_class = messages.UrlReplacement + elif message_node.tag == etree.QName(NSMAP['mime'], 'content'): + message_class = messages.MimeContent + elif message_node.tag == etree.QName(NSMAP['mime'], 'mimeXml'): + message_class = messages.MimeXML + + if message_class: + msg = message_class.parse(definitions, node, obj) + assert msg + setattr(obj, tag_name, msg) + return obj + + def resolve(self, definitions): + super(HttpOperation, self).resolve(definitions) + if self.output: + self.output.resolve(definitions, self.abstract.output_message) + if self.input: + self.input.resolve(definitions, self.abstract.input_message) diff --git a/src/zeep/wsdl/bindings/soap.py b/src/zeep/wsdl/bindings/soap.py new file mode 100644 index 0000000..b700434 --- /dev/null +++ b/src/zeep/wsdl/bindings/soap.py @@ -0,0 +1,387 @@ +import logging + +from lxml import etree + +from zeep import plugins, wsa +from zeep.exceptions import Fault, TransportError, XMLSyntaxError +from zeep.parser import parse_xml +from zeep.utils import as_qname, qname_attr +from zeep.wsdl.definitions import Binding, Operation +from zeep.wsdl.messages import DocumentMessage, RpcMessage +from zeep.wsdl.utils import etree_to_string + +logger = logging.getLogger(__name__) + + +class SoapBinding(Binding): + """Soap 1.1/1.2 binding""" + + def __init__(self, wsdl, name, port_name, transport, default_style): + """The SoapBinding is the base class for the Soap11Binding and + Soap12Binding. + + :param wsdl: + :type wsdl: + :param name: + :type name: string + :param port_name: + :type port_name: string + :param transport: + :type transport: zeep.transports.Transport + :param default_style: + + """ + super(SoapBinding, self).__init__(wsdl, name, port_name) + self.transport = transport + self.default_style = default_style + + @classmethod + def match(cls, node): + """Check if this binding instance should be used to parse the given + node. + + :param node: The node to match against + :type node: lxml.etree._Element + + """ + soap_node = node.find('soap:binding', namespaces=cls.nsmap) + return soap_node is not None + + def create_message(self, operation, *args, **kwargs): + envelope, http_headers = self._create(operation, args, kwargs) + return envelope + + def _create(self, operation, args, kwargs, client=None, options=None): + """Create the XML document to send to the server. + + Note that this generates the soap envelope without the wsse applied. + + """ + operation_obj = self.get(operation) + if not operation_obj: + raise ValueError("Operation %r not found" % operation) + + # Create the SOAP envelope + serialized = operation_obj.create(*args, **kwargs) + self._set_http_headers(serialized, operation_obj) + + envelope = serialized.content + http_headers = serialized.headers + + # Apply ws-addressing + if client: + if not options: + options = client.service._binding_options + + if operation_obj.abstract.input_message.wsa_action: + envelope, http_headers = wsa.WsAddressingPlugin().egress( + envelope, http_headers, operation_obj, options) + + # Apply plugins + envelope, http_headers = plugins.apply_egress( + client, envelope, http_headers, operation_obj, options) + + # Apply WSSE + if client.wsse: + envelope, http_headers = client.wsse.sign(envelope, http_headers) + return envelope, http_headers + + def send(self, client, options, operation, args, kwargs): + """Called from the service + + :param client: The client with which the operation was called + :type client: zeep.client.Client + :param options: The binding options + :type options: dict + :param operation: The operation object from which this is a reply + :type operation: zeep.wsdl.definitions.Operation + :param args: The *args to pass to the operation + :type args: tuple + :param kwargs: The **kwargs to pass to the operation + :type kwargs: dict + + """ + envelope, http_headers = self._create( + operation, args, kwargs, + client=client, + options=options) + + response = client.transport.post_xml( + options['address'], envelope, http_headers) + + operation_obj = self.get(operation) + return self.process_reply(client, operation_obj, response) + + def process_reply(self, client, operation, response): + """Process the XML reply from the server. + + :param client: The client with which the operation was called + :type client: zeep.client.Client + :param operation: The operation object from which this is a reply + :type operation: zeep.wsdl.definitions.Operation + :param response: The response object returned by the remote server + :type response: requests.Response + + """ + if response.status_code != 200 and not response.content: + raise TransportError( + u'Server returned HTTP status %d (no content available)' + % response.status_code) + + try: + doc = parse_xml(response.content, recover=True) + except XMLSyntaxError: + raise TransportError( + u'Server returned HTTP status %d (%s)' + % (response.status_code, response.content)) + + if client.wsse: + client.wsse.verify(doc) + + doc, http_headers = plugins.apply_ingress( + client, doc, response.headers, operation) + + # If the response code is not 200 or if there is a Fault node available + # then assume that an error occured. + fault_node = doc.find( + 'soap-env:Body/soap-env:Fault', namespaces=self.nsmap) + if response.status_code != 200 or fault_node is not None: + return self.process_error(doc, operation) + + return operation.process_reply(doc) + + def process_error(self, doc, operation): + raise NotImplementedError + + def process_service_port(self, xmlelement, force_https=False): + address_node = xmlelement.find('soap:address', namespaces=self.nsmap) + + # Force the usage of HTTPS when the force_https boolean is true + location = address_node.get('location') + if force_https and location and location.startswith('http://'): + logger.warning("Forcing soap:address location to HTTPS") + location = 'https://' + location[7:] + + return { + 'address': location + } + + @classmethod + def parse(cls, definitions, xmlelement): + """ + * + <-- extensibility element (1) --> * + * + <-- extensibility element (2) --> * + ? + <-- extensibility element (3) --> + + ? + <-- extensibility element (4) --> * + + * + <-- extensibility element (5) --> * + + + + """ + name = qname_attr(xmlelement, 'name', definitions.target_namespace) + port_name = qname_attr(xmlelement, 'type', definitions.target_namespace) + + # The soap:binding element contains the transport method and + # default style attribute for the operations. + soap_node = xmlelement.find('soap:binding', namespaces=cls.nsmap) + transport = soap_node.get('transport') + if transport != 'http://schemas.xmlsoap.org/soap/http': + raise NotImplementedError("Only soap/http is supported for now") + default_style = soap_node.get('style', 'document') + + obj = cls(definitions.wsdl, name, port_name, transport, default_style) + for node in xmlelement.findall('wsdl:operation', namespaces=cls.nsmap): + operation = SoapOperation.parse(definitions, node, obj, nsmap=cls.nsmap) + obj._operation_add(operation) + return obj + + +class Soap11Binding(SoapBinding): + nsmap = { + 'soap': 'http://schemas.xmlsoap.org/wsdl/soap/', + 'soap-env': 'http://schemas.xmlsoap.org/soap/envelope/', + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', + 'xsd': 'http://www.w3.org/2001/XMLSchema', + } + + def process_error(self, doc, operation): + fault_node = doc.find( + 'soap-env:Body/soap-env:Fault', namespaces=self.nsmap) + + if fault_node is None: + raise Fault( + message='Unknown fault occured', + code=None, + actor=None, + detail=etree_to_string(doc)) + + def get_text(name): + child = fault_node.find(name) + if child is not None: + return child.text + + raise Fault( + message=get_text('faultstring'), + code=get_text('faultcode'), + actor=get_text('faultactor'), + detail=fault_node.find('detail')) + + def _set_http_headers(self, serialized, operation): + serialized.headers['Content-Type'] = 'text/xml; charset=utf-8' + + +class Soap12Binding(SoapBinding): + nsmap = { + 'soap': 'http://schemas.xmlsoap.org/wsdl/soap12/', + 'soap-env': 'http://www.w3.org/2003/05/soap-envelope', + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', + 'xsd': 'http://www.w3.org/2001/XMLSchema', + } + + def process_error(self, doc, operation): + fault_node = doc.find( + 'soap-env:Body/soap-env:Fault', namespaces=self.nsmap) + + if fault_node is None: + raise Fault( + message='Unknown fault occured', + code=None, + actor=None, + detail=etree_to_string(doc)) + + def get_text(name): + child = fault_node.find(name) + if child is not None: + return child.text + + message = fault_node.findtext('soap-env:Reason/soap-env:Text', namespaces=self.nsmap) + code = fault_node.findtext('soap-env:Code/soap-env:Value', namespaces=self.nsmap) + + # Extract the fault subcodes. These can be nested, as in subcodes can + # also contain other subcodes. + subcodes = [] + subcode_element = fault_node.find('soap-env:Code/soap-env:Subcode', namespaces=self.nsmap) + while subcode_element is not None: + subcode_value_element = subcode_element.find('soap-env:Value', namespaces=self.nsmap) + subcode_qname = as_qname(subcode_value_element.text, subcode_value_element.nsmap, None) + subcodes.append(subcode_qname) + subcode_element = subcode_element.find('soap-env:Subcode', namespaces=self.nsmap) + + # TODO: We should use the fault message as defined in the wsdl. + detail_node = fault_node.find('soap-env:Detail', namespaces=self.nsmap) + raise Fault( + message=message, + code=code, + actor=None, + detail=detail_node, + subcodes=subcodes) + + def _set_http_headers(self, serialized, operation): + serialized.headers['Content-Type'] = '; '.join([ + 'application/soap+xml', + 'charset=utf-8', + 'action="%s"' % operation.soapaction + ]) + + +class SoapOperation(Operation): + """Represent's an operation within a specific binding.""" + + def __init__(self, name, binding, nsmap, soapaction, style): + super(SoapOperation, self).__init__(name, binding) + self.nsmap = nsmap + self.soapaction = soapaction + self.style = style + + def process_reply(self, envelope): + envelope_qname = etree.QName(self.nsmap['soap-env'], 'Envelope') + if envelope.tag != envelope_qname: + raise XMLSyntaxError(( + "The XML returned by the server does not contain a valid " + + "{%s}Envelope root element. The root element found is %s " + ) % (envelope_qname.namespace, envelope.tag)) + + return self.output.deserialize(envelope) + + @classmethod + def parse(cls, definitions, xmlelement, binding, nsmap): + """ + + * + ? + ? + + + ? + <-- extensibility element (4) --> * + + * + <-- extensibility element (5) --> * + + + + Example:: + + + + + + + + + + + + + + """ + name = xmlelement.get('name') + + # The soap:operation element is required for soap/http bindings + # and may be omitted for other bindings. + soap_node = xmlelement.find('soap:operation', namespaces=binding.nsmap) + action = None + if soap_node is not None: + action = soap_node.get('soapAction') + style = soap_node.get('style', binding.default_style) + else: + style = binding.default_style + + obj = cls(name, binding, nsmap, action, style) + + if style == 'rpc': + message_class = RpcMessage + else: + message_class = DocumentMessage + + for node in xmlelement.getchildren(): + tag_name = etree.QName(node.tag).localname + if tag_name not in ('input', 'output', 'fault'): + continue + msg = message_class.parse( + definitions=definitions, xmlelement=node, + operation=obj, nsmap=nsmap, type=tag_name) + if tag_name == 'fault': + obj.faults[msg.name] = msg + else: + setattr(obj, tag_name, msg) + + return obj + + def resolve(self, definitions): + super(SoapOperation, self).resolve(definitions) + for name, fault in self.faults.items(): + if name in self.abstract.fault_messages: + fault.resolve(definitions, self.abstract.fault_messages[name]) + + if self.output: + self.output.resolve(definitions, self.abstract.output_message) + if self.input: + self.input.resolve(definitions, self.abstract.input_message) diff --git a/src/zeep/wsdl/definitions.py b/src/zeep/wsdl/definitions.py new file mode 100644 index 0000000..b01e1c2 --- /dev/null +++ b/src/zeep/wsdl/definitions.py @@ -0,0 +1,264 @@ +from collections import OrderedDict, namedtuple + +from six import python_2_unicode_compatible + +MessagePart = namedtuple('MessagePart', ['element', 'type']) + + +class AbstractMessage(object): + """Messages consist of one or more logical parts. + + Each part is associated with a type from some type system using a + message-typing attribute. The set of message-typing attributes is + extensible. WSDL defines several such message-typing attributes for use + with XSD: + + element: Refers to an XSD element using a QName. + type: Refers to an XSD simpleType or complexType using a QName. + + """ + def __init__(self, name): + self.name = name + self.parts = OrderedDict() + + def __repr__(self): + return '<%s(name=%r)>' % (self.__class__.__name__, self.name.text) + + def resolve(self, definitions): + pass + + def add_part(self, name, element): + self.parts[name] = element + + +class AbstractOperation(object): + """Abstract operations are defined in the wsdl's portType elements.""" + + def __init__(self, name, input_message=None, output_message=None, + fault_messages=None, parameter_order=None): + """Initialize the abstract operation. + + :param name: The name of the operation + :type name: str + :param input_message: Message to generate the request XML + :type input_message: AbstractMessage + :param output_message: Message to process the response XML + :type output_message: AbstractMessage + :param fault_messages: Dict of messages to handle faults + :type fault_messages: dict of str: AbstractMessage + + """ + self.name = name + self.input_message = input_message + self.output_message = output_message + self.fault_messages = fault_messages + self.parameter_order = parameter_order + + +class PortType(object): + def __init__(self, name, operations): + self.name = name + self.operations = operations + + def __repr__(self): + return '<%s(name=%r)>' % ( + self.__class__.__name__, self.name.text) + + def resolve(self, definitions): + pass + + +@python_2_unicode_compatible +class Binding(object): + """Base class for the various bindings (SoapBinding / HttpBinding) + + Binding + | + +-> Operation + | + +-> ConcreteMessage + | + +-> AbstractMessage + + """ + def __init__(self, wsdl, name, port_name): + """Binding + + :param wsdl: + :type wsdl: + :param name: + :type name: string + :param port_name: + :type port_name: string + + """ + self.name = name + self.port_name = port_name + self.port_type = None + self.wsdl = wsdl + self._operations = {} + + def resolve(self, definitions): + self.port_type = definitions.get('port_types', self.port_name.text) + for operation in self._operations.values(): + operation.resolve(definitions) + + def _operation_add(self, operation): + # XXX: operation name is not unique + self._operations[operation.name] = operation + + def __str__(self): + return '%s: %s' % (self.__class__.__name__, self.name.text) + + def __repr__(self): + return '<%s(name=%r, port_type=%r)>' % ( + self.__class__.__name__, self.name.text, self.port_type) + + def get(self, key): + try: + return self._operations[key] + except KeyError: + raise ValueError("No such operation %r on %s" % (key, self.name)) + + @classmethod + def match(cls, node): + raise NotImplementedError() + + @classmethod + def parse(cls, definitions, xmlelement): + raise NotImplementedError() + + +@python_2_unicode_compatible +class Operation(object): + """Concrete operation + + Contains references to the concrete messages + + """ + def __init__(self, name, binding): + self.name = name + self.binding = binding + self.abstract = None + self.style = None + self.input = None + self.output = None + self.faults = {} + + def resolve(self, definitions): + self.abstract = self.binding.port_type.operations[self.name] + + def __repr__(self): + return '<%s(name=%r, style=%r)>' % ( + self.__class__.__name__, self.name, self.style) + + def __str__(self): + if not self.input: + return u'%s(missing input message)' % (self.name) + + retval = u'%s(%s)' % (self.name, self.input.signature()) + if self.output: + retval += u' -> %s' % (self.output.signature(as_output=True)) + return retval + + def create(self, *args, **kwargs): + return self.input.serialize(*args, **kwargs) + + def process_reply(self, envelope): + raise NotImplementedError() + + @classmethod + def parse(cls, wsdl, xmlelement, binding): + """ + * + <-- extensibility element (2) --> * + ? + <-- extensibility element (3) --> + + ? + <-- extensibility element (4) --> * + + * + <-- extensibility element (5) --> * + + + """ + raise NotImplementedError() + + +@python_2_unicode_compatible +class Port(object): + def __init__(self, name, binding_name, xmlelement): + self.name = name + self._resolve_context = { + 'binding_name': binding_name, + 'xmlelement': xmlelement, + } + + # Set during resolve() + self.binding = None + self.binding_options = None + + def __repr__(self): + return '<%s(name=%r, binding=%r, %r)>' % ( + self.__class__.__name__, self.name, self.binding, + self.binding_options) + + def __str__(self): + return u'Port: %s (%s)' % (self.name, self.binding) + + def resolve(self, definitions): + if self._resolve_context is None: + return + + try: + self.binding = definitions.get( + 'bindings', self._resolve_context['binding_name'].text) + except IndexError: + return False + + if definitions.location: + force_https = definitions.location.startswith('https') + else: + force_https = False + + self.binding_options = self.binding.process_service_port( + self._resolve_context['xmlelement'], + force_https) + self._resolve_context = None + return True + + +@python_2_unicode_compatible +class Service(object): + + def __init__(self, name): + self.ports = OrderedDict() + self.name = name + self._is_resolved = False + + def __str__(self): + return u'Service: %s' % self.name + + def __repr__(self): + return '<%s(name=%r, ports=%r)>' % ( + self.__class__.__name__, self.name, self.ports) + + def resolve(self, definitions): + if self._is_resolved: + return + + unresolved = [] + for name, port in self.ports.items(): + is_resolved = port.resolve(definitions) + if not is_resolved: + unresolved.append(name) + + # Remove unresolved bindings (http etc) + for name in unresolved: + del self.ports[name] + + self._is_resolved = True + + def add_port(self, port): + self.ports[port.name] = port diff --git a/src/zeep/wsdl/messages/__init__.py b/src/zeep/wsdl/messages/__init__.py new file mode 100644 index 0000000..70a5b5a --- /dev/null +++ b/src/zeep/wsdl/messages/__init__.py @@ -0,0 +1,3 @@ +from .http import * # noqa +from .mime import * # noqa +from .soap import * # noqa diff --git a/src/zeep/wsdl/messages/base.py b/src/zeep/wsdl/messages/base.py new file mode 100644 index 0000000..cc71e93 --- /dev/null +++ b/src/zeep/wsdl/messages/base.py @@ -0,0 +1,47 @@ +from collections import namedtuple + +from zeep import xsd + +SerializedMessage = namedtuple( + 'SerializedMessage', ['path', 'headers', 'content']) + + +class ConcreteMessage(object): + """Represents the wsdl:binding -> wsdl:operation -> input/ouput node""" + def __init__(self, wsdl, name, operation): + assert wsdl + assert operation + + self.wsdl = wsdl + self.namespace = {} + self.operation = operation + self.name = name + + def serialize(self, *args, **kwargs): + raise NotImplementedError() + + def deserialize(self, node): + raise NotImplementedError() + + def signature(self, as_output=False): + if not self.body: + return None + + if as_output: + if isinstance(self.body.type, xsd.ComplexType): + try: + if len(self.body.type.elements) == 1: + return self.body.type.elements[0][1].type.signature() + except AttributeError: + return None + + return self.body.type.signature() + + parts = [self.body.type.signature()] + if getattr(self, 'header', None): + parts.append('_soapheaders={%s}' % self.header.signature()) + return ', '.join(part for part in parts if part) + + @classmethod + def parse(cls, wsdl, xmlelement, abstract_message, operation): + raise NotImplementedError() diff --git a/src/zeep/wsdl/messages/http.py b/src/zeep/wsdl/messages/http.py new file mode 100644 index 0000000..6264dca --- /dev/null +++ b/src/zeep/wsdl/messages/http.py @@ -0,0 +1,91 @@ +from zeep import xsd +from zeep.wsdl.messages.base import ConcreteMessage, SerializedMessage + +__all__ = [ + 'UrlEncoded', + 'UrlReplacement', +] + + +class HttpMessage(ConcreteMessage): + """Base class for HTTP Binding messages""" + + def resolve(self, definitions, abstract_message): + self.abstract = abstract_message + + children = [] + for name, message in self.abstract.parts.items(): + if message.element: + elm = message.element.clone(name) + else: + elm = xsd.Element(name, message.type) + children.append(elm) + self.body = xsd.Element( + self.operation.name, xsd.ComplexType(xsd.Sequence(children))) + + +class UrlEncoded(HttpMessage): + """The urlEncoded element indicates that all the message parts are encoded + into the HTTP request URI using the standard URI-encoding rules + (name1=value&name2=value...). + + The names of the parameters correspond to the names of the message parts. + Each value contributed by the part is encoded using a name=value pair. This + may be used with GET to specify URL encoding, or with POST to specify a + FORM-POST. For GET, the "?" character is automatically appended as + necessary. + + """ + + def serialize(self, *args, **kwargs): + params = {key: None for key in self.abstract.parts.keys()} + params.update(zip(self.abstract.parts.keys(), args)) + params.update(kwargs) + headers = {'Content-Type': 'text/xml; charset=utf-8'} + return SerializedMessage( + path=self.operation.location, headers=headers, content=params) + + @classmethod + def parse(cls, definitions, xmlelement, operation): + name = xmlelement.get('name') + obj = cls(definitions.wsdl, name, operation) + return obj + + +class UrlReplacement(HttpMessage): + """The http:urlReplacement element indicates that all the message parts + are encoded into the HTTP request URI using a replacement algorithm. + + - The relative URI value of http:operation is searched for a set of search + patterns. + - The search occurs before the value of the http:operation is combined with + the value of the location attribute from http:address. + - There is one search pattern for each message part. The search pattern + string is the name of the message part surrounded with parenthesis "(" + and ")". + - For each match, the value of the corresponding message part is + substituted for the match at the location of the match. + - Matches are performed before any values are replaced (replaced values do + not trigger additional matches). + + Message parts MUST NOT have repeating values. + + + """ + + def serialize(self, *args, **kwargs): + params = {key: None for key in self.abstract.parts.keys()} + params.update(zip(self.abstract.parts.keys(), args)) + params.update(kwargs) + headers = {'Content-Type': 'text/xml; charset=utf-8'} + + path = self.operation.location + for key, value in params.items(): + path = path.replace('(%s)' % key, value if value is not None else '') + return SerializedMessage(path=path, headers=headers, content='') + + @classmethod + def parse(cls, definitions, xmlelement, operation): + name = xmlelement.get('name') + obj = cls(definitions.wsdl, name, operation) + return obj diff --git a/src/zeep/wsdl/messages/mime.py b/src/zeep/wsdl/messages/mime.py new file mode 100644 index 0000000..ba28522 --- /dev/null +++ b/src/zeep/wsdl/messages/mime.py @@ -0,0 +1,174 @@ +import six +from defusedxml.lxml import fromstring +from lxml import etree + +from zeep import xsd +from zeep.helpers import serialize_object +from zeep.wsdl.messages.base import ConcreteMessage, SerializedMessage +from zeep.wsdl.utils import etree_to_string + +__all__ = [ + 'MimeContent', + 'MimeXML', + 'MimeMultipart', +] + + +class MimeMessage(ConcreteMessage): + _nsmap = { + 'mime': 'http://schemas.xmlsoap.org/wsdl/mime/', + } + + def __init__(self, wsdl, name, operation, part_name): + super(MimeMessage, self).__init__(wsdl, name, operation) + self.part_name = part_name + + def resolve(self, definitions, abstract_message): + """Resolve the body element + + The specs are (again) not really clear how to handle the message + parts in relation the message element vs type. The following strategy + is chosen, which seem to work: + + - If the message part has a name and it maches then set it as body + - If the message part has a name but it doesn't match but there are no + other message parts, then just use that one. + - If the message part has no name then handle it like an rpc call, + in other words, each part is an argument. + + """ + self.abstract = abstract_message + if self.part_name and self.abstract.parts: + if self.part_name in self.abstract.parts: + message = self.abstract.parts[self.part_name] + elif len(self.abstract.parts) == 1: + message = list(self.abstract.parts.values())[0] + else: + raise ValueError( + "Multiple parts for message %r while no matching part found" % self.part_name) + + if message.element: + self.body = message.element + else: + elm = xsd.Element(self.part_name, message.type) + self.body = xsd.Element( + self.operation.name, xsd.ComplexType(xsd.Sequence([elm]))) + else: + children = [] + for name, message in self.abstract.parts.items(): + if message.element: + elm = message.element.clone(name) + else: + elm = xsd.Element(name, message.type) + children.append(elm) + self.body = xsd.Element( + self.operation.name, xsd.ComplexType(xsd.Sequence(children))) + + +class MimeContent(MimeMessage): + """WSDL includes a way to bind abstract types to concrete messages in some + MIME format. + + Bindings for the following MIME types are defined: + + - multipart/related + - text/xml + - application/x-www-form-urlencoded + - Others (by specifying the MIME type string) + + The set of defined MIME types is both large and evolving, so it is not a + goal for WSDL to exhaustively define XML grammar for each MIME type. + + """ + def __init__(self, wsdl, name, operation, content_type, part_name): + super(MimeContent, self).__init__(wsdl, name, operation, part_name) + self.content_type = content_type + + def serialize(self, *args, **kwargs): + value = self.body(*args, **kwargs) + headers = { + 'Content-Type': self.content_type + } + + data = '' + if self.content_type == 'application/x-www-form-urlencoded': + items = serialize_object(value) + data = six.moves.urllib.parse.urlencode(items) + elif self.content_type == 'text/xml': + document = etree.Element('root') + self.body.render(document, value) + data = etree_to_string(document.getchildren()[0]) + + return SerializedMessage( + path=self.operation.location, headers=headers, content=data) + + def deserialize(self, node): + node = fromstring(node) + part = list(self.abstract.parts.values())[0] + return part.type.parse_xmlelement(node) + + @classmethod + def parse(cls, definitions, xmlelement, operation): + name = xmlelement.get('name') + + part_name = content_type = None + content_node = xmlelement.find('mime:content', namespaces=cls._nsmap) + if content_node is not None: + content_type = content_node.get('type') + part_name = content_node.get('part') + + obj = cls(definitions.wsdl, name, operation, content_type, part_name) + return obj + + +class MimeXML(MimeMessage): + """To specify XML payloads that are not SOAP compliant (do not have a SOAP + Envelope), but do have a particular schema, the mime:mimeXml element may be + used to specify that concrete schema. + + The part attribute refers to a message part defining the concrete schema of + the root XML element. The part attribute MAY be omitted if the message has + only a single part. The part references a concrete schema using the element + attribute for simple parts or type attribute for composite parts + + """ + def serialize(self, *args, **kwargs): + raise NotImplementedError() + + def deserialize(self, node): + node = fromstring(node) + part = next(iter(self.abstract.parts.values()), None) + return part.element.parse(node, self.wsdl.types) + + @classmethod + def parse(cls, definitions, xmlelement, operation): + name = xmlelement.get('name') + part_name = None + + content_node = xmlelement.find('mime:mimeXml', namespaces=cls._nsmap) + if content_node is not None: + part_name = content_node.get('part') + obj = cls(definitions.wsdl, name, operation, part_name) + return obj + + +class MimeMultipart(MimeMessage): + """The multipart/related MIME type aggregates an arbitrary set of MIME + formatted parts into one message using the MIME type "multipart/related". + + The mime:multipartRelated element describes the concrete format of such a + message:: + + + * + <-- mime element --> + + + + The mime:part element describes each part of a multipart/related message. + MIME elements appear within mime:part to specify the concrete MIME type for + the part. If more than one MIME element appears inside a mime:part, they + are alternatives. + + """ + pass diff --git a/src/zeep/wsdl/messages/soap.py b/src/zeep/wsdl/messages/soap.py new file mode 100644 index 0000000..b1b64b6 --- /dev/null +++ b/src/zeep/wsdl/messages/soap.py @@ -0,0 +1,437 @@ +import copy +from collections import OrderedDict + +from lxml import etree +from lxml.builder import ElementMaker + +from zeep import exceptions, xsd +from zeep.utils import as_qname +from zeep.wsdl.messages.base import ConcreteMessage, SerializedMessage + +__all__ = [ + 'DocumentMessage', + 'RpcMessage', +] + + +class SoapMessage(ConcreteMessage): + """Base class for the SOAP Document and RPC messages""" + + def __init__(self, wsdl, name, operation, type, nsmap): + super(SoapMessage, self).__init__(wsdl, name, operation) + self.nsmap = nsmap + self.abstract = None # Set during resolve() + self.type = type + + self.body = None + self.header = None + self.envelope = None + + def serialize(self, *args, **kwargs): + """Create a SerializedMessage for this message""" + nsmap = { + 'soap-env': self.nsmap['soap-env'] + } + nsmap.update(self.wsdl.types._prefix_map_custom) + + soap = ElementMaker(namespace=self.nsmap['soap-env'], nsmap=nsmap) + body = header = None + + # Create the soap:header element + headers_value = kwargs.pop('_soapheaders', None) + header = self._serialize_header(headers_value, nsmap) + + # Create the soap:body element + if self.body: + body_value = self.body(*args, **kwargs) + body = soap.Body() + self.body.render(body, body_value) + + # Create the soap:envelope + envelope = soap.Envelope() + if header is not None: + envelope.append(header) + if body is not None: + envelope.append(body) + + # XXX: This is only used in Soap 1.1 so should be moved to the the + # Soap11Binding._set_http_headers(). But let's keep it like this for + # now. + headers = { + 'SOAPAction': '"%s"' % self.operation.soapaction + } + return SerializedMessage( + path=None, headers=headers, content=envelope) + + def deserialize(self, envelope): + """Deserialize the SOAP:Envelope and return a CompoundValue with the + result. + + """ + if not self.envelope: + return None + + body = envelope.find('soap-env:Body', namespaces=self.nsmap) + body_result = self._deserialize_body(body) + + header = envelope.find('soap-env:Header', namespaces=self.nsmap) + headers_result = self._deserialize_headers(header) + + kwargs = body_result + kwargs.update(headers_result) + result = self.envelope(**kwargs) + + # If the message + if self.header.type._element: + return result + + result = result.body + if result is None or len(result) == 0: + return None + elif len(result) > 1: + return result + + # Check if we can remove the wrapping object to make the return value + # easier to use. + result = next(iter(result.__values__.values())) + if isinstance(result, xsd.CompoundValue): + children = result._xsd_type.elements + if len(children) == 1: + item_name, item_element = children[0] + retval = getattr(result, item_name) + return retval + return result + + def signature(self, as_output=False): + if not self.envelope: + return None + + if as_output: + if isinstance(self.envelope.type, xsd.ComplexType): + try: + if len(self.envelope.type.elements) == 1: + return self.envelope.type.elements[0][1].type.signature() + except AttributeError: + return None + return self.envelope.type.signature() + + parts = [self.body.type.signature()] + if self.header.type._element: + parts.append('_soapheaders={%s}' % self.header.signature()) + return ', '.join(part for part in parts if part) + + @classmethod + def parse(cls, definitions, xmlelement, operation, type, nsmap): + """Parse a wsdl:binding/wsdl:operation/wsdl:operation for the SOAP + implementation. + + Each wsdl:operation can contain three child nodes: + - input + - output + - fault + + Definition for input/output:: + + + + + * + * + + + + And the definition for fault:: + + + + """ + name = xmlelement.get('name') + obj = cls(definitions.wsdl, name, operation, nsmap=nsmap, type=type) + + body_data = None + header_data = None + + body = xmlelement.find('soap:body', namespaces=operation.binding.nsmap) + if body is not None: + body_data = cls._parse_body(body) + + # Parse soap:header (multiple) + elements = xmlelement.findall( + 'soap:header', namespaces=operation.binding.nsmap) + header_data = cls._parse_header( + elements, definitions.target_namespace, operation) + + obj._resolve_info = { + 'body': body_data, + 'header': header_data + } + return obj + + @classmethod + def _parse_body(cls, xmlelement): + """Parse soap:body and return a dict with data to resolve it. + + + + """ + return { + 'part': xmlelement.get('part'), + 'use': xmlelement.get('use', 'literal'), + 'encodingStyle': xmlelement.get('encodingStyle'), + 'namespace': xmlelement.get('namespace'), + } + + @classmethod + def _parse_header(cls, xmlelements, tns, operation): + """Parse the soap:header and optionally included soap:headerfault elements + + * + + The header can optionally contain one ore more soap:headerfault + elements which can contain the same attributes as the soap:header:: + + * + + """ + result = [] + for xmlelement in xmlelements: + data = cls._parse_header_element(xmlelement, tns) + + # Add optional soap:headerfault elements + data['faults'] = [] + fault_elements = xmlelement.findall( + 'soap:headerfault', namespaces=operation.binding.nsmap) + for fault_element in fault_elements: + fault_data = cls._parse_header_element(fault_element, tns) + data['faults'].append(fault_data) + + result.append(data) + return result + + @classmethod + def _parse_header_element(cls, xmlelement, tns): + attributes = xmlelement.attrib + message_qname = as_qname( + attributes['message'], xmlelement.nsmap, tns) + + try: + return { + 'message': message_qname, + 'part': attributes['part'], + 'use': attributes['use'], + 'encodingStyle': attributes.get('encodingStyle'), + 'namespace': attributes.get('namespace'), + } + except KeyError: + raise exceptions.WsdlSyntaxError("Invalid soap:header(fault)") + + def resolve(self, definitions, abstract_message): + """Resolve the data in the self._resolve_info dict (set via parse()) + + This creates three xsd.Element objects: + + - self.header + - self.body + - self.envelope (combination of headers and body) + + XXX headerfaults are not implemented yet. + + """ + info = self._resolve_info + del self._resolve_info + + # If this message has no parts then we have nothing to do. This might + # happen for output messages which don't return anything. + if not abstract_message.parts and self.type != 'input': + return + + self.abstract = abstract_message + parts = OrderedDict(self.abstract.parts) + + self.header = self._resolve_header(info['header'], definitions, parts) + self.body = self._resolve_body(info['body'], definitions, parts) + self.envelope = self._create_envelope_element() + + def _create_envelope_element(self): + """Create combined `envelope` complexType which contains both the + elements from the body and the headers. + + """ + all_elements = xsd.Sequence([ + xsd.Element('body', self.body.type), + xsd.Element('header', self.header.type), + ]) + return xsd.Element('envelope', xsd.ComplexType(all_elements)) + + def _serialize_header(self, headers_value, nsmap): + if not headers_value: + return + + headers_value = copy.deepcopy(headers_value) + + soap = ElementMaker(namespace=self.nsmap['soap-env'], nsmap=nsmap) + header = soap.Header() + if isinstance(headers_value, list): + for header_value in headers_value: + if hasattr(header_value, '_xsd_elm'): + header_value._xsd_elm.render(header, header_value) + elif isinstance(header_value, etree._Element): + header.append(header_value) + else: + raise ValueError("Invalid value given to _soapheaders") + elif isinstance(headers_value, dict): + if not self.header: + raise ValueError( + "_soapheaders only accepts a dictionary if the wsdl " + "defines the headers.") + headers_value = self.header(**headers_value) + self.header.render(header, headers_value) + else: + raise ValueError("Invalid value given to _soapheaders") + + return header + + def _deserialize_headers(self, xmlelement): + """Deserialize the values in the SOAP:Header element""" + if not self.header or xmlelement is None: + return {} + + result = self.header.parse(xmlelement, self.wsdl.types) + if result is not None: + return {'header': result} + return {} + + def _resolve_header(self, info, definitions, parts): + sequence = xsd.Sequence() + if not info: + return xsd.Element(None, xsd.ComplexType(sequence)) + + for item in info: + message_name = item['message'].text + part_name = item['part'] + + message = definitions.get('messages', message_name) + if message == self.abstract: + del parts[part_name] + + part = message.parts[part_name] + if part.element: + element = part.element.clone() + element.attr_name = part_name + else: + element = xsd.Element(part_name, part.type) + sequence.append(element) + return xsd.Element(None, xsd.ComplexType(sequence)) + + +class DocumentMessage(SoapMessage): + """In the document message there are no additional wrappers, and the + message parts appear directly under the SOAP Body element. + + """ + + def __init__(self, *args, **kwargs): + super(DocumentMessage, self).__init__(*args, **kwargs) + self._is_body_wrapped = False + + def _deserialize_body(self, xmlelement): + if self._is_body_wrapped: + result = self.body.parse(xmlelement, self.wsdl.types) + else: + # For now we assume that the body only has one child since only + # one part is specified in the wsdl. This should be handled way + # better + # XXX + xmlelement = xmlelement.getchildren()[0] + result = self.body.parse(xmlelement, self.wsdl.types) + return {'body': result} + + def _resolve_body(self, info, definitions, parts): + if not info or not parts: + return xsd.Element(None, xsd.ComplexType([])) + + # If the part name is omitted then all parts are available under + # the soap:body tag. Otherwise only the part with the given name. + if info['part']: + part_name = info['part'] + sub_elements = [parts[part_name].element] + else: + sub_elements = [] + for part_name, part in parts.items(): + element = part.element.clone() + element.attr_name = part_name or element.name + sub_elements.append(element) + + if len(sub_elements) > 1: + self._is_body_wrapped = True + return xsd.Element( + None, xsd.ComplexType(xsd.All(sub_elements))) + else: + self._is_body_wrapped = False + return sub_elements[0] + + +class RpcMessage(SoapMessage): + """In RPC messages each part is a parameter or a return value and appears + inside a wrapper element within the body. + + The wrapper element is named identically to the operation name and its + namespace is the value of the namespace attribute. Each message part + (parameter) appears under the wrapper, represented by an accessor named + identically to the corresponding parameter of the call. Parts are arranged + in the same order as the parameters of the call. + + """ + + def _resolve_body(self, info, definitions, parts): + """Return an XSD element for the SOAP:Body. + + Each part is a parameter or a return value and appears inside a + wrapper element within the body named identically to the operation + name and its namespace is the value of the namespace attribute. + + """ + if not info: + return xsd.Element(None, xsd.ComplexType([])) + + namespace = info['namespace'] + if self.type == 'input': + tag_name = etree.QName(namespace, self.operation.name) + else: + tag_name = etree.QName(namespace, self.abstract.name.localname) + + # Create the xsd element to create/parse the response. Each part + # is a sub element of the root node (which uses the operation name) + elements = [] + for name, msg in parts.items(): + if msg.element: + elements.append(msg.element) + else: + elements.append(xsd.Element(name, msg.type)) + return xsd.Element(tag_name, xsd.ComplexType(xsd.Sequence(elements))) + + def _deserialize_body(self, body_element): + """The name of the wrapper element is not defined. The WS-I defines + that it should be the operation name with the 'Response' string as + suffix. But lets just do it really stupid for now and use the first + element. + + """ + response_element = body_element.getchildren()[0] + if self.body: + result = self.body.parse(response_element, self.wsdl.types) + return {'body': result} + return {'body': None} diff --git a/src/zeep/wsdl/parse.py b/src/zeep/wsdl/parse.py new file mode 100644 index 0000000..865ce15 --- /dev/null +++ b/src/zeep/wsdl/parse.py @@ -0,0 +1,164 @@ +from lxml import etree + +from zeep.utils import qname_attr +from zeep.wsdl import definitions + +NSMAP = { + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', + 'wsaw': 'http://www.w3.org/2006/05/addressing/wsdl', +} + + +def parse_abstract_message(wsdl, xmlelement): + """Create an AbstractMessage object from a xml element. + + + * + * + + + """ + tns = wsdl.target_namespace + parts = [] + + for part in xmlelement.findall('wsdl:part', namespaces=NSMAP): + part_name = part.get('name') + part_element = qname_attr(part, 'element', tns) + part_type = qname_attr(part, 'type', tns) + + if part_element is not None: + part_element = wsdl.types.get_element(part_element) + if part_type is not None: + part_type = wsdl.types.get_type(part_type) + + part = definitions.MessagePart(part_element, part_type) + parts.append((part_name, part)) + + # Create the object, add the parts and return it + message_name = qname_attr(xmlelement, 'name', tns) + msg = definitions.AbstractMessage(message_name) + for part_name, part in parts: + msg.add_part(part_name, part) + return msg + + +def parse_abstract_operation(wsdl, xmlelement): + """Create an AbstractOperation object from a xml element. + + This is called from the parse_port_type function since the abstract + operations are part of the port type element. + + * + ? + ? + ? + + ? + ? + + * + ? + + + + """ + name = xmlelement.get('name') + kwargs = { + 'fault_messages': {} + } + + for msg_node in xmlelement.getchildren(): + tag_name = etree.QName(msg_node.tag).localname + if tag_name not in ('input', 'output', 'fault'): + continue + + param_msg = qname_attr( + msg_node, 'message', wsdl.target_namespace) + param_name = msg_node.get('name') + param_value = wsdl.get('messages', param_msg.text) + + if tag_name == 'input': + kwargs['input_message'] = param_value + elif tag_name == 'output': + kwargs['output_message'] = param_value + else: + kwargs['fault_messages'][param_name] = param_value + + wsa_action = msg_node.get(etree.QName(NSMAP['wsaw'], 'Action')) + param_value.wsa_action = wsa_action + + kwargs['name'] = name + kwargs['parameter_order'] = xmlelement.get('parameterOrder') + return definitions.AbstractOperation(**kwargs) + + +def parse_port_type(wsdl, xmlelement): + """Create a PortType object from a xml element. + + + + * + + + + """ + name = qname_attr(xmlelement, 'name', wsdl.target_namespace) + operations = {} + for elm in xmlelement.findall('wsdl:operation', namespaces=NSMAP): + operation = parse_abstract_operation(wsdl, elm) + operations[operation.name] = operation + return definitions.PortType(name, operations) + + +def parse_port(wsdl, xmlelement): + """Create a Port object from a xml element. + + This is called via the parse_service function since ports are part of the + service xml elements. + + * + ? + <-- extensibility element --> + + + """ + name = xmlelement.get('name') + binding_name = qname_attr(xmlelement, 'binding', wsdl.target_namespace) + return definitions.Port(name, binding_name=binding_name, xmlelement=xmlelement) + + +def parse_service(wsdl, xmlelement): + """ + + Syntax:: + + * + ? + * + ? + <-- extensibility element --> + + <-- extensibility element --> + + + Example:: + + + My first service + + + + + + """ + name = xmlelement.get('name') + ports = [] + for port_node in xmlelement.findall('wsdl:port', namespaces=NSMAP): + port = parse_port(wsdl, port_node) + if port: + ports.append(port) + + obj = definitions.Service(name) + for port in ports: + obj.add_port(port) + return obj diff --git a/src/zeep/wsdl/utils.py b/src/zeep/wsdl/utils.py new file mode 100644 index 0000000..f5af860 --- /dev/null +++ b/src/zeep/wsdl/utils.py @@ -0,0 +1,19 @@ +from lxml import etree + + +def get_or_create_header(envelope): + # find the namespace of the SOAP Envelope (because it's different for SOAP 1.1 and 1.2) + root_tag = etree.QName(envelope) + soap_envelope_namespace = root_tag.namespace + # look for the Header element and create it if not found + header_qname = '{%s}Header' % soap_envelope_namespace + header = envelope.find(header_qname) + if header is None: + header = etree.Element(header_qname) + envelope.insert(0, header) + return header + + +def etree_to_string(node): + return etree.tostring( + node, pretty_print=True, xml_declaration=True, encoding='utf-8') diff --git a/src/zeep/wsdl/wsdl.py b/src/zeep/wsdl/wsdl.py new file mode 100644 index 0000000..3eb247c --- /dev/null +++ b/src/zeep/wsdl/wsdl.py @@ -0,0 +1,387 @@ +from __future__ import print_function + +import logging +import operator +from collections import OrderedDict + +import six +from lxml import etree + +from zeep.parser import absolute_location, load_external, parse_xml +from zeep.utils import findall_multiple_ns +from zeep.wsdl import parse +from zeep.xsd import Schema +from zeep.xsd.context import ParserContext + +NSMAP = { + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', +} + +logger = logging.getLogger(__name__) + + +class Document(object): + """A WSDL Document exists out of one or more definitions. + + There is always one 'root' definition which should be passed as the + location to the Document. This definition can import other definitions. + These imports are non-transitive, only the definitions defined in the + imported document are available in the parent definition. This Document is + mostly just a simple interface to the root definition. + + After all definitions are loaded the definitions are resolved. This + resolves references which were not yet available during the initial + parsing phase. + + """ + + def __init__(self, location, transport): + """Initialize a WSDL document. + + The root definition properties are exposed as entry points. + + :param location: Location of this WSDL + :type location: string + :param transport: The transport object to be used + :type transport: zeep.transports.Transport + + """ + self.location = location if not hasattr(location, 'read') else None + self.transport = transport + + # Dict with all definition objects within this WSDL + self._definitions = {} + self.types = Schema([], transport=self.transport) + + # Dict with internal schema objects, used for lxml.ImportResolver + self._parser_context = ParserContext() + + document = self._load_content(location) + + root_definitions = Definition(self, document, self.location) + root_definitions.resolve_imports() + + # Make the wsdl definitions public + self.messages = root_definitions.messages + self.port_types = root_definitions.port_types + self.bindings = root_definitions.bindings + self.services = root_definitions.services + + def __repr__(self): + return '' % self.location + + def dump(self): + namespaces = {v: k for k, v in self.types.prefix_map.items()} + + print('') + print("Prefixes:") + for prefix, namespace in self.types.prefix_map.items(): + print(' ' * 4, '%s: %s' % (prefix, namespace)) + + print('') + print("Global elements:") + for elm_obj in sorted(self.types.elements, key=lambda k: six.text_type(k)): + value = six.text_type(elm_obj) + if hasattr(elm_obj, 'qname') and elm_obj.qname.namespace: + value = '%s:%s' % (namespaces[elm_obj.qname.namespace], value) + print(' ' * 4, value) + + print('') + print("Global types:") + for type_obj in sorted(self.types.types, key=lambda k: k.qname or ''): + value = six.text_type(type_obj) + if getattr(type_obj, 'qname', None) and type_obj.qname.namespace: + value = '%s:%s' % (namespaces[type_obj.qname.namespace], value) + print(' ' * 4, value) + + print('') + print("Bindings:") + for binding_obj in sorted(self.bindings.values(), key=lambda k: six.text_type(k)): + print(' ' * 4, six.text_type(binding_obj)) + + print('') + for service in self.services.values(): + print(six.text_type(service)) + for port in service.ports.values(): + print(' ' * 4, six.text_type(port)) + print(' ' * 8, 'Operations:') + + operations = sorted( + port.binding._operations.values(), + key=operator.attrgetter('name')) + + for operation in operations: + print('%s%s' % (' ' * 12, six.text_type(operation))) + print('') + + def _load_content(self, location): + """Load the XML content from the given location and return an + lxml.Element object. + + :param location: The URL of the document to load + :type location: string + + """ + if hasattr(location, 'read'): + return parse_xml(location.read()) + return load_external(location, self.transport, self.location) + + +class Definition(object): + """The Definition represents one wsdl:definition within a Document.""" + + def __init__(self, wsdl, doc, location): + logger.debug("Creating definition for %s", location) + self.wsdl = wsdl + self.location = location + + self.types = wsdl.types + self.port_types = {} + self.messages = {} + self.bindings = {} + self.services = OrderedDict() + + self.imports = {} + self._resolved_imports = False + + self.target_namespace = doc.get('targetNamespace') + self.wsdl._definitions[self.target_namespace] = self + self.nsmap = doc.nsmap + + # Process the definitions + self.parse_imports(doc) + + self.parse_types(doc) + self.messages = self.parse_messages(doc) + self.port_types = self.parse_ports(doc) + self.bindings = self.parse_binding(doc) + self.services = self.parse_service(doc) + + def __repr__(self): + return '' % self.location + + def get(self, name, key, _processed=None): + container = getattr(self, name) + if key in container: + return container[key] + + # Turns out that no one knows if the wsdl import statement is + # transitive or not. WSDL/SOAP specs are awesome... So lets just do it. + # TODO: refactor me into something more sane + _processed = _processed or set() + if self.target_namespace not in _processed: + _processed.add(self.target_namespace) + for definition in self.imports.values(): + try: + return definition.get(name, key, _processed) + except IndexError: + pass + raise IndexError("No definition %r in %r found" % (key, name)) + + def resolve_imports(self): + """Resolve all root elements (types, messages, etc).""" + + # Simple guard to protect against cyclic imports + if self._resolved_imports: + return + self._resolved_imports = True + + for definition in self.imports.values(): + definition.resolve_imports() + + for message in self.messages.values(): + message.resolve(self) + + for port_type in self.port_types.values(): + port_type.resolve(self) + + for binding in self.bindings.values(): + binding.resolve(self) + + for service in self.services.values(): + service.resolve(self) + + def parse_imports(self, doc): + """Import other WSDL definitions in this document. + + Note that imports are non-transitive, so only import definitions + which are defined in the imported document and ignore definitions + imported in that document. + + This should handle recursive imports though: + + A -> B -> A + A -> B -> C -> A + + :param doc: The source document + :type doc: lxml.etree._Element + + """ + for import_node in doc.findall("wsdl:import", namespaces=NSMAP): + location = import_node.get('location') + namespace = import_node.get('namespace') + if namespace in self.wsdl._definitions: + self.imports[namespace] = self.wsdl._definitions[namespace] + else: + document = self.wsdl._load_content(location) + location = absolute_location(location, self.location) + if etree.QName(document.tag).localname == 'schema': + self.types.add_documents([document], location) + else: + wsdl = Definition(self.wsdl, document, location) + self.imports[namespace] = wsdl + + def parse_types(self, doc): + """Return an xsd.Schema() instance for the given wsdl:types element. + + If the wsdl:types contain multiple schema definitions then a new + wrapping xsd.Schema is defined with xsd:import statements linking them + together. + + If the wsdl:types doesn't container an xml schema then an empty schema + is returned instead. + + + + * + + + + :param doc: The source document + :type doc: lxml.etree._Element + + """ + namespace_sets = [ + { + 'xsd': 'http://www.w3.org/2001/XMLSchema', + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', + }, + { + 'xsd': 'http://www.w3.org/1999/XMLSchema', + 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', + }, + ] + + # Find xsd:schema elements (wsdl:types/xsd:schema) + schema_nodes = findall_multiple_ns( + doc, 'wsdl:types/xsd:schema', namespace_sets) + self.types.add_documents(schema_nodes, self.location) + + def parse_messages(self, doc): + """ + + * + * + + + + :param doc: The source document + :type doc: lxml.etree._Element + + """ + result = {} + for msg_node in doc.findall("wsdl:message", namespaces=NSMAP): + msg = parse.parse_abstract_message(self, msg_node) + result[msg.name.text] = msg + logger.debug("Adding message: %s", msg.name.text) + return result + + def parse_ports(self, doc): + """Return dict with `PortType` instances as values + + + + * + + + + :param doc: The source document + :type doc: lxml.etree._Element + + """ + result = {} + for port_node in doc.findall('wsdl:portType', namespaces=NSMAP): + port_type = parse.parse_port_type(self, port_node) + result[port_type.name.text] = port_type + logger.debug("Adding port: %s", port_type.name.text) + return result + + def parse_binding(self, doc): + """Parse the binding elements and return a dict of bindings. + + Currently supported bindings are Soap 1.1, Soap 1.2., HTTP Get and + HTTP Post. The detection of the type of bindings is done by the + bindings themselves using the introspection of the xml nodes. + + XML Structure:: + + + * + <-- extensibility element (1) --> * + * + <-- extensibility element (2) --> * + ? + <-- extensibility element (3) --> + + ? + <-- extensibility element (4) --> * + + * + <-- extensibility element (5) --> * + + + + + + :param doc: The source document + :type doc: lxml.etree._Element + + """ + result = {} + if not getattr(self.wsdl.transport, 'supports_async', False): + from zeep.wsdl import bindings + binding_classes = [ + bindings.Soap11Binding, + bindings.Soap12Binding, + bindings.HttpGetBinding, + bindings.HttpPostBinding, + ] + else: + from zeep.asyncio import bindings # Python 3.5+ syntax + binding_classes = [ + bindings.AsyncSoap11Binding, + bindings.AsyncSoap12Binding, + ] + + for binding_node in doc.findall('wsdl:binding', namespaces=NSMAP): + # Detect the binding type + binding = None + for binding_class in binding_classes: + if binding_class.match(binding_node): + binding = binding_class.parse(self, binding_node) + + logger.debug("Adding binding: %s", binding.name.text) + result[binding.name.text] = binding + break + return result + + def parse_service(self, doc): + """ + + * + * + <-- extensibility element (1) --> + + + + + :param doc: The source document + :type doc: lxml.etree._Element + + """ + result = OrderedDict() + for service_node in doc.findall('wsdl:service', namespaces=NSMAP): + service = parse.parse_service(self, service_node) + result[service.name] = service + logger.debug("Adding service: %s", service.name) + return result diff --git a/src/zeep/wsse/__init__.py b/src/zeep/wsse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/zeep/wsse/username.py b/src/zeep/wsse/username.py new file mode 100644 index 0000000..5fb0081 --- /dev/null +++ b/src/zeep/wsse/username.py @@ -0,0 +1,118 @@ +import base64 +import hashlib +import os + +from lxml.builder import ElementMaker + +from zeep.wsse import utils + +NSMAP = { + 'wsse': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', + 'wsu': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd', +} +WSSE = ElementMaker(namespace=NSMAP['wsse']) +WSU = ElementMaker(namespace=NSMAP['wsu']) + + +class UsernameToken(object): + """UsernameToken Profile 1.1 + + https://docs.oasis-open.org/wss/v1.1/wss-v1.1-spec-os-UsernameTokenProfile.pdf + + Example response using PasswordText:: + + + + scott + password + + + + Example using PasswordDigest:: + + + + NNK + + weYI3nXd8LjMNVksCKFV8t3rgHh3Rw== + + WScqanjCEAC4mQoBE07sAQ== + 2003-07-16T01:24:32Z + + + + """ + username_token_profile_ns = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0' # noqa + soap_message_secutity_ns = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0' # noqa + + def __init__(self, username, password=None, password_digest=None, + use_digest=False, nonce=None, created=None): + self.username = username + self.password = password + self.password_digest = password_digest + self.nonce = nonce + self.created = created + self.use_digest = use_digest + + def sign(self, envelope, headers): + security = utils.get_security_header(envelope) + + # The token placeholder might already exists since it is specified in + # the WSDL. + token = security.find('{%s}UsernameToken' % NSMAP['wsse']) + if token is None: + token = WSSE.UsernameToken() + security.append(token) + + # Create the sub elements of the UsernameToken element + elements = [ + WSSE.Username(self.username) + ] + if self.password is not None or self.password_digest is not None: + if self.use_digest: + elements.extend(self._create_password_digest()) + else: + elements.extend(self._create_password_text()) + + token.extend(elements) + return envelope, headers + + def verify(self, envelope): + pass + + def _create_password_text(self): + return [ + WSSE.Password( + self.password, + Type='%s#PasswordText' % self.username_token_profile_ns) + ] + + def _create_password_digest(self): + if self.nonce: + nonce = self.nonce.encode('utf-8') + else: + nonce = os.urandom(16) + timestamp = utils.get_timestamp(self.created) + + # digest = Base64 ( SHA-1 ( nonce + created + password ) ) + if not self.password_digest: + digest = base64.b64encode( + hashlib.sha1( + nonce + timestamp.encode('utf-8') + + self.password.encode('utf-8') + ).digest() + ).decode('ascii') + else: + digest = self.password_digest + + return [ + WSSE.Password( + digest, + Type='%s#PasswordDigest' % self.username_token_profile_ns + ), + WSSE.Nonce( + base64.b64encode(nonce).decode('utf-8'), + EncodingType='%s#Base64Binary' % self.soap_message_secutity_ns + ), + WSU.Created(timestamp) + ] diff --git a/src/zeep/wsse/utils.py b/src/zeep/wsse/utils.py new file mode 100644 index 0000000..e7ed5e2 --- /dev/null +++ b/src/zeep/wsse/utils.py @@ -0,0 +1,30 @@ +import datetime + +import pytz +from lxml.builder import ElementMaker + +from zeep.wsdl.utils import get_or_create_header + +NSMAP = { + 'wsse': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', +} +WSSE = ElementMaker(namespace=NSMAP['wsse']) + + +def get_security_header(doc): + """Return the security header. If the header doesn't exist it will be + created. + + """ + header = get_or_create_header(doc) + security = header.find('wsse:Security', namespaces=NSMAP) + if security is None: + security = WSSE.Security() + header.append(security) + return security + + +def get_timestamp(timestamp=None): + timestamp = timestamp or datetime.datetime.utcnow() + timestamp = timestamp.replace(tzinfo=pytz.utc, microsecond=0) + return timestamp.isoformat() diff --git a/src/zeep/xsd/__init__.py b/src/zeep/xsd/__init__.py new file mode 100644 index 0000000..65cce78 --- /dev/null +++ b/src/zeep/xsd/__init__.py @@ -0,0 +1,6 @@ +from zeep.xsd.builtins import * # noqa +from zeep.xsd.elements import * # noqa +from zeep.xsd.types import * # noqa +from zeep.xsd.valueobjects import * # noqa +from zeep.xsd.schema import Schema # noqa +from zeep.xsd.indicators import * # noqa diff --git a/src/zeep/xsd/builtins.py b/src/zeep/xsd/builtins.py new file mode 100644 index 0000000..5219ae5 --- /dev/null +++ b/src/zeep/xsd/builtins.py @@ -0,0 +1,676 @@ +""" + Primitive datatypes + - string + - boolean + - decimal + - float + - double + - duration + - dateTime + - time + - date + - gYearMonth + - gYear + - gMonthDay + - gDay + - gMonth + - hexBinary + - base64Binary + - anyURI + - QName + - NOTATION + + Derived datatypes + - normalizedString + - token + - language + - NMTOKEN + - NMTOKENS + - Name + - NCName + - ID + - IDREF + - IDREFS + - ENTITY + - ENTITIES + - integer + - nonPositiveInteger + - negativeInteger + - long + - int + - short + - byte + - nonNegativeInteger + - unsignedLong + - unsignedInt + - unsignedShort + - unsignedByte + - positiveInteger + + +""" +from __future__ import division + +import base64 +import datetime +import math +import re +from decimal import Decimal as _Decimal + +import isodate +import pytz +import six +from lxml import etree + +from zeep.utils import qname_attr +from zeep.xsd.const import xsd_ns, xsi_ns, NS_XSD +from zeep.xsd.elements import Base +from zeep.xsd.types import SimpleType +from zeep.xsd.valueobjects import AnyObject + + +class ParseError(ValueError): + pass + + +def check_no_collection(func): + def _wrapper(self, value): + if isinstance(value, (list, dict, set)): + raise ValueError( + "The %s type doesn't accept collections as value" % ( + self.__class__.__name__)) + + return func(self, value) + return _wrapper + + +class _BuiltinType(SimpleType): + def __init__(self, qname=None, is_global=False): + super(_BuiltinType, self).__init__( + qname or etree.QName(self._default_qname), is_global) + + def signature(self, depth=()): + if self.qname.namespace == NS_XSD: + return 'xsd:%s' % self.name + return self.name + +## +# Primitive types + + +class String(_BuiltinType): + _default_qname = xsd_ns('string') + accepted_types = six.string_types + + @check_no_collection + def xmlvalue(self, value): + return six.text_type(value if value is not None else '') + + def pythonvalue(self, value): + return value + + +class Boolean(_BuiltinType): + _default_qname = xsd_ns('boolean') + accepted_types = (bool,) + + @check_no_collection + def xmlvalue(self, value): + return 'true' if value else 'false' + + def pythonvalue(self, value): + """Return True if the 'true' or '1'. 'false' and '0' are legal false + values, but we consider everything not true as false. + + """ + return value in ('true', '1') + + +class Decimal(_BuiltinType): + _default_qname = xsd_ns('decimal') + accepted_types = (_Decimal, float) + six.string_types + + @check_no_collection + def xmlvalue(self, value): + return str(value) + + def pythonvalue(self, value): + return _Decimal(value) + + +class Float(_BuiltinType): + _default_qname = xsd_ns('float') + accepted_types = (float, _Decimal) + six.string_types + + def xmlvalue(self, value): + return str(value).upper() + + def pythonvalue(self, value): + return float(value) + + +class Double(_BuiltinType): + _default_qname = xsd_ns('double') + accepted_types = (_Decimal, float) + six.string_types + + @check_no_collection + def xmlvalue(self, value): + return str(value) + + def pythonvalue(self, value): + return float(value) + + +class Duration(_BuiltinType): + _default_qname = xsd_ns('duration') + accepted_types = (isodate.duration.Duration,) + six.string_types + + @check_no_collection + def xmlvalue(self, value): + return isodate.duration_isoformat(value) + + def pythonvalue(self, value): + return isodate.parse_duration(value) + + +class DateTime(_BuiltinType): + _default_qname = xsd_ns('dateTime') + accepted_types = (datetime.datetime,) + six.string_types + + @check_no_collection + def xmlvalue(self, value): + return isodate.isostrf.strftime(value, '%Y-%m-%dT%H:%M:%S%Z') + + def pythonvalue(self, value): + return isodate.parse_datetime(value) + + +class Time(_BuiltinType): + _default_qname = xsd_ns('time') + accepted_types = (datetime.time,) + six.string_types + + @check_no_collection + def xmlvalue(self, value): + return isodate.isostrf.strftime(value, '%H:%M:%S%Z') + + def pythonvalue(self, value): + return isodate.parse_time(value) + + +class Date(_BuiltinType): + _default_qname = xsd_ns('date') + accepted_types = (datetime.date,) + six.string_types + + @check_no_collection + def xmlvalue(self, value): + if isinstance(value, six.string_types): + return value + return isodate.isostrf.strftime(value, '%Y-%m-%d') + + def pythonvalue(self, value): + return isodate.parse_date(value) + + +class gYearMonth(_BuiltinType): + """gYearMonth represents a specific gregorian month in a specific gregorian + year. + + Lexical representation: CCYY-MM + + """ + accepted_types = (datetime.date,) + six.string_types + _default_qname = xsd_ns('gYearMonth') + _pattern = re.compile( + r'^(?P-?\d{4,})-(?P\d\d)(?PZ|[-+]\d\d:?\d\d)?$') + + @check_no_collection + def xmlvalue(self, value): + year, month, tzinfo = value + return '%04d-%02d%s' % (year, month, _unparse_timezone(tzinfo)) + + def pythonvalue(self, value): + match = self._pattern.match(value) + if not match: + raise ParseError() + group = match.groupdict() + return ( + int(group['year']), int(group['month']), + _parse_timezone(group['timezone'])) + + +class gYear(_BuiltinType): + """gYear represents a gregorian calendar year. + + Lexical representation: CCYY + + """ + accepted_types = (datetime.date,) + six.string_types + _default_qname = xsd_ns('gYear') + _pattern = re.compile(r'^(?P-?\d{4,})(?PZ|[-+]\d\d:?\d\d)?$') + + @check_no_collection + def xmlvalue(self, value): + year, tzinfo = value + return '%04d%s' % (year, _unparse_timezone(tzinfo)) + + def pythonvalue(self, value): + match = self._pattern.match(value) + if not match: + raise ParseError() + group = match.groupdict() + return (int(group['year']), _parse_timezone(group['timezone'])) + + +class gMonthDay(_BuiltinType): + """gMonthDay is a gregorian date that recurs, specifically a day of the + year such as the third of May. + + Lexical representation: --MM-DD + + """ + accepted_types = (datetime.date, ) + six.string_types + _default_qname = xsd_ns('gMonthDay') + _pattern = re.compile( + r'^--(?P\d\d)-(?P\d\d)(?PZ|[-+]\d\d:?\d\d)?$') + + @check_no_collection + def xmlvalue(self, value): + month, day, tzinfo = value + return '--%02d-%02d%s' % (month, day, _unparse_timezone(tzinfo)) + + def pythonvalue(self, value): + match = self._pattern.match(value) + if not match: + raise ParseError() + + group = match.groupdict() + return ( + int(group['month']), int(group['day']), + _parse_timezone(group['timezone'])) + + +class gDay(_BuiltinType): + """gDay is a gregorian day that recurs, specifically a day of the month + such as the 5th of the month + + Lexical representation: ---DD + + """ + accepted_types = (datetime.date,) + six.string_types + _default_qname = xsd_ns('gDay') + _pattern = re.compile(r'^---(?P\d\d)(?PZ|[-+]\d\d:?\d\d)?$') + + @check_no_collection + def xmlvalue(self, value): + day, tzinfo = value + return '---%02d%s' % (day, _unparse_timezone(tzinfo)) + + def pythonvalue(self, value): + match = self._pattern.match(value) + if not match: + raise ParseError() + group = match.groupdict() + return (int(group['day']), _parse_timezone(group['timezone'])) + + +class gMonth(_BuiltinType): + """gMonth is a gregorian month that recurs every year. + + Lexical representation: --MM + + """ + accepted_types = (datetime.date,) + six.string_types + _default_qname = xsd_ns('gMonth') + _pattern = re.compile(r'^--(?P\d\d)(?PZ|[-+]\d\d:?\d\d)?$') + + @check_no_collection + def xmlvalue(self, value): + month, tzinfo = value + return '--%d%s' % (month, _unparse_timezone(tzinfo)) + + def pythonvalue(self, value): + match = self._pattern.match(value) + if not match: + raise ParseError() + group = match.groupdict() + return (int(group['month']), _parse_timezone(group['timezone'])) + + +class HexBinary(_BuiltinType): + accepted_types = six.string_types + _default_qname = xsd_ns('hexBinary') + + @check_no_collection + def xmlvalue(self, value): + return value + + def pythonvalue(self, value): + return value + + +class Base64Binary(_BuiltinType): + accepted_types = six.string_types + _default_qname = xsd_ns('base64Binary') + + @check_no_collection + def xmlvalue(self, value): + return base64.b64encode(value) + + def pythonvalue(self, value): + return base64.b64decode(value) + + +class AnyURI(_BuiltinType): + accepted_types = six.string_types + _default_qname = xsd_ns('anyURI') + + @check_no_collection + def xmlvalue(self, value): + return value + + def pythonvalue(self, value): + return value + + +class QName(_BuiltinType): + accepted_types = six.string_types + _default_qname = xsd_ns('QName') + + @check_no_collection + def xmlvalue(self, value): + return value + + def pythonvalue(self, value): + return value + + +class Notation(_BuiltinType): + accepted_types = six.string_types + _default_qname = xsd_ns('NOTATION') + + +## +# Derived datatypes + +class NormalizedString(String): + _default_qname = xsd_ns('normalizedString') + + +class Token(NormalizedString): + _default_qname = xsd_ns('token') + + +class Language(Token): + _default_qname = xsd_ns('language') + + +class NmToken(Token): + _default_qname = xsd_ns('NMTOKEN') + + +class NmTokens(NmToken): + _default_qname = xsd_ns('NMTOKENS') + + +class Name(Token): + _default_qname = xsd_ns('Name') + + +class NCName(Name): + _default_qname = xsd_ns('NCName') + + +class ID(NCName): + _default_qname = xsd_ns('ID') + + +class IDREF(NCName): + _default_qname = xsd_ns('IDREF') + + +class IDREFS(IDREF): + _default_qname = xsd_ns('IDREFS') + + +class Entity(NCName): + _default_qname = xsd_ns('ENTITY') + + +class Entities(Entity): + _default_qname = xsd_ns('ENTITIES') + + +class Integer(Decimal): + _default_qname = xsd_ns('integer') + + def xmlvalue(self, value): + return str(value) + + def pythonvalue(self, value): + return int(value) + + +class NonPositiveInteger(Integer): + _default_qname = xsd_ns('nonPositiveInteger') + + +class NegativeInteger(Integer): + _default_qname = xsd_ns('negativeInteger') + + +class Long(Integer): + _default_qname = xsd_ns('long') + + def pythonvalue(self, value): + return long(value) if six.PY2 else int(value) # noqa + + +class Int(Long): + _default_qname = xsd_ns('int') + + +class Short(Int): + _default_qname = xsd_ns('short') + + +class Byte(Short): + """A signed 8-bit integer""" + _default_qname = xsd_ns('byte') + + +class NonNegativeInteger(Integer): + _default_qname = xsd_ns('nonNegativeInteger') + + +class UnsignedLong(NonNegativeInteger): + _default_qname = xsd_ns('unsignedLong') + + +class UnsignedInt(UnsignedLong): + _default_qname = xsd_ns('unsignedInt') + + +class UnsignedShort(UnsignedInt): + _default_qname = xsd_ns('unsignedShort') + + +class UnsignedByte(UnsignedShort): + _default_qname = xsd_ns('unsignedByte') + + +class PositiveInteger(NonNegativeInteger): + _default_qname = xsd_ns('positiveInteger') + + +## +# Other + +class AnyType(_BuiltinType): + _default_qname = xsd_ns('anyType') + + def render(self, parent, value): + if isinstance(value, AnyObject): + value.xsd_type.render(parent, value.value) + parent.set(xsi_ns('type'), value.xsd_type.qname) + else: + parent.text = self.xmlvalue(value) + + def parse_xmlelement(self, xmlelement, schema=None, allow_none=True, + context=None): + xsi_type = qname_attr(xmlelement, xsi_ns('type')) + xsi_nil = xmlelement.get(xsi_ns('nil')) + + # Handle xsi:nil attribute + if xsi_nil == "true": + return None + + if xsi_type and schema: + xsd_type = schema.get_type(xsi_type) + + # If the xsd_type is xsd:anyType then we will recurs so ignore + # that. + if isinstance(xsd_type, self.__class__): + return xmlelement.text or None + + return xsd_type.parse_xmlelement( + xmlelement, schema, context=context) + + if xmlelement.text is None: + return + + return self.pythonvalue(xmlelement.text) + + def xmlvalue(self, value): + return value + + def pythonvalue(self, value, schema=None): + return value + + +class AnySimpleType(AnyType): + _default_qname = xsd_ns('anySimpleType') + + +def _parse_timezone(val): + """Return a pytz.tzinfo object""" + if not val: + return + + if val == 'Z' or val == '+00:00': + return pytz.utc + + negative = val.startswith('-') + minutes = int(val[-2:]) + minutes += int(val[1:3]) * 60 + + if negative: + minutes = 0 - minutes + return pytz.FixedOffset(minutes) + + +def _unparse_timezone(tzinfo): + if not tzinfo: + return '' + + if tzinfo == pytz.utc: + return 'Z' + + hours = math.floor(tzinfo._minutes / 60) + minutes = tzinfo._minutes % 60 + + if hours > 0: + return '+%02d:%02d' % (hours, minutes) + return '-%02d:%02d' % (abs(hours), minutes) + + +default_types = { + cls._default_qname: cls() for cls in [ + # Primitive + String, + Boolean, + Decimal, + Float, + Double, + Duration, + DateTime, + Time, + Date, + gYearMonth, + gYear, + gMonthDay, + gDay, + gMonth, + HexBinary, + Base64Binary, + AnyURI, + QName, + Notation, + + # Derived + NormalizedString, + Token, + Language, + NmToken, + NmTokens, + Name, + NCName, + ID, + IDREF, + IDREFS, + Entity, + Entities, + Integer, + NonPositiveInteger, # noqa + NegativeInteger, + Long, + Int, + Short, + Byte, + NonNegativeInteger, # noqa + UnsignedByte, + UnsignedInt, + UnsignedLong, + UnsignedShort, + PositiveInteger, + + # Other + AnyType, + AnySimpleType, + ] +} + + +class Schema(Base): + name = 'schema' + attr_name = 'schema' + qname = xsd_ns('schema') + + def clone(self, qname, min_occurs=1, max_occurs=1): + return self.__class__() + + def parse_kwargs(self, kwargs, name, available_kwargs): + if name in available_kwargs: + value = kwargs[name] + available_kwargs.remove(name) + return {name: value} + return {} + + def parse(self, xmlelement, schema, context=None): + from zeep.xsd.schema import Schema + schema = Schema(xmlelement, schema._transport) + context.schemas.append(schema) + return schema + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + if xmlelements[0].tag == self.qname: + xmlelement = xmlelements.popleft() + result = self.parse(xmlelement, schema, context=context) + return result + + def resolve(self): + return self + + +default_elements = { + xsd_ns('schema'): Schema(), +} diff --git a/src/zeep/xsd/const.py b/src/zeep/xsd/const.py new file mode 100644 index 0000000..a2b4a36 --- /dev/null +++ b/src/zeep/xsd/const.py @@ -0,0 +1,12 @@ +from lxml import etree + +NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance' +NS_XSD = 'http://www.w3.org/2001/XMLSchema' + + +def xsi_ns(localname): + return etree.QName(NS_XSI, localname) + + +def xsd_ns(localname): + return etree.QName(NS_XSD, localname) diff --git a/src/zeep/xsd/context.py b/src/zeep/xsd/context.py new file mode 100644 index 0000000..744f665 --- /dev/null +++ b/src/zeep/xsd/context.py @@ -0,0 +1,49 @@ +class SchemaRepository(object): + """Mapping between schema target namespace and schema object""" + def __init__(self): + self._schemas = {} + + def add(self, schema): + self._schemas[schema._target_namespace] = schema + + def get(self, namespace): + if namespace in self._schemas: + return self._schemas[namespace] + + def __contains__(self, namespace): + return namespace in self._schemas + + def __len__(self): + return len(self._schemas) + + +class SchemaNodeRepository(object): + """Mapping between schema target namespace and lxml node""" + def __init__(self): + self._nodes = {} + + def add(self, key, value): + self._nodes[key] = value + + def get(self, key): + return self._nodes[key] + + def __len__(self): + return len(self._nodes) + + +class ParserContext(object): + """Parser context when parsing wsdl/xsd files""" + def __init__(self): + self.schema_nodes = SchemaNodeRepository() + self.schema_objects = SchemaRepository() + + # Mapping between internal nodes and original location + self.schema_locations = {} + + +class XmlParserContext(object): + """Parser context when parsing XML elements""" + + def __init__(self): + self.schemas = [] diff --git a/src/zeep/xsd/elements.py b/src/zeep/xsd/elements.py new file mode 100644 index 0000000..e7e3d87 --- /dev/null +++ b/src/zeep/xsd/elements.py @@ -0,0 +1,500 @@ +import copy +import logging + +from lxml import etree + +from zeep import exceptions +from zeep.exceptions import UnexpectedElementError +from zeep.utils import qname_attr +from zeep.xsd.const import xsi_ns +from zeep.xsd.context import XmlParserContext +from zeep.xsd.utils import max_occurs_iter +from zeep.xsd.valueobjects import AnyObject # cyclic import / FIXME + +logger = logging.getLogger(__name__) + + +class Base(object): + + @property + def accepts_multiple(self): + return self.max_occurs != 1 + + @property + def default_value(self): + return None + + @property + def is_optional(self): + return self.min_occurs == 0 + + def parse_args(self, args): + result = {} + if not args: + return result, args + + value = args.pop(0) + return {self.attr_name: value}, args + + def parse_kwargs(self, kwargs, name, available_kwargs): + raise NotImplementedError() + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + """Consume matching xmlelements and call parse() on each of them""" + raise NotImplementedError() + + def signature(self, depth=()): + return '' + + +class Any(Base): + name = None + + def __init__(self, max_occurs=1, min_occurs=1, process_contents='strict', + restrict=None): + """ + + :param process_contents: Specifies how the XML processor should handle + validation against the elements specified by + this any element + :type process_contents: str (strict, lax, skip) + + """ + super(Any, self).__init__() + self.max_occurs = max_occurs + self.min_occurs = min_occurs + self.restrict = restrict + self.process_contents = process_contents + + # cyclic import + from zeep.xsd.builtins import AnyType + self.type = AnyType() + + def __call__(self, any_object): + return any_object + + def __repr__(self): + return '<%s(name=%r)>' % (self.__class__.__name__, self.name) + + def accept(self, value): + return True + + def parse(self, xmlelement, schema, context=None): + if self.process_contents == 'skip': + return xmlelement + + qname = etree.QName(xmlelement.tag) + for context_schema in context.schemas: + if qname.namespace in context_schema._schemas: + schema = context_schema + break + + xsd_type = qname_attr(xmlelement, xsi_ns('type')) + if xsd_type is not None: + xsd_type = schema.get_type(xsd_type) + return xsd_type.parse_xmlelement(xmlelement, schema, context=context) + + try: + element = schema.get_element(xmlelement.tag) + return element.parse(xmlelement, schema, context=context) + except (exceptions.NamespaceError, exceptions.LookupError): + return xmlelement + + def parse_kwargs(self, kwargs, name, available_kwargs): + if name in available_kwargs: + available_kwargs.remove(name) + value = kwargs[name] + return {name: value} + return {} + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + """Consume matching xmlelements and call parse() on each of them""" + result = [] + + for i in max_occurs_iter(self.max_occurs): + if xmlelements: + xmlelement = xmlelements.popleft() + item = self.parse(xmlelement, schema, context=context) + if item is not None: + result.append(item) + else: + break + + if not self.accepts_multiple: + result = result[0] if result else None + return result + + def render(self, parent, value): + assert parent is not None + if self.accepts_multiple and isinstance(value, list): + from zeep.xsd import SimpleType + + if isinstance(self.restrict, SimpleType): + for val in value: + node = etree.SubElement(parent, 'item') + node.set(xsi_ns('type'), self.restrict.qname) + self._render_value_item(node, val) + elif self.restrict: + for val in value: + node = etree.SubElement(parent, self.restrict.name) + # node.set(xsi_ns('type'), self.restrict.qname) + self._render_value_item(node, val) + else: + for val in value: + self._render_value_item(parent, val) + else: + self._render_value_item(parent, value) + + def _render_value_item(self, parent, value): + if not value: + return + + # Check if we received a proper value object. If we receive the wrong + # type then return a nice error message + if self.restrict: + expected_types = (etree._Element,) + self.restrict.accepted_types + else: + expected_types = (etree._Element, AnyObject) + + if not isinstance(value, expected_types): + type_names = [ + '%s.%s' % (t.__module__, t.__name__) for t in expected_types + ] + err_message = "Any element received object of type %r, expected %s" % ( + type(value).__name__, ' or '.join(type_names)) + + raise TypeError('\n'.join(( + err_message, + "See http://docs.python-zeep.org/en/master/datastructures.html" + "#any-objects for more information" + ))) + + if isinstance(value, etree._Element): + parent.append(value) + + elif self.restrict: + if isinstance(value, list): + for val in value: + self.restrict.render(parent, val) + else: + self.restrict.render(parent, value) + else: + if isinstance(value.value, list): + for val in value.value: + value.xsd_elm.render(parent, val) + else: + value.xsd_elm.render(parent, value.value) + + def resolve(self): + return self + + def signature(self, depth=()): + if self.restrict: + base = self.restrict.name + else: + base = 'ANY' + + if self.accepts_multiple: + return '%s[]' % base + return base + + +class Element(Base): + def __init__(self, name, type_=None, min_occurs=1, max_occurs=1, + nillable=False, default=None, is_global=False, attr_name=None): + if name and not isinstance(name, etree.QName): + name = etree.QName(name) + + self.name = name.localname if name else None + self.qname = name + self.type = type_ + self.min_occurs = min_occurs + self.max_occurs = max_occurs + self.nillable = nillable + self.is_global = is_global + self.default = default + self.attr_name = attr_name or self.name + # assert type_ + + def __str__(self): + if self.type: + return '%s(%s)' % (self.name, self.type.signature()) + return '%s()' % self.name + + def __call__(self, *args, **kwargs): + instance = self.type(*args, **kwargs) + if hasattr(instance, '_xsd_type'): + instance._xsd_elm = self + return instance + + def __repr__(self): + return '<%s(name=%r, type=%r)>' % ( + self.__class__.__name__, self.name, self.type) + + def __eq__(self, other): + return ( + other is not None and + self.__class__ == other.__class__ and + self.__dict__ == other.__dict__) + + @property + def default_value(self): + value = [] if self.accepts_multiple else self.default + return value + + def clone(self, name=None, min_occurs=1, max_occurs=1): + new = copy.copy(self) + + if name: + if not isinstance(name, etree.QName): + name = etree.QName(name) + new.name = name.localname + new.qname = name + new.attr_name = new.name + + new.min_occurs = min_occurs + new.max_occurs = max_occurs + return new + + def parse(self, xmlelement, schema, allow_none=False, context=None): + """Process the given xmlelement. If it has an xsi:type attribute then + use that for further processing. This should only be done for subtypes + of the defined type but for now we just accept everything. + + """ + context = context or XmlParserContext() + instance_type = qname_attr(xmlelement, xsi_ns('type')) + if instance_type: + xsd_type = schema.get_type(instance_type) + else: + xsd_type = self.type + return xsd_type.parse_xmlelement( + xmlelement, schema, allow_none=allow_none, context=context) + + def parse_kwargs(self, kwargs, name, available_kwargs): + return self.type.parse_kwargs( + kwargs, name or self.attr_name, available_kwargs) + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + """Consume matching xmlelements and call parse() on each of them""" + result = [] + num_matches = 0 + for i in max_occurs_iter(self.max_occurs): + if not xmlelements: + break + + # Workaround for SOAP servers which incorrectly use unqualified + # or qualified elements in the responses (#170, #176). To make the + # best of it we compare the full uri's if both elements have a + # namespace. If only one has a namespace then only compare the + # localname. + + # If both elements have a namespace and they don't match then skip + element_tag = etree.QName(xmlelements[0].tag) + if ( + element_tag.namespace and self.qname.namespace and + element_tag.namespace != self.qname.namespace + ): + break + + # Only compare the localname + if element_tag.localname == self.qname.localname: + xmlelement = xmlelements.popleft() + num_matches += 1 + item = self.parse( + xmlelement, schema, allow_none=True, context=context) + if item is not None: + result.append(item) + else: + # If the element passed doesn't match and the current one is + # not optional then throw an error + if num_matches == 0 and not self.is_optional: + raise UnexpectedElementError( + "Unexpected element %r, expected %r" % ( + element_tag.text, self.qname.text)) + break + + if not self.accepts_multiple: + result = result[0] if result else None + return result + + def render(self, parent, value): + """Render the value(s) on the parent lxml.Element. + + This actually just calls _render_value_item for each value. + + """ + assert parent is not None + + if self.accepts_multiple and isinstance(value, list): + for val in value: + self._render_value_item(parent, val) + else: + self._render_value_item(parent, value) + + def _render_value_item(self, parent, value): + """Render the value on the parent lxml.Element""" + if value is None: + if self.is_optional: + return + + elm = etree.SubElement(parent, self.qname) + if self.nillable: + elm.set(xsi_ns('nil'), 'true') + return + + if self.name is None: + return self.type.render(parent, value) + + node = etree.SubElement(parent, self.qname) + xsd_type = getattr(value, '_xsd_type', self.type) + + if xsd_type != self.type: + return value._xsd_type.render(node, value, xsd_type) + return self.type.render(node, value) + + def resolve_type(self): + self.type = self.type.resolve() + + def resolve(self): + self.resolve_type() + return self + + def signature(self, depth=()): + if len(depth) > 0 and self.is_global: + return self.name + '()' + + value = self.type.signature(depth) + if self.accepts_multiple: + return '%s[]' % value + return value + + +class Attribute(Element): + def __init__(self, name, type_=None, required=False, default=None): + super(Attribute, self).__init__(name=name, type_=type_, default=default) + self.required = required + self.array_type = None + + def parse(self, value): + try: + return self.type.pythonvalue(value) + except (TypeError, ValueError): + logger.exception("Error during xml -> python translation") + return None + + def render(self, parent, value): + if value is None and not self.required: + return + + value = self.type.xmlvalue(value) + parent.set(self.qname, value) + + def clone(self, *args, **kwargs): + array_type = kwargs.pop('array_type', None) + new = super(Attribute, self).clone(*args, **kwargs) + new.array_type = array_type + return new + + def resolve(self): + retval = super(Attribute, self).resolve() + self.type = self.type.resolve() + if self.array_type: + retval.array_type = self.array_type.resolve() + return retval + + +class AttributeGroup(Element): + def __init__(self, name, attributes): + self.name = name + self.type = None + self._attributes = attributes + super(AttributeGroup, self).__init__(name, is_global=True) + + @property + def attributes(self): + result = [] + for attr in self._attributes: + if isinstance(attr, AttributeGroup): + result.extend(attr.attributes) + else: + result.append(attr) + return result + + def resolve(self): + resolved = [] + for attribute in self._attributes: + value = attribute.resolve() + assert value is not None + if isinstance(value, list): + resolved.extend(value) + else: + resolved.append(value) + self._attributes = resolved + return self + + def signature(self, depth=()): + return ', '.join(attr.signature() for attr in self._attributes) + + +class AnyAttribute(Base): + name = None + + def __init__(self, process_contents='strict'): + self.qname = None + self.process_contents = process_contents + + def parse(self, attributes, context=None): + return attributes + + def resolve(self): + return self + + def render(self, parent, value): + if value is None: + return + + for name, val in value.items(): + parent.set(name, val) + + def signature(self, depth=()): + return '{}' + + +class RefElement(object): + + def __init__(self, tag, ref, schema, is_qualified=False, + min_occurs=1, max_occurs=1): + self._ref = ref + self._is_qualified = is_qualified + self._schema = schema + self.min_occurs = min_occurs + self.max_occurs = max_occurs + + def resolve(self): + elm = self._schema.get_element(self._ref) + elm = elm.clone( + elm.qname, min_occurs=self.min_occurs, max_occurs=self.max_occurs) + return elm.resolve() + + +class RefAttribute(RefElement): + def __init__(self, *args, **kwargs): + self._array_type = kwargs.pop('array_type', None) + super(RefAttribute, self).__init__(*args, **kwargs) + + def resolve(self): + attrib = self._schema.get_attribute(self._ref) + attrib = attrib.clone(attrib.qname, array_type=self._array_type) + return attrib.resolve() + + +class RefAttributeGroup(RefElement): + def resolve(self): + value = self._schema.get_attribute_group(self._ref) + return value.resolve() + + +class RefGroup(RefElement): + def resolve(self): + return self._schema.get_group(self._ref) diff --git a/src/zeep/xsd/indicators.py b/src/zeep/xsd/indicators.py new file mode 100644 index 0000000..2ba117c --- /dev/null +++ b/src/zeep/xsd/indicators.py @@ -0,0 +1,565 @@ +from __future__ import print_function + +import copy +import operator +from collections import OrderedDict, defaultdict, deque + +from cached_property import threaded_cached_property + +from zeep.exceptions import UnexpectedElementError +from zeep.xsd.elements import Any, Base, Element +from zeep.xsd.utils import ( + NamePrefixGenerator, UniqueNameGenerator, max_occurs_iter) + +__all__ = ['All', 'Choice', 'Group', 'Sequence'] + + +class Indicator(Base): + + def __repr__(self): + return '<%s(%s)>' % ( + self.__class__.__name__, super(Indicator, self).__repr__()) + + @threaded_cached_property + def default_value(self): + return OrderedDict([ + (name, element.default_value) for name, element in self.elements + ]) + + def clone(self, name, min_occurs=1, max_occurs=1): + raise NotImplementedError() + + +class OrderIndicator(Indicator, list): + name = None + + def __init__(self, elements=None, min_occurs=1, max_occurs=1): + self.min_occurs = min_occurs + self.max_occurs = max_occurs + + if elements is None: + super(OrderIndicator, self).__init__() + else: + super(OrderIndicator, self).__init__() + self.extend(elements) + + def clone(self, name, min_occurs=1, max_occurs=1): + return self.__class__( + elements=list(self), + min_occurs=min_occurs, + max_occurs=max_occurs) + + @threaded_cached_property + def elements(self): + """List of tuples containing the element name and the element""" + result = [] + for name, elm in self.elements_nested: + if name is None: + result.extend(elm.elements) + else: + result.append((name, elm)) + return result + + @threaded_cached_property + def elements_nested(self): + """List of tuples containing the element name and the element""" + result = [] + generator = NamePrefixGenerator() + generator_2 = UniqueNameGenerator() + + for elm in self: + if isinstance(elm, (All, Choice, Group, Sequence)): + if elm.accepts_multiple: + result.append((generator.get_name(), elm)) + else: + for sub_name, sub_elm in elm.elements: + sub_name = generator_2.create_name(sub_name) + result.append((None, elm)) + elif isinstance(elm, (Any, Choice)): + result.append((generator.get_name(), elm)) + else: + name = generator_2.create_name(elm.attr_name) + result.append((name, elm)) + return result + + def accept(self, values): + """Return the number of values which are accepted by this choice. + + If not all required elements are available then 0 is returned. + + """ + num = 0 + for name, element in self.elements_nested: + if isinstance(element, Element): + if element.name in values and values[element.name] is not None: + num += 1 + else: + num += element.accept(values) + return num + + def parse_args(self, args): + result = {} + for name, element in self.elements: + if not args: + break + arg = args.pop(0) + result[name] = arg + + return result, args + + def parse_kwargs(self, kwargs, name, available_kwargs): + """Apply the given kwarg to the element. + + The available_kwargs is modified in-place. Returns a dict with the + result. + + """ + if self.accepts_multiple: + assert name + + if name and name in available_kwargs: + + # Make sure we have a list, lame lame + item_kwargs = kwargs.get(name) + if not isinstance(item_kwargs, list): + item_kwargs = [item_kwargs] + + result = [] + for i, item_value in zip(max_occurs_iter(self.max_occurs), item_kwargs): + item_kwargs = set(item_value.keys()) + subresult = OrderedDict() + for item_name, element in self.elements: + value = element.parse_kwargs(item_value, item_name, item_kwargs) + if value is not None: + subresult.update(value) + + result.append(subresult) + + if self.accepts_multiple: + result = {name: result} + else: + result = result[0] if result else None + + # All items consumed + if not any(filter(None, item_kwargs)): + available_kwargs.remove(name) + + return result + + else: + result = OrderedDict() + for elm_name, element in self.elements_nested: + sub_result = element.parse_kwargs(kwargs, elm_name, available_kwargs) + if sub_result: + result.update(sub_result) + + if name: + result = {name: result} + + return result + + def resolve(self): + for i, elm in enumerate(self): + self[i] = elm.resolve() + return self + + def render(self, parent, value): + """Create subelements in the given parent object. + + To make sure we render values only once the value items are copied + and the rendered attribute is removed from it once it is rendered. + + """ + if not isinstance(value, list): + values = [value] + else: + values = value + + for i, value in zip(max_occurs_iter(self.max_occurs), values): + for name, element in self.elements_nested: + if name: + if name in value: + element_value = value[name] + del value[name] + else: + element_value = None + else: + element_value = value + if element_value is not None or not element.is_optional: + element.render(parent, element_value) + + def signature(self, depth=()): + """ + Use a tuple of element names as depth indicator, so that when an element is repeated, + do not try to create its signature, as it would lead to infinite recursion + """ + depth += (self.name,) + parts = [] + for name, element in self.elements_nested: + if hasattr(element, 'type') and element.type.name and element.type.name in depth: + parts.append('{}: {}'.format(name, element.type.name)) + elif name: + parts.append('%s: %s' % (name, element.signature(depth))) + elif isinstance(element, Indicator): + parts.append('%s' % (element.signature(depth))) + else: + parts.append('%s: %s' % (name, element.signature(depth))) + part = ', '.join(parts) + + if self.accepts_multiple: + return '[%s]' % (part,) + return part + + +class All(OrderIndicator): + """Allows the elements in the group to appear (or not appear) in any order + in the containing element. + + """ + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + result = OrderedDict() + expected_tags = {element.qname for __, element in self.elements} + consumed_tags = set() + + values = defaultdict(deque) + for i, elm in enumerate(xmlelements): + if elm.tag in expected_tags: + consumed_tags.add(i) + values[elm.tag].append(elm) + + # Remove the consumed tags from the xmlelements + for i in sorted(consumed_tags, reverse=True): + del xmlelements[i] + + for name, element in self.elements: + sub_elements = values.get(element.qname) + if sub_elements: + result[name] = element.parse_xmlelements( + sub_elements, schema, context=context) + + return result + + +class Choice(OrderIndicator): + + @property + def is_optional(self): + return True + + @property + def default_value(self): + return OrderedDict() + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + """Return a dictionary""" + result = [] + + for i in max_occurs_iter(self.max_occurs): + if len(xmlelements) < 1: + break + for node in list(xmlelements): + + # Choose out of multiple + options = [] + for element_name, element in self.elements_nested: + + local_xmlelements = copy.copy(xmlelements) + + try: + sub_result = element.parse_xmlelements( + local_xmlelements, schema, context=context) + except UnexpectedElementError: + continue + + if isinstance(element, OrderIndicator): + if element.accepts_multiple: + sub_result = {element_name: sub_result} + else: + sub_result = {element_name: sub_result} + + num_consumed = len(xmlelements) - len(local_xmlelements) + if num_consumed: + options.append((num_consumed, sub_result)) + + if not options: + xmlelements = [] + break + + # Sort on least left + options = sorted(options, key=operator.itemgetter(0), reverse=True) + if options: + result.append(options[0][1]) + for i in range(options[0][0]): + xmlelements.popleft() + else: + break + + if self.accepts_multiple: + result = {name: result} + else: + result = result[0] if result else {} + return result + + def parse_kwargs(self, kwargs, name, available_kwargs): + """Processes the kwargs for this choice element. + + Returns a dict containing the values found. + + This handles two distinct initialization methods: + + 1. Passing the choice elements directly to the kwargs (unnested) + 2. Passing the choice elements into the `name` kwarg (_alue_1) (nested). + This case is required when multiple choice elements are given. + + :param name: Name of the choice element (_value_1) + :type name: str + :param element: Choice element object + :type element: zeep.xsd.Choice + :param kwargs: dict (or list of dicts) of kwargs for initialization + :type kwargs: list / dict + + """ + if name and name in available_kwargs: + values = kwargs[name] or [] + available_kwargs.remove(name) + result = [] + + if isinstance(values, dict): + values = [values] + + for value in values: + for element in self: + # TODO: Use most greedy choice instead of first matching + if isinstance(element, OrderIndicator): + choice_value = value[name] if name in value else value + if element.accept(choice_value): + result.append(choice_value) + break + else: + if element.name in value: + choice_value = value.get(element.name) + result.append({element.name: choice_value}) + break + else: + raise TypeError( + "No complete xsd:Sequence found for the xsd:Choice %r.\n" + "The signature is: %s" % (name, self.signature())) + + if not self.accepts_multiple: + result = result[0] if result else None + else: + # Direct use-case isn't supported when maxOccurs > 1 + if self.accepts_multiple: + return {} + + result = {} + + # When choice elements are specified directly in the kwargs + found = False + for i, choice in enumerate(self): + temp_kwargs = copy.copy(available_kwargs) + subresult = choice.parse_kwargs(kwargs, None, temp_kwargs) + + if subresult: + if not any(subresult.values()): + available_kwargs.intersection_update(temp_kwargs) + result.update(subresult) + elif not found: + available_kwargs.intersection_update(temp_kwargs) + result.update(subresult) + found = True + if found: + for choice_name, choice in self.elements: + result.setdefault(choice_name, None) + else: + result = {} + + if name and self.accepts_multiple: + result = {name: result} + return result + + def render(self, parent, value): + """Render the value to the parent element tree node. + + This is a bit more complex then the order render methods since we need + to search for the best matching choice element. + + """ + if not self.accepts_multiple: + value = [value] + + for item in value: + result = self._find_element_to_render(item) + if result: + element, choice_value = result + element.render(parent, choice_value) + + def accept(self, values): + """Return the number of values which are accepted by this choice. + + If not all required elements are available then 0 is returned. + + """ + nums = set() + for name, element in self.elements_nested: + if isinstance(element, Element): + if name in values and values[name]: + nums.add(1) + else: + num = element.accept(values) + nums.add(num) + return max(nums) + + def _find_element_to_render(self, value): + """Return a tuple (element, value) for the best matching choice""" + matches = [] + + for name, element in self.elements_nested: + if isinstance(element, Element): + if element.name in value: + try: + choice_value = value[element.name] + except KeyError: + choice_value = value + + if choice_value is not None: + matches.append((1, element, choice_value)) + else: + if name is not None: + try: + choice_value = value[name] + except KeyError: + choice_value = value + else: + choice_value = value + + score = element.accept(choice_value) + if score: + matches.append((score, element, choice_value)) + + if matches: + matches = sorted(matches, key=operator.itemgetter(0), reverse=True) + return matches[0][1:] + + def signature(self, depth=()): + parts = [] + for name, element in self.elements_nested: + if isinstance(element, OrderIndicator): + parts.append('{%s}' % (element.signature(depth))) + else: + parts.append('{%s: %s}' % (name, element.signature(depth))) + part = '(%s)' % ' | '.join(parts) + if self.accepts_multiple: + return '%s[]' % (part,) + return part + + +class Sequence(OrderIndicator): + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + result = [] + for item in max_occurs_iter(self.max_occurs): + item_result = OrderedDict() + for elm_name, element in self.elements: + item_subresult = element.parse_xmlelements( + xmlelements, schema, name, context=context) + + # Unwrap if allowed + if isinstance(element, OrderIndicator): + item_result.update(item_subresult) + else: + item_result[elm_name] = item_subresult + + if not xmlelements: + break + if item_result: + result.append(item_result) + + if not self.accepts_multiple: + return result[0] if result else None + + return {name: result} + + +class Group(Indicator): + """Groups a set of element declarations so that they can be incorporated as + a group into complex type definitions. + + """ + + def __init__(self, name, child, max_occurs=1, min_occurs=1): + super(Group, self).__init__() + self.child = child + self.qname = name + self.name = name.localname + self.max_occurs = max_occurs + self.min_occurs = min_occurs + + def clone(self, name, min_occurs=1, max_occurs=1): + return self.__class__( + name=self.qname, + child=self.child, + min_occurs=min_occurs, + max_occurs=max_occurs) + + def __str__(self): + return '%s(%s)' % (self.name, self.signature()) + + def __iter__(self, *args, **kwargs): + for item in self.child: + yield item + + @threaded_cached_property + def elements(self): + if self.accepts_multiple: + return [('_value_1', self.child)] + return self.child.elements + + def parse_args(self, args): + return self.child.parse_args(args) + + def parse_kwargs(self, kwargs, name, available_kwargs): + if self.accepts_multiple: + if name not in kwargs: + return {}, kwargs + + available_kwargs.remove(name) + item_kwargs = kwargs[name] + + result = [] + sub_name = '_value_1' if self.child.accepts_multiple else None + for i, sub_kwargs in zip(max_occurs_iter(self.max_occurs), item_kwargs): + available_sub_kwargs = set(sub_kwargs.keys()) + subresult = self.child.parse_kwargs( + sub_kwargs, sub_name, available_sub_kwargs) + + if subresult: + result.append(subresult) + if result: + result = {name: result} + else: + result = self.child.parse_kwargs(kwargs, name, available_kwargs) + return result + + def parse_xmlelements(self, xmlelements, schema, name=None, context=None): + result = [] + + for i in max_occurs_iter(self.max_occurs): + result.append( + self.child.parse_xmlelements( + xmlelements, schema, name, context=context) + ) + if not self.accepts_multiple and result: + return result[0] + return {name: result} + + def render(self, *args, **kwargs): + return self.child.render(*args, **kwargs) + + def resolve(self): + self.child = self.child.resolve() + return self + + def signature(self, depth=()): + return self.child.signature(depth) diff --git a/src/zeep/xsd/parser.py b/src/zeep/xsd/parser.py new file mode 100644 index 0000000..fd674d0 --- /dev/null +++ b/src/zeep/xsd/parser.py @@ -0,0 +1,42 @@ +from defusedxml.lxml import fromstring +from lxml import etree + +from six.moves.urllib.parse import urlparse +from zeep.exceptions import XMLSyntaxError +from zeep.parser import absolute_location + + +class ImportResolver(etree.Resolver): + def __init__(self, transport, parser_context): + self.parser_context = parser_context + self.transport = transport + + def resolve(self, url, pubid, context): + if url.startswith('intschema'): + text = etree.tostring(self.parser_context.schema_nodes.get(url)) + return self.resolve_string(text, context) + + if urlparse(url).scheme in ('http', 'https'): + content = self.transport.load(url) + return self.resolve_string(content, context) + + +def parse_xml(content, transport, parser_context=None, base_url=None): + parser = etree.XMLParser(remove_comments=True) + parser.resolvers.add(ImportResolver(transport, parser_context)) + try: + return fromstring(content, parser=parser, base_url=base_url) + except etree.XMLSyntaxError as exc: + raise XMLSyntaxError("Invalid XML content received (%s)" % exc.message) + + +def load_external(url, transport, parser_context=None, base_url=None): + if url.startswith('intschema'): + assert parser_context + return parser_context.schema_nodes.get(url) + + if base_url: + url = absolute_location(url, base_url) + + response = transport.load(url) + return parse_xml(response, transport, parser_context, base_url) diff --git a/src/zeep/xsd/printer.py b/src/zeep/xsd/printer.py new file mode 100644 index 0000000..3e3f86f --- /dev/null +++ b/src/zeep/xsd/printer.py @@ -0,0 +1,67 @@ +from collections import OrderedDict + +from six import StringIO + + +class PrettyPrinter(object): + """Cleaner pprint output. + + Heavily inspired by the Python pprint module, but more basic for now. + + """ + def pformat(self, obj): + stream = StringIO() + self._format(obj, stream) + return stream.getvalue() + + def _format(self, obj, stream, indent=4, level=1): + _repr = getattr(type(obj), '__repr__', None) + write = stream.write + + if ( + (isinstance(obj, dict) and _repr is dict.__repr__) or + (isinstance(obj, OrderedDict) and _repr == OrderedDict.__repr__) + ): + write('{\n') + num = len(obj) + + if num > 0: + for i, (key, value) in enumerate(obj.items()): + write(' ' * (indent * level)) + write("'%s'" % key) + write(': ') + self._format(value, stream, level=level + 1) + if i < num - 1: + write(',') + write('\n') + + write(' ' * (indent * (level - 1))) + write('}') + + elif isinstance(obj, list) and _repr is list.__repr__: + write('[') + num = len(obj) + + if num > 0: + write('\n') + for i, value in enumerate(obj): + write(' ' * (indent * level)) + self._format(value, stream, level=level + 1) + if i < num - 1: + write(',') + write('\n') + write(' ' * (indent * (level - 1))) + write(']') + else: + value = repr(obj) + if '\n' in value: + lines = value.split('\n') + num = len(lines) + for i, line in enumerate(lines): + if i > 0: + write(' ' * (indent * (level - 1))) + write(line) + if i < num - 1: + write('\n') + else: + write(value) diff --git a/src/zeep/xsd/schema.py b/src/zeep/xsd/schema.py new file mode 100644 index 0000000..000ac95 --- /dev/null +++ b/src/zeep/xsd/schema.py @@ -0,0 +1,383 @@ +import logging +from collections import OrderedDict + +from lxml import etree + +from zeep import exceptions +from zeep.xsd import builtins as xsd_builtins +from zeep.xsd import const +from zeep.xsd.context import ParserContext +from zeep.xsd.visitor import SchemaVisitor + +logger = logging.getLogger(__name__) + + +class Schema(object): + """A schema is a collection of schema documents.""" + + def __init__(self, node=None, transport=None, location=None, + parser_context=None): + self._parser_context = parser_context or ParserContext() + self._transport = transport + + self._schemas = OrderedDict() + self._prefix_map_auto = {} + self._prefix_map_custom = {} + + if not isinstance(node, list): + nodes = [node] if node is not None else [] + else: + nodes = node + self.add_documents(nodes, location) + + def add_documents(self, schema_nodes, location): + documents = [] + for node in schema_nodes: + document = SchemaDocument( + node, self._transport, self, location, + self._parser_context, location) + documents.append(document) + + for document in documents: + document.resolve() + + self._prefix_map_auto = self._create_prefix_map() + + def __repr__(self): + if self._schemas: + main_doc = next(iter(self._schemas.values())) + location = main_doc._location + else: + location = '' + return '' % location + + @property + def prefix_map(self): + retval = {} + retval.update(self._prefix_map_custom) + retval.update({ + k: v for k, v in self._prefix_map_auto.items() + if v not in retval.values() + }) + return retval + + @property + def is_empty(self): + """Boolean to indicate if this schema contains any types or elements""" + return all(schema.is_empty for schema in self._schemas.values()) + + @property + def namespaces(self): + return set(self._schemas.keys()) + + @property + def elements(self): + """Yield all globla xsd.Type objects""" + for schema in self._schemas.values(): + for element in schema._elements.values(): + yield element + + @property + def types(self): + """Yield all globla xsd.Type objects""" + for schema in self._schemas.values(): + for type_ in schema._types.values(): + yield type_ + + def get_element(self, qname): + """Return a global xsd.Element object with the given qname""" + qname = self._create_qname(qname) + if qname.text in xsd_builtins.default_elements: + return xsd_builtins.default_elements[qname] + + # Handle XSD namespace items + if qname.namespace == const.NS_XSD: + try: + return xsd_builtins.default_elements[qname] + except KeyError: + raise exceptions.LookupError("No such type %r" % qname.text) + + try: + schema = self._get_schema_document(qname.namespace) + return schema.get_element(qname) + except exceptions.NamespaceError: + raise exceptions.NamespaceError(( + "Unable to resolve element %s. " + + "No schema available for the namespace %r." + ) % (qname.text, qname.namespace)) + + def get_type(self, qname): + """Return a global xsd.Type object with the given qname""" + qname = self._create_qname(qname) + + # Handle XSD namespace items + if qname.namespace == const.NS_XSD: + try: + return xsd_builtins.default_types[qname] + except KeyError: + raise exceptions.LookupError("No such type %r" % qname.text) + + try: + schema = self._get_schema_document(qname.namespace) + return schema.get_type(qname) + except exceptions.NamespaceError: + raise exceptions.NamespaceError(( + "Unable to resolve type %s. " + + "No schema available for the namespace %r." + ) % (qname.text, qname.namespace)) + + def get_group(self, qname): + """Return a global xsd.Group object with the given qname""" + qname = self._create_qname(qname) + try: + schema = self._get_schema_document(qname.namespace) + return schema.get_group(qname) + except exceptions.NamespaceError: + raise exceptions.NamespaceError(( + "Unable to resolve group %s. " + + "No schema available for the namespace %r." + ) % (qname.text, qname.namespace)) + + def get_attribute(self, qname): + """Return a global xsd.attributeGroup object with the given qname""" + qname = self._create_qname(qname) + try: + schema = self._get_schema_document(qname.namespace) + return schema.get_attribute(qname) + except exceptions.NamespaceError: + raise exceptions.NamespaceError(( + "Unable to resolve attribute %s. " + + "No schema available for the namespace %r." + ) % (qname.text, qname.namespace)) + + def get_attribute_group(self, qname): + """Return a global xsd.attributeGroup object with the given qname""" + qname = self._create_qname(qname) + try: + schema = self._get_schema_document(qname.namespace) + return schema.get_attribute_group(qname) + except exceptions.NamespaceError: + raise exceptions.NamespaceError(( + "Unable to resolve attributeGroup %s. " + + "No schema available for the namespace %r." + ) % (qname.text, qname.namespace)) + + def merge(self, schema): + """Merge an other XSD schema in this one""" + for namespace, _schema in schema._schemas.items(): + self._schemas[namespace] = _schema + self._prefix_map_auto = self._create_prefix_map() + + def _create_qname(self, name): + """Create an `lxml.etree.QName()` object for the given qname string. + + This also expands the shorthand notation. + + """ + if isinstance(name, etree.QName): + return name + + if not name.startswith('{') and ':' in name and self._prefix_map_auto: + prefix, localname = name.split(':', 1) + if prefix in self._prefix_map_custom: + return etree.QName(self._prefix_map_custom[prefix], localname) + elif prefix in self._prefix_map_auto: + return etree.QName(self._prefix_map_auto[prefix], localname) + else: + raise ValueError( + "No namespace defined for the prefix %r" % prefix) + else: + return etree.QName(name) + + def _create_prefix_map(self): + prefix_map = { + 'xsd': 'http://www.w3.org/2001/XMLSchema', + } + for i, namespace in enumerate(self._schemas.keys()): + if namespace is None: + continue + prefix_map['ns%d' % i] = namespace + return prefix_map + + def set_ns_prefix(self, prefix, namespace): + self._prefix_map_custom[prefix] = namespace + + def get_ns_prefix(self, prefix): + try: + try: + return self._prefix_map_custom[prefix] + except KeyError: + return self._prefix_map_auto[prefix] + except KeyError: + raise ValueError("No such prefix %r" % prefix) + + def _add_schema_document(self, document): + logger.info("Add document with tns %s to schema %s", document._target_namespace, id(self)) + self._schemas[document._target_namespace] = document + + def _get_schema_document(self, namespace): + if namespace not in self._schemas: + raise exceptions.NamespaceError( + "No schema available for the namespace %r" % namespace) + return self._schemas[namespace] + + +class SchemaDocument(object): + def __init__(self, node, transport, schema, location, parser_context, base_url): + logger.debug("Init schema document for %r", location) + assert node is not None + assert parser_context + + # Internal + self._schema = schema + self._base_url = base_url or location + self._location = location + self._transport = transport + self._target_namespace = ( + node.get('targetNamespace') if node is not None else None) + self._elm_instances = [] + + self._attribute_groups = {} + self._attributes = {} + self._elements = {} + self._groups = {} + self._types = {} + + self._imports = OrderedDict() + self._element_form = 'unqualified' + self._attribute_form = 'unqualified' + self._resolved = False + # self._xml_schema = None + + self._schema._add_schema_document(self) + parser_context.schema_objects.add(self) + + if node is not None: + # Disable XML schema validation for now + # if len(node) > 0: + # self.xml_schema = etree.XMLSchema(node) + + visitor = SchemaVisitor(self, parser_context) + visitor.visit_schema(node) + + def __repr__(self): + return '' % ( + self._location, self._target_namespace, self.is_empty) + + def resolve(self): + logger.info("Resolving in schema %s", self) + + if self._resolved: + return + self._resolved = True + + for schema in self._imports.values(): + schema.resolve() + + def _resolve_dict(val): + for key, obj in val.items(): + new = obj.resolve() + assert new is not None, "resolve() should return an object" + val[key] = new + + _resolve_dict(self._attribute_groups) + _resolve_dict(self._attributes) + _resolve_dict(self._elements) + _resolve_dict(self._groups) + _resolve_dict(self._types) + + for element in self._elm_instances: + element.resolve() + self._elm_instances = [] + + def register_type(self, name, value): + assert not isinstance(value, type) + assert value is not None + + if isinstance(name, etree.QName): + name = name.text + logger.debug("register_type(%r, %r)", name, value) + self._types[name] = value + + def register_element(self, name, value): + if isinstance(name, etree.QName): + name = name.text + logger.debug("register_element(%r, %r)", name, value) + self._elements[name] = value + + def register_group(self, name, value): + if isinstance(name, etree.QName): + name = name.text + logger.debug("register_group(%r, %r)", name, value) + self._groups[name] = value + + def register_attribute(self, name, value): + if isinstance(name, etree.QName): + name = name.text + logger.debug("register_attribute(%r, %r)", name, value) + self._attributes[name] = value + + def register_attribute_group(self, name, value): + if isinstance(name, etree.QName): + name = name.text + logger.debug("register_attribute_group(%r, %r)", name, value) + self._attribute_groups[name] = value + + def get_type(self, qname): + """Return a xsd.Type object from this schema""" + try: + return self._types[qname] + except KeyError: + known_items = ', '.join(self._types.keys()) + raise exceptions.LookupError(( + "No type '%s' in namespace %s. " + + "Available types are: %s" + ) % (qname.localname, qname.namespace, known_items or ' - ')) + + def get_element(self, qname): + """Return a xsd.Element object from this schema""" + try: + return self._elements[qname] + except KeyError: + known_items = ', '.join(self._elements.keys()) + raise exceptions.LookupError(( + "No element '%s' in namespace %s. " + + "Available elements are: %s" + ) % (qname.localname, qname.namespace, known_items or ' - ')) + + def get_group(self, qname): + """Return a xsd.Group object from this schema""" + try: + return self._groups[qname] + except KeyError: + known_items = ', '.join(self._groups.keys()) + raise exceptions.LookupError(( + "No group '%s' in namespace %s. " + + "Available attributes are: %s" + ) % (qname.localname, qname.namespace, known_items or ' - ')) + + def get_attribute(self, qname): + """Return a xsd.Attribute object from this schema""" + try: + return self._attributes[qname] + except KeyError: + known_items = ', '.join(self._attributes.keys()) + raise exceptions.LookupError(( + "No attribute '%s' in namespace %s. " + + "Available attributes are: %s" + ) % (qname.localname, qname.namespace, known_items or ' - ')) + + def get_attribute_group(self, qname): + """Return a xsd.AttributeGroup object from this schema""" + try: + return self._attribute_groups[qname] + except KeyError: + known_items = ', '.join(self._attribute_groups.keys()) + raise exceptions.LookupError(( + "No attributeGroup '%s' in namespace %s. " + + "Available attributeGroups are: %s" + ) % (qname.localname, qname.namespace, known_items or ' - ')) + + @property + def is_empty(self): + return not bool(self._imports or self._types or self._elements) diff --git a/src/zeep/xsd/types.py b/src/zeep/xsd/types.py new file mode 100644 index 0000000..7ce7522 --- /dev/null +++ b/src/zeep/xsd/types.py @@ -0,0 +1,597 @@ +import copy +import logging +from collections import OrderedDict, deque +from itertools import chain + +import six +from cached_property import threaded_cached_property + +from zeep.exceptions import XMLParseError, UnexpectedElementError +from zeep.xsd.const import xsi_ns +from zeep.xsd.elements import Any, AnyAttribute, AttributeGroup, Element +from zeep.xsd.indicators import Group, OrderIndicator, Sequence +from zeep.xsd.utils import NamePrefixGenerator +from zeep.utils import get_base_class +from zeep.xsd.valueobjects import CompoundValue + + +logger = logging.getLogger(__name__) + + +class Type(object): + + def __init__(self, qname=None, is_global=False): + self.qname = qname + self.name = qname.localname if qname else None + self._resolved = False + self.is_global = is_global + + def accept(self, value): + raise NotImplementedError + + def parse_kwargs(self, kwargs, name, available_kwargs): + value = None + name = name or self.name + + if name in available_kwargs: + value = kwargs[name] + available_kwargs.remove(name) + return {name: value} + return {} + + def parse_xmlelement(self, xmlelement, schema=None, allow_none=True, + context=None): + raise NotImplementedError( + '%s.parse_xmlelement() is not implemented' % self.__class__.__name__) + + def parsexml(self, xml, schema=None): + raise NotImplementedError + + def render(self, parent, value): + raise NotImplementedError( + '%s.render() is not implemented' % self.__class__.__name__) + + def resolve(self): + raise NotImplementedError( + '%s.resolve() is not implemented' % self.__class__.__name__) + + def extend(self, child): + raise NotImplementedError( + '%s.extend() is not implemented' % self.__class__.__name__) + + def restrict(self, child): + raise NotImplementedError( + '%s.restrict() is not implemented' % self.__class__.__name__) + + @property + def attributes(self): + return [] + + @classmethod + def signature(cls, depth=()): + return '' + + +class UnresolvedType(Type): + def __init__(self, qname, schema): + self.qname = qname + assert self.qname.text != 'None' + self.schema = schema + + def __repr__(self): + return '<%s(qname=%r)>' % (self.__class__.__name__, self.qname) + + def render(self, parent, value): + raise RuntimeError( + "Unable to render unresolved type %s. This is probably a bug." % ( + self.qname)) + + def resolve(self): + retval = self.schema.get_type(self.qname) + return retval.resolve() + + +class UnresolvedCustomType(Type): + + def __init__(self, qname, base_type, schema): + assert qname is not None + self.qname = qname + self.name = str(qname.localname) + self.schema = schema + self.base_type = base_type + + def __repr__(self): + return '<%s(qname=%r, base_type=%r)>' % ( + self.__class__.__name__, self.qname.text, self.base_type) + + def resolve(self): + base = self.base_type + base = base.resolve() + + cls_attributes = { + '__module__': 'zeep.xsd.dynamic_types', + } + + if issubclass(base.__class__, UnionType): + xsd_type = type(self.name, (base.__class__,), cls_attributes) + return xsd_type(base.item_types) + + elif issubclass(base.__class__, SimpleType): + xsd_type = type(self.name, (base.__class__,), cls_attributes) + return xsd_type(self.qname) + + else: + xsd_type = type(self.name, (base.base_class,), cls_attributes) + return xsd_type(self.qname) + + +@six.python_2_unicode_compatible +class SimpleType(Type): + accepted_types = six.string_types + + def __call__(self, *args, **kwargs): + """Return the xmlvalue for the given value. + + Expects only one argument 'value'. The args, kwargs handling is done + here manually so that we can return readable error messages instead of + only '__call__ takes x arguments' + + """ + num_args = len(args) + len(kwargs) + if num_args != 1: + raise TypeError(( + '%s() takes exactly 1 argument (%d given). ' + + 'Simple types expect only a single value argument' + ) % (self.__class__.__name__, num_args)) + + if kwargs and 'value' not in kwargs: + raise TypeError(( + '%s() got an unexpected keyword argument %r. ' + + 'Simple types expect only a single value argument' + ) % (self.__class__.__name__, next(six.iterkeys(kwargs)))) + + value = args[0] if args else kwargs['value'] + return self.xmlvalue(value) + + def __eq__(self, other): + return ( + other is not None and + self.__class__ == other.__class__ and + self.__dict__ == other.__dict__) + + def __str__(self): + return '%s(value)' % (self.__class__.__name__) + + def parse_xmlelement(self, xmlelement, schema=None, allow_none=True, + context=None): + if xmlelement.text is None: + return + try: + return self.pythonvalue(xmlelement.text) + except (TypeError, ValueError): + logger.exception("Error during xml -> python translation") + return None + + def pythonvalue(self, xmlvalue): + raise NotImplementedError( + '%s.pytonvalue() not implemented' % self.__class__.__name__) + + def render(self, parent, value): + parent.text = self.xmlvalue(value) + + def resolve(self): + return self + + def signature(self, depth=()): + return self.name + + def xmlvalue(self, value): + raise NotImplementedError( + '%s.xmlvalue() not implemented' % self.__class__.__name__) + + +class ComplexType(Type): + _xsd_name = None + + def __init__(self, element=None, attributes=None, + restriction=None, extension=None, qname=None, is_global=False): + if element and type(element) == list: + element = Sequence(element) + + self.name = self.__class__.__name__ if qname else None + self._element = element + self._attributes = attributes or [] + self._restriction = restriction + self._extension = extension + super(ComplexType, self).__init__(qname=qname, is_global=is_global) + + def __call__(self, *args, **kwargs): + return self._value_class(*args, **kwargs) + + @property + def accepted_types(self): + return (self._value_class,) + + @threaded_cached_property + def _value_class(self): + return type( + self.__class__.__name__, (CompoundValue,), + {'_xsd_type': self, '__module__': 'zeep.objects'}) + + def __str__(self): + return '%s(%s)' % (self.__class__.__name__, self.signature()) + + @threaded_cached_property + def attributes(self): + generator = NamePrefixGenerator(prefix='_attr_') + result = [] + elm_names = {name for name, elm in self.elements if name is not None} + for attr in self._attributes_unwrapped: + if attr.name is None: + name = generator.get_name() + elif attr.name in elm_names: + name = 'attr__%s' % attr.name + else: + name = attr.name + result.append((name, attr)) + return result + + @threaded_cached_property + def _attributes_unwrapped(self): + attributes = [] + for attr in self._attributes: + if isinstance(attr, AttributeGroup): + attributes.extend(attr.attributes) + else: + attributes.append(attr) + return attributes + + @threaded_cached_property + def elements(self): + """List of tuples containing the element name and the element""" + result = [] + for name, element in self.elements_nested: + if isinstance(element, Element): + result.append((element.attr_name, element)) + else: + result.extend(element.elements) + return result + + @threaded_cached_property + def elements_nested(self): + """List of tuples containing the element name and the element""" + result = [] + generator = NamePrefixGenerator() + + # Handle wsdl:arrayType objects + attrs = {attr.qname.text: attr for attr in self._attributes if attr.qname} + array_type = attrs.get('{http://schemas.xmlsoap.org/soap/encoding/}arrayType') + if array_type: + name = generator.get_name() + if isinstance(self._element, Group): + return [(name, Sequence([ + Any(max_occurs='unbounded', restrict=array_type.array_type) + ]))] + else: + return [(name, self._element)] + + # _element is one of All, Choice, Group, Sequence + if self._element: + result.append((generator.get_name(), self._element)) + return result + + def parse_xmlelement(self, xmlelement, schema, allow_none=True, + context=None): + """Consume matching xmlelements and call parse() on each""" + # If this is an empty complexType () + if not self.attributes and not self.elements: + return None + + attributes = xmlelement.attrib + init_kwargs = OrderedDict() + + # If this complexType extends a simpleType then we have no nested + # elements. Parse it directly via the type object. This is the case + # for xsd:simpleContent + if isinstance(self._element, Element) and isinstance(self._element.type, SimpleType): + name, element = self.elements_nested[0] + init_kwargs[name] = element.type.parse_xmlelement( + xmlelement, schema, name, context=context) + else: + elements = deque(xmlelement.iterchildren()) + if allow_none and len(elements) == 0 and len(attributes) == 0: + return + + # Parse elements. These are always indicator elements (all, choice, + # group, sequence) + for name, element in self.elements_nested: + try: + result = element.parse_xmlelements( + elements, schema, name, context=context) + if result: + init_kwargs.update(result) + except UnexpectedElementError as exc: + raise XMLParseError(exc.message) + + # Check if all children are consumed (parsed) + if elements: + raise XMLParseError("Unexpected element %r" % elements[0].tag) + + # Parse attributes + if attributes: + attributes = copy.copy(attributes) + for name, attribute in self.attributes: + if attribute.name: + if attribute.qname.text in attributes: + value = attributes.pop(attribute.qname.text) + init_kwargs[name] = attribute.parse(value) + else: + init_kwargs[name] = attribute.parse(attributes) + + return self(**init_kwargs) + + def render(self, parent, value, xsd_type=None): + """Serialize the given value lxml.Element subelements on the parent + element. + + """ + if not self.elements_nested and not self.attributes: + return + + # Render attributes + for name, attribute in self.attributes: + attr_value = getattr(value, name, None) + attribute.render(parent, attr_value) + + # Render sub elements + for name, element in self.elements_nested: + if isinstance(element, Element) or element.accepts_multiple: + element_value = getattr(value, name, None) + else: + element_value = value + + if isinstance(element, Element): + element.type.render(parent, element_value) + else: + element.render(parent, element_value) + + if xsd_type and xsd_type._xsd_name: + parent.set(xsi_ns('type'), xsd_type._xsd_name) + + def parse_kwargs(self, kwargs, name, available_kwargs): + value = None + name = name or self.name + + if name in available_kwargs: + value = kwargs[name] + available_kwargs.remove(name) + + value = self._create_object(value, name) + return {name: value} + return {} + + def _create_object(self, value, name): + """Return the value as a CompoundValue object""" + if value is None: + return None + + if isinstance(value, list): + return [self._create_object(val, name) for val in value] + + if isinstance(value, CompoundValue): + return value + + if isinstance(value, dict): + return self(**value) + + # Check if the valueclass only expects one value, in that case + # we can try to automatically create an object for it. + if len(self.attributes) + len(self.elements) == 1: + return self(value) + + raise ValueError(( + "Error while create XML for complexType '%s': " + "Expected instance of type %s, received %r instead." + ) % (self.qname or name, self._value_class, type(value))) + + def resolve(self): + """Resolve all sub elements and types""" + if self._resolved: + return self._resolved + self._resolved = self + + if self._element: + self._element = self._element.resolve() + + resolved = [] + for attribute in self._attributes: + value = attribute.resolve() + assert value is not None + if isinstance(value, list): + resolved.extend(value) + else: + resolved.append(value) + self._attributes = resolved + + if self._extension: + self._extension = self._extension.resolve() + self._resolved = self.extend(self._extension) + return self._resolved + + elif self._restriction: + self._restriction = self._restriction.resolve() + self._resolved = self.restrict(self._restriction) + return self._resolved + + else: + return self._resolved + + def extend(self, base): + """Create a new complextype instance which is the current type + extending the given base type. + + Used for handling xsd:extension tags + + """ + if isinstance(base, ComplexType): + base_attributes = base._attributes_unwrapped + base_element = base._element + else: + base_attributes = [] + base_element = None + attributes = base_attributes + self._attributes_unwrapped + + # Make sure we don't have duplicate (child is leading) + if base_attributes and self._attributes_unwrapped: + new_attributes = OrderedDict() + for attr in attributes: + if isinstance(attr, AnyAttribute): + new_attributes['##any'] = attr + else: + new_attributes[attr.qname.text] = attr + attributes = new_attributes.values() + + # If the base and the current type both have an element defined then + # these need to be merged. The base_element might be empty (or just + # container a placeholder element). + element = [] + if self._element and base_element: + element = self._element.clone(self._element.name) + if isinstance(element, OrderIndicator) and isinstance(base_element, OrderIndicator): + for item in reversed(base_element): + element.insert(0, item) + + elif isinstance(self._element, Group): + raise NotImplementedError('TODO') + else: + pass # Element (ignore for now) + + elif self._element or base_element: + element = self._element or base_element + else: + element = Element('_value_1', base) + + new = self.__class__( + element=element, + attributes=attributes, + qname=self.qname) + return new + + def restrict(self, base): + """Create a new complextype instance which is the current type + restricted by the base type. + + Used for handling xsd:restriction + + """ + attributes = list( + chain(base._attributes_unwrapped, self._attributes_unwrapped)) + + # Make sure we don't have duplicate (self is leading) + if base._attributes_unwrapped and self._attributes_unwrapped: + new_attributes = OrderedDict() + for attr in attributes: + if isinstance(attr, AnyAttribute): + new_attributes['##any'] = attr + else: + new_attributes[attr.qname.text] = attr + attributes = new_attributes.values() + + new = self.__class__( + element=self._element or base._element, + attributes=attributes, + qname=self.qname) + return new.resolve() + + def signature(self, depth=()): + if len(depth) > 0 and self.is_global: + return self.name + + parts = [] + depth += (self.name,) + for name, element in self.elements_nested: + # http://schemas.xmlsoap.org/soap/encoding/ contains cyclic type + if isinstance(element, Element) and element.type == self: + continue + + part = element.signature(depth) + parts.append(part) + + for name, attribute in self.attributes: + part = '%s: %s' % (name, attribute.signature(depth)) + parts.append(part) + + value = ', '.join(parts) + if len(depth) > 1: + value = '{%s}' % value + return value + + +class ListType(SimpleType): + """Space separated list of simpleType values""" + + def __init__(self, item_type): + self.item_type = item_type + super(ListType, self).__init__() + + def __call__(self, value): + return value + + def render(self, parent, value): + parent.text = self.xmlvalue(value) + + def resolve(self): + self.item_type = self.item_type.resolve() + self.base_class = self.item_type.__class__ + return self + + def xmlvalue(self, value): + item_type = self.item_type + return ' '.join(item_type.xmlvalue(v) for v in value) + + def pythonvalue(self, value): + if not value: + return [] + item_type = self.item_type + return [item_type.pythonvalue(v) for v in value.split()] + + def signature(self, depth=()): + return self.item_type.signature(depth) + '[]' + + +class UnionType(SimpleType): + + def __init__(self, item_types): + self.item_types = item_types + self.item_class = None + assert item_types + super(UnionType, self).__init__(None) + + def resolve(self): + from zeep.xsd.builtins import _BuiltinType + + self.item_types = [item.resolve() for item in self.item_types] + base_class = get_base_class(self.item_types) + if issubclass(base_class, _BuiltinType) and base_class != _BuiltinType: + self.item_class = base_class + return self + + def signature(self, depth=()): + return '' + + def parse_xmlelement(self, xmlelement, schema=None, allow_none=True, + context=None): + if self.item_class: + return self.item_class().parse_xmlelement( + xmlelement, schema, allow_none, context) + return xmlelement.text + + def pythonvalue(self, value): + if self.item_class: + return self.item_class().pythonvalue(value) + return value + + def xmlvalue(self, value): + if self.item_class: + return self.item_class().xmlvalue(value) + return value diff --git a/src/zeep/xsd/utils.py b/src/zeep/xsd/utils.py new file mode 100644 index 0000000..108cfe8 --- /dev/null +++ b/src/zeep/xsd/utils.py @@ -0,0 +1,33 @@ +from six.moves import range + + +class NamePrefixGenerator(object): + def __init__(self, prefix='_value_'): + self._num = 1 + self._prefix = prefix + + def get_name(self): + retval = '%s%d' % (self._prefix, self._num) + self._num += 1 + return retval + + +class UniqueNameGenerator(object): + def __init__(self): + self._unique_count = {} + + def create_name(self, name): + if name in self._unique_count: + self._unique_count[name] += 1 + return '%s__%d' % (name, self._unique_count[name]) + else: + self._unique_count[name] = 0 + return name + + +def max_occurs_iter(max_occurs): + assert max_occurs is not None + if max_occurs == 'unbounded': + return range(0, 2**31-1) + else: + return range(max_occurs) diff --git a/src/zeep/xsd/valueobjects.py b/src/zeep/xsd/valueobjects.py new file mode 100644 index 0000000..2a508df --- /dev/null +++ b/src/zeep/xsd/valueobjects.py @@ -0,0 +1,167 @@ +import copy +from collections import OrderedDict + +import six + +from zeep.xsd.printer import PrettyPrinter + +__all__ = ['AnyObject', 'CompoundValue'] + + +class AnyObject(object): + def __init__(self, xsd_object, value): + self.xsd_obj = xsd_object + self.value = value + + def __repr__(self): + return '<%s(type=%r, value=%r)>' % ( + self.__class__.__name__, self.xsd_elm, self.value) + + def __deepcopy__(self, memo): + return type(self)(self.xsd_elm, copy.deepcopy(self.value)) + + @property + def xsd_type(self): + return self.xsd_obj + + @property + def xsd_elm(self): + return self.xsd_obj + + +class CompoundValue(object): + + def __init__(self, *args, **kwargs): + values = OrderedDict() + + # Set default values + for container_name, container in self._xsd_type.elements_nested: + elm_values = container.default_value + if isinstance(elm_values, dict): + values.update(elm_values) + else: + values[container_name] = elm_values + + # Set attributes + for attribute_name, attribute in self._xsd_type.attributes: + values[attribute_name] = attribute.default_value + + # Set elements + items = _process_signature(self._xsd_type, args, kwargs) + for key, value in items.items(): + values[key] = value + self.__values__ = values + + def __contains__(self, key): + return self.__values__.__contains__(key) + + def __len__(self): + return self.__values__.__len__() + + def __iter__(self): + return self.__values__.__iter__() + + def __repr__(self): + return PrettyPrinter().pformat(self.__values__) + + def __delitem__(self, key): + return self.__values__.__delitem__(key) + + def __getitem__(self, key): + return self.__values__[key] + + def __setitem__(self, key, value): + self.__values__[key] = value + + def __setattr__(self, key, value): + if key.startswith('__') or key in ('_xsd_type', '_xsd_elm'): + return super(CompoundValue, self).__setattr__(key, value) + self.__values__[key] = value + + def __getattribute__(self, key): + if key.startswith('__') or key in ('_xsd_type', '_xsd_elm'): + return super(CompoundValue, self).__getattribute__(key) + try: + return self.__values__[key] + except KeyError: + raise AttributeError( + "%s instance has no attribute '%s'" % ( + self.__class__.__name__, key)) + + def __deepcopy__(self, memo): + new = type(self)() + new.__values__ = copy.deepcopy(self.__values__) + for attr, value in self.__dict__.items(): + if attr != '__values__': + setattr(new, attr, value) + return new + + +def _process_signature(xsd_type, args, kwargs): + """Return a dict with the args/kwargs mapped to the field name. + + Special handling is done for Choice elements since we need to record which + element the user intends to use. + + :param fields: List of tuples (name, element) + :type fields: list + :param args: arg tuples + :type args: tuple + :param kwargs: kwargs + :type kwargs: dict + + + """ + result = OrderedDict() + # Process the positional arguments. args is currently still modified + # in-place here + if args: + args = list(args) + num_args = len(args) + + for element_name, element in xsd_type.elements_nested: + values, args = element.parse_args(args) + if not values: + break + result.update(values) + + if args: + for attribute_name, attribute in xsd_type.attributes: + result[attribute_name] = args.pop(0) + + if args: + raise TypeError( + "__init__() takes at most %s positional arguments (%s given)" % ( + len(result), num_args)) + + # Process the named arguments (sequence/group/all/choice). The + # available_kwargs set is modified in-place. + available_kwargs = set(kwargs.keys()) + for element_name, element in xsd_type.elements_nested: + if element.accepts_multiple: + values = element.parse_kwargs(kwargs, element_name, available_kwargs) + else: + values = element.parse_kwargs(kwargs, None, available_kwargs) + + if values is not None: + for key, value in values.items(): + if key not in result: + result[key] = value + + # Process the named arguments for attributes + if available_kwargs: + for attribute_name, attribute in xsd_type.attributes: + if attribute_name in available_kwargs: + available_kwargs.remove(attribute_name) + result[attribute_name] = kwargs[attribute_name] + + if available_kwargs: + raise TypeError(( + "%s() got an unexpected keyword argument %r. " + + "Signature: (%s)" + ) % ( + xsd_type.qname or 'ComplexType', + next(iter(available_kwargs)), + xsd_type.signature())) + + return result diff --git a/src/zeep/xsd/visitor.py b/src/zeep/xsd/visitor.py new file mode 100644 index 0000000..6a77340 --- /dev/null +++ b/src/zeep/xsd/visitor.py @@ -0,0 +1,983 @@ +import keyword +import logging +import re +import warnings + +from lxml import etree + +from zeep import exceptions +from zeep.exceptions import XMLParseError, ZeepWarning +from zeep.parser import absolute_location +from zeep.utils import as_qname, qname_attr +from zeep.xsd import builtins as xsd_builtins +from zeep.xsd import elements as xsd_elements +from zeep.xsd import indicators as xsd_indicators +from zeep.xsd import types as xsd_types +from zeep.xsd.const import xsd_ns +from zeep.xsd.parser import load_external + +logger = logging.getLogger(__name__) + + +class tags(object): + pass + + +for name in [ + 'schema', 'import', 'include', + 'annotation', 'element', 'simpleType', 'complexType', + 'simpleContent', 'complexContent', + 'sequence', 'group', 'choice', 'all', 'list', 'union', + 'attribute', 'any', 'anyAttribute', 'attributeGroup', + 'restriction', 'extension', 'notation', + +]: + attr = name if name not in keyword.kwlist else name + '_' + setattr(tags, attr, xsd_ns(name)) + + +class SchemaVisitor(object): + """Visitor which processes XSD files and registers global elements and + types in the given schema. + + """ + def __init__(self, document, parser_context=None): + self.document = document + self.schema = document._schema + self.parser_context = parser_context + self._includes = set() + + def process(self, node, parent): + visit_func = self.visitors.get(node.tag) + if not visit_func: + raise ValueError("No visitor defined for %r" % node.tag) + result = visit_func(self, node, parent) + return result + + def process_ref_attribute(self, node, array_type=None): + ref = qname_attr(node, 'ref') + if ref: + ref = self._create_qname(ref) + + # Some wsdl's reference to xs:schema, we ignore that for now. It + # might be better in the future to process the actual schema file + # so that it is handled correctly + if ref.namespace == 'http://www.w3.org/2001/XMLSchema': + return + return xsd_elements.RefAttribute( + node.tag, ref, self.schema, array_type=array_type) + + def process_reference(self, node, **kwargs): + ref = qname_attr(node, 'ref') + if not ref: + return + + if node.tag == tags.element: + cls = xsd_elements.RefElement + elif node.tag == tags.attribute: + cls = xsd_elements.RefAttribute + elif node.tag == tags.group: + cls = xsd_elements.RefGroup + elif node.tag == tags.attributeGroup: + cls = xsd_elements.RefAttributeGroup + return cls(node.tag, ref, self.schema, **kwargs) + + def visit_schema(self, node): + """ + + Content: ( + (include | import | redefine | annotation)*, + (((simpleType | complexType | group | attributeGroup) | + element | attribute | notation), + annotation*)*) + + + """ + assert node is not None + + self.document._target_namespace = node.get('targetNamespace') + self.document._element_form = node.get('elementFormDefault', 'unqualified') + self.document._attribute_form = node.get('attributeFormDefault', 'unqualified') + + parent = node + for node in node.iterchildren(): + self.process(node, parent=parent) + + def visit_import(self, node, parent): + """ + + Content: (annotation?) + + """ + schema_node = None + namespace = node.get('namespace') + location = node.get('schemaLocation') + if location: + location = absolute_location(location, self.document._base_url) + + if not namespace and not self.document._target_namespace: + raise XMLParseError( + "The attribute 'namespace' must be existent if the " + "importing schema has no target namespace.") + + # Check if the schema is already imported before based on the + # namespace. Schema's without namespace are registered as 'None' + schema = self.parser_context.schema_objects.get(namespace) + if schema: + if location and schema._location != location: + # Use same warning message as libxml2 + message = ( + "Skipping import of schema located at %r " + + "for the namespace %r, since the namespace was " + + "already imported with the schema located at %r" + ) % (location, namespace or '(null)', schema._location) + warnings.warn(message, ZeepWarning, stacklevel=6) + + return + logger.debug("Returning existing schema: %r", location) + self.document._imports[namespace] = schema + return schema + + # Hardcode the mapping between the xml namespace and the xsd for now. + # This seems to fix issues with exchange wsdl's, see #220 + if not location and namespace == 'http://www.w3.org/XML/1998/namespace': + location = 'https://www.w3.org/2001/xml.xsd' + + # Silently ignore import statements which we can't resolve via the + # namespace and doesn't have a schemaLocation attribute. + if not location: + logger.debug( + "Ignoring import statement for namespace %r " + + "(missing schemaLocation)", namespace) + return + + # Load the XML + schema_node = load_external( + location, self.document._transport, self.parser_context) + + # Check if the xsd:import namespace matches the targetNamespace. If + # the xsd:import statement didn't specify a namespace then make sure + # that the targetNamespace wasn't declared by another schema yet. + schema_tns = schema_node.get('targetNamespace') + if namespace and schema_tns and namespace != schema_tns: + raise XMLParseError(( + "The namespace defined on the xsd:import doesn't match the " + "imported targetNamespace located at %r " + ) % (location)) + elif schema_tns in self.parser_context.schema_objects: + schema = self.parser_context.schema_objects.get(schema_tns) + message = ( + "Skipping import of schema located at %r " + + "for the namespace %r, since the namespace was " + + "already imported with the schema located at %r" + ) % (location, namespace or '(null)', schema._location) + warnings.warn(message, ZeepWarning, stacklevel=6) + + # If this schema location is 'internal' then retrieve the original + # location since that is used as base url for sub include/imports + if location in self.parser_context.schema_locations: + base_url = self.parser_context.schema_locations[location] + else: + base_url = location + + schema = self.document.__class__( + schema_node, self.document._transport, self.schema, location, + self.parser_context, base_url) + + self.document._imports[namespace] = schema + return schema + + def visit_include(self, node, parent): + """ + + Content: (annotation?) + + """ + if not node.get('schemaLocation'): + raise NotImplementedError("schemaLocation is required") + location = node.get('schemaLocation') + + if location in self._includes: + return + + schema_node = load_external( + location, self.document._transport, self.parser_context, + base_url=self.document._base_url) + self._includes.add(location) + + return self.visit_schema(schema_node) + + def visit_element(self, node, parent): + """ + + Content: (annotation?, ( + (simpleType | complexType)?, (unique | key | keyref)*)) + + """ + is_global = parent.tag == tags.schema + + # minOccurs / maxOccurs are not allowed on global elements + if not is_global: + min_occurs, max_occurs = _process_occurs_attrs(node) + else: + max_occurs = 1 + min_occurs = 1 + + # If the element has a ref attribute then all other attributes cannot + # be present. Short circuit that here. + # Ref is prohibited on global elements (parent = schema) + if not is_global: + result = self.process_reference( + node, min_occurs=min_occurs, max_occurs=max_occurs) + if result: + return result + + element_form = node.get('form', self.document._element_form) + if element_form == 'qualified' or is_global: + qname = qname_attr(node, 'name', self.document._target_namespace) + else: + qname = etree.QName(node.get('name')) + + children = node.getchildren() + xsd_type = None + if children: + value = None + + for child in children: + if child.tag == tags.annotation: + continue + + elif child.tag in (tags.simpleType, tags.complexType): + assert not value + + xsd_type = self.process(child, node) + + if not xsd_type: + node_type = qname_attr(node, 'type') + if node_type: + xsd_type = self._get_type(node_type.text) + else: + xsd_type = xsd_builtins.AnyType() + + # Naive workaround to mark fields which are part of a choice element + # as optional + if parent.tag == tags.choice: + min_occurs = 0 + + nillable = node.get('nillable') == 'true' + default = node.get('default') + element = xsd_elements.Element( + name=qname, type_=xsd_type, + min_occurs=min_occurs, max_occurs=max_occurs, nillable=nillable, + default=default, is_global=is_global) + + self.document._elm_instances.append(element) + + # Only register global elements + if is_global: + self.document.register_element(qname, element) + return element + + def visit_attribute(self, node, parent): + """Declares an attribute. + + + Content: (annotation?, (simpleType?)) + + """ + is_global = parent.tag == tags.schema + + # Check of wsdl:arayType + array_type = node.get('{http://schemas.xmlsoap.org/wsdl/}arrayType') + if array_type: + match = re.match('([^\[]+)', array_type) + if match: + array_type = match.groups()[0] + qname = as_qname( + array_type, node.nsmap, self.document._target_namespace) + array_type = xsd_types.UnresolvedType(qname, self.schema) + + # If the elment has a ref attribute then all other attributes cannot + # be present. Short circuit that here. + # Ref is prohibited on global elements (parent = schema) + if not is_global: + result = self.process_ref_attribute(node, array_type=array_type) + if result: + return result + + attribute_form = node.get('form', self.document._attribute_form) + qname = qname_attr(node, 'name', self.document._target_namespace) + if attribute_form == 'qualified' or is_global: + name = qname + else: + name = etree.QName(node.get('name')) + + annotation, items = self._pop_annotation(node.getchildren()) + if items: + xsd_type = self.visit_simple_type(items[0], node) + else: + node_type = qname_attr(node, 'type') + if node_type: + xsd_type = self._get_type(node_type) + else: + xsd_type = xsd_builtins.AnyType() + + # TODO: We ignore 'prohobited' for now + required = node.get('use') == 'required' + default = node.get('default') + + attr = xsd_elements.Attribute( + name, type_=xsd_type, default=default, required=required) + self.document._elm_instances.append(attr) + + # Only register global elements + if is_global: + self.document.register_attribute(qname, attr) + return attr + + def visit_simple_type(self, node, parent): + """ + + Content: (annotation?, (restriction | list | union)) + + """ + + if parent.tag == tags.schema: + name = node.get('name') + is_global = True + else: + name = parent.get('name', 'Anonymous') + is_global = False + base_type = '{http://www.w3.org/2001/XMLSchema}string' + qname = as_qname(name, node.nsmap, self.document._target_namespace) + + annotation, items = self._pop_annotation(node.getchildren()) + child = items[0] + if child.tag == tags.restriction: + base_type = self.visit_restriction_simple_type(child, node) + xsd_type = xsd_types.UnresolvedCustomType( + qname, base_type, self.schema) + + elif child.tag == tags.list: + xsd_type = self.visit_list(child, node) + + elif child.tag == tags.union: + xsd_type = self.visit_union(child, node) + else: + raise AssertionError("Unexpected child: %r" % child.tag) + + assert xsd_type is not None + if is_global: + self.document.register_type(qname, xsd_type) + return xsd_type + + def visit_complex_type(self, node, parent): + """ + + Content: (annotation?, (simpleContent | complexContent | + ((group | all | choice | sequence)?, + ((attribute | attributeGroup)*, anyAttribute?)))) + + + """ + children = [] + base_type = '{http://www.w3.org/2001/XMLSchema}anyType' + + # If the complexType's parent is an element then this type is + # anonymous and should have no name defined. Otherwise it's global + if parent.tag == tags.schema: + name = node.get('name') + is_global = True + else: + name = parent.get('name') + is_global = False + + qname = as_qname(name, node.nsmap, self.document._target_namespace) + cls_attributes = { + '__module__': 'zeep.xsd.dynamic_types', + '_xsd_name': qname, + } + xsd_cls = type(name, (xsd_types.ComplexType,), cls_attributes) + xsd_type = None + + # Process content + annotation, children = self._pop_annotation(node.getchildren()) + first_tag = children[0].tag if children else None + + if first_tag == tags.simpleContent: + base_type, attributes = self.visit_simple_content(children[0], node) + + xsd_type = xsd_cls( + attributes=attributes, extension=base_type, qname=qname, + is_global=is_global) + + elif first_tag == tags.complexContent: + kwargs = self.visit_complex_content(children[0], node) + xsd_type = xsd_cls(qname=qname, is_global=is_global, **kwargs) + + elif first_tag: + element = None + + if first_tag in (tags.group, tags.all, tags.choice, tags.sequence): + child = children.pop(0) + element = self.process(child, node) + + attributes = self._process_attributes(node, children) + xsd_type = xsd_cls( + element=element, attributes=attributes, qname=qname, + is_global=is_global) + else: + xsd_type = xsd_cls(qname=qname) + + if is_global: + self.document.register_type(qname, xsd_type) + return xsd_type + + def visit_complex_content(self, node, parent, namespace=None): + """The complexContent element defines extensions or restrictions on a + complex type that contains mixed content or elements only. + + + Content: (annotation?, (restriction | extension)) + + """ + + child = node.getchildren()[-1] + + if child.tag == tags.restriction: + base, element, attributes = self.visit_restriction_complex_content( + child, node) + return { + 'attributes': attributes, + 'element': element, + 'restriction': base, + } + elif child.tag == tags.extension: + base, element, attributes = self.visit_extension_complex_content( + child, node) + return { + 'attributes': attributes, + 'element': element, + 'extension': base, + } + + def visit_simple_content(self, node, parent, namespace=None): + """Contains extensions or restrictions on a complexType element with + character data or a simpleType element as content and contains no + elements. + + + Content: (annotation?, (restriction | extension)) + + """ + + child = node.getchildren()[-1] + + if child.tag == tags.restriction: + return self.visit_restriction_simple_content(child, node) + elif child.tag == tags.extension: + return self.visit_extension_simple_content(child, node) + raise AssertionError("Expected restriction or extension") + + def visit_restriction_simple_type(self, node, parent, namespace=None): + """ + + Content: (annotation?, + (simpleType?, ( + minExclusive | minInclusive | maxExclusive | maxInclusive | + totalDigits |fractionDigits | length | minLength | + maxLength | enumeration | whiteSpace | pattern)*)) + + """ + base_name = qname_attr(node, 'base') + if base_name: + return self._get_type(base_name) + + annotation, children = self._pop_annotation(node.getchildren()) + if children[0].tag == tags.simpleType: + return self.visit_simple_type(children[0], node) + + def visit_restriction_simple_content(self, node, parent, namespace=None): + """ + + Content: (annotation?, + (simpleType?, ( + minExclusive | minInclusive | maxExclusive | maxInclusive | + totalDigits |fractionDigits | length | minLength | + maxLength | enumeration | whiteSpace | pattern)* + )?, ((attribute | attributeGroup)*, anyAttribute?)) + + """ + base_name = qname_attr(node, 'base') + base_type = self._get_type(base_name) + return base_type, [] + + def visit_restriction_complex_content(self, node, parent, namespace=None): + """ + + + Content: (annotation?, (group | all | choice | sequence)?, + ((attribute | attributeGroup)*, anyAttribute?)) + + """ + base_name = qname_attr(node, 'base') + base_type = self._get_type(base_name) + annotation, children = self._pop_annotation(node.getchildren()) + + element = None + attributes = [] + + if children: + child = children[0] + if child.tag in (tags.group, tags.all, tags.choice, tags.sequence): + children.pop(0) + element = self.process(child, node) + attributes = self._process_attributes(node, children) + return base_type, element, attributes + + def visit_extension_complex_content(self, node, parent): + """ + + Content: (annotation?, ( + (group | all | choice | sequence)?, + ((attribute | attributeGroup)*, anyAttribute?))) + + """ + base_name = qname_attr(node, 'base') + base_type = self._get_type(base_name) + annotation, children = self._pop_annotation(node.getchildren()) + + element = None + attributes = [] + + if children: + child = children[0] + if child.tag in (tags.group, tags.all, tags.choice, tags.sequence): + children.pop(0) + element = self.process(child, node) + attributes = self._process_attributes(node, children) + + return base_type, element, attributes + + def visit_extension_simple_content(self, node, parent): + """ + + Content: (annotation?, ((attribute | attributeGroup)*, anyAttribute?)) + + """ + base_name = qname_attr(node, 'base') + base_type = self._get_type(base_name) + annotation, children = self._pop_annotation(node.getchildren()) + attributes = self._process_attributes(node, children) + + return base_type, attributes + + def visit_annotation(self, node, parent): + """Defines an annotation. + + + Content: (appinfo | documentation)* + + """ + return + + def visit_any(self, node, parent): + """ + + Content: (annotation?, + (element | group | choice | sequence | any)*) + + """ + + sub_types = [ + tags.annotation, tags.any, tags.choice, tags.element, + tags.group, tags.sequence + ] + min_occurs, max_occurs = _process_occurs_attrs(node) + result = xsd_indicators.Sequence( + min_occurs=min_occurs, max_occurs=max_occurs) + + annotation, items = self._pop_annotation(node.getchildren()) + for child in items: + assert child.tag in sub_types, child + item = self.process(child, node) + assert item is not None + result.append(item) + + assert None not in result + return result + + def visit_all(self, node, parent): + """Allows the elements in the group to appear (or not appear) in any + order in the containing element. + + + Content: (annotation?, element*) + + """ + + sub_types = [ + tags.annotation, tags.element + ] + result = xsd_indicators.All() + + for child in node.iterchildren(): + assert child.tag in sub_types, child + item = self.process(child, node) + result.append(item) + + assert None not in result + return result + + def visit_group(self, node, parent): + """Groups a set of element declarations so that they can be + incorporated as a group into complex type definitions. + + + Content: (annotation?, (all | choice | sequence)) + + + """ + + result = self.process_reference(node) + if result: + return result + + qname = qname_attr(node, 'name', self.document._target_namespace) + + # There should be only max nodes, first node (annotation) is irrelevant + annotation, children = self._pop_annotation(node.getchildren()) + child = children[0] + + item = self.process(child, parent) + elm = xsd_indicators.Group(name=qname, child=item) + + if parent.tag == tags.schema: + self.document.register_group(qname, elm) + return elm + + def visit_list(self, node, parent): + """ + + Content: (annotation?, (simpleType?)) + + + The use of the simpleType element child and the itemType attribute is + mutually exclusive. + + """ + item_type = qname_attr(node, 'itemType') + if item_type: + sub_type = self._get_type(item_type.text) + else: + subnodes = node.getchildren() + child = subnodes[-1] # skip annotation + sub_type = self.visit_simple_type(child, node) + return xsd_types.ListType(sub_type) + + def visit_choice(self, node, parent): + """ + + Content: (annotation?, (element | group | choice | sequence | any)*) + + """ + min_occurs, max_occurs = _process_occurs_attrs(node) + + children = node.getchildren() + annotation, children = self._pop_annotation(children) + + choices = [] + for child in children: + elm = self.process(child, node) + choices.append(elm) + return xsd_indicators.Choice( + choices, min_occurs=min_occurs, max_occurs=max_occurs) + + def visit_union(self, node, parent): + """Defines a collection of multiple simpleType definitions. + + + Content: (annotation?, (simpleType*)) + + """ + # TODO + members = node.get('memberTypes') + types = [] + if members: + for member in members.split(): + qname = as_qname(member, node.nsmap, self.document._target_namespace) + xsd_type = self._get_type(qname) + types.append(xsd_type) + else: + annotation, types = self._pop_annotation(node.getchildren()) + types = [self.visit_simple_type(t, node) for t in types] + return xsd_types.UnionType(types) + + def visit_unique(self, node, parent): + """Specifies that an attribute or element value (or a combination of + attribute or element values) must be unique within the specified scope. + The value must be unique or nil. + + + Content: (annotation?, (selector, field+)) + + """ + # TODO + pass + + def visit_attribute_group(self, node, parent): + """ + + Content: (annotation?), + ((attribute | attributeGroup)*, anyAttribute?)) + + """ + ref = self.process_reference(node) + if ref: + return ref + + qname = qname_attr(node, 'name', self.document._target_namespace) + annotation, children = self._pop_annotation(node.getchildren()) + + attributes = self._process_attributes(node, children) + attribute_group = xsd_elements.AttributeGroup(qname, attributes) + self.document.register_attribute_group(qname, attribute_group) + + def visit_any_attribute(self, node, parent): + """ + + Content: (annotation?) + + """ + process_contents = node.get('processContents', 'strict') + return xsd_elements.AnyAttribute(process_contents=process_contents) + + def visit_notation(self, node, parent): + """Contains the definition of a notation to describe the format of + non-XML data within an XML document. An XML Schema notation declaration + is a reconstruction of XML 1.0 NOTATION declarations. + + + Content: (annotation?) + + + """ + pass + + def _get_type(self, name): + assert name is not None + name = self._create_qname(name) + try: + retval = self.schema.get_type(name) + except (exceptions.NamespaceError, exceptions.LookupError): + retval = xsd_types.UnresolvedType(name, self.schema) + return retval + + def _create_qname(self, name): + if not isinstance(name, etree.QName): + name = etree.QName(name) + + # Handle reserved namespace + if name.namespace == 'xml': + name = etree.QName( + 'http://www.w3.org/XML/1998/namespace', name.localname) + + # Various xsd builders assume that some schema's are available by + # default (actually this is mostly just the soap-enc ns). So live with + # that fact and handle it by auto-importing the schema if it is + # referenced. + if ( + name.namespace == 'http://schemas.xmlsoap.org/soap/encoding/' and + name.namespace not in self.document._imports + ): + import_node = etree.Element( + tags.import_, + namespace=name.namespace, schemaLocation=name.namespace) + self.visit_import(import_node, None) + + return name + + def _pop_annotation(self, items): + if not len(items): + return None, [] + + if items[0].tag == tags.annotation: + annotation = self.visit_annotation(items[0], None) + return annotation, items[1:] + return None, items + + def _process_attributes(self, node, items): + attributes = [] + for child in items: + attribute = self.process(child, node) + if child.tag in (tags.attribute, tags.attributeGroup, tags.anyAttribute): + attributes.append(attribute) + else: + raise XMLParseError("Unexpected tag: %s" % child.tag) + return attributes + + visitors = { + tags.any: visit_any, + tags.element: visit_element, + tags.choice: visit_choice, + tags.simpleType: visit_simple_type, + tags.anyAttribute: visit_any_attribute, + tags.complexType: visit_complex_type, + tags.simpleContent: None, + tags.complexContent: None, + tags.sequence: visit_sequence, + tags.all: visit_all, + tags.group: visit_group, + tags.attribute: visit_attribute, + tags.import_: visit_import, + tags.include: visit_include, + tags.annotation: visit_annotation, + tags.attributeGroup: visit_attribute_group, + tags.notation: visit_notation, + } + + +def _process_occurs_attrs(node): + """Process the min/max occurrence indicators""" + max_occurs = node.get('maxOccurs', '1') + min_occurs = int(node.get('minOccurs', '1')) + if max_occurs == 'unbounded': + max_occurs = 'unbounded' + else: + max_occurs = int(max_occurs) + + return min_occurs, max_occurs diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..88d4e5d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +from zeep.client import Client # noqa +from zeep.exceptions import Fault # noqa diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fc8d077 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + +pytest.register_assert_rewrite('tests.utils') + + +@pytest.fixture(autouse=True) +def no_requests(request, monkeypatch): + if request.node.get_marker('requests'): + return + + def func(*args, **kwargs): + pytest.fail("External connections not allowed during tests.") + + monkeypatch.setattr("socket.socket", func) diff --git a/tests/integration/hello_world_recursive.wsdl b/tests/integration/hello_world_recursive.wsdl new file mode 100644 index 0000000..d67624e --- /dev/null +++ b/tests/integration/hello_world_recursive.wsdl @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration/hello_world_recursive_import.wsdl b/tests/integration/hello_world_recursive_import.wsdl new file mode 100644 index 0000000..84be83b --- /dev/null +++ b/tests/integration/hello_world_recursive_import.wsdl @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration/recursive_schema_a.xsd b/tests/integration/recursive_schema_a.xsd new file mode 100644 index 0000000..85af13e --- /dev/null +++ b/tests/integration/recursive_schema_a.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/tests/integration/recursive_schema_b.xsd b/tests/integration/recursive_schema_b.xsd new file mode 100644 index 0000000..1233320 --- /dev/null +++ b/tests/integration/recursive_schema_b.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/tests/integration/recursive_schema_c.xsd b/tests/integration/recursive_schema_c.xsd new file mode 100644 index 0000000..c231b1a --- /dev/null +++ b/tests/integration/recursive_schema_c.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/tests/integration/recursive_schema_main.wsdl b/tests/integration/recursive_schema_main.wsdl new file mode 100644 index 0000000..8400cab --- /dev/null +++ b/tests/integration/recursive_schema_main.wsdl @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/integration/test_hello_world_recursive.py b/tests/integration/test_hello_world_recursive.py new file mode 100644 index 0000000..74661e1 --- /dev/null +++ b/tests/integration/test_hello_world_recursive.py @@ -0,0 +1,11 @@ +import os + +import zeep + + +def test_hello_world(): + path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'hello_world_recursive.wsdl') + client = zeep.Client(path) + client.wsdl.dump() diff --git a/tests/integration/test_recursive_schema.py b/tests/integration/test_recursive_schema.py new file mode 100644 index 0000000..a8b2487 --- /dev/null +++ b/tests/integration/test_recursive_schema.py @@ -0,0 +1,11 @@ +import os + +import zeep + + +def test_hello_world(): + path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'recursive_schema_main.wsdl') + client = zeep.Client(path) + client.wsdl.dump() diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..66befce --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,46 @@ +import datetime + +import freezegun + +from zeep import cache + + +def test_sqlite_cache(tmpdir): + c = cache.SqliteCache(path=tmpdir.join('sqlite.cache.db').strpath) + c.add('http://tests.python-zeep.org/example.wsdl', b'content') + + result = c.get('http://tests.python-zeep.org/example.wsdl') + assert result == b'content' + + +def test_sqlite_cache_timeout(tmpdir): + c = cache.SqliteCache(path=tmpdir.join('sqlite.cache.db').strpath) + c.add('http://tests.python-zeep.org/example.wsdl', b'content') + result = c.get('http://tests.python-zeep.org/example.wsdl') + assert result == b'content' + + freeze_dt = datetime.datetime.utcnow() + datetime.timedelta(seconds=7200) + with freezegun.freeze_time(freeze_dt): + result = c.get('http://tests.python-zeep.org/example.wsdl') + assert result is None + + +def test_memory_cache_timeout(tmpdir): + c = cache.InMemoryCache() + c.add('http://tests.python-zeep.org/example.wsdl', b'content') + result = c.get('http://tests.python-zeep.org/example.wsdl') + assert result == b'content' + + freeze_dt = datetime.datetime.utcnow() + datetime.timedelta(seconds=7200) + with freezegun.freeze_time(freeze_dt): + result = c.get('http://tests.python-zeep.org/example.wsdl') + assert result is None + + +def test_memory_cache_share_data(tmpdir): + a = cache.InMemoryCache() + b = cache.InMemoryCache() + a.add('http://tests.python-zeep.org/example.wsdl', b'content') + + result = b.get('http://tests.python-zeep.org/example.wsdl') + assert result == b'content' diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..122abc3 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,254 @@ +import os + +import pytest +import requests_mock +from lxml import etree + +from zeep import client +from zeep import xsd +from zeep.exceptions import Error +from tests.utils import load_xml + + +def test_bind(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + service = client_obj.bind() + assert service + + +def test_bind_service(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + service = client_obj.bind('StockQuoteService') + assert service + + +def test_bind_service_port(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + service = client_obj.bind('StockQuoteService', 'StockQuotePort') + assert service + + +def test_service_proxy_ok(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + assert client_obj.service.GetLastTradePrice + + +def test_service_proxy_non_existing(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + with pytest.raises(AttributeError): + assert client_obj.service.NonExisting + + +def test_client_no_wsdl(): + with pytest.raises(ValueError): + client.Client(None) + + +def test_client_cache_service(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + assert client_obj.service.GetLastTradePrice + assert client_obj.service.GetLastTradePrice + + +def test_force_https(): + with open('tests/wsdl_files/soap.wsdl') as fh: + response = fh.read() + + with requests_mock.mock() as m: + url = 'https://tests.python-zeep.org/wsdl' + m.get(url, text=response, status_code=200) + client_obj = client.Client(url) + binding_options = client_obj.service._binding_options + assert binding_options['address'].startswith('https') + + expected_url = 'https://example.com/stockquote' + assert binding_options['address'] == expected_url + + +@pytest.mark.requests +def test_create_service(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + service = client_obj.create_service( + '{http://example.com/stockquote.wsdl}StockQuoteBinding', + 'http://test.python-zeep.org/x') + + response = """ + + + + + + 120.123 + + + + """.strip() + + with requests_mock.mock() as m: + m.post('http://test.python-zeep.org/x', text=response) + result = service.GetLastTradePrice('foobar') + assert result == 120.123 + assert m.request_history[0].headers['User-Agent'].startswith('Zeep/') + assert m.request_history[0].body.startswith( + b"") + + +def test_load_wsdl_with_file_prefix(): + cwd = os.path.dirname(__file__) + client.Client( + 'file://' + os.path.join(cwd, 'wsdl_files/soap.wsdl')) + + +@pytest.mark.requests +def test_service_proxy(): + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + + response = """ + + + + + + 120.123 + + + + """.strip() + + with requests_mock.mock() as m: + m.post('http://example.com/stockquote', text=response) + result = client_obj.service.GetLastTradePrice('foobar') + assert result == 120.123 + + +@pytest.mark.requests +def test_call_method_fault(): + obj = client.Client('tests/wsdl_files/soap.wsdl') + + response = """ + + + + + soap:Server + + Big fatal error!! + + StockListByDate + + + wrong security code + StockListByDate + + + + + + """.strip() + + with requests_mock.mock() as m: + m.post('http://example.com/stockquote', text=response, status_code=500) + with pytest.raises(Error): + obj.service.GetLastTradePrice(tickerSymbol='foobar') + + +def test_set_context_options_timeout(): + obj = client.Client('tests/wsdl_files/soap.wsdl') + + assert obj.transport.operation_timeout is None + with obj.options(timeout=120): + assert obj.transport.operation_timeout == 120 + + with obj.options(timeout=90): + assert obj.transport.operation_timeout == 90 + assert obj.transport.operation_timeout == 120 + assert obj.transport.operation_timeout is None + + +@pytest.mark.requests +def test_default_soap_headers(): + header = xsd.Element(None, xsd.ComplexType( + xsd.Sequence([ + xsd.Element('{http://tests.python-zeep.org}name', xsd.String()), + xsd.Element('{http://tests.python-zeep.org}password', xsd.String()), + ]) + )) + header_value = header(name='ik', password='geheim') + + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + client_obj.set_default_soapheaders([header_value]) + + response = """ + + + + + + 120.123 + + + + """.strip() + + with requests_mock.mock() as m: + m.post('http://example.com/stockquote', text=response) + client_obj.service.GetLastTradePrice('foobar') + + doc = load_xml(m.request_history[0].body) + header = doc.find('{http://schemas.xmlsoap.org/soap/envelope/}Header') + assert header is not None + assert len(header.getchildren()) == 2 + + +@pytest.mark.requests +def test_default_soap_headers_extra(): + header = xsd.Element(None, xsd.ComplexType( + xsd.Sequence([ + xsd.Element('{http://tests.python-zeep.org}name', xsd.String()), + xsd.Element('{http://tests.python-zeep.org}password', xsd.String()), + ]) + )) + header_value = header(name='ik', password='geheim') + + extra_header = xsd.Element(None, xsd.ComplexType( + xsd.Sequence([ + xsd.Element('{http://tests.python-zeep.org}name', xsd.String()), + xsd.Element('{http://tests.python-zeep.org}password', xsd.String()), + ]) + )) + extra_header_value = extra_header(name='ik', password='geheim') + + client_obj = client.Client('tests/wsdl_files/soap.wsdl') + client_obj.set_default_soapheaders([header_value]) + + response = """ + + + + + + 120.123 + + + + """.strip() + + with requests_mock.mock() as m: + m.post('http://example.com/stockquote', text=response) + client_obj.service.GetLastTradePrice('foobar', _soapheaders=[extra_header_value]) + + doc = load_xml(m.request_history[0].body) + header = doc.find('{http://schemas.xmlsoap.org/soap/envelope/}Header') + assert header is not None + assert len(header.getchildren()) == 4 diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..a3ddb06 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,113 @@ +from lxml import etree + +from tests.utils import load_xml +from zeep import xsd +from zeep.helpers import serialize_object + + +def test_serialize_simple(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'name'), + xsd.String()), + xsd.Attribute( + etree.QName('http://tests.python-zeep.org/', 'attr'), + xsd.String()), + ]) + )) + + obj = custom_type(name='foo', attr='x') + assert obj.name == 'foo' + assert obj.attr == 'x' + + result = serialize_object(obj) + + assert result == { + 'name': 'foo', + 'attr': 'x', + } + + +def test_serialize_nested_complex_type(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'items'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'x'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'y'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'x'), + xsd.String()), + ]) + ) + ) + ]) + ), + max_occurs=2 + ) + ]) + )) + + obj = custom_type( + items=[ + {'x': 'bla', 'y': {'x': 'deep'}}, + {'x': 'foo', 'y': {'x': 'deeper'}}, + ]) + + assert len(obj.items) == 2 + obj.items[0].x == 'bla' + obj.items[0].y.x == 'deep' + obj.items[1].x == 'foo' + obj.items[1].y.x == 'deeper' + + result = serialize_object(obj) + + assert result == { + 'items': [ + {'x': 'bla', 'y': {'x': 'deep'}}, + {'x': 'foo', 'y': {'x': 'deeper'}}, + ] + } + + +def test_nested_complex_types(): + schema = xsd.Schema(load_xml(""" + + + + + + + + + + + + + + + """)) + + container_elm = schema.get_element('{http://tests.python-zeep.org/}container') + item_type = schema.get_type('{http://tests.python-zeep.org/}item') + + instance = container_elm(item=item_type(item_1='foo')) + result = serialize_object(instance) + assert isinstance(result, dict), type(result) + assert isinstance(result['item'], dict), type(result['item']) + assert result['item']['item_1'] == 'foo' diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..6364d85 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,89 @@ +from lxml import etree + +from zeep.xsd import Schema + + +def test_parse_response(): + schema_node = etree.fromstring(b""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """.strip()) # noqa + + response_node = etree.fromstring(b""" + + + + + + 45313 + + + ABC100 + 10 + + + ABC200 + 20 + + + + + + + """.strip()) + schema = Schema(schema_node.find('*/{http://www.w3.org/2001/XMLSchema}schema')) + assert schema + response_type = schema.get_element( + '{http://tests.python-zeep.org/}ZeepExampleResponse') + + nsmap = { + 'soap': 'http://schemas.xmlsoap.org/soap/envelope/', + 'tns': 'http://tests.python-zeep.org/', + } + node = response_node.find('soap:Body/tns:ZeepExampleResponse', namespaces=nsmap) + assert node is not None + obj = response_type.parse(node, schema) + assert obj.ZeepExampleResult.SomeValue == 45313 + assert len(obj.ZeepExampleResult.Results.Item) == 2 + assert obj.ZeepExampleResult.Results.Item[0].Key == 'ABC100' + assert obj.ZeepExampleResult.Results.Item[0].Value == 10 + assert obj.ZeepExampleResult.Results.Item[1].Key == 'ABC200' + assert obj.ZeepExampleResult.Results.Item[1].Value == 20 diff --git a/tests/test_wsdl.py b/tests/test_wsdl.py new file mode 100644 index 0000000..86aa0eb --- /dev/null +++ b/tests/test_wsdl.py @@ -0,0 +1,828 @@ +import io + +import pytest +import requests_mock +from lxml import etree +from pretend import stub +from six import StringIO + +from tests.utils import DummyTransport, assert_nodes_equal +from zeep import wsdl +from zeep import Client +from zeep.transports import Transport + + +@pytest.mark.requests +def test_parse_soap_wsdl(): + client = Client('tests/wsdl_files/soap.wsdl', transport=Transport(),) + + response = """ + + + + + + 120.123 + + + + """.strip() + + client.set_ns_prefix('stoc', 'http://example.com/stockquote.xsd') + + with requests_mock.mock() as m: + m.post('http://example.com/stockquote', text=response) + account_type = client.get_type('stoc:account') + account = account_type(id=100) + country = client.get_element('stoc:country').type() + country.name = 'The Netherlands' + country.code = 'NL' + + result = client.service.GetLastTradePrice( + tickerSymbol='foobar', + account=account, + country=country) + assert result == 120.123 + + request = m.request_history[0] + + # Compare request body + expected = """ + + + + foobar + + 100 + + + + The Netherlands + NL + + + + + """ + assert_nodes_equal(expected, request.body) + + +@pytest.mark.requests +def test_parse_soap_header_wsdl(): + client = Client('tests/wsdl_files/soap_header.wsdl', transport=Transport(),) + + response = """ + + + + + + 120.123 + + + + """.strip() + + with requests_mock.mock() as m: + m.post('http://example.com/stockquote', text=response) + result = client.service.GetLastTradePrice( + tickerSymbol='foobar', + _soapheaders={ + 'header': { + 'username': 'ikke', + 'password': 'oeh-is-geheim!', + } + }) + + assert result == 120.123 + + request = m.request_history[0] + + # Compare request body + expected = """ + + + + ikke + oeh-is-geheim! + + + + + foobar + + + + """ + assert_nodes_equal(expected, request.body) + + +def test_parse_types_multiple_schemas(): + + content = StringIO(""" + + + + + + + + + + + + + + + """.strip()) + + assert wsdl.Document(content, None) + + +def test_parse_types_nsmap_issues(): + content = StringIO(""" + + + + + + + + + + + + + + + + + + + + """.strip()) + assert wsdl.Document(content, None) + + +@pytest.mark.requests +def test_parse_soap_import_wsdl(): + client = stub(transport=Transport(), wsse=None) + content = io.open( + 'tests/wsdl_files/soap-enc.xsd', 'r', encoding='utf-8').read() + + with requests_mock.mock() as m: + m.get('http://schemas.xmlsoap.org/soap/encoding/', text=content) + + obj = wsdl.Document( + 'tests/wsdl_files/soap_import_main.wsdl', transport=client.transport) + assert len(obj.services) == 1 + assert obj.types.is_empty is False + obj.dump() + + +def test_multiple_extension(): + content = StringIO(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """.strip()) + document = wsdl.Document(content, None) + + type_a = document.types.get_element('ns0:typetje') + type_a(wat='x') + + type_a = document.types.get_type('ns0:type_a') + type_a(wat='x') + + +def test_create_import_schema(recwarn): + content = StringIO(""" + + + + + + + + + + + + + """.strip()) + + schema_node_a = etree.fromstring(""" + + + + """.strip()) + + schema_node_b = etree.fromstring(""" + + + + + + """.strip()) + + transport = DummyTransport() + transport.bind('a.xsd', schema_node_a) + transport.bind('b.xsd', schema_node_b) + + document = wsdl.Document(content, transport) + assert len(recwarn) == 0 + assert document.types.get_element('{http://tests.python-zeep.org/b}global') + + +def test_wsdl_imports_xsd(recwarn): + content = StringIO(""" + + + + + """.strip()) + + schema_node_a = etree.fromstring(""" + + + + + """.strip()) + + schema_node_b = etree.fromstring(""" + + + + """.strip()) + + transport = DummyTransport() + transport.bind('a.xsd', schema_node_a) + transport.bind('b.xsd', schema_node_b) + + wsdl.Document(content, transport) + + +def test_import_schema_without_location(recwarn): + content = StringIO(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """.strip()) + + schema_node_a = etree.fromstring(""" + + + + + + + """.strip()) + + schema_node_b = etree.fromstring(""" + + + + + + + + + + """.strip()) + + transport = DummyTransport() + transport.bind('a.xsd', schema_node_a) + transport.bind('b.xsd', schema_node_b) + + document = wsdl.Document(content, transport) + assert len(recwarn) == 0 + assert document.types.get_type('{http://tests.python-zeep.org/b}foo') + + +def test_wsdl_import(recwarn): + wsdl_main = StringIO(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Test service + + + + + + """.strip()) + + wsdl_2 = (""" + + + + + + + + + + + + """.strip()) + + transport = DummyTransport() + transport.bind('http://tests.python-zeep.org/schema-2.wsdl', wsdl_2) + document = wsdl.Document(wsdl_main, transport) + document.dump() + + +def test_wsdl_import_transitive(recwarn): + wsdl_main = StringIO(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Test service + + + + + + """.strip()) + + wsdl_2 = (""" + + + + + + + + + + + + + """.strip()) + + wsdl_3 = (""" + + + + + + + + + + + + + + """.strip()) + + wsdl_4 = (""" + + + + + + + + """.strip()) + + transport = DummyTransport() + transport.bind('http://tests.python-zeep.org/schema-2.wsdl', wsdl_2) + transport.bind('http://tests.python-zeep.org/schema-3.wsdl', wsdl_3) + transport.bind('http://tests.python-zeep.org/schema-4.wsdl', wsdl_4) + + document = wsdl.Document(wsdl_main, transport) + document.dump() + + +def test_wsdl_import_xsd_references(recwarn): + wsdl_main = StringIO(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Test service + + + + + + """.strip()) + + wsdl_2 = (""" + + + + + + + + + + + + """.strip()) + + transport = DummyTransport() + transport.bind('http://tests.python-zeep.org/schema-2.wsdl', wsdl_2) + document = wsdl.Document(wsdl_main, transport) + document.dump() + + +def test_parse_operation_empty_nodes(): + content = StringIO(""" + + + + + + + + + + + + + + + + + + + + + + + + Example documentation. + + + + + + + + + + + + + + + + + """.strip()) + + assert wsdl.Document(content, None) diff --git a/tests/test_xsd.py b/tests/test_xsd.py new file mode 100644 index 0000000..311f082 --- /dev/null +++ b/tests/test_xsd.py @@ -0,0 +1,587 @@ +import pytest +from lxml import etree + +from tests.utils import assert_nodes_equal, render_node +from zeep import xsd + + +def test_container_elements(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'username'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'password'), + xsd.String()), + xsd.Any(), + ]) + )) + + # sequences + custom_type(username='foo', password='bar') + + +def test_create_node(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'username'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'password'), + xsd.String()), + ]), + [ + xsd.Attribute('attr', xsd.String()), + ] + )) + + # sequences + obj = custom_type(username='foo', password='bar', attr='x') + + expected = """ + + + foo + bar + + + """ + node = etree.Element('document') + custom_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_element_simple_type(): + elm = xsd.Element( + '{http://tests.python-zeep.org/}item', xsd.String()) + obj = elm('foo') + + expected = """ + + foo + + """ + node = etree.Element('document') + elm.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_nil_elements(): + custom_type = xsd.Element( + '{http://tests.python-zeep.org/}container', + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + '{http://tests.python-zeep.org/}item_1', + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + '{http://tests.python-zeep.org/}item_1_1', + xsd.String()) + ]), + ), + nillable=True), + xsd.Element( + '{http://tests.python-zeep.org/}item_2', + xsd.DateTime(), nillable=True), + xsd.Element( + '{http://tests.python-zeep.org/}item_3', + xsd.String(), min_occurs=0, nillable=False), + xsd.Element( + '{http://tests.python-zeep.org/}item_4', + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + '{http://tests.python-zeep.org/}item_4_1', + xsd.String(), nillable=True) + ]) + ) + ), + ]) + )) + obj = custom_type(item_1=None, item_2=None, item_3=None, item_4={}) + + expected = """ + + + + + + + + + + """ + node = render_node(custom_type, obj) + etree.cleanup_namespaces(node) + assert_nodes_equal(expected, node) + + +def test_invalid_kwarg(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'username'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'password'), + xsd.String()), + ]) + )) + + with pytest.raises(TypeError): + custom_type(something='is-wrong') + + +def test_invalid_kwarg_simple_type(): + elm = xsd.Element( + '{http://tests.python-zeep.org/}item', xsd.String()) + + with pytest.raises(TypeError): + elm(something='is-wrong') + + +def test_group_mixed(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'username'), + xsd.String()), + xsd.Group( + etree.QName('http://tests.python-zeep.org/', 'groupie'), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'password'), + xsd.String(), + ) + ]) + ) + ]) + )) + assert custom_type.signature() + obj = custom_type(username='foo', password='bar') + + expected = """ + + + foo + bar + + + """ + node = etree.Element('document') + custom_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_any(): + some_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'doei'), + xsd.String()) + + complex_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'complex'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]) + )) + + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'hoi'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Any(), + xsd.Any(), + xsd.Any(), + ]) + )) + + any_1 = xsd.AnyObject(some_type, "DOEI!") + any_2 = xsd.AnyObject( + complex_type, complex_type(item_1='val_1', item_2='val_2')) + any_3 = xsd.AnyObject( + complex_type, [ + complex_type(item_1='val_1_1', item_2='val_1_2'), + complex_type(item_1='val_2_1', item_2='val_2_2'), + ]) + + obj = custom_type(_value_1=any_1, _value_2=any_2, _value_3=any_3) + + expected = """ + + + DOEI! + + val_1 + val_2 + + + val_1_1 + val_1_2 + + + val_2_1 + val_2_2 + + + + """ + node = etree.Element('document') + custom_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_any_type_check(): + some_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'doei'), + xsd.String()) + + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'complex'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Any(), + ]) + )) + with pytest.raises(TypeError): + custom_type(_any_1=some_type) + + +def test_choice_init(): + root = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'kies'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'pre'), + xsd.String()), + xsd.Choice([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_3'), + xsd.String()), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_4_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_4_2'), + xsd.String()), + ]) + ], max_occurs=4), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'post'), + xsd.String()), + ]) + ) + ) + + obj = root( + pre='foo', + _value_1=[ + {'item_1': 'value-1'}, + {'item_2': 'value-2'}, + {'item_1': 'value-3'}, + {'item_4_1': 'value-4-1', 'item_4_2': 'value-4-2'}, + ]) + + assert obj._value_1 == [ + {'item_1': 'value-1'}, + {'item_2': 'value-2'}, + {'item_1': 'value-3'}, + {'item_4_1': 'value-4-1', 'item_4_2': 'value-4-2'}, + ] + + node = etree.Element('document') + root.render(node, obj) + assert etree.tostring(node) + + expected = """ + + + foo + value-1 + value-2 + value-3 + value-4-1 + value-4-2 + + + + """.strip() + assert_nodes_equal(expected, node) + + +def test_choice_determinst(): + root = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'kies'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Choice([ + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + ]), + ]) + ]) + ) + ) + + obj = root(item_1='item-1', item_2='item-2') + node = etree.Element('document') + root.render(node, obj) + assert etree.tostring(node) + + expected = """ + + + item-1 + item-2 + + + """.strip() + assert_nodes_equal(expected, node) + + +def test_sequence(): + root = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'container'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Sequence([ + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ], min_occurs=2, max_occurs=2), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_3'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_4'), + xsd.String()), + ]), + ]) + ]) + ) + ) + root( + _value_1=[ + { + 'item_1': 'foo', + 'item_2': 'bar', + }, + { + 'item_1': 'foo', + 'item_2': 'bar', + }, + ], + item_3='foo', + item_4='bar', + ) + + +def test_mixed_choice(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_3'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_4'), + xsd.String()), + ]), + xsd.Choice([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_5'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_6'), + xsd.String()), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_7'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_8'), + xsd.String()), + ]) + ]) + ]) + )) + + item = custom_type( + item_1='item-1', + item_2='item-2', + item_3='item-3', + item_4='item-4', + item_7='item-7', + item_8='item-8', + ) + + assert item.item_1 == 'item-1' + assert item.item_2 == 'item-2' + assert item.item_3 == 'item-3' + assert item.item_4 == 'item-4' + assert item.item_7 == 'item-7' + assert item.item_8 == 'item-8' + + +def test_xsi(): + org_type = xsd.Element( + '{https://tests.python-zeep.org/}original', + xsd.ComplexType( + xsd.Sequence([ + xsd.Element('username', xsd.String()), + xsd.Element('password', xsd.String()), + ]) + ) + ) + alt_type = xsd.Element( + '{https://tests.python-zeep.org/}alternative', + xsd.ComplexType( + xsd.Sequence([ + xsd.Element('username', xsd.String()), + xsd.Element('password', xsd.String()), + ]) + ) + ) + instance = alt_type(username='mvantellingen', password='geheim') + render_node(org_type, instance) + + +def test_duplicate_element_names(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'container'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item'), + xsd.String()), + ]) + )) + + # sequences + expected = 'item: xsd:string, item__1: xsd:string, item__2: xsd:string' + assert custom_type.signature() == expected + obj = custom_type(item='foo', item__1='bar', item__2='lala') + + expected = """ + + + foo + bar + lala + + + """ + node = render_node(custom_type, obj) + assert_nodes_equal(expected, node) + + +def test_element_attribute_name_conflict(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'container'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item'), + xsd.String()), + ]), + [ + xsd.Attribute('foo', xsd.String()), + xsd.Attribute('item', xsd.String()), + ] + )) + + # sequences + expected = 'item: xsd:string, foo: xsd:string, attr__item: xsd:string' + assert custom_type.signature() == expected + obj = custom_type(item='foo', foo='x', attr__item='bar') + + expected = """ + + + foo + + + """ + node = render_node(custom_type, obj) + assert_nodes_equal(expected, node) + + obj = custom_type.parse(node.getchildren()[0], None) + assert obj.item == 'foo' + assert obj.foo == 'x' + assert obj.attr__item == 'bar' + + +def test_attr_name(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'UserName'), + xsd.String(), + attr_name='username'), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'Password_x'), + xsd.String(), + attr_name='password'), + ]) + )) + + # sequences + custom_type(username='foo', password='bar') diff --git a/tests/test_xsd_builtins.py b/tests/test_xsd_builtins.py new file mode 100644 index 0000000..0d3da42 --- /dev/null +++ b/tests/test_xsd_builtins.py @@ -0,0 +1,340 @@ +import datetime +from decimal import Decimal as D + +import isodate +import pytest +import pytz +import six + +from zeep.xsd import builtins + + +class TestString: + + def test_xmlvalue(self): + instance = builtins.String() + result = instance.xmlvalue('foobar') + assert result == 'foobar' + + def test_pythonvalue(self): + instance = builtins.String() + result = instance.pythonvalue('foobar') + assert result == 'foobar' + + +class TestBoolean: + + def test_xmlvalue(self): + instance = builtins.Boolean() + assert instance.xmlvalue(True) == 'true' + assert instance.xmlvalue(False) == 'false' + assert instance.xmlvalue(1) == 'true' + assert instance.xmlvalue(0) == 'false' + + def test_pythonvalue(self): + instance = builtins.Boolean() + assert instance.pythonvalue('1') is True + assert instance.pythonvalue('true') is True + assert instance.pythonvalue('0') is False + assert instance.pythonvalue('false') is False + + +class TestDecimal: + + def test_xmlvalue(self): + instance = builtins.Decimal() + assert instance.xmlvalue(D('10.00')) == '10.00' + assert instance.xmlvalue(D('10.000002')) == '10.000002' + assert instance.xmlvalue(D('10.000002')) == '10.000002' + assert instance.xmlvalue(D('10')) == '10' + assert instance.xmlvalue(D('-10')) == '-10' + + def test_pythonvalue(self): + instance = builtins.Decimal() + assert instance.pythonvalue('10') == D('10') + assert instance.pythonvalue('10.001') == D('10.001') + assert instance.pythonvalue('+10.001') == D('10.001') + assert instance.pythonvalue('-10.001') == D('-10.001') + + +class TestFloat: + + def test_xmlvalue(self): + instance = builtins.Float() + assert instance.xmlvalue(float(10)) == '10.0' + assert instance.xmlvalue(float(3.9999)) == '3.9999' + assert instance.xmlvalue(float('inf')) == 'INF' + assert instance.xmlvalue(float(12.78e-2)) == '0.1278' + if six.PY2: + assert instance.xmlvalue(float('1267.43233E12')) == '1.26743233E+15' + else: + assert instance.xmlvalue(float('1267.43233E12')) == '1267432330000000.0' + + def test_pythonvalue(self): + instance = builtins.Float() + assert instance.pythonvalue('10') == float('10') + assert instance.pythonvalue('-1E4') == float('-1E4') + assert instance.pythonvalue('1267.43233E12') == float('1267.43233E12') + assert instance.pythonvalue('12.78e-2') == float('0.1278') + assert instance.pythonvalue('12') == float(12) + assert instance.pythonvalue('-0') == float(0) + assert instance.pythonvalue('0') == float(0) + assert instance.pythonvalue('INF') == float('inf') + + +class TestDouble: + + def test_xmlvalue(self): + instance = builtins.Double() + assert instance.xmlvalue(float(10)) == '10.0' + assert instance.xmlvalue(float(3.9999)) == '3.9999' + assert instance.xmlvalue(float(12.78e-2)) == '0.1278' + + def test_pythonvalue(self): + instance = builtins.Double() + assert instance.pythonvalue('10') == float('10') + assert instance.pythonvalue('12') == float(12) + assert instance.pythonvalue('-0') == float(0) + assert instance.pythonvalue('0') == float(0) + + +class TestDuration: + + def test_xmlvalue(self): + instance = builtins.Duration() + value = isodate.parse_duration('P0Y1347M0D') + assert instance.xmlvalue(value) == 'P1347M' + + def test_pythonvalue(self): + instance = builtins.Duration() + expected = isodate.parse_duration('P0Y1347M0D') + value = 'P0Y1347M0D' + assert instance.pythonvalue(value) == expected + + +class TestDateTime: + + def test_xmlvalue(self): + instance = builtins.DateTime() + value = datetime.datetime(2016, 3, 4, 21, 14, 42) + assert instance.xmlvalue(value) == '2016-03-04T21:14:42' + + value = datetime.datetime(2016, 3, 4, 21, 14, 42, tzinfo=pytz.utc) + assert instance.xmlvalue(value) == '2016-03-04T21:14:42Z' + + value = value.astimezone(pytz.timezone('Europe/Amsterdam')) + assert instance.xmlvalue(value) == '2016-03-04T22:14:42+01:00' + + def test_pythonvalue(self): + instance = builtins.DateTime() + value = datetime.datetime(2016, 3, 4, 21, 14, 42) + assert instance.pythonvalue('2016-03-04T21:14:42') == value + + def test_pythonvalue_invalid(self): + instance = builtins.DateTime() + with pytest.raises(ValueError): + assert instance.pythonvalue(' : : ') + + +class TestTime: + + def test_xmlvalue(self): + instance = builtins.Time() + value = datetime.time(21, 14, 42) + assert instance.xmlvalue(value) == '21:14:42' + + def test_pythonvalue(self): + instance = builtins.Time() + value = datetime.time(21, 14, 42) + assert instance.pythonvalue('21:14:42') == value + + value = datetime.time(21, 14, 42, 120000) + assert instance.pythonvalue('21:14:42.120') == value + + value = isodate.parse_time('21:14:42.120+0200') + assert instance.pythonvalue('21:14:42.120+0200') == value + + def test_pythonvalue_invalid(self): + instance = builtins.Time() + with pytest.raises(ValueError): + assert instance.pythonvalue(':') + + +class TestDate: + + def test_xmlvalue(self): + instance = builtins.Date() + value = datetime.datetime(2016, 3, 4) + assert instance.xmlvalue(value) == '2016-03-04' + assert instance.xmlvalue('2016-03-04') == '2016-03-04' + assert instance.xmlvalue('2016-04') == '2016-04' + + def test_pythonvalue(self): + instance = builtins.Date() + assert instance.pythonvalue('2016-03-04') == datetime.date(2016, 3, 4) + assert instance.pythonvalue('2001-10-26+02:00') == datetime.date(2001, 10, 26) + assert instance.pythonvalue('2001-10-26Z') == datetime.date(2001, 10, 26) + assert instance.pythonvalue('2001-10-26+00:00') == datetime.date(2001, 10, 26) + + def test_pythonvalue_invalid(self): + instance = builtins.Date() + # negative dates are not supported for datetime.date objects so lets + # hope no-one uses it for now.. + with pytest.raises(ValueError): + assert instance.pythonvalue('-2001-10-26') + with pytest.raises(ValueError): + assert instance.pythonvalue('-20000-04-01') + + +class TestgYearMonth: + + def test_xmlvalue(self): + instance = builtins.gYearMonth() + assert instance.xmlvalue((2012, 10, None)) == '2012-10' + assert instance.xmlvalue((2012, 10, pytz.utc)) == '2012-10Z' + + def test_pythonvalue(self): + instance = builtins.gYearMonth() + assert instance.pythonvalue('2001-10') == (2001, 10, None) + assert instance.pythonvalue('2001-10+02:00') == (2001, 10, pytz.FixedOffset(120)) + assert instance.pythonvalue('2001-10Z') == (2001, 10, pytz.utc) + assert instance.pythonvalue('2001-10+00:00') == (2001, 10, pytz.utc) + assert instance.pythonvalue('-2001-10') == (-2001, 10, None) + assert instance.pythonvalue('-20001-10') == (-20001, 10, None) + + with pytest.raises(builtins.ParseError): + assert instance.pythonvalue('10-10') + + +class TestgYear: + + def test_xmlvalue(self): + instance = builtins.gYear() + instance.xmlvalue((2001, None)) == '2001' + instance.xmlvalue((2001, pytz.utc)) == '2001Z' + + def test_pythonvalue(self): + instance = builtins.gYear() + assert instance.pythonvalue('2001') == (2001, None) + assert instance.pythonvalue('2001+02:00') == (2001, pytz.FixedOffset(120)) + assert instance.pythonvalue('2001Z') == (2001, pytz.utc) + assert instance.pythonvalue('2001+00:00') == (2001, pytz.utc) + assert instance.pythonvalue('-2001') == (-2001, None) + assert instance.pythonvalue('-20000') == (-20000, None) + + with pytest.raises(builtins.ParseError): + assert instance.pythonvalue('99') + + +class TestgMonthDay: + + def test_xmlvalue(self): + instance = builtins.gMonthDay() + assert instance.xmlvalue((12, 30, None)) == '--12-30' + + def test_pythonvalue(self): + instance = builtins.gMonthDay() + assert instance.pythonvalue('--05-01') == (5, 1, None) + assert instance.pythonvalue('--11-01Z') == (11, 1, pytz.utc) + assert instance.pythonvalue('--11-01+02:00') == (11, 1, pytz.FixedOffset(120)) + assert instance.pythonvalue('--11-01-04:00') == (11, 1, pytz.FixedOffset(-240)) + assert instance.pythonvalue('--11-15') == (11, 15, None) + assert instance.pythonvalue('--02-29') == (2, 29, None) + + with pytest.raises(builtins.ParseError): + assert instance.pythonvalue('99') + + +class TestgMonth: + + def test_xmlvalue(self): + instance = builtins.gMonth() + assert instance.xmlvalue((12, None)) == '--12' + + def test_pythonvalue(self): + instance = builtins.gMonth() + assert instance.pythonvalue('--05') == (5, None) + assert instance.pythonvalue('--11Z') == (11, pytz.utc) + assert instance.pythonvalue('--11+02:00') == (11, pytz.FixedOffset(120)) + assert instance.pythonvalue('--11-04:00') == (11, pytz.FixedOffset(-240)) + assert instance.pythonvalue('--11') == (11, None) + assert instance.pythonvalue('--02') == (2, None) + + with pytest.raises(builtins.ParseError): + assert instance.pythonvalue('99') + + +class TestgDay: + + def test_xmlvalue(self): + instance = builtins.gDay() + + value = (1, None) + assert instance.xmlvalue(value) == '---01' + + value = (1, pytz.FixedOffset(120)) + assert instance.xmlvalue(value) == '---01+02:00' + + value = (1, pytz.FixedOffset(-240)) + assert instance.xmlvalue(value) == '---01-04:00' + + def test_pythonvalue(self): + instance = builtins.gDay() + assert instance.pythonvalue('---01') == (1, None) + assert instance.pythonvalue('---01Z') == (1, pytz.utc) + assert instance.pythonvalue('---01+02:00') == (1, pytz.FixedOffset(120)) + assert instance.pythonvalue('---01-04:00') == (1, pytz.FixedOffset(-240)) + assert instance.pythonvalue('---15') == (15, None) + assert instance.pythonvalue('---31') == (31, None) + with pytest.raises(builtins.ParseError): + assert instance.pythonvalue('99') + + +class TestHexBinary: + def test_xmlvalue(self): + instance = builtins.HexBinary() + assert instance.xmlvalue(b'\xFF') == b'\xFF' + + def test_pythonvalue(self): + instance = builtins.HexBinary() + assert instance.pythonvalue(b'\xFF') == b'\xFF' + + +class TestBase64Binary: + def test_xmlvalue(self): + instance = builtins.Base64Binary() + assert instance.xmlvalue(b'hoi') == b'aG9p' + + def test_pythonvalue(self): + instance = builtins.Base64Binary() + assert instance.pythonvalue(b'aG9p') == b'hoi' + + +class TestAnyURI: + def test_xmlvalue(self): + instance = builtins.AnyURI() + assert instance.xmlvalue('http://test.python-zeep.org') == 'http://test.python-zeep.org' + + def test_pythonvalue(self): + instance = builtins.AnyURI() + assert instance.pythonvalue('http://test.python-zeep.org') == 'http://test.python-zeep.org' + + +class TestInteger: + def test_xmlvalue(self): + instance = builtins.Integer() + assert instance.xmlvalue(100) == '100' + + def test_pythonvalue(self): + instance = builtins.Integer() + assert instance.pythonvalue('100') == 100 + + +class TestAnyType: + def test_xmlvalue(self): + instance = builtins.AnyType() + assert instance.xmlvalue('http://test.python-zeep.org') == 'http://test.python-zeep.org' + + def test_pythonvalue(self): + instance = builtins.AnyType() + assert instance.pythonvalue('http://test.python-zeep.org') == 'http://test.python-zeep.org' diff --git a/tests/test_xsd_integration.py b/tests/test_xsd_integration.py new file mode 100644 index 0000000..06df987 --- /dev/null +++ b/tests/test_xsd_integration.py @@ -0,0 +1,904 @@ +import pytest +from lxml import etree + +from tests.utils import assert_nodes_equal, load_xml +from zeep import xsd + + +def test_complex_type_nested_wrong_type(): + schema = xsd.Schema(load_xml(""" + + + + + + + + + + + + + + + + + + """)) + + container_elm = schema.get_element('ns0:container') + with pytest.raises(TypeError): + container_elm(item={'bar': 1}) + + +def test_element_with_annotation(): + schema = xsd.Schema(load_xml(""" + + + + + HOI! + + + + + + + + + """)) + + schema.set_ns_prefix('tns', 'http://tests.python-zeep.org/') + address_type = schema.get_element('tns:Address') + address_type(foo='bar') + + +def test_complex_type_parsexml(): + schema = xsd.Schema(load_xml(""" + + + + + + + + + + + """)) + + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + + input_node = load_xml(""" +
+ bar +
+ """) + + obj = address_type.parse(input_node, None) + assert obj.foo == 'bar' + + +def test_array(): + node = etree.fromstring(""" + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + schema.set_ns_prefix('tns', 'http://tests.python-zeep.org/') + + address_type = schema.get_element('tns:Address') + + obj = address_type() + assert obj.foo == [] + obj.foo.append('foo') + obj.foo.append('bar') + + expected = """ + + + foo + bar + + + """ + node = etree.Element('document', nsmap=schema._prefix_map_custom) + address_type.render(node, obj) + print(etree.tostring(node)) + assert_nodes_equal(expected, node) + + +def test_complex_type_unbounded_one(): + node = etree.fromstring(""" + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + obj = address_type(foo=['foo']) + + expected = """ + + + foo + + + """ + + node = etree.Element('document') + address_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_complex_type_unbounded_named(): + node = etree.fromstring(""" + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + obj = address_type() + assert obj.foo == [] + obj.foo.append('foo') + obj.foo.append('bar') + + expected = """ + + + foo + bar + + + """ + + node = etree.Element('document') + address_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_complex_type_array_to_other_complex_object(): + node = etree.fromstring(""" + + + + + + + + + + + + + + + """.strip()) # noqa + + schema = xsd.Schema(node) + address_array = schema.get_element('ArrayOfAddress') + obj = address_array() + assert obj.Address == [] + + obj.Address.append(schema.get_type('Address')(foo='foo')) + obj.Address.append(schema.get_type('Address')(foo='bar')) + + node = etree.fromstring(""" + + +
+ foo +
+
+ bar +
+
+ """.strip()) + + +def test_complex_type_init_kwargs(): + node = etree.fromstring(""" + + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + obj = address_type( + NameFirst='John', NameLast='Doe', Email='j.doe@example.com') + assert obj.NameFirst == 'John' + assert obj.NameLast == 'Doe' + assert obj.Email == 'j.doe@example.com' + + +def test_complex_type_init_args(): + node = etree.fromstring(""" + + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + obj = address_type('John', 'Doe', 'j.doe@example.com') + assert obj.NameFirst == 'John' + assert obj.NameLast == 'Doe' + assert obj.Email == 'j.doe@example.com' + + +def test_group(): + node = etree.fromstring(""" + + + + + + + + + + + + + + + + + + """.strip()) + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + + obj = address_type(first_name='foo', last_name='bar') + + node = etree.Element('document') + address_type.render(node, obj) + expected = """ + + + foo + bar + + + """ + assert_nodes_equal(expected, node) + + +def test_group_for_type(): + node = etree.fromstring(""" + + + + + + + + + + + + + + + + + + + + + + blub + + + + + + + + """.strip()) + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + + obj = address_type( + first_name='foo', last_name='bar', + city='Utrecht', country='The Netherlands') + + node = etree.Element('document') + address_type.render(node, obj) + expected = """ + + + foo + bar + Utrecht + The Netherlands + + + """ + assert_nodes_equal(expected, node) + + +def test_element_ref_missing_namespace(): + # For buggy soap servers (#170) + node = etree.fromstring(""" + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + + custom_type = schema.get_element('{http://tests.python-zeep.org/}bar') + input_xml = load_xml(""" + + bar + + """) + item = custom_type.parse(input_xml, schema) + assert item.foo == 'bar' + + +def test_element_ref(): + node = etree.fromstring(""" + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + + foo_type = schema.get_element('{http://tests.python-zeep.org/}foo') + assert isinstance(foo_type.type, xsd.String) + + custom_type = schema.get_element('{http://tests.python-zeep.org/}bar') + custom_type.signature() + obj = custom_type(foo='bar') + + node = etree.Element('document') + custom_type.render(node, obj) + expected = """ + + + bar + + + """ + assert_nodes_equal(expected, node) + + +def test_element_ref_occurs(): + node = etree.fromstring(""" + + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + + foo_type = schema.get_element('{http://tests.python-zeep.org/}foo') + assert isinstance(foo_type.type, xsd.String) + + custom_type = schema.get_element('{http://tests.python-zeep.org/}bar') + custom_type.signature() + obj = custom_type(bar='foo') + + node = etree.Element('document') + custom_type.render(node, obj) + expected = """ + + + foo + + + """ + assert_nodes_equal(expected, node) + + +def test_unqualified(): + node = etree.fromstring(""" + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + obj = address_type(foo='bar') + + expected = """ + + + bar + + + """ + + node = etree.Element('document') + address_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_defaults(): + node = etree.fromstring(""" + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + container_type = schema.get_element( + '{http://tests.python-zeep.org/}container') + obj = container_type() + assert obj.foo == "hoi" + assert obj.bar == "hoi" + + expected = """ + + + hoi + + + """ + node = etree.Element('document') + container_type.render(node, obj) + assert_nodes_equal(expected, node) + + +def test_defaults_parse(): + node = etree.fromstring(""" + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + container_elm = schema.get_element( + '{http://tests.python-zeep.org/}container') + + node = load_xml(""" + + hoi + + """) + item = container_elm.parse(node, schema) + assert item.bar == 'hoi' + + +def test_init_with_dicts(): + node = etree.fromstring(""" + + + + + + + + + + + + + + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + address_type = schema.get_element('{http://tests.python-zeep.org/}Address') + obj = address_type(name='foo', container={'service': [{'name': 'foo'}]}) + + expected = """ + + + foo + + + foo + + + + + """ + + node = etree.Element('document') + address_type.render(node, obj) + assert_nodes_equal(expected, node) + + + +def test_sequence_in_sequence(): + node = load_xml(""" + + + + + + + + + + + + + + + """) + schema = xsd.Schema(node) + element = schema.get_element('ns0:container') + value = element(item_1="foo", item_2="bar") + + node = etree.Element('document') + element.render(node, value) + + expected = """ + + + foo + bar + + + """ + assert_nodes_equal(expected, node) + + +def test_sequence_in_sequence_many(): + node = load_xml(""" + + + + + + + + + + + + + + + """) + schema = xsd.Schema(node) + element = schema.get_element('ns0:container') + value = element(_value_1=[ + {'item_1': "value-1-1", 'item_2': "value-1-2"}, + {'item_1': "value-2-1", 'item_2': "value-2-2"}, + ]) + + assert value._value_1 == [ + {'item_1': "value-1-1", 'item_2': "value-1-2"}, + {'item_1': "value-2-1", 'item_2': "value-2-2"}, + ] + + node = etree.Element('document') + element.render(node, value) + + expected = """ + + + value-1-1 + value-1-2 + value-2-1 + value-2-2 + + + """ + assert_nodes_equal(expected, node) + + +def test_complex_type_empty(): + node = etree.fromstring(""" + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + + container_elm = schema.get_element('{http://tests.python-zeep.org/}container') + obj = container_elm() + + node = etree.Element('document') + container_elm.render(node, obj) + expected = """ + + + + + + """ + assert_nodes_equal(expected, node) + item = container_elm.parse(node.getchildren()[0], schema) + assert item.something is None + + +def test_schema_as_payload(): + schema = xsd.Schema(load_xml(""" + + + + + + + + + + + """)) + elm_class = schema.get_element('{http://tests.python-zeep.org/}container') + + node = load_xml(""" + + + + + + + + + + + + + value-1 + value-2 + + + """) + value = elm_class.parse(node, schema) + assert value._value_1['item-1'] == 'value-1' + assert value._value_1['item-2'] == 'value-2' + + +def test_nill(): + schema = xsd.Schema(load_xml(""" + + + + + + + + + + + """)) + + address_type = schema.get_element('ns0:container') + obj = address_type() + expected = """ + + + + + + """ + node = etree.Element('document') + address_type.render(node, obj) + etree.cleanup_namespaces(node) + + assert_nodes_equal(expected, node) + + +def test_empty_xmlns(): + node = load_xml(""" + + + + + + + + + + + + + """.strip()) + + schema = xsd.Schema(node) + + container_elm = schema.get_element('{http://tests.python-zeep.org/}container') + node = load_xml(""" + + + + + foo + + """) + item = container_elm.parse(node, schema) + assert item._value_1 == 'foo' diff --git a/tests/test_xsd_parse.py b/tests/test_xsd_parse.py new file mode 100644 index 0000000..c7f959e --- /dev/null +++ b/tests/test_xsd_parse.py @@ -0,0 +1,859 @@ +import datetime +from lxml import etree + +from tests.utils import load_xml +from zeep import xsd +from zeep.xsd.schema import Schema + + +def test_sequence_parse_basic(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]) + )) + expected = etree.fromstring(""" + + foo + bar + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == 'bar' + + +def test_sequence_parse_basic_with_attrs(): + custom_element = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]), + [ + xsd.Attribute( + etree.QName('http://tests.python-zeep.org/', 'attr_1'), + xsd.String()), + xsd.Attribute('attr_2', xsd.String()), + ] + )) + expected = etree.fromstring(""" + + foo + bar + + """) + obj = custom_element.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == 'bar' + assert obj.attr_1 == 'x' + assert obj.attr_2 == 'y' + + +def test_sequence_parse_with_optional(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'container'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2_1'), + xsd.String(), + nillable=True) + ]) + ) + ), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_3'), + xsd.String(), + max_occurs=2), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_4'), + xsd.String(), + min_occurs=0), + ]) + )) + expected = etree.fromstring(""" + + 1 + + 3 + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == '1' + assert obj.item_2 is None + assert obj.item_3 == ['3'] + assert obj.item_4 is None + + +def test_sequence_parse_regression(): + schema_doc = load_xml(b""" + + + + + + + + + + + + + + """) + + response_doc = load_xml(b""" + + + + + + + + """) + + schema = xsd.Schema(schema_doc) + elm = schema.get_element('{http://tests.python-zeep.org/attr}Response') + + node = response_doc.xpath( + '//ns0:Response', namespaces={ + 'xsd': 'http://www.w3.org/2001/XMLSchema', + 'ns0': 'http://tests.python-zeep.org/attr', + }) + response = elm.parse(node[0], None) + assert response.Result.id == 2 + + +def test_sequence_parse_anytype(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'container'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.AnyType()), + ]) + )) + expected = etree.fromstring(""" + + foo + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + + +def test_sequence_parse_anytype_nil(): + schema = xsd.Schema(load_xml(b""" + + + + + + + + + + + """)) + + container = schema.get_element('{http://tests.python-zeep.org/}container') + + expected = etree.fromstring(""" + + + + """) + obj = container.parse(expected, schema) + assert obj.item_1 is None + + +def test_sequence_parse_anytype_obj(): + value_type = xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + '{http://tests.python-zeep.org/}value', + xsd.Integer()), + ]) + ) + + schema = Schema( + etree.Element( + '{http://www.w3.org/2001/XMLSchema}Schema', + targetNamespace='http://tests.python-zeep.org/')) + + root = list(schema._schemas.values())[0] + root.register_type('{http://tests.python-zeep.org/}something', value_type) + + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'container'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.AnyType()), + ]) + )) + expected = etree.fromstring(""" + + + 100 + + + """) + obj = custom_type.parse(expected, schema) + assert obj.item_1.value == 100 + + +def test_sequence_parse_choice(): + schema_doc = load_xml(b""" + + + + + + + + + + + + + + + """) + + xml = load_xml(b""" + + + blabla + haha + + """) + + schema = xsd.Schema(schema_doc) + elm = schema.get_element('{http://tests.python-zeep.org/tst}container') + result = elm.parse(xml, schema) + assert result.item_1 == 'blabla' + assert result.item_3 == 'haha' + + +def test_sequence_parse_choice_max_occurs(): + schema_doc = load_xml(b""" + + + + + + + + + + + + + + + + + """) + + xml = load_xml(b""" + + + item-1-1 + item-1-2 + item-3 + + """) + + schema = xsd.Schema(schema_doc) + elm = schema.get_element('{http://tests.python-zeep.org/tst}container') + result = elm.parse(xml, schema) + assert result._value_1 == [ + {'item_1': 'item-1-1'}, + {'item_1': 'item-1-2'}, + ] + + assert result.item_3 == 'item-3' + + +def test_sequence_parse_choice_sequence_max_occurs(): + schema_doc = load_xml(b""" + + + + + + + + + + + + + + + + + + """) + + xml = load_xml(b""" + + + text-1 + text-2 + text-1 + text-2 + text-3 + text-4 + + """) + + schema = xsd.Schema(schema_doc) + elm = schema.get_element('{http://tests.python-zeep.org/tst}container') + result = elm.parse(xml, schema) + assert result._value_1 == [ + {'item_1': 'text-1', 'item_2': 'text-2'}, + {'item_1': 'text-1', 'item_2': 'text-2'}, + {'item_3': 'text-3'}, + ] + assert result.item_4 == 'text-4' + + +def test_sequence_parse_anytype_regression_17(): + schema_doc = load_xml(b""" + + + + + + + + + + + + + + + + + + + + + + + + + + """) + + xml = load_xml(b""" + + + + blabla + solution + + text/html + Test Solution + false + + + + """) + + schema = xsd.Schema(schema_doc) + elm = schema.get_element( + '{http://tests.python-zeep.org/tst}getCustomFieldResponse' + ) + result = elm.parse(xml, schema) + assert result.getCustomFieldReturn.value.content == 'Test Solution' + + +def test_sequence_min_occurs_2(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ], min_occurs=2, max_occurs=2) + )) + + # INIT + elm = custom_type(_value_1=[ + {'item_1': 'foo-1', 'item_2': 'bar-1'}, + {'item_1': 'foo-2', 'item_2': 'bar-2'}, + ]) + + assert elm._value_1 == [ + {'item_1': 'foo-1', 'item_2': 'bar-1'}, + {'item_1': 'foo-2', 'item_2': 'bar-2'}, + ] + + expected = etree.fromstring(""" + + foo + bar + foo + bar + + """) + obj = custom_type.parse(expected, None) + assert obj._value_1 == [ + { + 'item_1': 'foo', + 'item_2': 'bar', + }, + { + 'item_1': 'foo', + 'item_2': 'bar', + }, + ] + + +def test_all_basic(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.All([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]) + )) + expected = etree.fromstring(""" + + bar + foo + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == 'bar' + + +def test_group_optional(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Group( + etree.QName('http://tests.python-zeep.org/', 'foobar'), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]), + min_occurs=1) + )) + expected = etree.fromstring(""" + + foo + bar + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == 'bar' + assert not hasattr(obj, 'foobar') + + +def test_group_min_occurs_2(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Group( + etree.QName('http://tests.python-zeep.org/', 'foobar'), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ]), + min_occurs=2, max_occurs=2) + )) + expected = etree.fromstring(""" + + foo + bar + foo + bar + + """) + obj = custom_type.parse(expected, None) + assert obj._value_1 == [ + {'item_1': 'foo', 'item_2': 'bar'}, + {'item_1': 'foo', 'item_2': 'bar'}, + ] + assert not hasattr(obj, 'foobar') + + +def test_group_min_occurs_2_sequence_min_occurs_2(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Group( + etree.QName('http://tests.python-zeep.org/', 'foobar'), + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.String()), + ], min_occurs=2, max_occurs=2), + min_occurs=2, max_occurs=2) + )) + expected = etree.fromstring(""" + + foo + bar + foo + bar + foo + bar + foo + bar + + """) + obj = custom_type.parse(expected, None) + assert obj._value_1 == [ + {'_value_1': [ + {'item_1': 'foo', 'item_2': 'bar'}, + {'item_1': 'foo', 'item_2': 'bar'}, + ]}, + {'_value_1': [ + {'item_1': 'foo', 'item_2': 'bar'}, + {'item_1': 'foo', 'item_2': 'bar'}, + ]}, + ] + assert not hasattr(obj, 'foobar') + + +def test_nested_complex_type(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + '{http://tests.python-zeep.org/}item_2a', + xsd.String()), + xsd.Element( + '{http://tests.python-zeep.org/}item_2b', + xsd.String()), + ]) + ) + ) + ]) + )) + expected = etree.fromstring(""" + + foo + + 2a + 2b + + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2.item_2a == '2a' + assert obj.item_2.item_2b == '2b' + + +def test_nested_complex_type_optional(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_2'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Choice([ + xsd.Element( + '{http://tests.python-zeep.org/}item_2a1', + xsd.String(), + min_occurs=0), + xsd.Element( + '{http://tests.python-zeep.org/}item_2a2', + xsd.String(), + min_occurs=0), + ]), + xsd.Element( + '{http://tests.python-zeep.org/}item_2b', + xsd.String()), + ]) + ), + min_occurs=0, max_occurs='unbounded' + ) + ]) + )) + expected = etree.fromstring(""" + + foo + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == [] + + expected = etree.fromstring(""" + + foo + + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == [] + + expected = etree.fromstring(""" + + foo + + x + + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2[0].item_2a1 == 'x' + assert obj.item_2[0].item_2b is None + + expected = etree.fromstring(""" + + foo + + x + + + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2[0].item_2a1 == 'x' + assert obj.item_2[0].item_2b is None + + +def test_nested_choice_optional(): + custom_type = xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'authentication'), + xsd.ComplexType( + xsd.Sequence([ + xsd.Element( + etree.QName('http://tests.python-zeep.org/', 'item_1'), + xsd.String()), + xsd.Choice([ + xsd.Element( + '{http://tests.python-zeep.org/}item_2', + xsd.String()), + xsd.Element( + '{http://tests.python-zeep.org/}item_3', + xsd.String()), + ], + min_occurs=0, max_occurs=1 + ), + ]) + )) + expected = etree.fromstring(""" + + foo + bar + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 == 'bar' + + expected = etree.fromstring(""" + + foo + + """) + obj = custom_type.parse(expected, None) + assert obj.item_1 == 'foo' + assert obj.item_2 is None + assert obj.item_3 is None + + +def test_union(): + schema_doc = load_xml(b""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """) + + xml = load_xml(b""" + + Idle + """) + + schema = xsd.Schema(schema_doc) + elm = schema.get_element('{http://tests.python-zeep.org/tst}State') + result = elm.parse(xml, schema) + assert result._value_1 == 'Idle' + + +def test_parse_invalid_values(): + schema = xsd.Schema(load_xml(b""" + + + + + + + + + + + + + + """)) + + xml = load_xml(b""" + + + foo + 2016-10-20 + + """) + + elm = schema.get_element('{http://tests.python-zeep.org/}container') + result = elm.parse(xml, schema) + assert result.item_1 is None + assert result.item_2 == datetime.date(2016, 10, 20) + assert result.attr_1 is None + assert result.attr_2 == datetime.date(2013, 10, 20) diff --git a/tests/test_xsd_visitor.py b/tests/test_xsd_visitor.py new file mode 100644 index 0000000..e731e08 --- /dev/null +++ b/tests/test_xsd_visitor.py @@ -0,0 +1,579 @@ +from lxml import etree + +from tests.utils import assert_nodes_equal, load_xml, render_node +from zeep import xsd +from zeep.xsd import builtins +from zeep.xsd.context import ParserContext +from zeep.xsd.schema import Schema + + +def parse_schema_node(node): + parser_context = ParserContext() + schema = Schema( + node=node, + transport=None, + location=None, + parser_context=parser_context) + return schema + + +def test_schema_empty(): + node = load_xml(""" + + + """) + schema = parse_schema_node(node) + root = list(schema._schemas.values())[0] + assert root._element_form == 'qualified' + assert root._attribute_form == 'unqualified' + + +def test_element_simle_types(): + node = load_xml(""" + + + + + """) + schema = parse_schema_node(node) + assert schema.get_element('{http://tests.python-zeep.org/}foo') + assert schema.get_element('{http://tests.python-zeep.org/}bar') + + +def test_element_simple_type_annotation(): + node = load_xml(""" + + + + HOI! + + + + """) + schema = parse_schema_node(node) + element = schema.get_element('{http://tests.python-zeep.org/}foo') + assert element + + +def test_element_default_type(): + node = load_xml(""" + + + + """) + schema = parse_schema_node(node) + element = schema.get_element('{http://tests.python-zeep.org/}foo') + assert isinstance(element.type, builtins.AnyType) + + +def test_element_simple_type_unresolved(): + node = load_xml(""" + + + + HOI! + + + + + + + + + + """) + schema = parse_schema_node(node) + assert schema.get_type('{http://tests.python-zeep.org/}unresolved') + + +def test_element_max_occurs(): + node = load_xml(""" + + + + + + + + + + + + + """) + schema = parse_schema_node(node) + elm = schema.get_element('{http://tests.python-zeep.org/}container') + elements = dict(elm.type.elements) + + assert isinstance(elements['e1'], xsd.Element) + assert elements['e1'].max_occurs == 1 + assert isinstance(elements['e2'], xsd.Element) + assert elements['e2'].max_occurs == 1 + assert isinstance(elements['e3'], xsd.Element) + assert elements['e3'].max_occurs == 2 + assert isinstance(elements['e4'], xsd.Element) + assert elements['e4'].max_occurs == 'unbounded' + + +def test_simple_content(): + node = load_xml(""" + + + + + + + + + + """) + schema = parse_schema_node(node) + xsd_type = schema.get_type('{http://tests.python-zeep.org/}container') + assert xsd_type(10, sizing='qwe') + + +def test_attribute_optional(): + node = load_xml(""" + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + value = xsd_element() + + node = render_node(xsd_element, value) + expected = """ + + + + """ + assert_nodes_equal(expected, node) + + +def test_attribute_required(): + node = load_xml(""" + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + value = xsd_element() + + node = render_node(xsd_element, value) + expected = """ + + + + """ + assert_nodes_equal(expected, node) + + +def test_attribute_default(): + node = load_xml(""" + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + value = xsd_element() + + node = render_node(xsd_element, value) + expected = """ + + + + """ + assert_nodes_equal(expected, node) + + +def test_attribute_simple_type(): + node = load_xml(""" + + + + + + + + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + assert xsd_element(bar='hoi') + + +def test_attribute_any_type(): + node = load_xml(""" + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + value = xsd_element(base='hoi') + + node = render_node(xsd_element, value) + expected = """ + + + + """ + assert_nodes_equal(expected, node) + + +def test_complex_content_mixed(): + node = load_xml(""" + + + + + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + result = xsd_element('basetype', bar='hoi') + + node = etree.Element('document') + xsd_element.render(node, result) + + expected = """ + + basetype + + """ + assert_nodes_equal(expected, node) + + +def test_complex_content_extension(): + node = load_xml(""" + + + + + + + + + + + + + + + + + + + + + + + + + + """) + schema = parse_schema_node(node) + + record_type = schema.get_type('{http://tests.python-zeep.org/}SubType1') + assert len(record_type.attributes) == 2 + assert len(record_type.elements) == 1 + + record_type = schema.get_type('{http://tests.python-zeep.org/}SubType2') + assert len(record_type.attributes) == 3 + assert len(record_type.elements) == 1 + + xsd_element = schema.get_element('{http://tests.python-zeep.org/}test') + xsd_type = schema.get_type('{http://tests.python-zeep.org/}SubType2') + + value = xsd_type(attr_a='a', attr_b='b', attr_c='c') + node = render_node(xsd_element, value) + expected = """ + + + + """ + assert_nodes_equal(expected, node) + + +def test_simple_content_extension(): + node = load_xml(""" + + + + + + + + + + + + + + + + + + + + + + + + + + """) + schema = parse_schema_node(node) + + record_type = schema.get_type('{http://tests.python-zeep.org/}SubType1') + assert len(record_type.attributes) == 2 + assert len(record_type.elements) == 1 + + record_type = schema.get_type('{http://tests.python-zeep.org/}SubType2') + assert len(record_type.attributes) == 3 + assert len(record_type.elements) == 1 + + +def test_list_type(): + node = load_xml(""" + + + + + + + + + + + + + + + """) + + schema = parse_schema_node(node) + xsd_element = schema.get_element( + '{http://tests.python-zeep.org/}foo') + value = xsd_element(arg=[1, 2, 3, 4, 5]) + + node = render_node(xsd_element, value) + expected = """ + + + 1 2 3 4 5 + + + """ + assert_nodes_equal(expected, node) + + +def test_list_type_unresolved(): + node = load_xml(""" + + + + + + + + + + + + + + + + + + + """) + + schema = parse_schema_node(node) + xsd_element = schema.get_element( + '{http://tests.python-zeep.org/}foo') + value = xsd_element(arg=[1, 2, 3, 4, 5]) + + node = render_node(xsd_element, value) + expected = """ + + + 1 2 3 4 5 + + + """ + assert_nodes_equal(expected, node) + + +def test_list_type_simple_type(): + node = load_xml(""" + + + + + + + + + + + + + + + + + + + """) + + schema = parse_schema_node(node) + xsd_element = schema.get_element( + '{http://tests.python-zeep.org/}foo') + value = xsd_element(arg=[1, 2, 3, 4, 5]) + + node = render_node(xsd_element, value) + expected = """ + + + 1 2 3 4 5 + + + """ + assert_nodes_equal(expected, node) + + +def test_union_type(): + node = load_xml(""" + + + + + + + + + + + + + + + + + + + + """) + + schema = parse_schema_node(node) + xsd_element = schema.get_element('{http://tests.python-zeep.org/}foo') + assert xsd_element(arg='hoi') + + +def test_simple_type_restriction(): + node = load_xml(""" + + + + + + + + + + + + + + """) + schema = parse_schema_node(node) + xsd_element = schema.get_type('{http://tests.python-zeep.org/}type_3') + assert xsd_element(100) == '100' diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..b0e8a16 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,44 @@ +import six +from lxml import etree +from six import binary_type, string_types + + +def load_xml(xml): + parser = etree.XMLParser(remove_blank_text=True, remove_comments=True) + return etree.fromstring(xml.strip(), parser=parser) + + +def assert_nodes_equal(result, expected): + def _convert_node(node): + if isinstance(node, (string_types, binary_type)): + return load_xml(node) + return node + + # assert node_1 == node_2 + result = etree.tostring(_convert_node(result), pretty_print=True) + expected = etree.tostring(_convert_node(expected), pretty_print=True) + + if six.PY3: + result = result.decode('utf-8') + expected = expected.decode('utf-8') + assert result == expected + + +def render_node(element, value): + node = etree.Element('document') + element.render(node, value) + return node + + +class DummyTransport(object): + def __init__(self): + self._items = {} + + def bind(self, url, node): + self._items[url] = node + + def load(self, url): + data = self._items[url] + if isinstance(data, string_types): + return data + return etree.tostring(data) diff --git a/tests/wsdl_files/http.wsdl b/tests/wsdl_files/http.wsdl new file mode 100644 index 0000000..5622ff1 --- /dev/null +++ b/tests/wsdl_files/http.wsdl @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + My first service + + + + + diff --git a/tests/wsdl_files/soap.wsdl b/tests/wsdl_files/soap.wsdl new file mode 100644 index 0000000..6cc923c --- /dev/null +++ b/tests/wsdl_files/soap.wsdl @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + My first service + + + + + diff --git a/tests/wsdl_files/soap_header.wsdl b/tests/wsdl_files/soap_header.wsdl new file mode 100644 index 0000000..b3f6c6b --- /dev/null +++ b/tests/wsdl_files/soap_header.wsdl @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + My first service + + + + + diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..be542d5 --- /dev/null +++ b/tox.ini @@ -0,0 +1,7 @@ +[tox] +envlist = py27,py33,py34,py35,pypy + +[testenv] +commands = + pip install .[test] + py.test -vvv