pax_global_header00006660000000000000000000000064141516624600014517gustar00rootroot0000000000000052 comment=c6a0d7eaa73d3d6ebfb3231fa790c7be5bca9b8a pywps-4.5.1/000077500000000000000000000000001415166246000127105ustar00rootroot00000000000000pywps-4.5.1/.codacy.yml000066400000000000000000000000651415166246000147540ustar00rootroot00000000000000--- exclude_paths: - 'tests/**' - 'docs/conf.py' pywps-4.5.1/.github/000077500000000000000000000000001415166246000142505ustar00rootroot00000000000000pywps-4.5.1/.github/ISSUE_TEMPLATE.md000066400000000000000000000005071415166246000167570ustar00rootroot00000000000000# Description # Environment - operating system: - Python version: - PyWPS version: - source/distribution - [ ] git clone - [ ] Debian - [ ] PyPI - [ ] zip/tar.gz - [ ] other (please specify): - web server - [ ] Apache/mod_wsgi - [ ] CGI - [ ] other (please specify): # Steps to Reproduce # Additional Information pywps-4.5.1/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000007521415166246000200550ustar00rootroot00000000000000# Overview # Related Issue / Discussion # Additional Information # Contribution Agreement (as per https://github.com/geopython/pywps/blob/master/CONTRIBUTING.rst#contributions-and-licensing) - [ ] I'd like to contribute [feature X|bugfix Y|docs|something else] to PyWPS. I confirm that my contributions to PyWPS will be compatible with the PyWPS license guidelines at the time of contribution. - [ ] I have already previously agreed to the PyWPS Contributions and Licensing Guidelines pywps-4.5.1/.github/workflows/000077500000000000000000000000001415166246000163055ustar00rootroot00000000000000pywps-4.5.1/.github/workflows/main.yml000066400000000000000000000022271415166246000177570ustar00rootroot00000000000000name: build ⚙️ on: [ push, pull_request ] jobs: main: runs-on: ubuntu-latest strategy: matrix: python-version: [3.7, 3.8, 3.9] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_SERVICE_NAME: github steps: - uses: actions/checkout@v2 - name: Install packages run: | sudo apt-get update && sudo apt-get -y install libnetcdf-dev libhdf5-dev - uses: actions/setup-python@v2 name: Setup Python ${{ matrix.python-version }} with: python-version: ${{ matrix.python-version }} - name: Install requirements 📦 run: | pip3 install pip --upgrade pip3 install -r requirements.txt pip3 install -r requirements-dev.txt pip3 install -r requirements-extra.txt - name: run tests ⚙️ run: pytest -v tests - name: run coveralls ⚙️ run: coveralls if: matrix.python-version == 3.7 - name: build docs 🏗️ run: | pip3 install -e . cd docs && make html if: matrix.python-version == 3.7 - name: run flake8 ⚙️ run: flake8 pywps if: matrix.python-version == 3.7 pywps-4.5.1/.gitignore000066400000000000000000000002251415166246000146770ustar00rootroot00000000000000*.pyc *.pyo *.egg-info dist build tmp .tox docs/_build # vim, mac os *.sw* .DS_Store .*.un~ # project .idea # git *.orig .coverage .pytest_cache pywps-4.5.1/CODE_OF_CONDUCT.md000066400000000000000000000062311415166246000155110ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at pywps-dev@lists.osgeo.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] [homepage]: https://contributor-covenant.org [version]: https://contributor-covenant.org/version/1/4/ pywps-4.5.1/CONTRIBUTING.rst000066400000000000000000000170211415166246000153520ustar00rootroot00000000000000Contributing to PyWPS ===================== The PyWPS project openly welcomes contributions (bug reports, bug fixes, code enhancements/features, etc.). This document will outline some guidelines on contributing to PyWPS. As well, the PyWPS `community `_ is a great place to get an idea of how to connect and participate in the PyWPS community and development. PyWPS has the following modes of contribution: - GitHub Commit Access - GitHub Pull Requests Code of Conduct --------------- Contributors to this project are expected to act respectfully towards others in accordance with the `OSGeo Code of Conduct `_. Contributions and Licensing --------------------------- Contributors are asked to confirm that they comply with the project `license `_ guidelines. GitHub Commit Access ^^^^^^^^^^^^^^^^^^^^ - proposals to provide developers with GitHub commit access shall be emailed to the pywps-devel `mailing list`_. Proposals shall be approved by the PyWPS development team. Committers shall be added by the project admin - removal of commit access shall be handled in the same manner - each committer must send an email to the PyWPS mailing list agreeing to the license guidelines (see `Contributions and Licensing Agreement Template <#contributions-and-licensing-agreement-template>`_). **This is only required once** - each committer shall be listed in https://github.com/geopython/pywps/blob/master/COMMITTERS.txt GitHub Pull Requests ^^^^^^^^^^^^^^^^^^^^ - pull requests can provide agreement to license guidelines as text in the pull request or via email to the PyWPS `mailing list`_ (see `Contributions and Licensing Agreement Template <#contributions-and-licensing-agreement-template>`_). **This is only required for a contributor's first pull request. Subsequent pull requests do not require this step** - pull requests may include copyright in the source code header by the contributor if the contribution is significant or the contributor wants to claim copyright on their contribution - all contributors shall be listed at https://github.com/geopython/pywps/graphs/contributors - unclaimed copyright, by default, is assigned to the main copyright holders as specified in https://github.com/geopython/pywps/blob/master/LICENSE.txt - make sure, the tests are passing on [travis-ci](https://travis-ci.org/geopython/pywps) sevice, as well as on your local machine `tox`:: tox Contributions and Licensing Agreement Template ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``Hi all, I'd like to contribute to PyWPS. I confirm that my contributions to PyWPS will be compatible with the PyWPS license guidelines at the time of contribution.`` GitHub ------ Code, tests, documentation, wiki and issue tracking are all managed on GitHub. Make sure you have a `GitHub account `_. Code Overview ------------- - the PyWPS `wiki `_ documents an overview of the codebase [TODO] Documentation ------------- - documentation is managed in ``docs/``, in reStructuredText format - `Sphinx`_ is used to generate the documentation - See the `reStructuredText Primer `_ on rST markup and syntax Bugs ---- The PyWPS `issue tracker `_ is the place to report bugs or request enhancements. To submit a bug be sure to specify the PyWPS version you are using, the appropriate component, a description of how to reproduce the bug, as well as the Python version and the platform. Forking PyWPS ------------- Contributions are most easily managed via GitHub pull requests. `Fork `_ PyWPS into your own GitHub repository to be able to commit your work and submit pull requests. Development ----------- GitHub Commit Guidelines ^^^^^^^^^^^^^^^^^^^^^^^^ - enhancements and bug fixes should be identified with a GitHub issue - commits should be granular enough for other developers to understand the nature / implications of the change(s) - for trivial commits that do not need `Travis CI `_ to run, include ``[ci skip]`` as part of the commit message - non-trivial Git commits shall be associated with a GitHub issue. As documentation can always be improved, tickets need not be opened for improving the docs - Git commits shall include a description of changes - Git commits shall include the GitHub issue number (i.e. ``#1234``) in the Git commit log message - all enhancements or bug fixes must successfully pass all `OGC CITE `_ tests before they are committed - all enhancements or bug fixes must successfully pass all tests before they are committed - enhancements which can be demonstrated from the PyWPS tests should be accompanied by example WPS request XML or KVP Coding Guidelines ^^^^^^^^^^^^^^^^^ - PyWPS instead of pywps, pyWPS, Pywps, PYWPS - always code with `PEP 8`_ conventions - always run source code through ``flake8`` - for exceptions which make their way to OGC ``ows:ExceptionReport`` XML, always specify the appropriate ``locator`` and ``code`` parameters Submitting a Pull Request ^^^^^^^^^^^^^^^^^^^^^^^^^ This section will guide you through steps of working on PyWPS. This section assumes you have forked PyWPS into your own GitHub repository. Note that ``master`` is the main development branch in PyWPS. for stable releases and managed exclusively by the PyWPS team. .. code-block:: bash # setup a virtualenv virtualenv mypywps && cd mypywps . ./bin/activate # clone the repository locally git clone git@github.com:USERNAME/pywps.git cd pywps pip install -e . && pip install -r requirements.txt # add the main PyWPS development branch to keep up to date with upstream changes git remote add upstream https://github.com/geopython/pywps.git git pull upstream master # create a local branch off master # The name of the branch should include the issue number if it exists git branch issue-72 git checkout issue-72 # make code/doc changes git commit -am 'fix xyz (#72)' git push origin issue-72 Your changes are now visible on your PyWPS repository on GitHub. You are now ready to create a pull request. A member of the PyWPS team will review the pull request and provide feedback / suggestions if required. If changes are required, make them against the same branch and push as per above (all changes to the branch in the pull request apply). The pull request will then be merged by the PyWPS team. You can then delete your local branch (on GitHub), and then update your own repository to ensure your PyWPS repository is up to date with PyWPS master: .. code-block:: bash git checkout master git pull upstream master Release Packaging ----------------- Release packaging notes are maintained at https://github.com/geopython/pywps/wiki/ReleasePackaging .. _`Corporate`: http://www.osgeo.org/sites/osgeo.org/files/Page/corporate_contributor.txt .. _`Individual`: http://www.osgeo.org/sites/osgeo.org/files/Page/individual_contributor.txt .. _`info@osgeo.org`: mailto:info@osgeo.org .. _`OSGeo`: http://www.osgeo.org/content/foundation/legal/licenses.html .. _`PEP 8`: https://www.python.org/dev/peps/pep-0008/ .. _`flake8`: https://flake8.readthedocs.io/en/latest/ .. _`Sphinx`: http://sphinx-doc.org/ .. _`mailing list`: https://pywps.org/community pywps-4.5.1/CONTRIBUTORS.md000066400000000000000000000015341415166246000151720ustar00rootroot00000000000000# Contributors to PyWPS * @jachym Jachym Cepicky * @jorgejesus Jorge Samuel Mendes de Jesus * @ldesousa Luís de Sousa * @tomkralidis Tom Kralidis * @mgax Alex Morega * @Noctalin Calin Ciociu * @SiggyF Fedor Baart * @jonas-eberle Jonas Eberle * @cehbrecht Carsten Ehbrecht * @idanmiara Idan Miara # Contributor to older versions of PyWPS (< 4.x) * @ricardogsilva Ricardo Garcia Silva * @gschwind Benoit Gschwind * @khosrow Khosrow Ebrahimpour * @TobiasKipp Tobias Kipp * @kalxas Angelos Tzotsos * @Kruecke Florian Klemme * @slarosa Salvatore Larosa * @ominiverdi (Lorenzo Becchi) * @lucacasagrande (doktoreas - Luca Casagrande) * @sigmapi (pana - Panagiotis Skintzos) * @fpl Francesco P. Lovergine * @giohappy Giovanni Allegri * sebastianh Sebastian Holler # NOTE This file is kept manually. Feel free to contact us, if your contribution is missing here. pywps-4.5.1/INSTALL.md000066400000000000000000000016111415166246000143370ustar00rootroot00000000000000PyWPS 4 Installation ==================== Dependencies ------------ To use PyWPS 4 the third party libraries GIT and GDAL need to be installed in the system. In Debian based systems these can be installed with: $ sudo apt-get install git python-gdal In Windows systems a Git client should be installed (e.g. GitHub for Windows). Install PyWPS 4 --------------- Using pip: $ sudo pip install -e git+https://github.com/geopython/pywps.git@master#egg=pywps Or in alternative install it manually: $ git clone https://github.com/geopython/pywps.git $ cd pywps/ $ sudo python setup.py install Install example service ----------------------- $ git clone https://github.com/geopython/pywps-flask.git pywps-flask Run example service ------------------- $ python demo.py Access example service ---------------------- http://localhost:5000 pywps-4.5.1/LICENSE.txt000066400000000000000000000021161415166246000145330ustar00rootroot00000000000000Copyright (C) 2014-2016 PyWPS Development Team, represented by Jachym Cepicky 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. pywps-4.5.1/MANIFEST.in000066400000000000000000000001711415166246000144450ustar00rootroot00000000000000prune docs/ prune tests/ include *.txt include *.rst include README.md recursive-include pywps * global-exclude *.py[co] pywps-4.5.1/README.md000066400000000000000000000053461415166246000141770ustar00rootroot00000000000000# PyWPS PyWPS is an implementation of the Web Processing Service standard from the Open Geospatial Consortium. PyWPS is written in Python. [![Documentation Status](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://pywps.readthedocs.io/en/latest/?badge=latest) [![Build Status](https://github.com/geopython/pywps/actions/workflows/main.yml/badge.svg)](https://github.com/geopython/pywps/actions/workflows/main.yml) [![Coverage Status](https://coveralls.io/repos/github/geopython/pywps/badge.svg?branch=master)](https://coveralls.io/github/geopython/pywps?branch=master) [![PyPI](https://img.shields.io/pypi/dm/pywps.svg)](https://pypi.org/project/pywps/) [![GitHub license](https://img.shields.io/github/license/geopython/pywps.svg)]() [![Join the chat at https://gitter.im/geopython/pywps](https://badges.gitter.im/geopython/pywps.svg)](https://gitter.im/geopython/pywps?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) # License As of PyWPS 4.0.0, PyWPS is released under an [MIT](https://en.wikipedia.org/wiki/MIT_License) license (see [LICENSE.txt](LICENSE.txt)). # Dependencies See [requirements.txt](requirements.txt) file # Run tests ```bash pip install -r requirements-dev.txt # run unit tests python -m unittest tests # run code coverage python -m coverage run --source=pywps -m unittest tests python -m coverage report -m ``` # Run web application ## Example service Clone the example service after having installed PyWPS: ```bash git clone git://github.com/geopython/pywps-flask.git pywps-flask cd pywps-flask python demo.py ``` ## Apache configuration 1. Enable WSGI extension 2. Add configuration: ```apache WSGIDaemonProcess pywps user=user group=group processes=2 threads=5 WSGIScriptAlias /pywps /path/to/www/htdocs/wps/pywps.wsgi WSGIProcessGroup group WSGIApplicationGroup %{GLOBAL} Order deny,allow Allow from all ``` 3. Create wsgi file: ```python #!/usr/bin/env python3 import sys sys.path.append('/path/to/src/pywps/') import pywps from pywps.app import Service, WPS, Process def pr1(): """This is the execute method of the process """ pass application = Service(processes=[Process(pr1)]) ``` 4. Run via web browser `http://localhost/pywps/?service=WPS&request=GetCapabilities&version=1.0.0` 5. Run in command line: ```bash curl 'http://localhost/pywps/?service=WPS&request=GetCapabilities&version=1.0.0' ``` # Issues On Windows PyWPS does not support multiprocessing which is used when making requests storing the response document and updating the status to displaying to the user the progression of a process. pywps-4.5.1/SECURITY.md000066400000000000000000000011141415166246000144760ustar00rootroot00000000000000# PyWPS Security Policy ## Reporting Security/vulnerability reports **should not** be submitted through GitHub issues or public discussions, but instead please send your report to **geopython-security nospam @ lists.osgeo.org** - (remove the blanks and 'nospam'). ## Supported Versions The PyWPS Project Steering Committee will release patches for security vulnerabilities for the following versions: | Version | Supported | | ------- | ------------------ | | 4.5.x | :white_check_mark: | | 4.4.x | :white_check_mark: | | < 4.4 | previous versions | :x: | pywps-4.5.1/VERSION.txt000066400000000000000000000000061415166246000145720ustar00rootroot000000000000004.5.1 pywps-4.5.1/debian/000077500000000000000000000000001415166246000141325ustar00rootroot00000000000000pywps-4.5.1/debian/changelog000066400000000000000000000212761415166246000160140ustar00rootroot00000000000000pywps (4.5.1) trusty; urgency=medium * Fix app/Process.py to cope with None mimetype (#620) * Add security policy (#621) * Better error handling in WPSRequest.json() (#622) * Fix output mimetype assuming string (#626) * Resolve invalid Exception.msg unknown attribute (#629) * An input default value is only set when min_occurs==0 (#631) * Fix Sphinx build for UbuntuGIS packages (#634) * Remove gdal from dependencies (#638) * Fix bug triggered when storage requires the creation of recursive directories (#636) -- Carsten Ehbrecht Mon, 29 Nov 2021 18:00:00 +0000 pywps (4.5.0) trusty; urgency=medium * Initial implementation of OGC API - Processes / REST API (#612, #614) -- Carsten Ehbrecht Thu, 12 Aug 2021 18:00:00 +0000 pywps (4.4.5) trusty; urgency=medium * Fixed lxml default parser (#616). -- Carsten Ehbrecht Tue, 10 Aug 2021 18:00:00 +0000 pywps (4.4.4) trusty; urgency=medium * Fixed sphinx build (#608) -- Carsten Ehbrecht Wed, 02 Jun 2021 18:00:00 +0000 pywps (4.4.3) trusty; urgency=medium * Using pytest ... xfail online opendap tests (#605). * Update geojson mimetype in validators to match that of FORMATS (#604). * Simplify the implementation of IOHandler (#602). * Give a nicer name of the output file in raw data mode using Content-Disposition (#601). * Fix the mimetype of the raw data output (#599). * Fix erroneous encode of bytes array (#598). * Fix kvp decoding to follow specification (#597). -- Carsten Ehbrecht Mon, 10 May 2021 18:00:00 +0000 pywps (4.4.2) trusty; urgency=medium * Added csv format (#593). * Update ci badge int Readme (#592). * Fix scheduler: don't sleep in drmaa session (#591). * Show lineage also when process failed (#589). -- Carsten Ehbrecht Tue, 30 Mar 2021 18:00:00 +0000 pywps (4.4.1) trusty; urgency=medium * Added option `storage_copy_function` (#584). * Quick-fix to avoid import ogr exception when running tests without gdal (#583). * Fixed issues with metalink URL outputs (#582, #581, #580, #571). * Fixed issue with stored requests (#579). * Fixed incorrect use of `self.__class__` in super (#578). -- Carsten Ehbrecht Sun, 21 Mar 2021 18:00:00 +0000 pywps (4.4.0) trusty; urgency=medium * Dropping support for Python 2.x (#574). * Backport of patches in master (#574). * Fix a namespace warning when importing gdal directly (#575). * Fix incorrect use of `self.__class__` in super (#578) -- Carsten Ehbrecht Fri, 12 Feb 2021 18:00:00 +0000 pywps (4.2.11) trusty; urgency=medium * Dropping support for Python 2.x in requirements (#569). -- Carsten Ehbrecht Fri, 05 Feb 2021 18:00:00 +0000 pywps (4.2.10) trusty; urgency=medium * Moved MetadataUrl to pywps.app.Common to avoid dependencies on sphinx (#565). * Fixed output stream of scheduler (#563). * Fixed scheduler: use with statement to close drmaa session (#561). * Fixed embedded json in wps request (#560). -- Carsten Ehbrecht Mon, 25 Jan 2021 18:00:00 +0000 pywps (4.2.9) trusty; urgency=medium * fix bbox (#551, #552) * fix CDATA tag in json serialisation (#555) -- Carsten Ehbrecht Fri, 11 Dec 2020 18:00:00 +0000 pywps (4.2.8) trusty; urgency=medium * update scheduler with drmaa config (#547). * process error with formatting (#546). * allow path list separation also on Windows (#545). * allow inputpaths to accept full windows paths (#544). -- Carsten Ehbrecht Web, 15 Sep 2020 18:00:00 +0000 pywps (4.2.7) trusty; urgency=medium * ext_autodoc: support RST anonymous link (#542). -- Carsten Ehbrecht Tue, 04 Aug 2020 18:00:00 +0000 pywps (4.2.6) trusty; urgency=medium * Fixed tests on travis (#541). * Fixed imports in gpx validator (#540). -- Carsten Ehbrecht Fri, 03 Jul 2020 18:00:00 +0000 pywps (4.2.5) trusty; urgency=medium * Added validation for GPX files (#535). * Added encoding in `configparser` (#532). * Fixed long_description_content_type in `setup.py` needed by pypi (#534). * Fixed init of process.status_store ... needed by scheduler extension (#539). -- Carsten Ehbrecht Fri, 03 Jul 2020 12:00:00 +0000 pywps (4.2.4) trusty; urgency=medium * Added support for multiple languages (#510). * Added AWS S3 Storage option (#451). * Fixed type check (#507). * Fixed file storage (#504). * Fixed async keyword (#502). -- Carsten Ehbrecht Thu, 06 Feb 2020 12:00:00 +0000 pywps (4.2.3) trusty; urgency=medium * Check data is defined in literal template (#499) * Mention known issues with sqlite memory in docs for logging.database config (#496) -- Carsten Ehbrecht Di, 05 Nov 2019 12:00:00 +0000 pywps (4.2.2) trusty; urgency=medium * Fixed scheduler extension (#480). * Fixed ValuesReference implementation (#471, #484). * Fixed AllowedValue range (#467, #464). * Add metalink support to facilitate outputs with multiple files (#466). * Rename async to async_ for Python 3.7 compatibility (#462). * Improve queue race conditions (#455). * Numerous bug-fixes, additional tests and documentation improvements. -- Carsten Ehbrecht Fr, 20 Sep 2019 12:00:00 +0000 pywps (4.2.1) trusty; urgency=medium * Fixed `flufl.enum` dependency. * Updated string formatting to use `.format()` convention. * Updated docs for release packaging and contribution. -- Tom Kralidis Mon, 17 Dec 2018 12:00:00 +0000 pywps (4.2.0) trusty; urgency=medium * Jinja2 templates for output generation * Support for HPC cluster (Slurm, GridEngine) * Support for streamed URL-based data input (OpenDAP, download on demand) * Sphinx directive to automatically document processes. * Refactoring of IO handler classes * Added validators for JSON, DODS links and netCDF formats * Numerous bug-fixes, additional tests and documentation improvements -- Tom Kralidis Sun, 16 Dec 2018 12:00:00 +0000 pywps (4.0.0) trusty; urgency=medium * New version of PyWPS * New processes structure * Logging to database, jobs control * Jobs queue * Separated processes and PyWPS-Demo project -- Jáchym Čepický Wed, 07 Dec 2016 10:54:43 +0100 pywps (4.0.0-alpha2) trusty; urgency=medium * Re-did debian packaging for v4.0 -- Khosrow Ebrahimpour Wed, 17 Feb 2016 14:21:27 -0500 pywps (3.2.4-1) precise; urgency=medium * Overhauled debian build * Moved all pkg reqs from control file to requirements.txt * renamed binary package to python-pywps -- Khosrow Ebrahimpour Fri, 12 Feb 2016 02:47:46 +0000 pywps (3.2.4) precise; urgency=low * fix logging bugs * fix output type handling -- Tom Kralidis Thu, 11 Feb 2016 00:49:14 +0000 pywps (3.2.3) precise; urgency=low * Release of current minor bug fixing patches -- Jachym Cepicky Sat, 06 Feb 2016 19:22:22 +0000 pywps (3.2.2) precise; urgency=low * Changelog version number updated to 3.2.2 and removing of entries in /debian/docs as the files don't exist in the doc directory. -- Jachym Cepicky Tue, 02 Feb 2016 19:22:22 +0000 pywps (3.0.0-1) stable; urgency=low * New version * New code structure * New processes examples * New configuration file * See change log for more details -- Jachym Cepicky Thu, 16 Sep 2008 10:00:00 +0100 pywps (2.0.0-1) stable; urgency=low * New version * New code structure * See change log for more details -- Jachym Cepicky Mon, 4 Jun 2007 15:38:00 +0200 pywps (1.1.0-1) stable; urgency=low 1.1.0 devel Changes since 1.0.0: * ComplexValueReference input type definition is deprecated, use only ComplexValue - PyWPS will recognise the input type and handle it according to it. * GRASS location not created automatically any more. * Rewritten exception handling * Basic support for Range in LiteralValue definition -- Jachym Cepicky Fri, 2 Nov 2006 15:38:00 +0200 pywps (1.0.0-1) stable; urgency=low 1.0.0 Stable Changes since RC3: * Fixed HTTP POST method * Added longer name for PyWPS PID file * Fixed small bug in BoundingBox -- Jachym Cepicky Fri, 2 Nov 2006 15:38:00 +0200 pywps (1.0.0rc3-1) unstable; urgency=low * Release candidate 3 -- Jachym Cepicky Fri, 2 Nov 2006 15:38:00 +0200 pywps (1.0.0-1) unstable; urgency=low * Initial release -- Jachym Cepicky Fri, 20 Oct 2006 12:02:58 +0200 pywps-4.5.1/debian/compat000066400000000000000000000000021415166246000153300ustar00rootroot000000000000009 pywps-4.5.1/debian/control000066400000000000000000000012771415166246000155440ustar00rootroot00000000000000Source: pywps Section: python Priority: optional Maintainer: Jachym Cepicky Build-Depends: debhelper (>= 9), python, python-setuptools Standards-Version: 3.9.5 X-Python-Version: >= 2.7 Vcs-Git: https://github.com/geopython/pywps.git Package: python-pywps Architecture: all Depends: ${misc:Depends}, ${python:Depends}, python-pkg-resources, python-dateutil, python-jsonschema, python-lxml, python-owslib, python-werkzeug Suggests: grass, apache2, apache Homepage: https://pywps.org Description: OGC Web Processing Service (WPS) Implementation PyWPS is an implementation of the Web Processing Service standard from the Open Geospatial Consortium. PyWPS is written in Python. pywps-4.5.1/debian/copyright000066400000000000000000000023631415166246000160710ustar00rootroot00000000000000Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Source: https://github.com/geopython/pywps Files: * Copyright: Copyright 2014-2018 Open Source Geospatial Foundation and others 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. pywps-4.5.1/debian/rules000077500000000000000000000007301415166246000152120ustar00rootroot00000000000000#!/usr/bin/make -f # -*- makefile -*- # Sample debian/rules that uses debhelper. # This file was originally written by Joey Hess and Craig Small. # As a special exception, when this file is copied by dh-make into a # dh-make output file, you may use that output file without restriction. # This special exception was added by Craig Small in version 0.37 of dh-make. # Uncomment this to turn on verbose mode. #export DH_VERBOSE=1 %: dh $@ --with python2 --build=pybuild pywps-4.5.1/debian/source/000077500000000000000000000000001415166246000154325ustar00rootroot00000000000000pywps-4.5.1/debian/source/format000066400000000000000000000000141415166246000166400ustar00rootroot000000000000003.0 (quilt) pywps-4.5.1/default-sample.cfg000066400000000000000000000027041415166246000162770ustar00rootroot00000000000000[metadata:main] identification_title=PyWPS Demo server identification_abstract=PyWPS testing and development server. Do NOT use this server in production environement. You shall setup PyWPS as WSGI application for production. Please refer documentation for further detials. identification_keywords=WPS,GRASS,PyWPS, Demo, Dev identification_keywords_type=theme identification_fees=None identification_accessconstraints=None provider_name=PyWPS Developement team provider_url=https://pywps.org/' contact_name=Your Name contact_position=Developer contact_address=My Street contact_city=My City contact_stateorprovince=None contact_postalcode=000 00 contact_country=World, Internet contact_phone=+00 00 11 22 33 contact_fax=+00 99 88 77 66 contact_email=info@yourdomain.org contact_url=https://pywps.org contact_hours=8:00-20:00UTC contact_instructions=Knock on the door contact_role=pointOfContact [server] maxsingleinputsize=1mb maxrequestsize=3mb url=http://localhost:5000/wps outputurl=http://localhost:5000/outputs/ outputpath=outputs workdir=workdir maxprocesses=10 parallelprocesses=2 storagetype=file [processing] mode=default [logging] level=INFO file=logs/pywps.log database=sqlite:///logs/pywps-logs.sqlite3 format=%(asctime)s] [%(levelname)s] file=%(pathname)s line=%(lineno)s module=%(module)s function=%(funcName)s %(message)s [grass] gisbase=/usr/local/grass-7.3.svn/ [s3] bucket=my-org-wps region=us-east-1 prefix=appname/coolapp/ public=true encrypt=false pywps-4.5.1/docs/000077500000000000000000000000001415166246000136405ustar00rootroot00000000000000pywps-4.5.1/docs/Makefile000066400000000000000000000025501415166246000153020ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build -W PAPER = BUILDDIR = _build # 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 clean html linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 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." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." pywps-4.5.1/docs/_images/000077500000000000000000000000001415166246000152445ustar00rootroot00000000000000pywps-4.5.1/docs/_images/pywps-scheduler-extension_interactions.png000066400000000000000000001170411415166246000257100ustar00rootroot00000000000000PNG  IHDR= IDATxol[a:EJϿFb[8E7&:cawQ`7к%dPU$e܅ip]ݛP T <?&EEht]>8Dc_m$G{'@XP%.Q%.Q%.Q%.Q%.Q%.Q%.QRJt]˜R%0d e'Umm,6 95 Cv~Y}FEʼn& Ѻ3[Z^1ӐaTq`K)% CF0'Uk* l꒤&CT_{yeLÐaXr*V6n؞@tm;Rc[nw]?D5?f쨥)9fU׺IuJdR|`JTk[~:?.lUͤccˤ Tzz]-$dRuI -\{\ IYw۔(41eSen6~R79%2Y9ͷX^=*R[$PS6eɕx<&0O*dLAˍwR4luaJdJ>=]r}倚X.rJ]'0dX ew{^f8y%JYKC%HhsWΥ_V܆Xsݯ5?HIJW+TQY+3RE򬦧KLp6;'0di!33z1熻43-O1M,/k1L5?~o㨮TN)lXQOc{ a620,嚇gkd4U]_rF9 rq{}N=:t ~W=8HtJZS~Ef;zԍB~_rW%󔎶fQgRzY|e&KAp֟<ĔW]U!/۱]LVn"FC1SFrB㎻#Pqԫ*Nite-//k\V\V.KJחK/Z]T9v#fmr2 K1GҚk[;ŭ5\qZw2޶ч=0~y$zn)MNLT۪DoPyZs$u kz)#0lܚnNU >y LFV֝v|sv&B!m0Dg CHw@y|y4CN6„ƚ Hq`K]hU SSoWdmmEyFyZD:lZ .)vFe;ΉXgon46m>^s0a&ˬ|oG`kw|гD Y%[׉Wί:w]BV aKi-Kjsչ3רKn~ ;ֵ:?.l&LNꥭZTWf%<}&5DMYfmvvϙc*([+k)i2LG\fe:wCu)wa0;Dy:W*G%sZZw%BR1e^6=G0hSM'2] %.Q%.Q%.Q%.Q%.Q%.Q%.EDS{S6;;~ j0"YUL&8~w*;vo.EPEr/0 boa'?IAdK~? -0ӟO0 З~? `H??՝wީSP2 C~/+ͪX,#ȔwyGwt-,,h}>G?Q??+gؘ~_)"淿K?UVY}՟ɟm~iN8I-..Rƍj4}|TKtRw]}?/~:sL_׾5>}Z?>On}#i?_~yW{Wy{t}ӧ511}k}M_Rlv\oVбcVn{9 +/_}K/顇ұcǔH$~֡Cd#СCz׻ޥ??Ԟ={Jggz599=g?Y{?C~ɤ;zH/RcMz{߫cǎ/>9=}BAgϞչso|Cַ4==)}_VPk>|P|@ǎ{^}}'OjtM?25CN<~.Dk&@<ӺOo~_otk_ 699}k~]1^}C~‡>S,{P(+_J_}_WBqDC"?>it׿O~§?i}_s>g}}`g?>~?m:tO97Μ9~[{@{0"seVsN Ga 4. LX>{@{> ΄sN Ga 4. LX>{@{̙3~ |yxy%.Q%.Q%.Q%.QҦ%}^FF?T5Aya//~͌`[ 0#`# og:h*# %Zo_ed}熼kFB5XȇΫWa);!Wa%: y /0k}A K K(!hFvnKya!/ X# NFXvUFXrED3°sC^5 # y!AnPv5RvC5’+Jt%A^a 9vr\QC0(ь0ܐ` C^AFз`퐫`Afa熼kFB5ݠkl\k%W J4# ;7%X0re'X#,eg;*X#,D`Paع!/`o7(;a);!Wa%: y /0k}A K K(!hFvn7)m:*\YZ!.U{{AS2=GW^aGG.M^ͼK*ed:zk*eJ<2rQ樦mWj^!È9xTӯ, \^7+k?Co=59ɭ!9mR0dfrK6&p4پ z{ fZ77{^|?9eųz@Qpqi럻S|}R/>#i-ns?7/(;o%\_mgbynt%-y𜮯d4 kQCzaqǸܓ+Jt%@e}Uzz){a6+jlψr3ipX1bJy67՞>-7MAw㎳n޾Tux=CJ>*זTvA[AKJ[ϕ $-Li%'=1|KrN2=f6Ӛ͘[זQ9Î1e/6<tײS.ꁑ!q1Gvz}-##:̂_.9]981ݹľ59*sd5sןj>jӎ}'vq'W0 J4# ;7K7%z4'6)P}YMP{޿gbJn]^{e[TI%̸_/;J lvIdvyϭfm"sT|zK?K?,i?YTnψr B\'U}t唒q66^:"ִR%zI#W69znZv.P|^^r.CGUޘ<#ѺZjDc=dȈ3LNwaܧtUYo<DNhG/bN pl QXv]XSg^ChPLO./{eSԍK'f./36[!.ƖE)ѝPC0(ь0*/=.rJ ;䥳 AYrK3uDc3K, )sN}^_5ߏ);}F<CN&q%9;z挊br(ٕSJTˣ3UꑇV,*gKj,'g[}?9薿G!PN/Ξ_9R\{zTmT.:ۘ9,*?wPVkﶏFWf4:-۷)ћ)ΩY7ԗ%TYPwFBcNZi/ѭ Lӝzrnߏ~oYҍ)ϗg4:1,ܴ2֟R0db1$c(.Ct\:rN%zolGqiNo\Z5ׂ=X:_[2tRnGͩڭ]:xlToc.1GGxsz0’%U*#2 CWUo~~N__U;iA|sJ[I_-\&t;7` )]P:*&U_i}}AwVn*3<gUڵ GHt2KtGsJ=԰䞸i+ iY{]yB{ҶtײxVwdžtgu҂Z3Go1}9їcjf#WΥS3׉t?5*k jsPzs߹34ǦKqCJ˯;'zDw/%]kHFڕыmvO{mrO,%zI˟˴h[T/PR0DEM?w^<&%ZRJ4 %:XtF79vu#UZQrw4@7$փx \6uDoR9vTp4-%gb7d|oLٞNvsS:~{rA=Dk%vZsj[C~z>5NѷG¥V1i[{={[ZЋ CzLN;)] tnm:n/z5]1}GXr㇕2G45_=wPTu}SrHt;9e~q59M9(4dX$zF?vooσ(҃\ MJ4h̩p눻vߟK voe겳`jNqWs!W_nA0:UګܷfTɌf+OڼcĔ:ST *}&.RΑhWJ_&;7 koTpTΞuS#>(ь~[.;7Rv\Kz㩣rFMK_kSq|vg/%6W'+mcv|(oJh.Cv5͍̽נ%9F`΀5Wן:(kh9ӟ_:z}|FϝSJ*i.oŚcHWW7LDoehBspya!/ X#ۍAa);!WaUK~xRIc&֝]iUXZ?ϩsKxoJP  ;`o7(;a);!WaUKK*ݿWuPߺTIe,C\P[SJYq/Ω9Ό.@Di}获}熝` C^AFз`퐫`*%_>dLQ̹Xm:w[)?K~D?\X7ahJt ehs ]ۻcrSזcNg2̓eXRpT[9W+j,'֜*yNѵWtˏsY=pwƁzᢻImب^Ʋ]UrvUX IDATow}rTs_Bj.<\"- I1%1X M k{|)7qmkEM?}~"@l7Vƒ?9|@]Pڜ/PzxH)<%zWCJ'^E=02?t}^}T=C2!9r*\}Iէ(=2>ʛ9Me2?ζֳY3>uPΐ!#6ϔVs<]) ?)|n"a%:q ehslwmkgޫs3Wf4{^w47{D܅8<4­1e+y~N#lw͕JTh:;"P~ã|iAW)[L/mן; }G5K'vd%1bJ.J˼ PկSVcé*aK \8(}'܂at,]l6{9>#C9܆#^Jc?|Js q#{2bipiIo=S|(gחų+Wǟ3z2o?'t9Ǵt._J?Ss~yF/ujhÈ3t_etRcq=̜/{Fd>]Ouŏek}C.725UV"+Jt%ek{ ˞Rqɿ3|P+nj /뾯{o*Ys5+KZ~`{+irV [sDӋ%g*%5 %z8-iϥ~LJ/V˼T.l^D{PE=úk_LЈgA͵h_<]._;.{rQ:ztݔ߷ex\[_e{DeS\)'7.ճO-PwڧYέhT/\vKi؈^ںDKzRhpyA?9vTR&#gl޿G+ٜv=vonӹsV57Gc>9h_FGdkFOSN%v}vD/Ft3TZ^^ھDk.>u≶vJc3sAl(Kzc b1$c(l+ыb?؟#a-;In\.m])K|lDN=o{bn>9*:Gt3۔W4ܷW=u1O̾^.p.uXrED3°slwmoڦY/vmaֿiGvQyi4A^6כsƺf#G6zzNo7\%kg5~됒_nKt/NˢD3Tثu_HNԱD{.Pon듣2֝CXX))ן86%v%8_ԒZlNm?}ldÑ''7NEDws5' ų3G/=z-K[gFeOJ~u>ێDwSMV)є J4# ;7vrJx<9NʺĦ5sDVv^l%]n9sD_9;bJ򽯞vdvBk%կmnYJU5^:zN59'گQR޽2j ^[Pe2Æth/ۅ %:zE[#ыguwlHwV/-]>sDMDs1'ghTMtrQŇdb~)7ϡFek9sϣ1}9cjfk['ŭ?%s1N咮<8*3vXf֟}`9w.i#^Œ_^7ՌԕŒxakȈ?S>Wc([fMҠD3°slwms)yێhJtmZ՝k{򲙾[s++η])4`bdչ;&ۮOl]hG{F/䔼u3FnʏV}1!sQ-[vai`L ؔD_;cA={dw%1w}s=t&],ѯ^[OܧF +6:'d Ћڙk|jghHw?SW(WG=f룺s;@ne:ּ׋gXg:q{2wZ=qJwt|sbxA^yD @䪷"էDwVT*rO? / $ro(;Ho5r!mɲrEURVMR-ǶdZS5$5JY9ْܮP)(32 T";Z 7 / $rov ?ފtFy\tAuI% UIUMeO&5cqqϰs/ /䪷"tnt㭷{d?4;xA^yD nr[.шzKe0 LNQ%5o~?E^r˦_׻ޥǏ{/`3L˄97LUoEIka1lg#CCC2MS_җ]cJ4:a xA^ xPHo)^s;J4vaxA^ xCفUoEzZ{BN1-^H0~ WE@|{h~I R(&tCN1-^H0~ W&bb xA^ xPHo)уbg^HPvr[3{PSL˄97LUo$)h~I R(&tCN1-^H0~ W&bb xA^ xPHo)уbg^HPvr[3{PSL˄97LUo$)h~I R(&tCN1-^H0~ W&bb xA^ xPHo)уbg^HPvr[3{PSL˄97LUo$)h~I R(&tCN1-^H0~ W&bb xA^ xPHo)уbg^HPvr[3{PSL˄97LUo$)h~I R(&tCN1-^H0~ W&bb xA^ xPHo)уbg^HPvr[3{PSL˄97LUo$)h~I R(&tCN1-^H0~ W&bb xA^ xPHo)уbg^HPvr[y:wS"3-'*Jؖ,Q*WT!^Dڑe2uIRR27#˲k^5OضlQzb^˦LYJN'()e 䪷h}(dIMjHjǕH\ԨjzQPUu*){z9LnVuIbRtս}1c+Y>rCRcYI[zR*T*,g :J4?$QIUsKyGP2tGV"WVWN(3^t-ܒ{$+wsSՕǩM%ʪ{rQzƦ`4%"$>IG}[51QiA]\U8\ڑ*V.o(ї6)vD=^Do~<Dc / $rov ?ފtfaW_}U/zlZ[F)'i.QTQv|B|,׊Jכӹkrs-% gNH7vDw͟O} J4vyD @䪷"ߪD?sb]_J9%˴ʻ :\Ғi2Ʀ*jy$:S&a2-%ƦV˧ mz$w|>GN3 / $ro(;Ho7^jwԊ)%ˁ?G()e 䪷IDǜbcKL˄97LUoED,H={;;NYJ[_jn+*Ug0m%#N(L#a++7yD @䪷"էDC Bk>m\z]/+h0rYTb=x]uMcg^HPvr[3Rsd' ʘtn+iKB9a:we"!'rAiJwsrMeW 9bZ& /a-@z+%%ZeMmdz\c e[G9e,Rbljea\R.,'*TjQYǔaZr2yƓ4OXr2EUUWi2#DzdRy.} %Z1D2tf:7L|L˄97LUoED,,b xA^ xPHo)ѐ(axA^ xCفUoEztnH΍cZ& /a-@z=`t]*N^=܄dRBu,h`t]kEy-oX7 h`"] Oӹ%MY)N)?_TTƖaJdU{mh˴dcEUݦZɖy7T:7::l˽t.4KtMi9-vh]W|Zmɴe*82yD nr[.,,? UJKfUeL*R!DBit(sHJ561PPU}>+;1>Ʋ&ug[N+cT9!A@"r[S!??y(異ʌ4]]7UQ<_T!Ut_ntU;fS+GZw,rVrrTRN_jo4y$axA^ xCفUoEztnHަٝ={Vi[nOӮnsUʥ٩*Қ\/+h0rYTb]|vswuͦ/f%Vpt{0-^H0~ W tUMӔaAnh9P"_vo|]͂Lp:}QW"]r}Yog;~IjsJdob&ag^HPvr[3Z1%{R7\=ܪTRNR+эr^ ;xAMCn+*Ug0]ٔꆖ% k}/k+vtѨoʄs7JWח ;d2鎄Dϴ+F~vQ羚%S+`=e 䪷"]љ[JMDwVU8\ڑ*>JAƕLd4^ViB*Mk>mӹ;fݗh{]m8Do6)ѵꛕחmQWVm]^uSDZ1)j&wpRPqMyHv;|B|ՊJ,ZnH-[+%z9UyLvМ@{}S Jͨgb\뻏[ΙMp>rѩ$`T.j+R/mU*H},RVI҂ 1Y W0ShNNAC>υ޿ǿ"z?v[<$ێ_.{Vk}wbb& $r3\˪U/z}(cvX:X^xAy c*U%SKK_S_|u}e~ogg% [}O|@>~衇ϗ}xV'xB;;;'{G|;>D1;,3= /0vܐ9nrU,K4 .ьsCbf LHfEU.l, _`yD` rU,W}J4$.n`yD`(Kۺ{u5u:\`Vhƹ!1fn߾-uUT8e9nrU,K4Ab!/:XܹsϑHf y WŲzէDCҡb⋯ornܸaX\(0AAU^Ę̐O^*q_\S/ `y Wp,Ah QX}Ry^D87$`\^>ߧD# L0vܐ,hda݀ nrU,X%HhB%qnH yAJ4nc*l, _` %YX7` rU,W}J4$.n` %YX7`ܐ,hda݀ nrU,XI9?[ .'| @y~fcv0C^,0-@e0Ab!/BF `(\U !/BF XVsCbf Pu&EUx` TgPa78aU,^UA{4#8h@>A:W'ߦDldufcv0C^B4Vs]yAM+{%6ո6JN P*lkF P个Day?TũikQkޱO%./h/ r:U'T%BUG5WA'k{{n@!XVhH\ yA[ r:մyOIJ'jU IW4H4WW.3& Ӻ^(1QRney WŲzgcv0C^BhhyJp}JWc%pL0vbU3ͺ#lºlܐ,hda݀ nrU,X%HhB%qnH yAJ4nc*l, _` %YX7` rU,W}J4$.n` %YX7`ܐ,hda݀ nrU,X%HhB%qnH yAJ4nc*l, _` %YX7` rU,W}J4$.n` %YX7`ܐ,hda݀ nrU,X%HhB%qnH yAJ4nc*l, _` %YX7` rU,W}J4$.n` %YX7`%70C^ŤD_;;;'~ݸq豀PvrU,Wxƹ!1f3Y-Eu'W^8z/l[\5PwD,+<k'c*+<Ab(!/ByD96BU^)ѐ(E0C^`s\qnH yA$r1vy [\˾6FQ0C^2'*Xȗ:rZEMu^aM5R{U/u{R:i*ZjByS)9ACDJ' ѻJQP4z5jG=O+j426b(\k VDC yA̦ĽPXdR1TJt[p.I5WAgtҐ}>P个Da_3>^~./h/N =GIF\`0C^Ŧ򄳳)餥 Tkv5īQJߘW+4G%UïjM%ijx {3~?[ EU. `SyY4ָQ#UhN4UفQlR}S-Y(u gbORM[^1y?+/jJTsC܃t^ڋyfYS}dn(E_787$sa = *@Е8r@ݯө:'FMuv6 -O*l 2+D_+>p(6kE]W+jo,v.ۭ[[\k Vcc1Hl3Y*O83rt+oYķ۷uڵkt:k@!XVhH"!/ByDr1*շl&ǝhy trՎ|#ǫ5Zt\_T㺯(ռW_k\WAi*f- =ϑ44JtҐKoyTOW<\&ݺϕ6XF(u55jRx5jG=O+j42XB87$sa Ð.V^(owDR2i*i%:Tŭk8X3S:i[>(r\sIs "O0/D/xWDRk(q*[PnEAF= ڋNꄞ$Oz/t1],_l,` Ð.VIKj0WΧh19t?WT!Ѳ[ ÐΖO~_U o8#I5m Z{rW򮼰7gfn(ыN yO/W}swn$Oz/ĺ|/.^aHf9G?Wz_|#0@Е8r@ө:'FMuv6 -O*l 2+D_+>J4仮\S5kb  ˼V+TZ1\_QkcXuX0}SRѯʯ蓟dGe `'_ ݖa ÐG?;N~kU϶Ųb( f \dXw4WUО)i1넥nt+^ ~O>^Wg~gOSO׾V`uG> oj4n?-z߮^Cy}+_Yo}K_ /??W_D`-e@XXVb)ѐ(E0C^\ sq`:%:1l./OD?c?7ͪT*|+z5Lqc>_^O>SzG;;zgVCگ^=ywz+_|Pnwu[V%/իWL??e?//n?~Xp7QO|?~_}k?O7ooַ'mqi ],_sCb<f \C<#'7ykMC_*)i~#.6yjG?R]zWAGwo~OO'>g=y>ϬnG=c?#ݺuku~t }\77_%ju6O~'Ro~W߼ySW\Ύ:X}V]}OngV?쳫?O>_җV_]ݾ'e@.XpY.W_7 =Gp.I5WAgvc,ޅ΃I^lc3w~?\ݖ$_} /Xsϭ>?>'O=w]On?'e.^\ / Ӻi 7&ws:˫5?O驪hN5mIq__+_Jk 責ec+qnH yAr1|ZJwDDQ/}o RlqbYec1Hl3YλD?sNnVS}?=KJgEGzK; rU,WxJ4$J̐d9ַU.C"+7 -O*l ؠsZh\r\qnH yAMӲ<硝t+/*/(c*+<k8kyږ,IuGƳ `` &Ν;U6J4qnH yAۿzի^Gyd͛ٺy&ohl[\ QaI[%/#<9BƦ y WŲzDCD8g-OiP)@e 87$sMehl[\N5/8UΫ<-8,N.y_ՠiZ`'np@I.ьsCb2I' ڑ'5UDn]|U}XJQP(ZT㺯(=XU#8) P" 30-@e;=Abc$4:XtTu5M$n|}rTR~U4E_&hd}& y WŲzէDC2I' ޟuN5mx0\|D%z?+P/J4>\UqnH]&!?jQm%:h!QO"oDcS/J4>"X>nه +Pq(8%Hh\r׾V??4emwJƝ|וxFqtfʯSu"O+SqJ4,/ьsC:y`y?\Q6hda&EU~gc1H7|<8+J4L@eOҗDyZFJ4ney WŲzgbT*T*ys͛G}Be4_s;%Y nrU,q)|_k^U3|cHjG\בU-AvoM/ m톾\wqq],W}ƹ!1f3Y(ºs:j=RIT P{vzvTR~U4L {TusjcP\ǯ :3)8~»1ĽPXdR E=PA,)կy $qbN(q4i2۪+p|%گ>MM5is랪w4/G?(z 7&qt\'~9M7:tR5L{n* jjvO'VJtQ՟/>DJt#Wc%zyj6=4ָQ#UTO|̣%)їsCbf T*pnDܟo[SiW9MNv|R|]^|]q%3"O^Yq.JtSTCi+P+"Qc^~*gY.l, _`yD`Ѝ꾣ʯmŽfTȓ :꧔hia*uMJtUAZu\TRqN \M!rGh!|%70C^`@"0C^~U'y}^]vMNg#WŲzgcv0C^`@"0*DJ"q.XVhK`sNهQ˜lR9+WƍF|X}fĘ̐ /0vӜIիW8qkcJt~>l, _`yD` p%`y>]>,%70C^`@"0CisT(2 1;!/0A^ awϞ}Jt8%(D387$`yD`qnl]>,Ab!/0A^ ac1l]>,%70C^`@"0CƦ(2 1;!/0A^ aD3 []>,l Jt>sCbf LHfƦ(̲$6|96æ(R!qq3& $r3hl]>,ܐ9ƹ)Jt8%(D387$`yD`qnl]>,Ab!/0A^ ac1lj}MU*\_~O±zէDCf LHf(E$2|n3dcv0C^`@"0876E.,A.,A.ϐqnH y  %|V?C6Ć/0C^`@"0b%zMTX<|P!qq3& $r3hly_ՠiz}Aa>Ę̐ /0΍Mm_N4x\W,~$6\R:i*{KsZ|ϕfJ%Xj\U}YJQPhg|gQ@Di")5vcIz`Vqa]^(@5>3>3$PUSM[*^0 _1Y޸SQОJ{=[T$&|}Ds[qnH y  6=;pSdӶ"ow--щFwb:3=dzbf LHfX ڮ-Ԯ_U/ɮw@59Pqc8s%g*AK}Jt2Ut9s8ϬS:S'j^q*.a@"0CƦ.838UvC_*kJ\v$yHsݥD'c#_jIz5O>\Pq/>rx\_=vqWmf<i~#5D|FHq}{zQ^ICAvo}To?3&ݺϕ6[d>'i֩6Ec0A^ aD3cPAEYy{uyA{ i:U'T%+LZ5JdYGQWjڳI3Jt:χu+ Ny>ɰ.oyxWUt2T-xNrPqx~ j,[ ve99I{D %|13WW%,Uߣ[ OWi 7&|RN'-^Z$ޑ4i2۪+p|Z%:W 'ԩM_a˚|^=U|ǝV?L' kgRM[*^0 _qKOKUXjY}>E+9tzw)ы5wԈ|yծf_*ɸ0h<[ R"o1Ν+%;D>>Z7y!?: <4Qf=Bc0A^ aD3bg3u ZsGl1|`zON5m Z՘r4JWҬoMJJ]ED7ƚwݽj(~g>ǹCU ƹ7{'=nJgE$9Iso `@"0b%|Vw @/_; @O\fUq6y~h>3J4x.19ib& $r3hl]>F{ェT*Mo*жƼ_U)W\4!qW:L3 9ƹ)Jt:cx;׽ޫ˿,%z]*9IDATz UXQSѭı׽n&w}i]>_ (`>Z_xᅲ(j~K_z/Hb,f $r3scS %lV}}(`)zH?$396æ(=9@s=z;Y!\`蟉~衇4N݇yD`MQg{ӛtHs=yQˊ1;,ݸqCX LHfƦ(=z|A> IY%|1Y}(p0f& $r3scSY}^.p L /̰6E.g !/0A^ DcSY}Ę̐ /0΍MQ-A.g %|V1ƹ!1f3& $r3scSY}X  y  aSY}(ѐ9J46E.gqnH y  %|1q`KPgccv0C^`@"0876E.g ̐ /̰6E.g !/0A^ DcSY}Ę̐ /0΍MQ-A.g %|V1ƹ!1f3& $r3scSY}X  y  aS&x/Q!qq3& $r3hlOgecv0C^`@"0876E.g%|YY}fĘ̐ /0΍MQgec1Hl3& $r3l,MQgDCf LHf(%|VYƹ!1f3& $r3scSqf`KPǙ-A.gqnH y  %|VY6Ć/0C^`@"0b%|VYJ4$.n`yD`MQgecv0C^`@"0876uJ*wHƚvDh&%͛T*~]~\]Kj4=_{*J4lIߛc-:nqWmjޯ*lr]Wn_TR幮F+ѱPqWT8 ͕hҭ+\jc19C^sCbf LHfƦ)ѡ*NMX\vcŽjoQ~CXunOj3$:4D]Mcv?>sK4Ab!/0A^ ac1lʖͬ~hH\ y  %Dg87$`yD`qnl]>!XšNO={*gyJ4l kJjֹPO87$`yD`qnljJt2V;庎(WT8 ͵]Ǖ v'{y}Fi7Byn=רJ yU̔[S!qq3& $r3hlsM'-^Z$DȨv4m8Zt+UWTӦeM^o{x&hƹ!1f3& $r3scSE{LhggG7ok]ͤC9755u]JtQ-ƹ%0ZD{'ЧVhI%zYQR߿AN5m ZEy5䦦4k[SiW멹*ы]yawo${6J8w88w;T&qP~U6%E%Ν;k-)v.7J'jZ_qWwTq\P^}ҩ:'FMuvGi.6.bVrcV$u庾bfUq˵ܐ9ƹ)?+Wk׮=s>峺D$6|96æJ;wGիWuՍ> ^)ѐ9J46G>hDg87$`yD`qnl*,Ӧs_VhIQ%z֭[k=eB-QtqV1ƹ!1f3& $r3scSY}X  y  aSY}(ѐ9J46E.gqnH y  %|1q`KPgccv0C^`@"0876E.g ̐ /̰6E.g !/0A^ DcSY}Ę̐ /0΍MQ-A.g %|V1ƹ!1f3& $r3scSY}X  y  aSY}(ѐ9J46E.gqnH y  %|1q`KPgccv0C^`@"0876E.g ̐ /̰6E.g !/0A^ DcSY}Ę̐ /0΍MQ-A.g %|V1ƹ!1f3& $r3scSY}X  y  %êY罪LY<k},F.g !/0A^ D++ooT}U:%|V1ƹ!1f3& $r36shDsڑ|ϓ#͵(~zs]›Վ|#ǫ5J$ϯi$j(lt%IK0UԞ*zjvNK2/ʚC-6?I,.oS{]?K).Ͽ_ Sŝ|.-Eѥ?\zJD@bQ"_h6ik) b^%tD+87$`C^`A^ 3νDG])o:Y1X/TUiWiTw0t>ӠDdzlD+bX69> =}TϞ=hn|8Iuz1=^I*f[e[*4%Jz)慴8I*$0RD)fmE`=jN{]?%7!/ /l+O>￯~[E _ZX)CawX驙D Pig @A=ܬ]MB͑ˑZq TiM}-)g0Vuo'jM{]?ܐ yyD`k{<5M1m+YUCW V%I8Qx6d)~`*߿z/ 0+87$`C^`A^ /֛oO>D^d{Jt,,_`C^`A^ w}{O꫺ JthH<@"^ɓ'iQecv!/ /lvM%~\Y%]_'D׏+ D%7!/ /l(;p\U>ܐ yyD`-\ WDpHhufcv!/ /l Z^hĂ/!/ /lX .jy}קDC69 e.jy}gcv!/ /l Z^h .ьsCb69 cp\UbX69 @rU-hH<@"rU-sCb69 cp\U !Qؓ%qnH@"a.jy]YX  @"a(@] aXH6@]qnVe‚@"a.jy]8$J4(fmD^\T_ ,{]jixXl|Ug| o^hƹoF%z5R#i~ls.Jt~VvbMKYUb,9 cp\UbkQCiD4VJ#- IZir)"EQd^PNXa(MrIK %VZjI`m}b5'ES,5ꤊPQToJV2Q .^?VfY*Ρ}k6h)BQFn/- 5Ǵm, w}Jj(hjԺP58oKQr~3\O#&%z9L%峞SryWn \'Im h/CCacթᑒk'?m):~9V3jhts~fzƼΡ}_̺Jg^ܲߺ$֞ aA^ Pvw}ƹV(ӖƟu'RzUZ SŝyyUYOIh<[^sEl2ҠR͋Jt:mPwck|?[[o["4.N6FvvCΟW~?:i$7%{?eor1Uc /l Z^hKJێDwtW'5ZyAΧ]ISXLFkEQu_]%zvJt1>%zRDob%'[gYʋ߶:QJk`5Ju4^i=ΝJxIuz8wy/QHY$צ w5/I7W!)%zM1k+J㱚A$/x8wT}եqs}E+vŝy x=V_}[,я=R$o$IVQCQQ3ba^8  -ꤡ P&j-UMB͑ˑZq TiM}-)g0Vuo'jMo+ Ez^/Ula#tm( Cab_XKΡ}ko;8?Kߺ뷰?om˄yD`-\ WDǺ}?zA݇Vըdfy{>@Aw--, WwG])g۷oO?kA3t_L>{;wviaA^ PvWwzkg>ܽ{CD Ù|%ؐH6rU-Jtז7x_Çߟ;wqݷ0*/z}޽@D߿_?C݇WW%:>>Yoݺ?CD ÙL5/mC^`A^ 0v U*ђ+E֭[zXg>CA@/"%27%O}OhHlaa%^k^> (\U*\'`i+|}"Q܃D/WfEaaeT' ѮiK"^ݶsJ3?.{H&aa`ݳlp>7]ٺԀVmvNT-ZaazDU/ufyMw^ Ly^`MxR#ބ0 0 ;xy|6!֮mR;>RMlaa xӹ^[ޔ&(-UМ.3 ]}2 0 0w6*tNk9zemgkvM3>6 0 06*tvl& $E׶sM0 0LMM0 0 6*6 0 03x0 0 0`b0 0 8MM0 0 6*6 0 03x0 0 cËU-x pu barxQ+Fvj̪TUòS+[6*5gba ۂ! oBrېY1\_| SkSodiAAEy5"o&CA&]}QG߾}j*,EGGCW)~={$ * yyyPNU켜_O0 S "~W#22'ND~~>AmpUرFΝåKӧGE.]P\\WBVرcU.מ~ƍCQQT*|||qqqX`VZ/ŋ0 Xzu}L{gp\rK.|2~7Tr&X,t^mmJQ))Pm2-U WGv&T5}y (V90U_'OVVb(VNU켜_O0 S̙3T֭CTTJVCp^A_]rٺu+vm۶_~h4(,,J™3govԩ8p`}-X* `ĉ111Q^gdd3؞)GkWdcKśt*~kc/2Ԁ LU4".@ED+3*gfW%~CRaРAؾ};JKKs}9;/g{pban?9ZnFFF*6 .pΝ;ɓ' UL@Uʵ$!++ χj| IO:`L&=z(̙3؛r֮]%Kȕk6n:SjV{WqDt"Kw L̏Q $>Tw8t1V90ļ* Ν;s"22R%Te_YaۤIpi\r0aBw˖-P?q%F\ζ9r$V+JKKq!ݻwW$ItRH!CVZ^ .… JwMmL@ff&ӧOGXXp̙ ٗcZ?z{UM~(^ ~ϋQ'D^JP֊ 89 kp0/p(g)*Mv n MA)mv/ZZ}GpaϮ;j_vvnOOtNDˮDԢl f5޼y3Z-^SNAETbٳgR233R/׮]s(*rv^80 SoqjDd̙`$IS'0fT*%/--O< FI!C`0T6m$IBii)~7@RR;3/^JŽ;P\\ j߿SpUfLPNM`czk_߄'kTC՝]ON"M0?Ecj.$a`|<1?C>}VPo4­;rD3bDх(0a LXk!P5|MRL+**B׮]!I"##ѯ_?TbXII z=bcc~'e* sr/gUQ.Su0[u%h0 !eш3g*Ooݹɀif~z*Q D%eqM^2e$[쟊_ ҈W&T ";P nVL;D$aGӔNz{0$Q 0h482\> kxǒ#?h $q$W$H"&ɀ͚/f>sZN#R} 'G4;Nid1HME})U5vJ% [Ž%Dd ؎}}yC.A@e芾D) (iDAMHF^!wtY35kkTOǎZU+O ]7zRFFެN?oPX"ܑ#pjFh}|$Ys>U7BPyp\"T""iH>:ޭ#H"^ϱ{D1M#軨na|"j-Ҹ>/'Q܃>WWN)g{SKvM:Nܔ&|=,E_^1oEV("KYۺ_>eq| \>ځasw}9|BC}+K 96q6͞U nܑ#;H$ {/ 8{'~ Apf[DQŽ~T+CEoq}Gy-"N^YeЮszL3KDa(&?8}d5:$7zcnkc3\jю>% rw`#0 s6^Om![R`F3WWNR-ӪCZyS7)}A pOzu}ǔ M`)âѳίgn{ ,ܘC9Kpi<z$ew,*IR$~].]0-3SUj?'VTmNM@egHW7=M. |:v O9:Tf*}%Qyj;Y#hR_"k/;Z[}8̏zZSu=ӑu2*FarI6q係v W5gfDtSx`O@HVMqcd1yەO ~ސ&~iUy}iXE߶ΝSc݁*U+Pf5U`@\>fwDZ2S li4҃Sl8"?͚uf~ܑ8ucD;FSfb#0 cGo5>MGVݩ"EnSSDAAEi!I\@}Xpz{\3X̲#}R0>#ݘ p 5pqr}~-DA@(ܺ f5>JQel!aC;*-'}kVA㣴H_ @E+Te'"޲մ h9=)>,v<}+Г-ZZ⅍)VqV_"" F1 gDԺ)^{>o]/G[L+f*U)u#~!~M-4g(foVS՝PYQRҲEǘ޺Eէ:/؇,@A7h4X> ۛ[6+۬<*I ]1y"e+AzePPfNC1m[58X'>*R>TR^W.] *jRۆS)=8 $EҦEZRK}8^p~1?Pk+8e?vY0 {!?T8[jdeu^Ը(*gV4$ (**R$ z=zᅬk׮!11$)/))AtٴI&AVCVcvZ/<<\;//ʩʾoV:voڱU`r[.)hĽiٮk-A -&<ӥ>eSue9AS\~LpګCh;%zij~:RMxzS>ԕ|jw^hD߾}sA^^z!<<VnlUbmgƾ|g*V5fn> o8Au#}O>[0 LٚSmOifXhH5(O#&%Q믿8q">Ct/_oyQtŸz*j5;Dkǎ08w.](L>x&O^zX,Ue_YyZz>.Su cן_#wtHkw2Aɯ ^cL|7I,f7nR Am6\zU1ՍMV`ŋqE ^Ӫl[՘u㔭~jtM#^)_qk0 L Ip?epSGـWVf׭[( ''Gy?##Ct^A_PeeeARd2d2AbСǓJAaʜ76l_Yj})ΔڡEGM~Bݨ1Wg[!5$z3bpqE7r3l>,ٿB~oKmRf| }Ŵ[k׮ضmFBT*9s u17Ħ ~SNl*Uٶ1)X}"jfuRSdqH^ xV4kkU8t"##[ عs'|||0ydVp=Q JlISNEFFXs (Ve۪,ũGhlM1 S"yl9(߱@ySyfhZ\zN( $ Oƕ+W &` CQQΜ9t9r$V+JKKq!ݻwٳPT-[@V?ĥK`4rJDZZJKK/@\v͡y9+2<S"j&3uS>ɭì.6%*i$ ?.] I+3ՉMV^Dž pz^i/Y<ʶUY.S^$]YD4\u:bc4  X5+]j"t$!22SL@TT $IB\\P\\ j߿?}[+l0L%pd/"j('DMCC0Oz LX=e])3 skp0 S $wE bxy(IY߈=~cÈM Y!;3̭PrHYD4ܔם077nib~&av#l"Eb"Q2x'}ˆa;aI_Jo[c8vqY r2L*M7GoqFc}ߘQ1y#\}Q»^ Uv]lF 0ՂM0L=>kmJj [w0L݃M0LA¸,6y'睜ۘL|.A=p0L݃M0LMKn Y!\@FK}J^7a6 0ofZw,](MPZhˠ4??r0 aACw5k)4 @Ev_}ذjˏ.fF˚[%4]}1 S`0 S?pKpT1$ )i|/kɀJƉW81-tI4g0 SlaniDCߟs0awdvhshBڠPh8z#CEhF|: >!~k& gY9>W_| =0 $ Xf;z1 W_|y;0s7h$Wmaϱ\~cBRtm;'0 Slani Kp໳?dZDQDxd %pK((,A%I9y ע$a\~l-0 xAyO ɞAbJMܽ PvJ}ub $a<6 04g-h*h1S˙VMM0 6 04;(f E?3 `L# "t{&M0N`0 S?pKPPXew* j J: 8q*K PhEl0 6 05,6 ՌOjzLR_*Vls6Txїظ `s7DAxD ^}E^l;#$z=A1mr=;z%ZaOIM@eɲ o¶?`4)&cr&Q0 r0 x?ۈ`KDš#Ni'㧊` 8+;!YGnב " aѥk *.WFE&P$I> KAuE+*UvLl˰ ` $&dn7r͔6̃h˜Is]Vš1a[A7`m!>! PPXA!$IYL@Aa #Tv|11~CUʫ0 Sa0 xQW"@D#[.]dU3jlA@ר~#O!cגQߞ_. Ma((,ATLe;qT16'N_-WFe&`˾!g9m ?8s2KlWʫ0 Sa0 x[Dt?Mā$ O3R1%HgJF֪kcI%?޸@yA0㓕((,]' -#*ҍ"`Os E mľ12*3%^  Q|ف_Aa 2G4%Icb0L]M0Ж"zPN#%OdnJ&D}f1u@HNd5Fw Va5$i"sS LX~dn*W*w}˥G0 S"Q}CD}›8&= 27(-tOywLicLo\@F-7W&s&a 7؀dٝԈ'D4J(3[֧gWTe]tpyx9K^{kl޿n0.7+a\n[cYuC 7z}W/IDm {P~rAE xOmgĽaq;s~creb05uy6Wo#,701ƺX,gn*?I oBן@2nHewvDZ\^񲪧V/&7/|@Nmv-,eK܍( #x0L 2>_s>du{l 87I9\aֻ#<}? u{{)~{ح!Q]1y#jh[Ay;߱h֧kMYU<ZA^W߫t[ѾUWl:oY:eՌynhN?椷<&WPH8?DQVog!PPX%k;{kC {4wSnaN(IZ;+˖/[?Zcpz%vEbS((&4t DQ$0oiݐ bs|*6 sxW'y iM2}wˠ46g)lxAu?ڙ.OZY5} 77I8b{Sgq@D9|4ZH [}ūoU6LEhkFL`v9AɲuQ1}У\Ys>[ڧs۸ `s7DAxD4 e CߟC?Kz?Y bMvG&TnUl6FM>ꄕU;&{jb5~~ /Gr&/Swuʪcy_z: M~~ ͵'P &6j QPP(QϚœR`lhVTnUl65wW'c%O~3jhix&<)?m oB<6 MNVYK6NBˠ4?/,]+&`ڼش;EDAl$ AڠkIRUh.CM@Aa Zf}) 8~VkVoZa=㓕Phʽďq 0'5cAW"Su!<2a\ͼTnUlZseW'т]SSg&5ZBZ[M3{UkN\vuʪXq׿sjlDQ'* /D~ѢE3ӦM;S}dc ]R;19ŕP#ݢxwVyNM@FhYAAcEhc'ϭ0ɶ$rQqr-="·\.7u9 )-Y]1U=[NrgUnYw>vyʪ-1|gM/3Y cs`D]*^c7 =W NOڼ/L畤{ڵEEEu 3vPEOz.b*ϗla{w&&`WnE G[B[FSX% IR)[IB,`îa]F獯 Q|}ENLk N:kעO>UV!??իT*L&*RdffBVCV#336mbcca4Rлwo\xÒ;b؈i @0nʼ*OX@qLk=; Pe杙 WCT7e^rN&GU vϿPn>;Pv>+z9}evQTFTn>7-tI4g0Eu NS6?F~M$jtԧfwPGa[rj뾬"//Z*5 CII L&.].7V`ŋqE ^ӦM$I8s cBڔSRdneR#& ܁ܭs5cűr& 0ۼvz[̪@AE횛ͧl YF6ϕVNN)QЖAi~~~ ͩCCe}o=LVݒsYmݗ^D(?&r 8M@FF&Omԩ8p M#22mB]- oҙ.Q ^sʲoy դzeM`-tI `nU;i[+{Iթ< e rxꂩ7~Aǝ4IeSn % Z]%K@y>D|Yf9I&):u*2220m4XV/n"꣫Y U<ծ*m^ͲTlhxU'(6effd_!EEEOR>,ٿU`r[U'f|lAm6܄qyiPM@M}Y ^͛7Cի8uDQ oR  wp.\^ԩS|2Gպ o2훶{9f,_Hjlhd3W^ľ:={j<*0wrנ{z* x:5#>Ȇ$I$ #'$I8ZxWBV):(B$^*#'֠őӇͯS\~6d: Wk$IشiеkWHHׯ&j$ 8s U*T*DzHD}WgY5ukgE71gFtt4`Ν&M̊0`\v 2dj5$I¬Y8-'NCۇ%6YZ nmQ,۔Qxblz#zS ҆a{^Gpah Z.a.?5S pU>a͙N- ˱oy ιwgՌ|Yp)aԪ;)^u2.Tpcwŋ]tҥKjqa;`4q9\t QQQ>}:-[0W믿ge* 8ZxSM({r 0C%^fIC#broע$lzϝM@MML c}"%ASg_{iκuLj}= 8=ɢ{ SmͺYYY{YYYʜ& ZCbϝ={W^ pV&PcOP-5XXP.)pګ5Ԣ}" o“i7}1ls74urkS+N̏5t04_ IDATbz޽z7olٲj'.]ш+WO?E.]poEOv:3ٳgR*-MrS|AٺEKlި PU<4 ZO}g X0L("KW[`ņ}Ph\~uA`ZD7eQ3IoyM5M`m6کmPjG)+llgjh oB2(ͯcc.Y}2}睜d,e9ו:gfYBRcm-HmB/LZ 5?KoC\Sp07 Bvv6ؿ?|||p%1cR I R\v  PfK v63CII z=bcc& 8A}e΄(PT8jJW-<çFȉ#]~l<4OCR2W j8}M@͛/"z"''@Ywӭ:$R<QDwPJczV;dnUCT{j$ƨj5ZBZ_lcJٮSJh}JerݪuJ NlcJڔd5<q4qwSu%c!3(oo.o^^v1 FˏMSl?` #&`ɚh _?фAá oqhF'(lu>4Z,_ɢwۇ%㫎Ai~$j|54g|}| s[ %LVru}+cG&ܐlSc794,;JVwgh?rK&ϗPPX!JYs@*EI¦߹kDCpkI[pĽr̷Kz}6qwuWS6ʌ@x07%Q[MxRK}/_Bs&&U"j虉SZꐤn!I1ekEP>~.)_߄'IoyLNM0 `jO4o8?Q#O6,טWE ?tGpK8?1${A+5r&@R+y*׉iC6hZThc|'=(}CoK-;&JmKK=ev(w(ѣcKzcJ+[7)A"!׶Vl=2w⶯0+>& {*}eo`r rQ[p84n&`%0M7M>fj9*8c PS`2?JD䴿'uvwtQc-wS=rLu +33Jld6MW'7O Onk"݀8]7:ED%t}r貝V_L]D@rrmFDֈ,DBoPL@^=z4DQĵkΝԩS pKhZHJaÆ) PLV|;̬ED͉v"j/YCsU "H$I:&m^BdLzWNgV[v1H ~MrJMI(ń5_GrĜIi/]j_cM^DVlw/9̏˕ ޗr7<ձ5c(vG16`MM;=3H{=,='U PxdZd`M{ynvee7"Dt&:jH z$?QiO]8@D!D4lʭooJ3"X*ov{5" ord` U^BE۷,ED(**\rzBrr2۷gϞEpp0-ZT?5B)=ǘlלϡ y"I 3?;vLybl{ǁrF;e SM  V2mhڛo $K#۰-CV>7acn؎^z&a?{gĵWv[Fi I C-ʵE(iIHvKuתhWK+Z nիtm*̐ |hf3 'yss^OoNEU7o~5/Ϛ5kPP͛7q}nTTTϩ-澬ɋ3݉;TmwNjh&sVL9EBuN+w0!يEX*k E%{[ޜyw+MUB迕⿣ uG"&|mw? T=.w0HQW9w\z, 0ouN\pw#80;cx@ |B/s_ [05 /ܤc帞pkGS@sWy'prgJ$'QP e6[ .xdʹB xl,[;}hփ<6 ˄AgRoG65׻gR:qn*r U=Sʂv#PC$cp۶mGEN=zLDt57oĘZh2a4͘׮]ɩ35)]=J`:Obqakͩ+v( Wo[3>RK,㲍KLq!2",g Q$a (Iqqbn?5 {S?DF`dH\mUwc3)•[VO"EQ wi$6҆ǿ=I$Thd'2 2)2 -+й/Cyz-_~ Ti}Sݱc&$$[lyall,jZDׯ_|իFFF"08x`4(PPoyyyܺIIUgp=5jb8j(w^mT֊{(֓Oď!&jEED*#Ң}YPIO"Im^ԗʌ'87hzUlc! ys<:@.F Im j+@8+B*@&ֆyɔg ˯ EQx(wف4M A;}0ٚ{OAih-qܺ_9FP9$-y+u(J2 I>z۹d(¯.3ŅÕR4M &f2cbO]Gs?}/[l jIj;+ j[ `'}6FEEɓ1??)K.? ,{a=p H-[Php֭U%=ƏM}KtJ+ p}6eح hӁc"U:'/ۃ{j (G3PWUS^. :s)@Ѡ),  9 t3.XtB.JeIj;pA&xh=EXI7=^|Z1v2 [Pf=M13?/w6+is{ :b5.!{@m0 S=^)_H~k}6_t7j; sfQ1y|Mbtf@0aJM+v[ AXo{yX?L?BQQQva?DD!C+!;w.:ʶbynUm]\Mߧ1xbRjˍ~ϓ|B-)i 7X4N&KZB az ؖ f5@_n,t3\Я| p's]ٱ{7<k :J:j׸>MIPpNJzRR:@[X8]U3^I^|YqF<VV`n)"? H$kpA]kHZmHQ r RXaXmP 8y$2 ǎ{aqq1vLLLka<P%Kg[uiJ}9SMBFCx.RRHB< ( ߉rsL_4%列Zt"Fq06)FzEeo57եHeRa Tgp˛8۷ctt43 #u*2CO2_Uh--K˹c8Ǚk}3Z%v U/r>t DDT6MxO?!`vv 0֭CL& %$$oVs8z Z=5TqYPYكT:{æUgr)4B+b(Gvtzʹ5:ǁHM#EQPdJ7!EQx,Ue&ՁNp&7 ZuIOuw߾}P(Ν;x2s:˗֭[ϯ DJTT1ݻqqq8dDҁj5`II jtmYa:}e}IqY}c ^=zbgM}T.EyΫ5&Z4bZ HO d$&5vxTݵ'W@&qy"\jg #\~) Z ϵ/eM^RnG@X;zΫ g5yAݫ6#O/⾜t̙0˶#AyMΑ5:/S\Lm\ft\ie& 2 ~f@%u>3&J'9OԌ? Pl}}=`# ض, 6}f*{Mٳ'2 QQQ,T0 ۷keJKKQVcllK_)((+O?!b`H"ebpemYT}_HFp<\x8} Q$+w -U:{՛K2y<` Bӆ (G3x{(J} Ԧ6ƶjYIj;>7^&Z=5$Lf˚$ޒVo?\nlOgL6yT"n)"2wARixBiaxqn?zkj9JO;JQd^-f >[}YБ=_'= ؖ3i[BYF_+)^>@aj:MbW*^:~5/~d$y%e+DhMP+0cm#;>QMK6)oBCAZ9|Bj׸aQGaǿR⾹b ""؅_/X")>1 xǛ 6lcI].ې+cƽ(Az~7 iAapSPG&+DE۰1n7 \w֎번i@V*ch$)˖5 |,~yJer=L6q |x9&#?o|y3 "l*k3?9WhW9d8_o>t +2"Fmrٿi3݇/T?#T{OX,ŢR4s1GxMw%HQ|h ټ&*W+Ka4@ dN@%T2a/X=ƥ s!+\:WBL?N hd_eyn˵0{rj]bfЀL Vo2y@IT)=`L"&1OD R_HR]RCd!֮ pKO?6pKO_,U%5'/+g*aRxwn`w%EqӐ(ܴ8K0&Wx7ac(d ӗ02*O] W?VimͫTw"& UB;.l(W>p0We+DU9q^~U_|-96ofՋ5S/022 8_sl0+7\_6'aVJg*ٴ$ά d-'7tTwVMn0{Q&jl-c&EQd 79sdUlO]}b}/ƽG W`Fpa_涃.&@*SffTwLLBF9GKI[OTW>LE%ULtY%mrlw_vP* _꼲 R_f٭3hӂ-7G fg!,PH$|Y5xkL~5"oI"f >K"ImjS .wuE` %\-17<~Bٲ$n} i ;ߢBa'UkN4M=x¯hI-k6VۚQF&gy E_x!eԗ_zw (*.#%x f,8cr'& ^ (*.ŅH,EPeq ¾Q MiRp&$[pFƚ*W9du qZHCܼycbbjb^^R;w-[Php֭xmG.?CVūW7O>`={6{p׮]h2ޔLϖaC{t0)̜u.&`y:n=Z5FD+\>4å{92 E .XB> &GN7A>sNrxޘGk@:4?J4z_~P wqsСCd2˅;޹sǎ"YEeQPѣ1//E"6 9߉-K J0&I\L?:7 ?|#R'?!~hsTɆ1\+r/2x*ּ"yT1HF du ! زe j,))TոuV\z5/矑ir ~(~7nV͛7Qf{ҥKHQ޽{MߎE>gC Sr1}!^)@F]LϖX*-pβoG&Ps hӁz ^ #u@l4tZ{!#CRkO D(w!C۝'O4 E"2 #G{FdD*7ePHh:vEFG3?r1g QuNHr6c(a{)~뛈 hśn 5e<d@HU #'rߕY 6vF`CQ$ޓ{\La,R J2 N~wW4 h*?Ԧ6<@p`5t/7:/H Ah6hv潉L=8eX]cߛ;!0)w|;Hk܉s'&O5 r&ww;k#ǻ&OU"'wO NiOtBi_aVB]5zt*O6~*sZoYkPY˚ 4\3P:M Mx?]LB4P+0Q1Q`LІ8q~jb21bxHeo;[CZD !tϗI<jFH`TIÇ=cWgo<]­."QSƶ fes9ylnvzu <*$ 1+B]}Y~ᖞ~l[nS/kCbbHR9/D-ŨgrwJhۖƕL@Eb---wDF~wĺ*],f >2 [&4y(H|@S }+ B0xecй?7@T;<Yg_C+ƧK14iQZOob0YF3klZҙEaV\*3&k O*ѣѧ^3N5Tk-oB]r=1s>xf/w9Ftզ6\3yoYH|~ ~) %3|٫|sݟ=i%C-0@2buBsx׳Mm$vK|5&"u5&5&/$Mma^BYU_};;=}tL]}d6wM.;R D\G#}0BZs?c3e< _GCR@.٤G[*܉FvyLؾ8 7P8ڍ~5ԗ 4 ( ((P^ \ Ӛҵ͍k/u{JT:xq\ O ٣afSpLL{Ҍ|f}9@>9KT;;ץ_ P*^w2|lLeс=`-pRI,=BFF/:G̐ BiׄGK+/׮ a˒"g?wDu9JFǫXk?Tk 0觀Hs角lcKTzᒞ5V:~a CR6hu ww:?wx~zCGvM T ̯z <o3x&q,+}Wњ7Ƽ/@ow^NVM䴜lmjJBLݷr"Hd{ -/=yс "7GskUV!4߱*4k;Cs;rc{.s/Nq]Gyű$jg5xř^wwKpZ#[ ٣p/EQwY æ~9lN9dgMԜ΅ʥ MM*yo/fn(3t@֢֘$hH @Րmo|6cd@{pFΈ;&bZ~zK*$I*Pť_fEG9 Oyg~2_7OY>]skBmyor?RWݎ{?kQf@ GA5ˍ s$ۜvhVӍEN,F^x`m5Uh5^Zeo(ٴ*K(@B:aȌ=3>1=^$tSf6mW= e,?:$@3kBrW2yRtjEEL:P#ߒjNKm;hl”EC>}o}qz}^vCw8QW740|#=<<-(G-ݕlZX5 T/;;f蚱qg-J.წ 6ߨ2eeOX3}aBDiiFP$ڵk4_f FEEUy|Pa{ջ)x""&| 8rH|8:Y&@k eooQ]}Z IP;P;m<=iс11ʈhWV]}u(?6-_FZ4Sdd K]n \vMw؁E͛7 I & P64nC.7֘$^*"hlZ7jmY25P.7%#|#ch5Mmmʴrc{^&3w=-}}B>!f+f >Vo?YS?y(iۂԆ 2 0i hecS=I8 ˻ l6\bEL@zz:Ξ=[7w\:t(Λ7V=''PZ[ztf$:a M˷h /@S:bp1UE7K7/ejLf@Lpqq.BRn'@)T$c-~u<ڈb_ ,+$dpq(Kܽ{333Q*Vjbbb_ HII'O""E\LY\L@zz:Λ7-}*]~R"U_}sDDF mQs~#ZmƠX*Pk1)e04 mCBEak0]@h8/;5_/9cAmjjS;[qw{D.U?;uUXP?hd)JM"bii)޹sۇ ܹ/_FklpĈx=!C "VKJJPV]|oݺ8|b*Q70W4fn;ژ0&n ~{s#OV~FL #Ϗ}k@/nUwK@ -΁ L }XCk` uI-PgCAc藍]UDDap޽xMٳ'2 QQQ\cPPP A^?"M D(\&ZFa0%%o߾ML@:8xxT(4H4FF2?R?45cxG4^@ j1P4_0~%QdhU+W۞n|8@8QvI܉4^@ j1)kew‰@ծ}w Tf BiChmyO 41g21W@L@h4grt0')l٨At 8peho IDATc\nl4a4ѱI/<>^7MضaG>R]bFDv~q׮S+G]I ؅_Z((hYq6 )p~9@EO(3Ws?6.M9\[)BV)l T:2E߹|qR+7M(K n@ߔOa& Zw҂u.M"3XT\h0EXT\=Cu8sE"NxDd^Ƣbn E۰1(\Lf\⪍{Q,!EQ_X1* 1"2JPUKqiH3 ?bj.B p A F<׀,T;tIg= ?0v˧u='pϰEb)n} 2.gDHRK3ss?c-EaL@,`GMe_0.15:}Ǩ2 #T׾{h\q/8j`kN|Xq/2/\CL@p By܁AX U_%z/@dd]Gachy'Gh Ce*/w9v*I^w _B_Í\CX2`أgqw%HQ|1*3E\0x.&%$[h&[ypTudi6Nf  oRxZ.7֘$^*BŤV'ےQ&`ʜeֲ8iR\2~4e 5<-]^KQ~q+t[`&L2ڑ(;=m( F$cTeK1"2 R.Gxյ|; #"P*S`u9@ j 1@YUd',U쥳H%V[ֵ [( {=KHQF&@Szwڏ ^bWe#$?u1_{~ܩpLǾA>|l#K4؄y Eťp܆ﯣT@m_un @h8E> ` 4V(̯^HR }ƣ{ X}NQq)>|)er_OLFS ')ļS? MӸ"{+ZGLՙRar/okߚi\5 /W]5}jKe.KC@L@ B4D$>a/@PKXXtl2eqxo¶kV酇 {_PT\Ѩֲx\j+Ra݇/< 8{2Ձj_Qq)?]XhH369+ٔb ~e%b㆘@h<8(_׳ɓvM  @h|8XR4)ä4^@ j1i-ޛ;+bBm &@ 4r@ j14 &P 1\@L@ 4 4]鲗[h Tg/EZUpĘW(:vn1QBL@ 4  X]E"ne;NdE_7)0 .X N~VoCfp&14x$& kJGZI)iTnkj( 6,*.m]*K1FFT(sf(  :t<;WV^T<, /cQ1WLѓd.ې+niBa12qmGL@x@ Mn~<3Je d~~\~y1梾GYaZsdHQރc`p%vH,)Bpif~yg@Q9*&Qڼ2{R6'CL@MGL@x@ MGb(rM3 Fx< ee{_Eaw%KMo/EťXu4ZcdTLc}4-lD6rT <>\x/t Kq秄6$$[h&n i biHLeqѯ;zQ0i:)h6Fx0JM2TBE)| j-jJ쌕Q,U~kO4%}~ӗsʮ͹ Q1. xR@ Rί%㱧He ǎUQ1.Ʉ>V!2?yJl~$a\_4ܩ0pOxҭ 'lhen;(8 &@x|[LLD4+9ˌgβ,Oc[nef-ϱ0L-oۢO5- 2C-A @?ܨ ؑWA|qpK3xǛQ㧣BÅWp<e#*]h #VZ!bQq)>|)er_OL٧9?^y?4a~eԲ M bGK3h>ٰ,CW/\FdW0}076 L  ALP4?5xeoυg^8'GB}sf+$>rE9d*ѣѬ^>S- vR &@xx-9~^ꛙwutgc /w`K_sc^i\0Dq;i f)D`6s߭cM]}d6wM.s 1ƒl s>ۃpXC~NF"nww1=^$tSf6mW= *]/kCɦu N(@Ϝ@ \!&@?p"J_ Sp̿[HwdC3h}[Oojw7w5cZ0*{]YFemQYenHҿwGsw;JX35bUJgG-ݕlZwM7t,3B& |:㦹?jmx‰UE$܀g@Y khMᾬ ?6`=*Zky!Ij~Gj~dzvz2"-:=&=Ft*OǦКYSpOoM6"y: ٧g}UݥGŌQUtI Y2 >H|h@c9<- [cx,@֢Qia03+ e!֮nn I~]"ZU,Z¿-?M.Hps?_״wXBmzyUiaE㥲(5&\nlύZ9%zB@ Tޓkn;@\TaVI:[仠10o u@Lf ӦtjSdޡ625WpUaVѴx-TwtfJg֪;[CYFEUngMx䝂SdRWM$;ۖ  !&@a+&lqiM-'u|Bj׸jhq0Os_VZ& VL{].7ˍe2s[!Oo3R'bjb#l:5Rz@-MmЊ IP3 eM{K?.mZIs)YEU~zCG t|Ɖc0<FH|b_bR_j0 0*h$<x9pizsɃ̄ALP5V~nV~m^rĿ-R%5D|*> $h4s12OD?4Q?p{Ut{\<: g̬82uSxC+! ߷觀& 1B4|gsw;?jeNۉ>كc XBmz/E!sS3@ F @$ i1;X]Q hڦ I$ --~;!YVFA S}"K2R;Q,&XZJ:/^yMr{9|<\8v'hiӼu+8w/mm 1/k#^:[W| +1o 0Na# iϊ\Z3cG'5mk=+SA~!m 8s["R=|ES_0 S$|JBڷK3l=[Ҝa+h/="@o"p|cv S>w|i;h ņ:Ok_l8AK&5;#c<@p|cv S>eyVMJFޕGsug9GhވZ5uyv^aI2LT(vyڱ Z8v C?M7eE}Y@7uy#rh5ct c<@p|cv S>U 9Gh4{r;|%L[>O=_QG#k:mx?LsVii_Η&,o 0Naʧ"v;{;|v,VOF_KsӼ9xz8w}6};6M ?%6MkgϦo s hވ1h1>VON;AO` 81 T;I)?,ʳN^3]gt`qڿ(Yyv-=DҮh#Q:8n>Ew3w.b<@p|cv S>Lܯ"y0 S$|X`/a*$ÔL0Lda"p|cv S>,X0o 0NaʇEn#a*.d]"Eli kY"ajIrPd,XTj`=ZAMp< 0P+NTmsEz`P]յm`LIχ1)n7X0 T@m8IOPdեq]Gzy{:`}FM{a0 s'!8(r`P]QRfffQVVVקO>|xd2y&.\@H&L&#A(::~G""ڸq#iZP(hԨQT\\Lv}`oHf`t$'(2ՃE.\)T_HTVzͣppdt""ڶmz]F}!NI&~2ʹdRu׆ڂmg%HoH~E0Dc-;IOPdG"@&r7bJ7'"@M6Q߾}=_uٳjd"\N"v\.'@zLy&\.)>>AԩSITRAy:3ܜ?A7nХKH.Ә1chܸqDD4j(JMM\ܹs$JGni"zcL @.ŴoG"aNc1fzd͊WPdGDy+):M4eN JL&~_Nqqq4e1cEEEիW駟~"BAOQQQtuq)J:}tNd_\B={YfP(ȑ#aJLL\2DDE˗/QBBj%\NvVZE*:4imw*+XVR\rS` wԎ%ٲxh*u6:R(,*/sHPHdޓT_H}OI $d4> l=# sBr iǺdHTQA~!-x5*DHWYfeܷL#*w)))7o={RBBx<3yEZ~=9Acǎwcƌ!BA((jiĈw_LB4|y&rC O?wGLF㏴tRJJJ"BA;v 77MYں'I0fyLn-0 Ɛ @/G?OPdJ3ϡhZ]fFV/^O6A Т9ā(>.bc 3gB}[ц[Iڻ`ͫvL&K7ѡGɠ5 Pb|ڰr+<AFs-ob2̕~]8o!?eR#%]\\%&&lڴfϞ]ҥK)**|E۟u3PMJ-z(1G!? 4pA rjQrP"S\ JJrȺIDAT@|󬴅L1J4i{t)@}OYgBͫvPA~!/(%4ϵ iv;DMO۳G',DKk>\P@cvEZnܸA.\r*,,k׮Qll,}4k,"xgsܸqp8N8A2:D ) ""ڽ{7)Jʕ+d2hڵwBoOHr8cǎ\.#GPqq1㏔AɤwPivRM7$v?"}cc52ߍa^@W<׽!ӻuAbU~5hMlĭI ,Z[=ZdL̙o=L3GҭJroN)6x$=zK1{ݘP;"A<דUDh-.4!Hɿ~ 8Ig0ƷzLϭOIaV1߼E {*1RIZsh'<i3C*tRɉ@#3P" ibzx@TRJX6iބeX-ܯ 3xBu{}[сݩ 1g*OnR^50o"@7qRuaԅ ?2< i= ftx~=ͻ`,1.݀l ,h4lذޮlR&/nF3mӥg׳p/ 0*@+KG )nuK{Fa,w K!VGLx6./x0@fB zs,RRzH=[lԳ (Np9p=B!@9 kn(vcE6ܟ\u|3r}w]v `3  @>,>0\qyX2<ѤtOi?w+n:H y+(iKh*/aJC;rIRѓ<" iZl3d2Z|39A&\px1dt~)PLD=` :zɿ[QaxJܣ֖@^b׿\·P} ` Dk%3W =6(۲r;J@)f.lL `d(ې zހRwD )8@HH顐z׍z^]?R΀w'Oh:е2@l <D<-"/Լ U1cR K帕<^W/ʔ#Z2Sgޭ(/~Y(*<ɣB:uk27g@ A)1 ߢ7gDS]BlJ߳0&dɏ#T]F#_Rýt>Ku-+xgL 0.^|$.!6eTu@]7gxl]iU)6>3<.*'\@^f/"UѤt^4_/k\Tr%KV-odggȑ#K?@^ca an}2JGhHAQK֫օ݋Ԅhq-s=!O`}֡KMevT )_DB l[0.E@m"j5M ~jvj_DBmJevwN:9xZ^ؒ!ey~/?\Qo]31М kРAtjusgtvlIA%koP֮ߜ3 Tp-p- *G+kA*I= ueugAyKٖһB@){tܻ AH9UN 6'dQ\+u*t$ݳkmYX)k D?T[n˗DׯltE~:{WsΥ(z*O_ D+]rzIfͪv6 s'3 %wiu X؃ib*G~QJGJ7J[D)|[.6A_.>[E* ZZjKgV_q?K ULYYYӧO!CPvv6yoٲJ]#11rrr<\3f ) EDQ$VK#Fv]=!a&:Ie5'n񺽜4±剀9JL @23R{T_HfHH.om7yBEGF{ Ъ -o󾧤^}I 2d9gǫIT $D}j" kW"{7PE0uRnMs|ƍ_ Uw n:5j;sҥKoU}eη,m"4C~a49(7@ꈀUޓ$-[?Eq '!鎑MES)9rRo٤P(hߖCaVn:H?'Aќi (iꛘL suE@QMw֭[G.]D.]"Pj… tU3gԩS)22ŋ]vV7nЅ M֭ݻwR\B&֮][aAb}Z"`Ϧ$/}[QA~!}};L;D@E&O"J9"LF;I!WU; ?EPt䭑Gϓ tUCLT߹@) R(@` \NtR׸z*%''\.'@IEEECrh9M'N$BAr Fna.9(gz$hHPҪdd*STN*)*-XUyߜ 2rIgHq\*%sE@P۴iw}233kE/ svS 71 0A~S-PrNfZ/B|7LPQHUb$ErlϥN]9HTјaiI?SX 4-'|WRhʕ5k gNGSW-]`>wMaAb}B^\3l< @VlB5t,i:Z*5M"}G8]a;c>?j-}wB 4TJy>ߖKj&Dw#LFKP3nDZsXTB#M-`LVH^=E9UKx01ޜjP7}Dl`0 ӠA>S@8M 3\. г;n,JCQ$h/*C_SE +;^@`"R*$ &ڻ`"KgV۟gPe C`6N#f7+@X#G:86%mpJR 0L"J)i mk>ݬ_mͬ7o;Di"`y 3"#Hot3A0n*:".v?p Ҩg0~Va89(W(k:9-[8~u4/Ӻ{l]5%DsQ14bEH0Xu^JҥSP=)-]|D gݣco0 À"K'g YzڲK~7_дjczx'cAzYTFD(`y `L`}:K[bY`}$4iy̵tsOaipPX_i D?4wDWgW 3ӬwKL 4giBbTN|H~5"r\>ͤ{4'-1 0A>oiۮu~mմ>p}5C#Va6zrYU{\ 5{okaRpPd4-缓ÉgМ#fIkvB)GI(x/ 0 0 0j]B`l1] aݻ;O9dɗ˯ڮetj?%6:txIڵ0˯z{Cxo%iœοW9_3oiCm.!6Yi@y 0 0 5"У3VI)wh 3}fNr:%_zkGo/0{7I9[n,yoneˮ欷ߌKc!}d=*_tш׺Fd$WqaHUtyNAVo&#txRsaa<\s  VkoV۟W.ʀ7 Vl7iBowg_hz! d09^K7yo&}0CM1ߴf/50{M:spr7{Oh}q~Ǧ s&jœpkz1W׈]3`r duGvwFjBl7BeJE?I[Iaaa*ZJ{KÜ3 0 0LMPJ> X[zDA)A  y{:x{:h4)5:9߳jVko-< s6=u+鷶>T3 0 0=-\#hvK?"%=Zxz,ɏ#$ $>'5ڸ[[J׈}T'o[=3L]VP2IENDB`pywps-4.5.1/docs/_static/000077500000000000000000000000001415166246000152665ustar00rootroot00000000000000pywps-4.5.1/docs/_static/pywps.png000066400000000000000000004112551415166246000171660ustar00rootroot00000000000000PNG  IHDR{PbKGDC pHYs  tIME b E IDATxsdy>X{ǫ2Y)JhٺĩbJJ])o_oH"ٮЎ)%,ъeCQDJ].v1 ε;?t9=gpSu,f3{N B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B !B!ZYBM#B!%xEKeB!BG!i/R({B! xP[ɫm/䟈B5XOe E1ޗ)V {krZ\ĘE#rN̸䩝k ! =$RCo}8p`o1=B!=@xszO۟)B`yNhoϷJ+y;yn|k)C=B!Wm.c>kCAޑ B+z!e7L/@9(w2$k;Ď3%޽[!2Kh@m?DFӾCwSE/&!S$o?1~?@[C[EäsTJߙ){BK?{DcN`:-ƭV{םCBREuϿ_DI!y7p]w?'r#h .@|dm&3({B; 06Ǹ'}.ו=_\3wuM1bW{_6*VwA $2B`m#\ã_@Wno^ 1G!=?nKh7w/t#'[ʓܓ.;҇EЏc$D"D6,fCmx|6 9"! &F!£y=B!W_OV joFyCL>q[n44Q(Ң#~vü-F*"GSS4uῖb6?Bȇ.zc@;'еO.K s@~q!j%V r<_/bs|/ch* iEHrtD^ ] ('{=~jh#)z-P3IJ?J!FP<.Ch $ 5V>7>!w!znue+wYWo`.AW*#hB҈Eh B>+k d }cÇ~/n}/'W~԰d;vOlE/KZ  @*PAD%Vtfqb ({B.70F0nlMb B1TAt-%4[4 `"|D}eWZMo5d !KmHnR Rdg!P!ZltWj/B4g ^/c(zD%GB{]Do+z6Uԇ PyUfɰ }a[xceB{1雮@7c5b  + ][SOy'lQ@[aoҧl5vJUC+kSEjREa2By)Í R YfBit+Qn"U3Z !Otuh_V8励Py<-zj$G`l#Yk{9yL!ĥp0-F0gsoCe#TfE4k@+~C-tB#968ԭ Ո&L%9>Xa =c'm}sshgF5Q=G 8۷1ط¾zwdBA"O0 i?gmDκ!AA&_a3<+{uYL$^ E`oc[Pu6'P%LꦷLZdvw>aށ+"PȒ Ǻ0n`GˑY40m +sH3 "Hefe-{DhK7C#\f1r m1{0Wnc=zd\!Hsd?՟| @6g=B!E7:[}AFT)+3^%|+J /M$ܩK}!#FkdyE)!d1>dm&ifeTQsBmѰUOW l3 B'z/> V`2#zl@!Z-?YE?*WgB6Oxs|N9K >FA TQ"&HKX +os=! EO#D[qot:_'Pٜ٣WvE/>&&+teBeGf7Qgx+ q2-oq+h!\yւ5wɿ\%|uAAG2"z[{ {.!\EgPCvjHȊ.FN@gi/czn ^Fa2VDB!dA'!%q"H_ä'Zs:Bޱ+?L-^b:>UTKiEO z߳e9Dۮ\/H#r=\|s  .'z#pWkqY4qRqh!{?Ж.?! 9s}^aB,a"zw[x߀***i"'cDO]Q!\Ɏ-!>921i1)/G.V4ERi|uD!(AGOVY$o]s)#gH^vfۀKyx#yr`$Ftm mD/)$-$kO`loQ!\jCB([Q7c sk\Ź`u'' Nv?'M٬Dt"w~hY+7 .WKKYt{Ԃw-V91QFi=Eo0&q株AUm gTdR@& q_'6qP!Ks@`PBDXA5DLi hWSm;M4 zWz5ޑ$3bmaIm*=yDPBD0A?ϼ)Bu+ڙ7q$yG^hȿ8D\^ѝkk]z+M|UFBOŽu6~.yNw^/F<3W~!tiUTx-lfm! 2${ǸK/.}/_5ѣB,k%`x!t*Y]~K:T}5&hvY="d,9ǘnoSTފ!dJ ~xÇ;V6a"zdg&&L$Z}1{A^a=Zد cdOybWsˊ;ކ>yv~0͂#[g ))&i]%u ^`*@׮-DhȨnx7k}a[m {vDG!2p@ ^cxٓyh&% d\nOKw犩M4R#zVdd+[6&: /cBL:S&]SԀ!D rtDz@!M`vV^c~=WC#WA|=iHG#|W 'BN\9{ G̀]s{puβ$-_Jecqr'F|3@MY:AͭVXxݓW2vM24Eg6}&mLWJeBȅ!R¤؂Lɗc|Wb saQvޫif;.unA>3w\ \A+jӲ3G򲯜XpM *M5 j#x[^!Hk4&b2ҵ r6:p+qjov;vlo]=FU=_\j߂#K#LWUO!޴!zl3Q crl>az-H!ՈG ȏriGN'P^ʱy|9(C@kiZ۟$ Qjϲi@yr!;i2>@rke( $zBI`CXE ]A)tCtmSmTjZNFnZr8B齠I5uQHӏL(]yI iXA&fApn srPZ+ D#Gޤ[SmfOc߻u$+rqםē%Jpa=?%՗:?$&#smnZ)׵YdJoW?.G΀|s}g6:!ySȜ~-5MTFONLʦ4,2viXmY? ڴM t(_keBEO=5wqubWs_ ]mt;)78O΅ lʤnS'_M6B*C2*?E)C (i f_Dv:?ʏU;ת󊞯:G5Eb{Yp.2c<&}n!&+Wyߞ%߈QUlS^{QΝ[x{"lMunǴ\^MHQ |AyiSfWHC5dl[$hk? +{a%ۛ|m2I!f%&SxbQݏk z9huhRxthStdr)&6.-Rh[N!s#h@&@'䷀fپ_ʬ"6js=pn[#|Q8FmuLѢYTR)t-={3/e)#N{\ `oT_\^[';F9{{!l1@CdW%D!0?Ɲ{dm#yϭݟgW\.!>M\ftleg ?\;r<)ٳfz)@:8*|],1|h T ѨD4eHä{ nrGBg= R۔!}WvRϜgYzluEOF)y Pst]7 {].CUBcAG+ '3~̈mJy7eQ:EjEĴFc=򪏹ƹB[.M6B{XK+wOsi^+ZG!]ddϕ_i`hK9,}55&&v u 7)Ho/<~{c&5 qEeB 0Vi&S+(>mWϻHb7BzdRn]Vdpۣiب;ǻiЌ9&6,7*Í]+waV7 ހ6um&V} rtBY7oB+PlUDХ_#R]x'(fJO&2kyaYۛFRTtM!2*Dd6? ݄Yps)Iyk/G!҄Nʀn}W3&zdOw9sMLc jNꁕ@rt}N*mPhϾ(w]jU,(y"c"*yEҔ(׻çgɟ%=?>} qf~#y&]F"e3) 80;_|d~6e;ѴkYG!ҥ׍0aoJIugH2q4M5)+4X5F Ԑa\rWsN0\{fD.B d/Bvaf}bWo\ߞ57V4\-$ZBUge&Bg_e3 _Ťg:mRd{m:b$-߽\ƹTImi~U3ط#Ѷ^S[zu{^S8 \e2' 2,!" Í`s[V^^޼nkNG!*I-ҟB t=Qe0ѽ&Ɓ槦g0ͪ-΂ L4oG6)_!o"z~dϥ{c@EMlDc$kMTxhkB,B*2?gqJ>LpzBXԡht75${yt*MTo41'zi*8V$*ӛ h>nډœDM5Goڠ#qB(#p-|*@`aӇX= D^/k)ޛheBuFμ >=nf3n=`{ubzu)iT[E j {/qQ=WT-C@޾ڣa%%nxdw/~8{C., mo.\ {JB4bK#Q5+p֕5LM8ڌ6'oBdmZT@h V%6 $kgEo _o$J#HEO 8]Eo&h% ɺKt7K@_:窑l3aWODj2$9>YQtTԭ?b) gB4/iߖ_}jhR(􌨚>&G^莬I_ >~VHnki a[$ Y]){۸Wծw=$G!mRڨ^9C4׳_z }R8J-` '!o~#PQ?i+:%…C1L0NJLW(s,09)qr;D#M@ P j::tM4ڥ?R닪mLZihI!Z6'P!Fiug -xJRї4xvR({Bț˞M>r~=76b\@cS~_)$U㌐mF6QN:wq(n1n{it$Q듵s>UH.{/u >_|$G!ݯxfC6ܧJ-l[yW)yi.Zѓ/S"RA񲓽c mҮ0w J<=׍Fn 0xQY"m=ԥ{ͺ>juOu~v=WG#BN?rA#z<&@te4 ?pȖ \Qx[sl#{J)ԭ)좌Gˇ<} t25Hl{>#|QGbu\T 3dM{G|Œ|H`W4UysUQ!&LNLqɣ!MT&߯ǖ \8=mdGbd"hMQ>ziY܆ص 4-{_ttwԿ]O?EQ5?]"-gYLG0}}SUCOB!H?ӭ{-3(rfBŒkbԙ+ W5()2+v"^v?uQsiAs|:VpR!Lg\rw0+Ho&mS&#p4;A1yQ{Y;NN<eBy $[.r}Wr'VavAwDG={x+N{e]V^L|͒>3PQ({BUjNB|)tvd{N?{ L#0ӹ.ho>s5eB\L˅ vZ.BN'<穒Z^B GqO*y2?[{׶%F#B> \X_-9{Q#|vf>mٓ5?JG!c#[.rY{1ЁIFIؓ5hz%:)U!r`B-ݨ9yCZ M?^Rg \ =B!r#zݶ &Wg1#@X4޾7T@VY`VV-#Bt-M4^#^%NYaz݆!r'G[.Lpږ Ѓk l.}\fe_wu`=B?/` T9*>ڞ>[!+-*=B!ׅΣ-&zZ.trD *?@.@ 8y$\OUZ QPY ]Е0{>!Q)ד tkV$=B!zfIΜݯgR"=? !j $L đs:;L*rW]QH[߁n*qBVH+q!rEެs雦19h_i#D M-<۹'D93ڝN̚ 0TB^pL({B=l_oˡݯLE z~SXi!.|~ A!^RG({B5=DoGM"t=BJ,~ qNiE#҅o&׃i j]M󋳰'B!L"W4c^p[L3uǤpēNh"{B4rYUc%NrBEo:ݿX*b"0UK$\n]kM B49~=^!Unxh6+_ ҫy!h.@V(a7] '+q!r DoDo&uӉ ѯY"zGiY!,'{< eBu=B5rm"|Ҵ]0{\ YSeBzfv=|[wߊC||&TpP.T-ߺê3~ϦpB(,N\qB!WXBf=\Dww܄ʧEOI~=G#$~=ur4{ Y#Zv8칆=B#BɎ cgE>}.jD/MO7ug$Oz>B.\6TG|l[_Bޕ=Ǟl@({BȕmL;VWnc?ހѫs ]ڋtEϖm7{~ɣn hF3ޞ аwV+i:TtZmޕOeB"7w-+zGX'+ὓQ heZ¤__bt% +q l@({Bykp<w?2T1%Ј^=i6uȞ4D ",!"!' 6@QFPeUк@*q 6T9L({B;z݃iDѯD;B m*f:^"ҐLJ(!C_ds=ZNU ]  c6SϋOW-*=B!P=蹊ݛ(wA0]Ĵp3ՓLT/* ??0`o'EȞC*#8|ot_*O+F4P!˗<7k;m;M=QT؊VTy0{}DXC%ev<#uӵG8C@ګ۩9`%NB#BCū?V( ')@2!;{^>OM:Gy{Oӡ=ISC "3*q'{XP!w*zgws CO jי㉩, " -O=It4tӶSH~a^pvT}z@S4UℬQ=V$=B!I=?:_{BB@ z)R8єk/!!0i/a8]dz` '!o'y}s++w]{^B^pO@({B =t _AC=SUȹ]>tϟ' [=AN6LDoǛ|֔=BEI+x!+Qh#ѳE[ D8KB!TfлOKP,.¶H )y9h,}V 4#@ЅͤQmZ%D "*!0_)0@')D L_J`@wےddKs!g8s[hꚣؾC1T)Ȟ>SSCݴ]1Qm^8 eB;޳߾ yjzىFd\Cdapo8Az;?c쩀B^Ti d -.uvDI޼eLh7ol{Tk@CSɦ(!G#{\!=B!&~gwænڊ'z dRA&9d<.1tN4q \.K$NEmeџ(D9[I߼r/BsPE EPݧ[6ӓ4 {!rAw\=Z7n"T_Ԑ+IaGƸs`zۉ2[\ODWaQfzrޒwJ?]*2*B\ʻVG[<Χw\fp!r&|+pxun{eT%-̪}!2 d +?ky;-#Tm^&oOtMzrQ.@WsPEbeUXɫ)^&:/kDndO! :{=oz1tъ!" ՐI \ŝ M~x%ԇ5T.mpk-`'BTF.z,zk^%y^$o]PE])dM? ~ z62_yB#B]Z+f;7onZ+Е%4d! AZ@&w|d)'V^ȞDT,&JfagȽ>乨"y]ɻȞ/yBNB?&g%Ou\odzy!*˥`<B!8 kp݊S=򁩸YX ]!2$Ag'qA#fZI|o&D4DPc#?Mׇno_Yw<'xݓg$/*V*YڋOs}2}ђ=8 G!EO7'wwn@勨mYu=Z!Hq  c+e; a':0iqФpv.p=Mt+hcɓжҮ+b*mifF7Ltpï2:O({BNZ+, жVXL".d/݈[+S+x{mLɧK)KP#@y==-zS_6"#{T'XŽ-Tuֈ^9^WWQs &M-y=^g슳_!Z:w_[A/zy5d 29Lvq^b鳛{ S}sb'r}&8^ @&7v؟lqY"]z_qVy~r|[_]79'K_+5;vk+wg+qM%N>G!};n]{x5Co+Lt(;a 5d\Cd0雛0=@]ZsS ldi`mdg>#,B(y7sͫePT1" gSy&m@N Q=8JI({B96m|ni4Kljd ⺩9?_~h c7Vtzf"@m g8Y#f~=v i[A[xeHnտ_./#[x ax."h,={6ͳP7EX! s)].ms}Cn[:Z l4Gcq/B,x G'z{/&mL ={vޙ$2PJM7Qg ^(L3Jo IDATBbis(PC5DPCZki՟Px}sSBG`C({B^_[+}ǴV(w %4L#pa幊Oa+azeMR;AMa"{1%ힽvfh˵ _؁_wT\$oW4Bo TC!t%l "TQ 9A?{AM #Z@)@KP@/4!Dd۩s!2{8myb[w˦]ke[+Lb ]gVg0 0X)>ཟ|3I-w^ Y!]/1cK޼Ԧk6]Ӧly1}'"! pb=D(!j B5[\V6(i}~%ÄG! z~%;!{=ֿƵVRb DdTC&%dpqB,nk;0蜀[t\.4ض8]#s= Md'ym<'OIMwc+g2# !+҈tMAÍ[S8m?B({BHwCUs{g_4g+nedLͤL&ϿD V<ѫ= |*8ű8]q`P"WG(y%,4(Kn],nA6r+z5u>æbTndOTR/zL&=B!=Xѻ:g_Y@ *$A'0\!zD֯iz858j!Ltӿ%okY@ t->۱ &E5]{ XFYžGs_J{!Q坿ܯG({B)Ewˊwt O9ҵ4ҥ+R!H2tB,[0&V܄+]\jbJ={Dӛ/G({Ô<] Mu2+y&J?ٜK"rWAF%DAF۲ :\sE/ŝ#iYcnDHS eB9˟&?^2=\kbbB,+|nV!Ga !֥p*΢CLh1L]A `%I_xEhжPtM+y{D۱ ;~blv-컕 %dmcB`'oCowb hDD"\ϸ'b'j-WMl]d/EgQ-΢!_OɚqBk$Ϟv"АDTBFYgOsuEygyXr:3h#*Ȓc؄G!GΟﮢ]SCrg5dl cl-$kO0y\!Y-Β4G9NQzJ.Y ]J^!zSa3Ґjȸr>n'y[^t$o&z?d-(YFKu}2)q雙'{I({B8y{{U|*OzZ dRC9dz7)rBڤLrSګٷOHL7Sot 2G(y朶«YBDx;<;xz=o'X׸M b8xSYz}>M3zN:9I$7^[H^`m&g>FA&m>+ŠhmuNWt*%N?:϶ G!䃝DުCN-0Uqs7+}bg(rvklJ*qIb8KDﴅXfo&ȞJo'?YNBɛJV5k+vl+;m /Ϝo<%'z~qBqBB@v Nzޙy|z#l*wFZ\ [qsd^8\o#{fAȞgOhYDJMCяO+/0J_?+{>h?*q KǮYaWSEy8k3R){rY8͚((}:ggZ+|[7Aã=:7wp^"Z+lۉ`֝wq))PQ_WѨD."@(y y;V^-ls{mEnCxG(]EΞ={n-,nѦ_:s71T7kճ;pvZq ǻT$:=fE7+U|uB=yhq+ xÊCR_? 7i0o'zwK71y<̄X9d?=Lц=i]Lݵ]HI2!\&̢)`TP.Kr?n&E阳9ocƼƹ:ȟьsu4+>-` s-Mjn]Ԕt 6Dt'6!duVy 7kK<&?{o$Ir~qdUuu=pW@I@Fb@Ҩd$ʴ/vdkZ\ Ac 03S=3]]GVeVfdGʬꮪ,,#3#WiXpGWDgxGw\ b20dÀڈA3HTP53.\Bm}%;M_˸EJTK1*K1v`1kG)08ap qs[y$;SQ b詀R00F^^үl`XR> #C**;> 7 | *-&Q˒$۟H$o.p`Ԟ~=&QGd qA8GeL+'/ҿkݿ#: k:8 Ε1\I fB, s']1EuxH2Jnc9,ٳ.xލ+2@1o;>>>;>ҁ.U/*s)4 M(3=3IQ, =8:f\  )&K#xcBK!WB8(gkŰ.K``8fBi읡O!ޙVl3QDf'ڈŏuc'`r^!؊;ʪ Ti 樹\ʜ?nD]vɻ6v_H$Lf$DfɼQm2i,%-ӹd*.$}՟Ad9j F `NRԃ辒G,,?v%-6r6,ɂq`8h3C8n>.֟B akgF+)2bqvpʂqdx SP,DF+؅Ht#J+}Ԯ#NΈeTWA X3bx`i] ~쩞6)K2NIS:#}{5]s頢M";Fo r2'f_r(ޥ`Nt"ގs!o~;z !}u%}e%x{wpL6Qz-^'5Tz>h!vzeA<,}/ HBcQ#3I sR0'A%w2?;@]}4PJIߖ~52~ưw(SӁV-:nj#<&Aa65X],`}ĉs|=CSx|E q$gzO̒nNƒ"G\sgń Ұ5T5@pN 25-Rw\8''<қ #zU?0h+s>( !SG=4AqbMp8RJf %{vuQ Z}w޹B]DP?Z2U>NV+HS;K h#J'dO&1G$2d,ϊr W&~ ލN}{yo^M}t(@,;E0DVr|qQFjpWy J07k=,#IɞR s+V}}XĹ%y'q?Sj+;F"8, [5B0N ;J%ugLMoq3賒k7fq@G: 髂 0w#l=pn]=0Ȕf/ ؕGڍI}6l4pfYԲ@EeB ev`N X]EncSJ&iJE_g:ZAn2. QbLޫUBJ{ {?c5b9 )Ϟ}Xiv2.\2L9Ξj Wޜ2c¶bĹCq8]AgZ9&Źgza`y;:pIqnEnI%{vu xŞ4VQ&yu~@v^E@K1g58b';y w`)$+@Ɛ?s<07`NWY5z|^{K<*{T`",?)a " m`¤~/ x›? F,7W˨{᪯V%cR\#d_>-;@|[)sC) Ŋ& LrEܥFY9(\vm˳{Iݾ\gsHqέuW g~O3hr3FJ;-ٳˮsdh^zGu-BT.4\L+@OKV  A${#!3]I 5)jg Vf4집Q@pmQVnBDUȃLtKp7c+;qiyfɸ)p<,^U-؜ـS3g%yy.\ƙ.%*Kx-&#q :a+T(ΥRX¹E91 W~֟ƷFܴk%G0Blϒ=:s8点Wl>[Ώ}ۂ@syuO@iVel[*4L;nfzoD"wa+CqXŝ@ut2wqNԮQsZٳdϒKL4Io+W?ǝ?ijWLDUDflYaAn`;g0~A-OiO%UR% \ՃbW]ǸSw)L+ IDATE$X3CFz SE*!x˿֍UBfD(S?Fׁ,xI2L]t\"Tzi y7;Efѿ9cI\$0z9ne\3^fș@9H2N~0)NL0!qί`*v~GFd_]E`Yg])WQ$tm5UK su' -+8Wr } mbY)vt `(}"!w E3]T nQ "Ҏp?Z],:hӟ9skoeόP,S%˒H^"AƁ%ys i3m)<;{BAX=1=! ~7)ԮR;ƨ!|)Ӓ=:E~U770pIWɾ6?»uZg]?M#a#wlT$/La&:/6V!SՋ'hā02lqWl> d$3{=LF1Ϣ~dD-ʢJ~΢lj MσfAqQ %{v')uvMcdLaMS/O? u`0҇+x/j5Ǖ| $ɆJ0&tM^M!8% a,B5iXCU*ØP8Jz?6+žA s㜏@N7IH?O可jrgF+сs{XRՂgi'a^_7]M%ȷ$o!m¹L!Uq%ĐsD;v?8g0,P{Jq rᚏRXU3R"|>/ϒ=:9+Qʨ "{õ6;7 (L/eNase ? n:&>1 x RQϔti C8 @Ns+]=?HGԈ-`A3bڸiPԳ44R4O e/@Hirgo mun9=]&z+G+;b2)Sn+lYd1e, 9%yɤH5$o?!Ms /Mb2+Z%vG#%l++SsŌ2wX ێq@2xqNHuT6M^Nk">K,ai lFƷ,r⏔i*r2^5c@OXs+`pj xR AuI[eQ RIxm!" dh5I E}uU"!ᢿbS ꡺"C䅓KRNf ak; &zh~#ü>(KnȠHphg 'ѻ5hC7b9 S-sscY~=SՋa%Y7ɹ-!yAJ4)J @9 yfٹM[!qVr39Ucp.D\8o:ދs )Np8>\K;qpNܕ4)5_y nzxzx俫v\g~%{vu2gfMȝ 2w4Vg53g7-J9Cl| IR; v<c"q<X  Fu9R1ʃb<` b,V4(z_soDiXA#roW0A <=:0RGSH.<Ҭh9zO1`Ӂ?%PV4ҁ'z+Bd,\3NlC!SǨ,z4,9PN3RMsj^^ia:sTWxcHrL#8 ;*&FpQU,JM')v kgr/9ڵ 8a+9Bd4K;% |Ia>Z1>K4ϐc 7R [/Ȅ8(Vٔ7ܕ@OU7r&L5%`<x "X L@b+"`6 @B ʳ<#Iծ|4YA. \ 0\`pJE;:VԃHudZE}} 4IYۆ`>ƈdWr)9fЁ>;*xQ@HNfxN@+{1Nv(G'D 13؏g\il~Ms)z\co s51IK'I LhŸ΅%,fEHO ya]SAfP92Oi2KS/җ$A Kϒ=:qe^wZj@*Qnac]z"xSx o&B!X~- <2^1xgZvٟ]ݤNF⚿5Z7jYӿ \rݕ*dZч%&(䚱!}tG53?vO9xw=WrA83@Zљk^ 3X2{8,m֜<3܌cɛ?9$o, Wڸk25.s%0D Tte_ ,F Gb֜?M0L-B |ƒKxofQ"yq"!#tyU\sCK(g3KǀWSFᖮS i~:M]>Z,S@&:/jg9 i@LDI;]}TWm,tӾ=ڳ('\_ueg%FU6d*zgylTmB2wf ^hmGMQE(CntH9hp-Nj\,-59!C Jp?DX]4޾`iKw!Ϻ]VDhqn΅ l~HvLQ's:}O;,?r%%{vuW%W0]I깥$"5ۏ~7s"E!3D"w]5%z#(bgF+eɊAlOJ&;$24ꗲ&mwZh^oy_o:ob\Xdt~:d3=|=@m?cf',wT^ }_ xWY(92sdesKNmF?/ed/cil}o 2Cc,>v!$㼐<#զ+m%g H 5Fx{PRcYSįH5m|^#3}|\M}`fO}u7D)~Sue$27{OZ bWtߗ%n8yOS>h\߅r7犕Gr&lXY~-#6+ $ RAN0֍n(΋S| "u"RHY<*G^9^ wf4R&Z٥=kEoUJ:ĉSp#{YEvY#yk7|o"CDZRi =yMsLmfF ~<_+Ue8xα|"u 3pdf@&MwHIB'oe`qp3 ED󼺞KA*RRS꿟=K rtkam7?eKtVxgYsb07sBx3Lk;{]!)9*ӤbɄnTs,sGR Fa (GTԮ΢vung}B,Edqx*Je{)0tXӨ] z&FΙ;Eϲ3LkƉsscdT ,g y4sVInCYWĹ Ls6RXc֡qOsKftN9h70 302&7&#vk2-Ms %7G)Z(;>WDڕ鸌>mK첁 GK` _)06f@ox,~wڔў.btJGA. PЄGKMt<I*|Gh[*cF0տP2YP)YnYH#.@7d3L:qfi8fnB" cG9@j%ypVON\iq1L榺\ͺZ)~,Wit d!Y愨.vkeyaŹù9,?5i5A*GPA&|U&p)ӣMIGU%{vXEG7#nt $yFB G@!s%g^D4Q,qM4T@`p?| 'W~tJz`v/K,txdg w>?+'&0JAH.7|HB1NYx\YquI䵱6Ҟy+eTQVz1IpS\³vJ /ùCK!ZO Q6D$ȵo"ޒ^20,$H٫]g/uzT,=0έ(Tr`I,(EBpi%‡X׽)niC8Aفub{;wK4xs]qT3gyq]gJP6猺u䕾yP V"EdU?_J!|HgvxI7o%Gщ[g]ฦ&э,g5]|n|'{D+JbcaݑbCu IxPAmUWkxDGێ^Ia`N:;vwFQW9*E ^AKma9ˮDFy!H2p)ȸw<{}pbZ>>N`3fgfe[cB4װUU5 Ԯ^9l4/tSp<`e7^u:oNcs4JF]PݳdϮ\g=[4ֳӪٿۀyf#2MWU1;wpj}L.ZOP%L#x\YD[rwĀ(eaXut+_U $)㐜؏=Y~c`\cMaKx䟕{HC}\ɎR gsnXl<vH33v;s.=Jh2ΙWs^~~m۱R1s+6?=$AmQ -ΥzZ7.q94*Wk0jqqLZ* esWݒ==>yܔ>ggpkHĶاGjrv3ΫxD;pXoHhN@h%ϒCV" &\vMk%H0)8-*#o 9z`N;?j<׍,g=Z1~0bĉm#G2/AɅ;g] g'yùo`[ DF85c.f7 ΙfŌi:wGez| x k>*s#;-8W"~DoCNG~ I`]U"AUMƧpsxЪˆ-g]VRq߷K;@iĕJX;, }!iccIpNBh_N]2OE4MdʓG_:aV \{ ggɞ]!(+ Mst?k@ʶ[&8pLb3gNcZĜ̋uv=y}ꐃVhpa {xA] IDAT;3h>fyq o}*P{K@8 &pP 8%\=rP!\gg1= ]ѕ=<ܻǜܳPY mhI&yƨ9έ}[hGn᪛?Su+x o:Rn+{smv  pC?WOd#"Ѽ~ ZyhNv=s1JboN"ڗ)U 2s_g *L7>a4I&Umf2cG{ɪP<p= KYl=P}1Dg,2IT# ~ 2ʾ'#OIhc "ESƷh=>mC@rSC *;Ɖd  y};$ 3=y94z&Dѿ8%tHV` %3ΓkBeF,D YCD2wh+'7#" [ ڗfp ,ΪY%{v@:,yl%?3S2_TSzb*ɦՌ>B ˻}ߎQ(cfMlZw/&zs4{e%l=ׂQrC*"L+S p\|VC5;.!2)X9$֟S:KQQz'["=c׮#6@$T}pndH!9ov>|T8G3Py:*g8ȜuStpϷQò?s=׼>ĵ?qs ok\I0qo\z䁹5~6sh^"CgrYgy 2ܭFKY:/Lc>UU3vUBJx7v^IXԮyhу0+ gq4D2GKنV!b r bp?zX1/`pu\~q[ϷoG@K bz`q[k,jW/SP'pTeʕ=ę] jťf7ik"(XBp=̾(q&pęG2Sg1(y8-Ŗ)Gh2U]JCT ok8 M,G냻u}98~ftW,ٳ<e)*R~6Ք8M e"} n\%a]n "uK{x4v7ѫj7Y_D"noL={ 9~g$0տ 'Sz!yHqmf-ܭci\9E\N8*'vs)]=v4_/#2~Bz6@3A ?W]&tpG)l=;$ W:-K:l֑[LTE-eݯ D\|4R9]5Ud"9%lSN#a@f7K4kTe WY$(=4wvq폶,pV{z7uPdž]Mq3M>+`!c"{MTT>Ԕ" xʉ3u4S0f!lɑqmD=8͐dBs`[Z9 ǹKV8Gg-f_u_g2{˫2#Ⱦbwpկ~)^O>&,i9!ك~R""ս1y8:yn8s=K:F9I%4éWgDMկP@D73Gg2y= ~[$A@pe ˨^A%zz]p#W$;NgX!jWzxok&{[z1 n9 @M,`%=۸:l2Dѽ$H^7RU_WiYc։ӒsfRVzIqU,\Ym= Yn.Zvl*^)$}^Q=})TBsyzxH5= }w*{%D%fpp8M͒d2rrٌTRi< =}mkƹr2*y6$?Qjk{ټKN'342ia- *c}xv=װ[ݣ37wϒ=RqPsAK6Vljfa(ٺW;X 3:7AY04,䶊w^tsQ.c6ҨyT1>-}A.2䎓 &56(>Qne O}ȴ6i^Sv_w ǽh @HC)N,#|,AeHs{8-;s87&DU-mI\^ST$L 0 Ź}a{C=}1w nr@rB qyN c鹐Iy<,c2KPǨ, 0LsõK ޚqEKY8DBB\rK=*P3=~=A%yg虊e k_\7!5=Y2bu.!y陦;o bh`H8wOxRA.Ϻr.g: ׌1K>c/?M&c\ђ3sKߜ`T5WkCݔ=,cR:ZЃQ96)ٳ>'Bd)ŕspпd\2ba-=޽8xcLVYg]'LEl=;_mC qyySYNL2𫿽_Feqd7!bYw>~3L(eWITdA643&-Us|іI-u$,pY,;8f9nr͜6Sst}Xrez8`tC>Jw +?wJ1#Stw/SgN5gU3&,ٳr.cVf3,C6KH%yzg-z2MiO<06b 1K],|Np<yod>-#}␂O4N2|V$xr i؄4jdÐKW}ף nĪ];qzm3L] Q_pNK5Ӗ0 Ƒ<#Q{X8Jϲ|^pGz81hm"|L_SVO*,T[4I fCV2cD 7F \yc; =K:Y۟Q6QM$gAǘ+)vǐ3PulD3y.O1PD9n2!f?ŏkrGqzFd>AbS|ˀP _%d6hTe=cϏ; qA SQ}Be 0vA8%:HuhIŹqGsE露R\/OT!ی+s"%zdľ[W_`ټ0IGC1j]!_Bz[gL%{v,v~6&3K2 G,ɳk= 3.#\?FS}md.6OAo!T. !3U,@e>S炥O RNȔC$.xRA:Z7Le홡0ʼA~語iG2N,lC ȪsDK,+uNGT;c/\52N̉K{v]'8+KV`5@>&0yf5K30O aN@g$9{&Z,ٳՓ߼7S唱9 _rME@ zH%yv'л e_Zh#٩jUQΝumBְ׈V 3Jnz+lLU38앥2u EݗDi!$"=8^@q˲aqOPfdxBYwqꥂjbRV*9dFKeP;cl>ה$'hSUVp e s.V0&ϳāH3K-k)Q#9 62sӯgIǹ;Ώs!ygF\ W]|n&z X2,Ɖ<_ϮO3ϰr .r kS(Oԇ:uȒw/ f4d&ɹ3dbɞ]5drf2vx ,g/`-8=wKhI]3wգl|1$Jp5U{oqcIJJުރ[SC|:z_p),~[VG8 {ƥ,ąL}W`d8H^y2g&_6g))skq,'ym~-KL% 84w1seEE_S *)cJ3kr`Idlsj`'նr>L:2)@&D5 Z,s(Zǀwuz2pY%}TjgU7`~I!=Faw1-xmP4]}Xg޸z:2nn[ϵ^q\bIJw(#|,oݘݯ7!jf&)L1vWP O?G#q(c>yoU2 ad.jy񃓼{_oc5K:q8jù܏>^ v^p܏pU T/h%U٣DϚURz=Y 5jAdRAu<9eCc3iɞ]'PS> A5b/Yp 'vK!?pXiJ_'λ6uPA3bIݗf虀liA:dT깩XbĈݗ}f>!|;{wfQ:/}J ')sR1JqmfQep/3Sك2fg%2>KNg ڛpnY>8`(3}yb#SpSc!u&Xڵ=dY7Ki|)m}W! D'ŽYzo; xmjL㔻rZgIye0r۟E*)b~܇6&X% MyXg%z x?o %i j21̋P}+r ^\{`#B#tCMCS`^DYϬoOQ pѿYA(g9=7j;q,o3T*Y5LYz<_4ob̝ 8b8 ΙJޙƹjG[n6fB}iNӚ\GbS=,ỦT,@Qʩ)c 0=|juXiӘEt_ǭOtyo֛zXl頲@I .65u1ڄAXw/eXHq`~=,| N`*zf~ޑg'#{rc:`dcR;]Euy%wDJlpr^c{YI5SO9Uk=~!HޘQqI.}[W4jHb;:Np.!yg(3Q!Lz MG]٭99^9;=OD\Á^u!+Ur@T᪯|ƞcBg,ުݒSZ1ռysʄfƹȇH]\xk0/<ܨdgcKU7X0NS,ꞑsnfXXh s"baHi:ŸrbzX_u&QMڜ%ef" մpʹsHYc,}ݗ+[j)WD`nbwv~$lX&])^Z7f"sH>[A8Hy`V]IZ `u_s_rU,;sϒj6ѿ97?9VDsۘ1X+FS"ygF;Jq@u2WR3c,F`@4YƷT!\ΙU&r)S.A5Ԯ#{oϒ= fYy"6/T%.],?Ձ gzLpa:hoR$1/aBj#6bDrdrȬ'{c6>ĝ&SQA=;XYU$$\e=te|R z;,q4T,;3ILX\y5O|fD8gHyƹC1,,) IDAT`,c1*Cc*vf-}3po H{{>.eQWdϮSL綹Wys;58VƾCKe3 {)cocI*F@l1]}yvofy^ g0XjWx_lk߅2b1ͻ8!#Cf=%]}lA>w#5qs~1Iw)v%5$+ޑ{(ŀyN{s_z!NI5$ KNh ,.gsՊ6wRs9rټ8W&;p2 OpK׮ U,CG͡y}l aRGT#|Θ¨KrUˑΘdϮӘO(ظ:*vp/},|sˤYO~;h燊r_+g)kM6i )ս'h3s=P1* tCLHLlɛE%y'0Ԅ%m*JK6}5Z"soDt,zK1s29Z#{Ԭe##|ƭO7!GR:c۳dϮt'4 T@q; 4!?A󺑲ژN,v^]Q3TpsXi ެC x06b)brȬ'gzvм ʨ%]0ɔ3p Bd6s2e~=k*kpZ{(ՀCi&If.h2CT+^>7AUDp:]8F$sr7N1eVMިY5g0sW'`ճq}N]Ͳ8ebߞZ" XYg׃f:G I_B.ۼL΋kĄq7<J&h`%vD yPf:[icF28nzC\.O7OA (KYϞ&|=45c%.gH9lfIERhQ^=ӳg8|=YQCRUy~h5Hpm_$d$(+sxHzL"S27/1+;yBܘ{9$;m\uv]4B_~uWɁ: s0Y}Yϗ1_¨`Zm*< Nj7MiE7FmD6n Q}tv4׮"z{]dӮQs =:,}+@M&p\]%w*X&Ad= P6b0GKYx%ڕ3\u|C2GBJJ*wT߷ߖ]]{(Հ##yqgnBӒ!z~iG* ,}eJH_4scp?B.?Ahq|Y,H9h+WXgיqMBp+XM.Pٕ`קO;caQfb9G%迢 \msvMZH֛25=Lۅ !Aܰn:a|`.h(mbfA(;ڦ}-`g<X/ε=ǞxvcmJ@4ؽ|wt+5˘}r,V =;x5n+1|l1&.r`>%DC D#1?D3hF%--qc&tjG"v!밧 \򌴳^6X : {n(|mr,";; X+Ziyz/έⱧR7Xqʎfrw͆ag0aP^fkj)t\+q:{cKn͖t1z \ry">g{o؍Fq{Тj)FV9QM x8P[7/CwFy8~٧f`gPpf3 )$=44Pޞ|ms{WJ%N_I-j:{N[.=j{|0K7`@^ A y"F ŢyKM͋,k%]rqZy9R5laY'(ΒtؗP.`8,&.~[١kHs7AZn5*2ƱH`81>&E䊜=;ΔDwm:EFFNީrY\8~ dfz wt[QP  10N?#bPKb?sB,~MAX!zg4@hгۻ'cuԉgG$TD&վ.݀y"܂vi6; yʜ;+pʩJ-ٌ.=ѶC=;.H\ msa!L^rB>V Q޿tWnމ^w#z^)'iP^Ak(!ITveiCVbY '`*SJ!PVZe4P4@8 y!A;]&\EY Ew&h,@^3Pv(=QݽC=;6zC:;#8A'7hIMG)޾+T9OdK7;y3=^ǑLa$uk3 v|bu !ГA*gkn#X kcsRL3,JSv+qvgz΀P?׍y%E/uycMy)uS6zڦ΋S>A )uRYދN{Vv:v!oج E^~_tT&*)|w"@rE.TUlTZ^R t'h[10qB2h6&n^psPaEQ4^!-]"}' k+C.Yg|2|"Hf 벣R_[3/^K G23_tɊ@/@ƫeY*.xפq* T/w,](Q o-7;=z=5\G|p㵿rUwuNVBʦs .쪴 :m=i:v(ΒW_ԩ FͺQDWVB@ C~?)߅^g~,9XnrFUCb~#;=;6$>msQLS5}| :T׋IFJnc!7BPRW&yC̹Vg]/]b- `{2+R$p) B( !𙕩YB bp fWS1| c\逞Mm7 ڵ^k1q <]s$}#5ܻݷ:3-pkY 'haΦdP<3Vµd.p[SYcϞ˯Fh^c3v_ B\{}!\c@. qZ?]t}C vί {vUYer{sb _ M^eO )fts g?^ \qžƦj-VR-Kv6=]sYGkUvҬ CX~2>vrN{mOg4S ,g\@92A냥P鸣mٹ9cq@i<)HMn^5 i>@V:[}նLf*gX!jY8yN#Kmj~m K_w _ yEJb'0"]7ڙ7+g *QH Ԗ3J} YL ; 8CVeMYۅpf}t- m2>4+5<_nZmc80kN+޻Nϐ.33OuBT ?b< $d7b2=::#u1sG^VXz.gYot|`9$9,4s{4lJ@(ܞ|lDޛ +iE:=>_FPF-2YAtvtBe/.>`mZuuN]LrЛ5\!I =O.Uz +½X]4@):Q&o3>Brڳ]BVznM:6Qb=C,bgɟez|F=z+XEcPݍBg'^ܗ@ VԃØ}l:īOޖkvJnWw `/\!83Tw1^XU{CO`Q+Ē9A':5?獚]lqk]m?٬[g=hzcg!hEy LԪwkF^|)>uT<33yr&Zs3LkM u󠓧Nu)(BJʨ55%F#b׮)159)FF,vTǢ. ֪ w(O4t&PGzlK%=zEi_o?`Fp GS\'ٛQBad +i(ξ+\`SuVV^ *NT!^AUthڡҩSbt\k͵Lk͔T ;סr6?4b!RR{yE@cO<)R*eUW^_q_^~٥ K)ӃF BA(Բ` շ${RpzǦyZQn:&S*-#K!SVŚQcAd5KNh^?UffJ[tpS<.J FMA=d UBӛ5^A Qk؁]k~,&RH ;F"β/`? @;pW{RI+]RJEJ)bJ[pUJc +QZ+cQBA)1RC)52C(фӌRE)UO'?`LwMM6ݍn:e mB1{) ;Pw`G^7[_1oqe!B*[B0 ~6Bc{Xi ayV$"聽) :+t2 ʷ_ٮszlGnG2[:!`5Vpfk-"U_n֝&|($tvvKJ7!EyOU,Or3&F}Y/'W片b :`D3NǁnF_Gi^XAKU6]|g;yCyljZTaD:,`Z< :sX<ӽR&>|=+sÂ{vN_ d/aH-eb?mYم5҂+SV?/~^e3HP0-/N}v+-č$Q;옛Q3g)gAqɬc F({XYb?L#=^{CedY+]RFRJJ*ZS4F5J4zOW/VRP F(%2f(19c2&9{w~XkI{ﻗ&}Ӝ}\RKaczIr*Ԕz`wl w&[v \@˨I<ޟ`/-8O @'KAzaT8'\+Ki 0% OAOP3ھksz_Q9)!mTŠۯ*n=L 堒]ԩ#G+`>|tYW^Ѻ&Hv٥&Ȣ[^xPX)Y".G4YJ&N]Mԉ0A +>0Bcq ŃҩrNO"cL.kBr9*JAMGcpwL҅8[޲[횚h|և+V(7W<Nc0r:*fa#kwaz441bu= IDAT:Ztrrҥjr}~-:~+I2ҋ)F#ᇦ4b1&Fҕ,%")TqEGqf"8҈2V J$ Į{* &t3 355+" G cDx.$Ja] -T78BpD;4T_7q.ͨP zз3>x[UJUr!bt,J*.bRJ҂<BkCFCk[2cݼ{AB"B@)%@8(%ubƹUgs.L=z,fƓz}ďO̠oa5зE'*]`;R@4.)w2&X94.?苪 P5`Ƿ}WG~tYh!dPï\-U9hJ!(!ȉ_}H^ZSb[%3\JQZz <Š ߺ5x荵WFpoj96/vDG?0o9kjJR*20c FVD0P*1&gG,]4<-!4N Bs@rI:{aR|;=S#{kKe%0m(F.bJ(\,heDJITZ)(%`s~K%^.˕jR1܇?TnUв߈S@* 6AWB7= C{Ń#w{5 $1@QJ$c<O"O?U A ^x1`ŗnλ>|*Rc׎1%\g?*% !h $JY%qq(((sJ1E(QPMMl~)-W\'g D֪UkjJu)/LGzzS6 ¯pm` Y#=n ͦYi&3:t*V1yݱǟxrG?qDUYRRHL((;m cw}> !v1J(2je`v8s8cq8r׿͑ox׿];|'灟_,m*/:Nݽ8W8끽ޱy^(r_Sc8|0^kPewX;,Vos;Ksk'R3ұPic6.4M}]kF'CьM4#D/eRYҔ@ (J(Ny~@~CZW|sg!4 <(cfI$sOW0*F79Xv7`hl}nD 1 QBJ) w][P p(#Gҿ+@o?ܹ]\Z14^" PHG-vIQMwlK(2 `O}nsRRa@e)!R gO 9Z#bG/ݗ{?33[yGE.BE,BJ&RY'њ()ZeZ?3ᳳ9OnW#)zRb}P(c0gk3GQG{ࡇַC_}no{눺'#0gx*`'2I:UM*xN(W :^Ad4f;/K0Kw1J()Fq>C 4Iyr9S:%8]6ڟsPKoZ \1L \˷^Hk)nc}lGc'Ģ@"O50tBpKAGh4F?W_}m<˲,jY6 K;/atUfepN7Nq(r|>GBܹ OIOJ|lWVX|F7>n=Ulz@p?pI#Iv}K_eY6y%RsGKҵUIgQJAIo'KRZKc VTiI Y?A_ LMMU J{3 @-Ww 4;y :<ᬢ/L xpǗom<˳<˫]랬|!3fxRZiv(׿O\PDs1ZL>SS_/Hl$Zf%!%BOMf ^(hmv-Y0aJ)(RRB)  8 ,h9F}1&!Akygz-]}ۗW;/|baQ=lmCq7gK+W(Oh1EeJev m$F,/<Q.s*$ZiHGU܅] V6G ene8 (RǕl6>y}{o uq3Cs/Ey"ah!`1=g@L VnzaրmI%hcm Oݼ8K 78LGֳ'~pdv!R.s'Bq4@COzlPLJꪹC) 'P (՜)QQr_ŷyCE3vV4᠊je6v}{~skTb/>;yС,$O4Ҍ )Fk^5`G 13sn8fJ)1_ᒜ0i- .VQƢB]=5|ރvOH=W^Ot0MJfq.r.RH#KmjfE&Nv00.7J)QRRBFB|Cj5J$f0h Q~~Fyčw ԗoʶ޻jeq~V2(e (%Qػ?+^ )>fff_oOt$i$I,cW\GcGɷ_K֮1B(B)e\M/Vt1fq2MRyϽ?PKu5wꥫ~bR1c.X"EO͸كc8DuwHfLǠw5ݻ>}za ˳jfqs 8TM U^Uw[T"s8BTX'9gQGqq<K{Κ3@,CC4PIy 쁽 `݉ iVa*>T}tB '4L BAӿOچݞ3HݟDSOkf7B>2֌녅et-˸ysǞq ౗zxhÝ_x_Ǐ̐r/!{nں\ֺ9G-!!8gTD˅yǥRo߁ʏ\wMPG/J ()9ܞ'RW4M[7HKmm[;Xő "I[]>W ia礣*H6?xKsI>J]M*%RT(IEЅlh1Z'i}x)?b!F5#*sZ{ifiG#hoUW򘻇{:` ֹ aA%x}HKq@VSK`BcO\aM)UE^ɳ<γiF<'nfq QA8(}>WS|W%."Ozs?F4ʲBW9_Othx,'BJhH uBwj%V/ūimVXqI9c'~p!q&6[WܛY%W_xCFH8MS!tڟUN5 -î#>>y-˲RV8AIEg5c?a3&7Emv4d_eiFg}lgZjn%!##M\XXy^gBxwnZ7Bg`fC@H)Y|er 3΢(TE(jw1PpsOX&-ms!mWsxш\JŤDJ Gk^ܫJiH)!BJ&TÇW'''}R4LGwgRFgB;~Vo$۾ov^H(M3 J%͍NF<3FR+A%,~ &|l=oN^.EYwǎ %ٍ@Ogiu\ζw}``߾5!DYX xs-)_E$1aBaG:.QE%ʽ_}齵G?ss՝޸k*m8-0)'`/4Ν~?BD{iZu@n^{G;ܿL"nqvdB) $pmL P?c;vln,2b#nk(b UPI9~^XGǃ ;$cA *-CXzf6{&;?h'n6;6")zx2\8S a7[ 6J 0(WJ\QI3✳ҁ=_}Zb(~v&X݁+xPP ^˱緒@m?<}3 ?5PƀkҊmp~9i()T)FRf2@R&s+er pX/qƫ/b1٥u)APGCrȞf?tBm VGmx/!Ք%Ln`G3X'*R?:WZBi.Xލ 0hJGW T"BQ3MbTpr<g͗r𞇔N֌0Z [+0+nJTvn] y6hSqʀ9_@~=g?U2D9.5+.̛uZ^i&Qۓ DfffG~,jU@eBXXD PhmB;y#ȫ_w[שRm;R,i! `1G&\EQ+zMyILO\H)Urw0c1( *dJhg;SY Knx0̱p(:{v;/|y,Ia^4wTᐲy6i6 BpIb[ Jhiy3h!X@9ʎ&Τ\V4d: B[O f+W?0omtdpĀr֟bGm34I_OfxJK3KL,a* Ko (҄(j $ 0iءSѿs_?xUg/KCd=zse37ʀq\T`9v~r  $ҍ )dh͝5;ޟ b}LsK#4l6-qܢSQ4B Is8,YGi tƻ؉!u+(F4 O@^u0~yt0JQNDE9}@~Ɣ2qn8<"xћ?uBCu/UP}ŗ*ZRkRx^ԸL+'m&_UJQ5?Sr{&A5J0eD<A{:$gxIc0i$FDYjg|j>Okm4vV-w2s{uM6omv - {oBVt'S5h;zbgl@eWĩ[Of++c"5UZ< IDATe98 !D'CDiC{i=#}q<}*{sOj. Q1 *.cw]]y#}'!´ nD=?zyF,F ?c",u"zOMUR3 j,՝F)hP~ݢ"zM`{?gy-RfLHAlŝ+У1 xuĹ8{UW-_~ϰonߺϽPVJEJ*&Zy*]o -$QJqzJ+7D_ފ +gy*ڼ). uz/tp,I/"iJ,u/8GB#51ΫhZO>M@x̆/ybbW9>^7N s:lU0 TF9?MN`itJgU֑PI-1f˼f h3І1m@=Ɛ1{FF;zY#P OaW2 ]=/2[|#˰w MtI1Nxor)؀ wh{HJQ$N-Js pPJAc QG++8-eX"ϻNz}+.]Pwlfffjud=]=qDZ88Go:w,I^*M.)Қ*6Nf~J)PJpњm:Z %FHMj{Zh-%NջzřVR7[@/I$4(IVRWSO]K\狻z;([aiƘ+h=+_lw[]<'1KЌ('d^=;) wDKm5|j-mU[v|rWױU/UCgʘ29tgz^x9} .  OUblǏc.,ݶ\t'Zݽ\ P^kBĮM"7ޛyᬓ` 'wנE**S >xh%)ǂ$zhŧ*lUN-6#IPc ( 6pߒI\`?o&'ں|hctE &8CϩoJ%o2ߔ)TvJoËn]ݓ d<+_;}5֊ )`jC[OB-R.PtJ>;M9j7aݞy.yAlZ~G\.#{>ՋHq,8n|cM/ϓz^*cJQZKf BN % tFD*i9߽{ei%Y݂Fy"y#W_e/[EzfU\o$d"V:آy JVW>GRPF97QiyחW/}u-sI ^{@kf/8=~N҄Ut+B^I&Q NV567,c#EڦN6O8פ4T0no#(!!!#➷5}eΖ1m@Ыin4+vv7!J=/0{Fȯ_R\ң /ypTb9&?,8pwʝEN ՠ@ fR|ѢQ#8 j+C3 v6_W#c聓?"X$8ߩ Y9E{ǣI;܉lpW/ضtezˊPx)wN_,>/R@o%kENC)%Ҷ?,حiTr18*X!:Qk$dRJ2urR)fX_ZMB N5P?G5`e?K^5쁽:y!Kmn>OF09tޗa秖0pռwEڦW<(f1(*qV2QybIi j翬 Fe8$x0J RPb m-gIod jl }gBJ0{%rKS,,pot7d%ɇ@oέ}ڊmuc|9n$IEl]=!i5ھr ւ@W {8W5`ZXXs]=. bꅅ+bF8c2E0N]A^р0zZNn|W|yz..nqhS~KJivK*W(c1 ${ )N w:,hn%B?/ghm!@uۥ5rIS?䎯M{F\Ww+ :^RC)!0{j59 qs&x⮙xeh"hS4(vJʉXkdևsfx5',5;{alvu% I 5@QAW/{^yð<³v>O.:[C[])>xh%(^u@UmvXArќ{EV_M0=#(gDSQ9# 籱Z* ʋhOks DQZ!iAU;e֖>^PƐ߻zT?B& ٠ 47.4\y^aA|/U rPb%  'G-`ڔ=o+jU8l߰u<,")?[䡆2(3$[]naŘۿA!E,`RzT}zw{{ [roH,>\Rک:*@qqqnjΘiJv 81f#!n L{Bߜ3+mO<_ ^) &9c[o|l Q&Z^T Ks(O`#;|Wazi Ʋ@o%c~]5q0F50 T3'!R %͘ m֚jc1*Za#c URQJSJ}Y{NěI՛ >9֑mPAeB#͒hialq.>4WGK7w`D D!V &nZ F<+z(mb5Pҁ;W̔9eNU̩ N"((0۰{@ @1TCj\~6ZhXy Q}Im$6|@d![JS G\H{~+EyBP/&|Zj QK@ʕ#jaAep)u Ӷy=OLS`ǞZ\\B<y9[s/C2Nl`zF `޾yZ٥) hfjѨ|TJ)S%#"Zu2 J"fM6Sj"ZH֚d'MO [I(aY{4j͈R[?a9= BOb%|Yn$'$Y1o@o%EӪ2 3ӌsq)cs(q!*)'!DV955-:tìH1kL+͌1\k͔L)ŴR\HIT*ETTJiDImg6|\+ePnغ q˕".8YJ)MAHE%XlXi;m}Pi&ZkfPIR P)k~aB@k J)1?J_z7:vfYuzYTϊ`w|gcCiV4 'q3[p]-űIqH0g,eW^qy뒩ltOMNJ% Sf٨H̩SkkMGT*VREBH ɥR\9un" Z}|(rΣ;qW4E kH{ߢ7K@yߋƹ=zm0xGnGrl |v.QJ1AhNtjʁ=oDI` %}ōfz9b_L ?F{FژHDJXZR34˕fRD(CsH;-tӽ1?g[ޛuW.oڐ @DHiMMjI$Gn˖GǞX=1ሞ/혎{m)JnK\, D WTa#(\޻f|e-@F!BE)ޥpO`ɢ7O)t:nfxf-һST:!MB0J*e2َ^+NȄW:3֮6Gh-5o)/8|;y/05# & 33GٮId%YZnLu,β,BP)V&lw/jVl[xaKDJJ)j+G,ࢴө5 U?gSE9t=3jZ8=s4` iV׃#Gԩ]L.YFL))%AO{oJA( /l܋@ߜeڏmO53sFhDfiF3u&L[ opwYt{l5́ݼc<UJ {nw03C'K>),Ȳ0M3nQie$K3" "5@Aw.d{ >FWgX3g F&DG}}]zkyf>oGl߬+؇6$*864VGTC0 \=ʄ[GZÞI!2X#0̧?3* 77`Ge63_tr?6||6dfg\ERQS07b\JT*BTXe/J֠ D( =,=yΌzsY@kj`QRm\LXjf]k[Ŧgq YƁo[9e5 L% ͘eǾszkkQV.? ǥ(IaAeAS XƘ% ^ \\1{NB%@2[ g7UUd9#-0y83$3\E+.idxB`/yKC%mNFFuȨmo2oߌ}D{Em46g)ǥVP~07xSzZj BEpIF M SR0RØhJ9|F6x5zḛz+3ɳz<-Vg,и54Y,>ad۶n]g8Sh{E{f?KJR=nݳC?xy_|;f&aD4 &4MA)!Y&"EB%KRz~1v) ~?ifl"mC߯̇(HaFq ðwݲߺgoJLثy{mk`JߥoSeCJe;+wyO>]wW6yYl6ffI ' %iYlټ85~?$g@6̟wZNL|ُW+?V͐ '2n&8o` G\6U8՗mv+(תR:} S7,P*F^;2gc>Μ'd}sg"90`7_=+3##f9`P2*8@h3z ^ :{vlqʚ\.7& K% P"zZ!m;0gBv8B+gТMI8Yc^lA}|VO%AԤ-r>8+6?3siƙ=ȳzV%0l gd0j0 )J) So?30TA;n)AN=?љU<3|;/\Q?}sgv&s^I7ݹyRk[~SЫvw&&7~v?1W6̳  8͔5Yf)!!Lg<(} pQ;*47#1MhÕNr1˙Y@ұּ^+(L別)cS;SKl_h4h0rlm7 IDAT}_?9B  15( j|?Z(h[wަy>oXqX5`%rA)AC7 k"Dh Hnl H=u&DC3*&}&ez,14-i2Gff^ɲzA?WJw~9VWRs Сb̼&fzʟbq9cs޼4ɺ+gFI8.jrݛoݳ{UG̞-SJJihF ic]q~N66˭VU `mtrь&OJeO=0Q8eiG(S}Ȭ4KP3@wzr=ΌWZ>J?c<џʫ쐉KwÃ.s6Ezt*+羸Mhm0֞B@3k!hO/"*kE!y(P)̈́VY켞}kFd{qN#) ,)6@"қ6BÆ2 cQ{ڳ{g۰w ?u料z;lXjIMOgh82=C޺gl`F1 l&Fn%+|c^@7)(­YyYǾ5Yjo\>末0 Gx`:\=7X=#oOGRJ.2q0u7G@4c&s&M뒘^<_Z83E}^3^sq||~wC9HiEl>5pWy-DzM,ǿѴ40m,RY7*[FOQ*% ;o̗wJIr^Ws O30^/s]~AHb^9~Ư-# &cN srBS+ï ƭ{v/b̒kvbcr?Hm#&a\{Wz@π=0͹@L5~r,>˚m^@wGBo,(NIsl=]DRytt5o;*LՓ?Ri*&R)r{֔Ӛh( ^>vucMz-oJ !E?k4+JJdimA W˟!a=&@=zi?5囵1p3Yo;X= w߇F^HiΒ<*YE~aXm'^r&E3#kyi YJk+7ߐ3)%URRs005Kc0ߴcju+iY-S^kt*cYJf,ySK_RLvE[=;t߼@w m'K\shΏ_/JjjE ,3ts^s͖ڞ=Y@)M}/y9jhjܨCuS(p"\e烜|P;hیtvtP| ŶGl1zk].H^.Ri.ʰM# Q%TnxਛIHGzNۤI~>0581_^Bi&TRM&=g\l= B)v:{ eACo9qJ{ 51r+lѫ,ibV H2 }GRY=kB)X\%L LV)҉H(18cۛYQME}3wfH(% 's%y|,2b1yxІs5@:L'&syKXlzβ&Ͳ(%RMHxqM7@7-;fs\g52?GwӦ[ʥ$i|_H)B,6TKy3J5c\|)TH 7g%GI{h>t9u男QYvcEOχ,4ȔLb=5TZKԚIgF})'_aUyvVHNt& U <лG]3|̬^!z굃)&b-8~$VDJS LVL)Ӝ[VvpҰ μss|XJH)4 S/ʜ1PJ5c55 nyov.Wwd||,_aӌfB0$Eܳ2/iz{kf:kSzf}/[Tx^7c0q݌꣣#tsa[7}B{-b9Wf)ISVZ!\#F2{y} \S[εG 'd tc}), zRHum\k x?#l3:vgo;CO{]wp]bo,{bWCrⴳz~rJZo(Qgۨ|7K7/yvot8{=P0c>:8qV!:^Rs5֐J4S|1~k>{wMMz1Аp1 joƉ64Wy &W^-YYoX6kI~t?Ahιf"X^JzmV/\<{>xBRqYe[ p&G#)iZY b5LRJ^@lkK `u']q\ @q4!Tkš\4a^c{9/QkCY4 E1!S8g:HqFQ<˿s3΢-7rZ)a":_?=vM7Ix!f2BP  ]T*gf950f)ZC'ebVyXx{ٚ=xѶu o878Z&@xY  #vpg.oCր^ҡ9  `\WF{o I\䶳åZiP*9%RAϞ W 䉑s(yoҿֿ5V;v90ŷbhmGS=)?ܾ!DbŬ^[iFEjttD~##ʘ}0͘]\kΣeYC_+sf Pp,C}|~5"|wL$J)Un͘cLR҉qX3Jkz:S)J؃ A4ΥiЖ(1;2=W(=7\2eBJPM#w,f.0 TYG}stvkv#~S#v#x$3_N$I8nq,(Q(dEYE0mn7EM϶ O6pG=AaQG{ּUwj`P`$m'cxa[ encQ:Zss,ZSqf!چќ$M;ko6kvݠCz9 -̀Q̺:RRtW5Yhʞk"xF }YA7S0t(aQ{V d ,ŖKW:u7y.%g:tMGXQ(?ܼKAӌ~ݪ[>wWV+u Qyχ^T*nϲ<3rgFV%o}$TҚhec(-sƘwMywYdABB|;n_`5gQ^䣧vJZ MB/~{xnneYeB%vOY0dͭ[YxwX;.&zkvY5Gw|odsgO&dT*5JIQ܌GQ#š;n3 I68̺&5fkbpW-i.ՙK+y~U8>7;D(O61s8eAkmB_X&J/;)g"mV.n"fĘxz*|=̞97=8uy&7&5QB))zusN5kydy"Tkh7X {*AM tҗʀggv9: B~S 3< J*#fvXe^yDžz20aA~g=$N:33bdJJ"v^@uF1g1NbS~xr޻7pqEK̍UٲY73ne 0iVkY5ˍ3*@G:WJ*:sΡGڸpRIh]u . u JB{Fo±bE AX-6E|-Zuۼе&I^GL*ERVW9ٜ5#6L]AzM9vWtͺ4/pr9 7U9D0J45EB[gc#is ux$;='Un+n)F[@|7}..s #@qNdDB (8vᵲn+YUa 4aؼCw^>7tDJJ)j]Tcp2MlxKsp]*ArE+=[DP0 9heiD)lR*zakj{:?>PBDmD )!a*sf<0Qwq/uo'}Xyɝ~??3J!%e a~#@;XOt\>զe5ogip,ġb?{~ .snAm0w*G%x9aF'zvIK(8ػ/4\=RZJP&T S**ZKoUtJ:k쬔?8:k=ShsH!s:Y#9 T*jnjbM 9Bh+s]7(\ZFDPib6RPzKR8ϲL\6XX\A 0F}8yO@#1(Q =+8eVmVk0=}taЙ֞{H$ T*IvE{QNMՔRI)M'9ˆQty_K:6VI)Re@˙[#cƊy?7. !,˸?rV3pp.]NxzXZ.ϝ)g- sGZbR2J ɏL)IA᳞2f:={o 9 M1t42b]W`m3u&@287" bD#,SJ8m^RA7XgD1J=׎vfdN7sNrcg%!&Aܘ|}*^W(JY ZDCS_ΣtI_Pvsf^ <7C(33B.k~!mFι PA|O~ )5•nkk}Y=&GgּҤW&+ '۲esVTl y泚)١(4ﲽݘW BHd )U/2(U39oyn\bK d~xNi'=zLߺww㾏|E;gpV9I5;z{^ڟ57쭛Z*[K񀞙KgqOP yD`&=N׫Q:m^Da18q*JkRIz є(l'O| ^M9'\: J`lGfm ! @KDi2y{a3B0{$g?jGD)ET ,Eqy.{ χfڀ=&p<+-jwX!Hyvl! yNI;׮73[ZJ\K-bhnWyɿɲo޻}X,9Z'3Gc̼&LzK0èen(˶ohSWlsCyM&me) cfז{{z$\ I/jθgctKzb* 怞3bJU7:mNxf9?d ˼s֜E0u41  gԹ S64Qf/zZג};n֗K~~,LwMMfPA QRM)S؆ SژQ &&}pE;.NT'F© Bnrzc9O ,.,$BPdՓR]6atp  zsZEA(Q뎗YZKmgFacjul6}Űz\Z`L L*i#ta ½ IDATa`T1$c4Y^7%6kol[@ B|Ox饗!e(`B K `_MtԤZb6^遺=0?PBBW2 gx-#yCGRd-Y3]Y四8 JZ}}e7'2A 2[K`|eP)g΢H//lm eopn7c]NPu $C_;<gŎN^/d@(ՄP llg0f}`X*{Qz+ƒlHg@1kⰡc$kbEL~\W+]qszKgpԏIo ޶v~Λnus @o {lLjm" Zpmdd;63|5;i1B`GEAm)`ppqnLwZװj-,~Ckb`PbVןBieڙwc0Q5*vAXG}w~1{iJ<=yjȟuGD ^k{rA#M]Mx`%Dv7@q|oV*PLu8ɦ I_w]kseWIao^0u+aj%m؅s#Jr5q!|B?!!`/d ;jӦQ#(sLJI?x9`t-^`Y }op͜0*'|Em{:^s5FN[d:!")n!?ˏK:ۛ1&LxCrH?^~P mV/Au7z D`)X„{_req&OY/ϿXV]QJEJ)l.U?YMܚLLg x~\=kPW8:/I3hT|$wG4,D+]|Yr2gsyZkLZB{ cIW<km"Ǎb+ `nFbavh % tEU=67;SZ]xX(ۜ$Μ|%9~1ΧK430ɼSBĎMequjsޭ7fղDҍջyz7/LM<ぽYwjg2J(*'q5mmg2 S,HS#]ITƅpkgRs#,E>QJͻ|nf^oLLJ+&Ziǀۋ0簢JiOJ)fJ)*,l9idJpzz&o+' ϰq][c## Z6Hr1 y@[{.06gPF, '`:e],̃ _H^.DRi.ʱI2J#rhF977gQp-+c㳱:Lr71D1BD9 R3wDրRrW^Fir 5H'KYEiiڜ.bsWuVߺHMj7xr(K[[*^KfU̵h5zAk'!"_rm=חt: ,jԦ7#he,)J }Х~-?'OU(Ǭf `I?b`ǢEsn9Vbvj_2mCBaTan^0 S**Byj67E|rQ8*1V¹m(ж0o"JۯgU_"k3`/\=(T'CϿɓ'K>X(`N{Sx̀&:Cv.WoV}ɧ4ku]eᲬ˕v(Aٜ}7)R*)1g 3*PZ3ǚic\9fUQB$TLLon55,г`(PsVDH),x7K55|~UojӨ #-c $h`GkB.x% 6,g7d#nY3Au)v~Nrtq<1t&`gcuB+"U+˪A>)Qlϵߙ0X@ctrZ)['Z苶~/@=vo @|4K\Xtj2㩲)/!77+֣uv_|ۣsiƙkZq5\ɬ^W%ȁC"1gś1F\ޖ-Jϲ^ J%,ki T!Pc1&2{/skкFC,+z?(!@f$2fic_QmPd-k},_?kSHW*AK;Rʍ8R fZoHf<zWQx1N~=0JO`5۰^Iy{k[\"lWq R(voqwg6$gY>=]DAsfLr(!þ4*nW o튄>1BڠgBL="7s{{牰ոIb샾`kCo@aecjgu6'r9ZOscOS:)Kls>줴rcu0Di 2/Vg\صNKSs^Z^]^ۍo}{ĉnk[Py{Ųz*AffJI(%R+]$y=Fbe۷O4)+AŜ%~$GLGBHq;[:7I)vWGgaGvt?YkvO4SNoҟ韏욜PFzu;}’ءBy1g2; dkԝE'7\z>40Zk"Ժ3{v:af9 ]@F, ^OҡTPӘjN┊nMeVzRQ^(Wџq,!-}Ft@k, D>r!>x+:B=e|Fc|FȟsQڵrBmafZ]P_jƙܭaP{ϕŰz] NTL3Ɯ !L2Fөɝuo(y`+FFRuEs̀tjjg 5C.Π^kv,Md1J#Ji[Wĸc|9(r{mW_OK?ƌ6ܼ1)q!M]@0T|nun̲y2XFre7~/B( u;>%5`C K ~P滴:N.4sP(ńTiGU%bu~^R~Vl#S;=fYCJfY=N䌤{6-)5Es-Ni#54hBvg=z®U!^RSߔEy+w`BYV6[y/B3H8k( `y{?P{(A}$JBJ*$JYCN4c3 2Xkj{E, `É*Rp%%RY}+Θزesj&Ɉ@HW*}Bk !$4 !qzFO%LL/k7ErVx&WBygj3>`Zy=#4 (cd?} y{@ozCh+ߋ k(W`gtI7ٷc+?W %oTbu臩>$(SyEn>Wo./MҁT%5!%DsF4gDpJ=F\d1,u IB q|=%Z2mڭG pP<'au*1 Rf74|W_q9].XLF=[Q]N֭EyY=8s0<}R8~푆hyY%ȁCּFY`e0!vAc0xᒔ"B2!%Q z`18c1rIpV!]ђq{> /_џ #t*|<:g2w6 |3Fg4zZkM X@s^#! Ƌ|m`3bq!,?hLf\œP.ty(Tf7Vn6[bOKJl.HFHcSy9 8纴0Fua`\=ʤR]53JeȘ{ּg)/Շޯ{VS11{m ځStΠbNgmh`_D|/,{ n̙.%Ia[?mgzjθ@a NpiHu(Aj0 )J) Sw F.(Fل)Pai:S2R"]Q siΘf ykהܮX0݊R<9G D9SJѿ˿~3?90>>>d þ>ɼ~ B~lҺSCc:6dۜӟC^u].z>>97ĘO&`MYysKy:~ncQ㗎v)kRXiF 8Q4Y Һo* "4R58{V5$f^SF$#ЛG#JzzI]S%TPB!MQڄR;xpkjұD΂2.r,eVqGZ+ >]~}=f.9OL/u]ӧX붒¥+'9p(8C+ Ƈk>yK ] 3Gc̼5!EmLAcs&&1=5LݿxZzD !5A X;x 1595}yH@M6J)%K/?z#|CT*DLxA_2n?_+ Gَ!?P!ehD0 <9,T Vqq[<瀞1,8lc_6Au;,i.˹|*m~ζ9~|(Ϳ/,C9 ,{Xd:r֞{TI(m'{n8*T4?:-"d %Jrzl-KDRnO TH.c[N9hA\ '@5h"0,n^ |嫃^7w錐?/Dz,]3hVX,:oSV0g2S@sgbhY%CML*i#t_ Ls0C?xyYJ !Ɖڅӓpr%g<ݻZi>W5CQ IDATMMM J|"2ZZi*"B*`YOJ W/jV*\A*쳹bhYVb Gޞ\*^.dWB[C\4*eY )H%{n{ t"x㶽.z (Odk$# F1 c֤umVD* )Ba4M0 }ϿXw;4<44zm.užY=_ZC̢7&0.l+,BvZR#$5V0|aF½2@/6cC~Za)?,g2 Ob8~|$<ǖ`sJFp:swxaMiͨ~L$ 鳬& 2d4-s/u~oߚ#Hz]}['nL(v!W0sy('  =׆,s!4&WO)yY`|C. cU Mu[a缬+笞Bu@aAP{衏r/wU8x(T_LBY_}Sﲇ)o!,ˌSL|^Ǩ* p3 mV 911d1SPM(!NeJi&RHeYƃff'~|kr͍[Mק&w.z@No47OmT3]M-0%Ij7ц ro7Vo y=e h3B8K$yMy9t*ۼXrB,BkX$]9 \ cm8INY_>jw@&U(B)*y'Լ%$V_rkzE9@m4*{n6hzZ%3}34ooaju}d"2!xfdG+[# P^uCg%[׾z"3(j*3g#^{Y%L LII'Ez14L}qaX?Q#ϿA!EeB0uEsc t|m.䬷O,x~R曧/?c*#5@Ҙ{J)(%"pi9AǜL|/e/am2[nuTcbΛR)|@u,r䅟6@gey>{(J;Ԉ\&VQɯeY%ӢlkrVKn;cJW%W~$Nܖc[e%ʒ5ER"%K8F{~s[@PUda-U$Ep=ޠ[ذzsY1\^L+=7w;H>›Π.`E2zVvO@yw My=4mIDQdwuU.ܶ/sd)KJa.5!TY G(= }vb@y/|_u*\ ڹBoVè x0Y ^kM̺Jv@O^|s39Voav-bz캍 --C$DSF5];-g%ܕ-ݲes8Mf4!0P?cZW# R@(A1RN(%`(%" W_cY;_m[ya*)?ttE%ܦ\*TNg!z+ 7W"^zeq+ So!$n;:};use 8xjSU'N3צO6cd^[6Gne#ip { ozDW,U I81gaꭻ U&}%#(k}6 y5ەtNt%>ΡTq144W2fc0TY5;@d~~;[*z}*Nj€"!{ƕIPvK_r֢&ΒzyKϋgb]ꚛTTd^O+UzÛFAU %`'Y.ݮ]zHJNMRiOٜŲJ.0 `ķ5d.܁ç&~66"H,$赀aEг, ,1ø7[-T u:P =^@σ ̾l =лg{gf2<<1ajѬ"!@&:׊&VP(v)s:Rw1,J)9K2~Vny5]va ֕ }݂09B`D.:̧xHo̞fv^U(2(쬔s~Ї FRJ&0ƔSe$l^b%T1yDŽT. vٝvkz:8^/}d &_AKl(aWse-kw3zV0zN`!ٕYg .9;|kHmh2u&@E0#cCo9%9+NX_yTH(ƥ"½ЪmHb`Q,|J8..e\ΎˢPu=A8YF$zL U_! fx[*z}*cq2#nR!wNjq#%XYf8L/ݽ{^c VrM7' +\81Zg΢u{Y!PB-S+.0yzz_?}L.c NHQS+| L` ep[o5c4dҘ.Kڬq-=ab#]GiJ &j([eG;ИRZ]vر}&vn6^ݯ] kd5>KMTN֘z`HD1րp횳^.cVoق&ٶ`/c7  pFf ;VXR2JifY卸UfOksӽ+:ϚZXxWK?R!$ˣj0>%*אQAou;ztz]`d`0u ,_sPeyoff&DZEz҃wьקY⁁< Jf$X/JKyhy}/B+z>pߤs |75g9:r,U?&1ք$޶!׉sMV\s:Z:\(E1 .2MVzGpϞOfp> 9M];f}e4K(esc-{M>Z.Cc8 H?%$$HJdROVs-]v 𛆚+xuA9osB p@ 몬޹ ˒[`I` 2 `e~ׯ߉s0'zqc.DYyvݙ:9v,9K3c c"1|ppeҊlb?i N؏yL3۪ (%zE]3w5]]‽;O[vMǑb?pJI##5~k=,1 -Ԅ`L0&{RJ5M %3Z~]][o}m3|Vhed(}; y/[X })Be&ffvY-kQw !x0E?@BѳlZy :ybăw˾Қ)؅6q]V #14qһvFo# y'&g\e!I$$J2 MuH (gwNkD9,y[/ Po.zWMY"0uu6}Rj@_q.DZ衮SB}^G$IN"N . B߳=z/.*"־@8::,=1g6 Ҷ~6~ý=JȋsΑTcjāIbK{*砹 +j;nzy^qα0VHkhiV`;OV _ aa(PcUG1߷g}n(>pC[=~yZ3i֏v_^NFP#JfT-.Kl@Fu!V=/Q: V"ЀizBicUsZp"p-L͜I+7Yg\XW?}LK Ex Yڍ0z#뾩Fx@I7NhNw=o׀ ;f^KG$ U7wjYmQFkg BA:yQ ŇWcT3x1Dee50gI+A2'F|db"o΂1BXl26/Ơ8@o& 7ɮ((I$ȅր=bƘ=O_M}.sSl9O@a&{}1cY1/f s!Gs10ݜtrGϰ}H=1%~c,fz뵟sM7?p}C[^kLg}:n֑N]|3k>4Df1?wܜQ\Voś2-{dJ;KF!f(k1::΍e {d8q*Jk(FF;]H7 S_׻LY:SoBv\R![f1O(!#Q>w=NzM>`?] 7 Y嚲's`(jHƺoĬxΜ9;qq!qb!7vVyՃ=Ԟw) MйvBϿPeXQ@7R;{SҘS=*.y{%a| /K\5|kJJ0!xiX{8p0O'WP;o3RqޒZ _#rX_0zP걁YF-{[e(WkanIa% 'pݿJw{u>uz$xKP[6IU7V#]"=kk i 粕\ٱ' BѻM5gfX%.iEHKR-ի71%2d$(-ks99xq8cXJAJ|,i>$Əs2SJ<3ssŗ7~#{rto)4]Jy.,Lh-!Fi u#*pwg<^^p5lb V|^o3 ҄KtY w1f@cβsCq%9˒oά 5[\ňR,2X28 k!z@ O)2"BFK7~i,@2OTs Ҡ֚84gQ "XsWZ`Y/vkŗ^;}L˲c>`!.6o ȡN^T.ɧr۳լIѱPJI) k3'5Ә`(xppS3%Ȳc P=`in-|ۛl\.w+?"ioRJQ}ׁ ?#̧@!r3fτ__e|.R )$5.7sI8)%s==s&ַ L>=fR<3o giTD8Bz;_WDR]mr޹n#$cuZkOϏ9SS P6 ƚ`(Fbhw~%9">ܧZ5XFo$f,*77:=[E i|ӝfN7UȈxRRn8 )%iX`n{WX+5ha }j+]XRlO~ W>{)9Okmԫ'^)^#&ŃvZ=o,9!m^V 0r :4Z,[y1`TlAWoA`7h 8mryrW\J2DE1!@)J}y'ߏ (mX~>fi&/^={PJI)J XUM㖷;g;6{mҠ}B)%R~T/>OqjӼ͹v&T Qs@ڻƔ%Z+eY*PTjafDkp6pyF,zsB.&RkڔWwH`ĵ\+t{7]nG+W^ܵ_=7{Bv"HTBڜ^+䛉&5dF␑٫zz՛9Iso@0 Z'9XiEcۆ,p` _Zkd oi7fp}6m{+՛{[!'7\O,so.WVA]) aqfR)%R5Lݾ mېmy::W/ U/ ٚ~_do\Δ+RX\kcPyU"ӟOτi9h/JҀݫ@M]g?׿I)i3j/Ʈ~ kdZJ㌫VZ'~#\*~_ľ^+{6}҃D!oMߗnb#&W d`ɀ{M=`(( 9rqVXM7@y* SO`4B |8U]pj,^o~k`𩉁xXgK̔K\2J7-kF@IBFT# ^J!ܮ9 c~7QF-g澗1y I֋'ŝ({FQT*aR(Q(AJ-O@)1 _AP `Qعczw_-Mfc?tW8:z{3P4Јlە+w*Z!$J#hPTF<dpK=V/%BkzF@ճͅ2\!,Klt0(/N=Wl*k*!OMxBYcxЮEc4IfԮ }9uBak79S;ӝu wbo9U2[/su3 뤛-7d!BFtaadyks$XIk_ޑr殮yLdQHk }0{Oi! PQY}iY|;Y=Ƙe\q;'>+^ i^*~JrOާz&JFQWU*Ǽ:0z%qyڜ^2 ( 뮽fs3^4qX $!sͦ~.4q0::;&I2h ƐF]q _#j;8E^zgP 0)R%![d\3 ̔MX=D$t\5nk<Ռ∡"㬗.Gqt%Y.sWX:s\m PiDhe^Y]3ϯt2I9[~=+QvϳŻޑHlK?+ I"HdH( :dDe=*<:Zʀ^]v~\`nČXh,{7'_ !.d7H.M1s W|s7p\P sIzBl9> XY.uid^{p5&ic:gfӮSCpg wXɽ7_;,Ta$KUP5ɦ1bQus@9ku[Ge-—>x)p m U |x.j YP吿 Jֵsv'zqbs8e1Orݻ0I1]5>  ]iXm޲O)k΢PdZNUVkyp:@{ٷ$RW֋R;t赮W+ /_?Zƛ}aTY+Q֙]]9G'sT3eMbߩye S=% pI`4Y K=f Vҗ )ST sk Hjt@Wtg)ͭ:\4гb%xP rX*KEc Fºm<=0OG7ww|F$LYJ&H#'~5::fאns  i(0}7W/_(}ykAX,,`R)󘠌?gt& (vNݘC us`Hzi4 K/[6sݓ0leS%ёʓJ%݇lyBRJra_j̀Ywr^z;_3zȱȱё7B@pp=!9'pc)UI*lb"g6m v.K.[5b3(@XBnMdӰD#&`onqj)a w|Irl6O(MX*Rm3g;k@Ri-hBSyoNN#! Eo6EVީwr R ;JTKEb0O S Jҫ-m$ #xG+݌YNe}fQl\&hG>d(<1Ћ i؃deǟ[ ".q]kJfIce eG.(:m'H?:26-t/dVo[{ h2VRJ&$2aLz;^J !-}sXTzt;?{' o|b+Jttw(;9qSJ1)sN NLcq!aR l-W2MOpꡇ~O:S=9 nTOL}c#LBsB_e+  1obhxpp5 9EsΟn|KM,!K͟@26~f/^@&d4esY5bY[18VTՉv.(AjT"bAbB0 ~> mMtOM ( L)MTU@D(ED(MԘ+X( TUd&t10t@ 1ZxtfMn9}Mn6]Z[*/ ?~*X=0k R<ͤXuN5̍@ 21A k)nH^Y[+[/BGGOR**e2v5^,QR}1JH C"@+mcEbΟ' ThҊh(T$E,RJ$ f6 Z \j9zz03s1$M/sY{Zg08I?&|-1L0%8!-+%Ac7_K0,Uugtݢ=9 wf8% $ag*M7)\Rܤ:_:]zTͳ:?pD dY4HK>S`[g=b2!Lw.kRk55V*^iE!k4Z7 Z#U}e="d=:stSY}€40^e u6*{+S}~y1DnXepdijX=JY?6e 9awQz`8vf- Pwe%e3'F|L4J6(߭@*8QcPJKIC: Hݰ&"@R!ͯG%%J|UJ RHP:L3S\| LfĖ3@oԋw&w[&svͿR_?}PE *zqIYŀ0cy_>˟{Yfޠ{dZf7r?G"н=͞f5\ 1@`oa^^W 9@·Ƌ&LC,V@TRiJi%Ɣ J1 _'mZd$H)@ ~>Hjm2 eWūy AУDg<2+ܴ`$;r7!8ã'F/i%=7e׻ arŜ\{ջ{$-(UR1/wpi!Ҭ^ @cFH!54pz{NyYbX[Bj5#S*w9)iY2 RvpXJ(Rj ԋ$T5o 2RFI$Gr|rnZuO !$9zURSVc_5Rfo1w <e°[VbI7!$C !4c9(@/@#cLHD)!$sܱ}vǎy%kЩƦ,8D4S%>,7 fCvn*Ks ]ڃNJifYm5g5(@(@` \)$9[w22Vk0,s\\. ,lK6- %2QH9Mz vpF&q;+5{:oY:uҗ==` o,9!j!5T3)8cNP+fpb}i3(J8_9v?}^g-Q)Q {7PJzJ)DR*Ftvsj>S)"`0&P[ AHYwDd-վ:ɶ`iU2ȸnNҹMt֌qu4ehy玛+-:ute4G)#XT%s!cJIe[?e!5?Ā1BJ) (];vܼiUZ6*1e?䔅 rH8z gCd ^ϭb{`#INx0sT KP=i9K@`tZW=XTt.(`VX :nRGMa$z22u/}`ۻtm;\zgȕ̽| ?kmYRܷɧy93<zL=|ߏ|9x1ẖF$IN"Fqnmmؔ{نF-]zY=v,Ry̙Z)cҙ}"A)RH]A'_4XwRWƄ pXl0y @AP $G7,г5__Cg4JP4og~zo>JiE,1EqL(‚ 4_O) R9@:^F`/oȠ9qL"X3{ yTtKrA'j67 a($zo1``^'smʲl^U,iINbăw˾Қ)؅'d 29ى-zKAB iU}8 J$ZP"2F!#G}Sh[SMyU;/^Ee,3}333֘pΑrzk3JwIY gZ[{ BA-^x zt~Vڼ;'|$Rb%փN4Ԁt@A \[bfO2cI *|7L& OB2mgSvՌy Ě\q@1Xqko7YIJ>lHb3w^N/|lj1Bs*azO;2a8vvOٽ)Kj^&~Ơ&X >Lmr7 ػ[},)CSԚ WH8MG#PzY-/9],3F,3y#2g^՛{?ghl\Ϸ%{CY }:\Ծ8anjsNVO"բY=';fNBͪU,+7ʣGղPGi~-gp^ bYOy=̚uZ Wψg5.C|߷UJ$͘a=O/|ߏ (??X1u‚SP?]KYfsż4/}qCo>}s؏ŜSsv.9 J>R(8FQLMMw=̳5PYKksѓeD-($GFi|s9Dz{B:fa&L8Q$jg(Qao2 ȳy#0>ڧD8`22[&uչu.xߔ)`nPmrOu'0TNmko! ".sY`p2=g2B!<{',Hł+1Dm|!U&!c(Ռ1 |_k9?85_tSs][RdZeS0s;wlxzN9󸗉\0RJb)$J]\暺\٧+T #.DQ=3shIceB }$`l 襛WU^SKz _*̈́8qҍZ(< X0y$abFOMؼ=uq IDAT 75wSEfX:] v#+tgaNQ<Q}  xծ(5|{9o怽cR))TD.hЫuiFXc#iFbS_+Uܱ=sBagt92}RzR*O AL+)?_ⴊ<\3r~ivqV/ֳz LSg {g{y'.c̅h9[[6Swe!GRJ*m@VW}҃d9Z4DBnj<~y^iݺ5_&L8ԌsP?YeI:,_z2}f>Ov@wP8x(ś)'LJ\'Ӏ>d?$Z'TNf4~';66388瀽1Ya5op1fY)XqP`oԸcNЩ@j_.mb4UQw2ZC`tM ,z#Xx أRwۆofr].(8yZ7sټ80{P[M@R kVoϞKX6oxŃIg" _(}yJX,kxwb~O)k΢BIWA\g*H5> ]jBbI1I=ϋe|n6r5kyi6o7uοԾ2<|מ,dFFg;=29:RT ARDJIR Lq쟐2TCϥn$3]6"!gj74VCk]da%`G$dK7n U47y+TH ;˙{F9 s<8k"Д`M٦}cFp/Jf÷^3.L8Pc&{s;+j5h:ɞJ+r| -p`PuR7{F2Z]{+{U(I&B+ nb(Bˤc7 ߧ-S>a&))|-1B`$F` F@W喍};(nfMIiEl+v 'ggg̭K>YY޼.##^H\ӌevs̓uV+./}?z\y1-Ř92+2,RuƜŝY3$AS^YеKD' UW 2My?!!I!XaL$XJ%$&ƹ\[w ع}&3 jƔff5yM͘II L=5.L&k̮;,( 3 T2\0!1R8q"9JQ/a5(@*DJɎwaVީ^IQT3?+|H@)azK0Y{1@xD 9ajqI6Γ-m>, K6 ]4*˧3P%2M0 1#A0!xƾhdžʎ }u$vTXBmȻ܀^#'~5::fU[~V]5PIg^lcL6\qǎ$>vA΄Հ:-7?5f}{g<,cb$Z֌9,'N֙t 8r&D[&, :.\1Fg*t^ͯ+" K 12 _m޲mh4<NN5e.Y`ٙ_?-֜~ C٣#Dzr sL̽@pČsG1Fs@#!tF6k,A!=Ψ ,G1 9z|S]oPҦ9UX r:.٠2Ji&J&+IT$ZIbM1R`MR*TzI!W #^y=0(1B $0.vl荮Ur>eg+6A๲;&!5 sx^u v[ `Y(;1*#dd_ZVsT& PzyJP r4H/KMjqO>cpΙºsN54g9:r,.г!%A Q6~c Zkf WBZ!0I5kb`` ڶmM̓^0ԋ4"r򚁾dkm5V̜&~7޵sG׮;>OuK]\nzj&EQ( %4D8#*.}InF2ёݴx iS'FOH5[yz*[}\!ݸf>W:qJ @Xz>:`DHF0#3ht]*xC%Nޛ Zυj]WZ]yT^ωu]!_ ,Wq x.u~-֍ L<}~BaF?G7\PbX=Orݻs;nb+/t@&,H-97'F-vy֣{$ _|徙  )];f^z^/ :ikJ)xi{<P1'-> I@%6mhHԶ5CLs]֩ suԴL[/8w2~|Ac6FSPs2;͛{7o{Lgbb*K) !Da)d;Oxe/Aϟ/ Z,)?@M5Yv@oU VԺ{băcg|5S Ґ. $DI0:dTe<7Eׯ)#eY.'EZ@cCp ITQumm.:8hP]{@u%ݹM3%Tׯq> JNv~=w1u(֔RyLPٳg:V9Y߮^S8a,-+c ܵ蹝j7*#ow~O0c9' '5Y8Y.n9ȱXL)ў)?]w~+2BPUV9,A@ub&d.^ϊ,jHs.N5WYyet=w͙g7bw3o{^g0>EQl]Z6K:%{IdR:nF)N`M1 /)w45[tvJX[{,Y |kHPpA]we-]UYWucn,E!,҈" Qz(Q(m9&3H,֌$j%R\ A$Et7($ʮ-+3߻~?7_eUoߍ(]Uo;99$Ncn>/bԔUeӟ>x"tΒs\`\Tt3wgKkW+<&ko l 宇uw3tۙ뇏CaV~5X8>{김e!Ȇ?_^ :vr^䄤*Jڹ&g;o{V\%HσU`뫯}kܹYR-S5\|#v1g+Aʧc+FQߜ%BERI{=c!83j>aP ϋٗL!yI2GQH_7\RJ_X{rK0WTWUK_Z1J1@,sYgZsλAkMV7V̳N9Utg Ld?^6g} W{ZK'2CeH?w#N9g[>0u=aא.6,.ît0!c,J?¢lZ؟ZQXy}U /SGB|sXk{IUJg蕎@zg|TUo#d&"^'/D9^e!IXťRnZp:` ʀv^.ETsDwi?y2^:Y>yWm?t2~?vY7rdjzx#p<"a=ٮ>2.:ZrZ5|= ^oxf96䊘0px`o/cdXυ 7+(>q_dJ}B5_ @?_PC۪/| a̽j њW0 0ܱz=mؽ{8n*!E u3NUg+<ϱg`둣o;w~% Xk37X6"]VspzL QGQk5KOZ@0!*s)wn̸yM5W1jcq&`/x}P8QqQFwS@[zbシX|7f`mX=Jo:А1c1~`f>/ `>ACش Pϓ%)eŮ9՚ݻX`qV@/1 %օf 읬0uicl iY=JP H wnz Brs'4ebh8n=B #4Vϝ</=|ǖ$n îu{9=#M$L|Rmv3CRcI" ck7nRB4DrJ^?=;ƹ9q~1U쾽Ks$Wqp_\WeBO}d.z^B>u{uB{##f3ۚkz0ug3aD6>8;o%m6pq#Pkn̋ǶJoo*=*,KB!2Z *^ #FUh ϾQCwnݎg$ I/E Mps'7|kpKt9I]_x# jdh0m>sܖv^/iA'Ѵl8c⸊/.V*ZScˬujNS-CԭPyFa53ùgDG|b$UիCY=X%SӧKv^ S3 a0)errr a4LmX7 X>{(Txѿ_ !\p2R㈚{On ֘$cl+ދ+ N3¶9 :sTäQ DZ wC`bGBs^gO4nIgz.U* {^O]M1bK 0 Xs 5W "Z \! +ݜϝ<7Ͽ.hCr]j!5L#hZ"#(3n JHJ ))IM(n=;w yWXX#G饋jO>gv^/4fP@{2rϮcBpj:= Y\mGlLd b!H)ՌQY.3 E4ïKV:]==;[h",E.X1h`^Fv~&D2Jc> ]e3σ> 0Q5en6耙{^[zp瞣79٥-uT)-wz%gD{Y?x wŃ,-l*ʘ@ǭT[:j:_3OoYoII 6PN+<[A4"Y3hD{SJ}ᥗ3)KR)0c q FpnD$0")?G`uΞ߻+1gyoj:FDΜEcY=%v^Q2y2Mӄ, hHlߌcPF Y؅Q_kmɻj%m\ZX& DD}I7p%[{뜒SSi +V!Kٲg̓.Mz;`=5Zzt*dNIThysRS T94QYɻ&w%>Z=;!"V/E]B85 3t9VhŁv6=QtkЭb 5 <[d&EeL)EfqzZRq a^XXxjrT9K[ "L3Fɉݍ C"l è(Nl;%LBu%VL{aI{ZM"t `|rf1F4›hԷ1{Tw )U+0In%2ťoJFFrN/a+3qTaJș+bs-cHZeZ6#[7̳2%)PJQO=̄\f49,ǎ\Ss1Q`fԳnk^8O&X|BW|W^ fUfnO#wǨ3^e|ܛń+kh)^a C%G ӈT[IZKfKs`SyQ/\IۡujipJ2AQHeԨ6&z==jSg_7/oi(8G%'ΐČS],+ 2{sK/ R @ן_X(LF XNnY9I$NHZD=NvofJr0YE/eRJt{Ʋ^pCxE8>WREq`:ޯAݳCJ$$^f(q9pw Y I U=2Gzjfg,g`^3m2g#%N/fb6G^Nc=33^™LM9u$-sΘ޳g" 2a( ;+ϸg~~<\Zn45]2P#hi\ƁRRXGIy\u\EzaïPz N$9}xg}n1{268Cs`q`iiPҬgJ JQb(BI;M|2 M||ruy`p[MC 㫕oFo[Cw8wY=ph腠6jg @𑣣 -f(.fh9(h6{=W/VD9ǂ~C;BôH<Qvo-,,Mu$o5HrوnV3.,y%HtQ[ss2k,799:q6 Zleǐ{ărm<8[{10?<=}5CD -)uo,Q`ȢɲqV`/Vgĉ: ֌el:wz;|`vqF|^ʖevphS1J49743k4BΉ}֗=gҎ]((QƇ<Hq=}’{Zқ3ND.hC:r`0l:q/h'?G}C|Zs85Tmf(D-YFYvzW{&wzθBh,;Yt2t$~ngdH y˯9{v M8)ߗ*Jּo5j5AiX6rC17uinxŅʼn,veY+M]f: /NuizFo lyRR+H;G'dgܱ4=x^^8+[u^|ܜޭJژHe- 8Ɓ잝ًf^I9 tF0xfnxm9dP U0cn#g$ěQx?dLP8c)~c +lz^W Lk7澽; ֜e:囫u$vFνl__fR&Y&E&ew-!QC9c!=b[RoTvq)Kxp& /<$3Y2)d]ЬoJW$Nŭy=4Z3"̛0F4T3Zs`:ٶ7}44AIApa|1g?H3pva|ulRr Fl}J8 ݷ +^-{GƈFh4LCǹz+ Μ9s*C>3hyI!,uh&UE0XB2sZ.ĵTcWإ8 ֔e;oֳL#h\ eS뾹LŖ/8vWS`E4֚4.\^y[L(Kb1. wGll4,O&.~U%m |77h,Se7Ĭ*J餶SSU_m9dh^^}[ #i 7i)KS("D#`?OV/sOooޯYn!xL{׶;w>2˨R c=k#=S}3 W9Rbpqމ3҉`^AۛHۜy9ˍr6=ػ24UF0;J s{ǽq77mgXS7[[f䛉e;G˜|s%Vt~oܹK Zm-n?IeYƥ4tZfȳ\E"=`Rx ,t:ntV(A:Ve=E |9Y)z߬C޿QX=X%ӧKv^B~ /V3g9*Y60˯6`4M4M)j(hѠa }hѠ'X9IxP=4T?Xn8Xt_d"R)"d:$Bd?tz9IB{ wB'ґ¶9 4 IDAT0%Y( AF2Jrn$ KGn|gZ+t?=DG&F-Ό0So'lު4jH7%L#Vy-B|,Xc-.B7W˂߷R07!\hЗHD)E5jd^|.gYFT]3!΍FQ$399Jߊ*0`@kV…-F6$Q{Q+z;vlmV/zީj4j`ފ,\ R C` j:`&$2)Kӌ j jڹYC^ַLW_rg/vv9 w=wɺkgϝRRI"ZK8} gqX)*,Йss{'gùy{̱0Sog_fJ' y4M%SpzDb"N8KK-ڣwVbzUXE @`V©F"@| _f煋ZR*Јu Q8=C> z9>g0h/$2ҔK)R{wjOAsBS(%"󖗙y{1սIo z[;4d;S/ѸXq~&G4GΕӒڃ`fV7oZMsKe~:ͳ]r2]FR_-S-r AK_dYf: _\KaU?>Ki̲٤5َ39ђgd  g$2.&dz驒ZhkfC S_{sɉB 1yc7˯ fR,J)kNe:A41֚A45}:\X^@i y@^>0{`Oo~ߎi:hMnhRϙN0Tۗ&&&3:+$+Yb[NnVoY Irw,|n޷IYz \!o=~l?ts9wjZ,.So@=ѕgpXL&5?816wN\t <70+sW*c855~s, 8} ַ^Xo&+착Ӻ夌H5!(`wcguSH :k\k;g\s?Y(,Ru~?, BVU7zkq%QZw bڭQ RDZ|kcK'^8來z. K6xlo~oh4f4K u*=cL> X+:n[ ggKM79˭cC$gueK"DVZ JَD+1du1kg}SsԥTk*\1S7ۙz1g;`]7Љ\5X`nҗscpjaRu|繗3~ψ{flsn^]>55k)j9 *lԞip^,k}0n2֘eX)r$JcM0)%c5E|ǏfS=#,φ &:zWb d>֛I&;N|gh 44n6TfظӸqfvlVS9S`Xh-z S_GsX,Еsu.w5/?qJΨarC>oO 7et~l* n{^~|Ԣ)uK-Stg:,K[o>XVy.kM; ^ FB"5!R pm{q[`'+'(`=z{"ΜŠ]cJQf(c1&ߗWœe힓Y˃RX*ťD#g<#RA4De*gK~$yܿapl"=T6Ay WJLgCڍB Fco~o?yF1h4f2|3qQeV}QUs.V~q}aj-F4›\:I9 D֞+$9AwmPd`g9qˎ:)#۝x  Qh6&zbF}@۔ePSz&Zf.S/o J) &D?,]!лp@`'+& ^66;I(ՔRx,{VZ).#txxcfszp( LO)!9jhib\삦f֜%4ZoWd̲i̢ߝδP;&Yijĵ8|h[o=?'?\.MN3#*8gtiީ򆡓K;~?֓3l4zh67eQJUo1茂b}7ߴw;gf4+^wr%9b,0H5Z'Fiˉ]$gB@"5ZfܦMHmNr2W+=wΦ? ێ|kpLY2T OySMLS`i_|` Xhtx뇏lFL=9Y IJ N]ا' #dd^o( 'ܳz#O>1_NjaY˳c'N$JHs>Q1YTAB  •6,($n%5SZ˲q=7Jzt߬;cj:#T5nWl-ysol?/c憛fhDM=D.r|HqEqW%t,Y W )MZ*Kg1#Я!0gq>_OV*$9WVgYR}PY;gg Ŷ/֟b*4K}Awhlm[jgꅦ,L=/t7˂-oelB|^ (jwzbN%Y-JPJqXHq  {Q?s X'`k:-z5jje YNk8S{E=83 Vo}+6fYNYgZi&J*"bRJeYK__3[y?cJekfyI|!U^CWr7Ug_Vőf9l4KfSL=n*R`QQ8q7n WLrwo/icB$p>h4#Dz$gee'~nojm|\ќR&$R-8sQ$O>PN}ER@qk^qјRy=F)24Z9l &"W{ @[9Yñ|GoJӴl6f4hѳ E)űKqE~g.@o,+{BS]L'241oNgŏ=\? IBM5t\C{z佔S%@ \0pDcO=|WKѦQID9SV/oNf3|Wz# | @Gm$SWpW!{@6sj8FE$? w)24c4^3ȭ(>t &c+}y?JmR KA3YK!8(Jၣɟ?[k`ϻv n W^RS!wj:k~ŗΞ=WβeYlA^4%RJvf,SF28FDXqQ|jRh$/-f W } ;LK@rl0llKN9 Uw٥-$9W(ljKS"F `u/H<~cҟ=5xa)K3+4FaDG^3jbNu[?x-A~yX.HZ%@a5Sm$S^PʩFR;k`B(rP\̱z}zσdjt 롶aŵԉa0mnQ"o F^yk1fR|(EAJs&Bf\DQ$4M}Ͽd56wOcbx!ׄNWpu `3'N3K ~绯H))U,,ΤYI6Mvl7Hxf}=:z+ֆ[yItX0ufU^?{Ε$9nP>|Ӷ?+%3ʯ,k$"421oܨ ?z"I:SͤZ|7cΰ,* 327B;)]TݯBw5SlF23 Je }#jS^9pq.O}sh8ۅf`tf\0} SwPb=Jܺu<+˾Հ1=ZƒMrRuX1a&7pi> BJ8#s&Rι9/3K/"_y3 d{n??HF3|W4&qzi'OzX*)JRH)%2)2IITD6gLg}qlJ*JiNj?OVIr '?'{@5# yZK2RLPvAR(JZEݷksTS(d?44PڐL!cD7/,$?|v_=֐E4fB") ,9fhKYlW1ޙK/fx$@osz!WkzsHD١U*/u Se }uEGt@Z=\JR)YƞdֺհC@%8#s8gQ*Gƨfzg#G5T*r*$zrr5>_ןS*;5=% DVBJɕ\*ɔTT)ERDkEs.ZwW{ۈ9ι"a8Vqq>gkΞsgw)Xkn˺\Z,ic"aBiFHv`(,F]{9; F`0@Rw@cBLB ;=_??zeYe iŔPYL#&˂?~޹w\c{ԣz6=@5s#"QI!D>p޿ǎGޜIn`p(5Yforr2t 39 za V9J,QTk7fZA!vJ4"PHQ#Q3Z7j2Q$j.-̌"\!Wn5"h6ڊIVԱwx6d@/bS*tR*$IzS?c1MԺS{Vbm,7gIީJ6LF.9Lݲz6L|CI: ov)zd9G0,n,-JLI@ JV`F)3o,e(D*z N|3ᬱgd`g7}bu Dkr]6m2#\X:%YXqV/4hKLMM'`Q3_՜^KP4T߷3zm~B|lX5PMa2fe„3V2L32`(`"et@c(jM4"!~O+ hl$ǻz֐%cbJY$-sNS^uUk+ڔ9/-V'gc~^φzӎ~ӎo|ϟW3 }#0Sᛷ6~0[u fŹ= h4$ßPp)ݗbbZhP#%>h>dqȔJ%,'LIT?wR; v̞|`Xv-f3v`χ$ D޻kĻyvo0ttۓ`whO{DqF"ZOY`ܽRNچ@R>7^˔%.Ne>zwxwwկc]@nڨk72[!1=CJ%hpEscǎZ#3~ ޜ1RgDv^km|?| ?IQ[ k#-̳¹hψwZ&w%1`8=(,]uY%I"$i$dG:) ڕfUbmPsVaYfϚieF pB-أ$gh8{Q]I۞I5[`,jw6^zoF˙kL9M`  4Ti1{VSL giI_{@,B*X&36W̽΃(BSF3nyv%17ܕY]wQۿYhelTМ?NME\ 9 e2crrO˜%lv'ӧxCvu}EtMgB/·^5W.r2G.'?1@{`Y=ߜg&Z7XAsXhDS7?Y $6L*CZ#8R"(oIWC7ompJ2g҂ǨuxR @f( /or%D7oϸz\?8)1@ru` FABHEQP; }?V\,EB4ޥ97 ۬ 8TM$X.;O{},P47*d7;>_kgBwpMwz{wv|۔kP2k(i.]eÕ|wFZ&L8K˂-:0fa>(l[|T7jG-(8dGC?PT`e5,:Boԋ$B(twAr]}?w{Kw`@ HQ$.(T-E+He9ەRC*?8T9.;.ũr%؊y%K)Y@ZF`w|xv~f0_ի{?AaYQzzQ\3W^}Mhc̕7gܮ:eTNMMջYzkOcE9oz̫S;@f_tZl<6& 0 [7_3S4vH$DQJ5%@>"@ ь, .MtQYFhQ=eĉhC]gW"މxJ PiXzS._8sƖ}'g)}XJi(mJ$D sDѷ>$.m{p(\Թn>g 4 ={|~21y郞뻺zR-(;d5Ƨ w Fr.?gk?9ӂC S/DK|@7E:A[K/(7 Ԑ]]M 0 񑧞\VG=6؛-gd )4cLYG( 'Jq.䎦3zQ$^[o\j38I杇-{]=ϊo?[ju/m{7_WdYV4LTYe4ˈR(@iJijnDuy29 a0̬W㖽7/=gfww4)mz(d7Vi7<7k* %@SJ@j*O ~y9gnm0cpcHVhRѰ@\KqVCHRɵYxvǵO9k6T]7mj0)`X#|tĉCRXu7=m}颡w#K1R`BϞĪ(qGF?rǧfo5Øgi_2YP(1B(He.61'2qԊ㸶wӟ:m_p#huY\oc/RRBM+^ʜ9j} @QHA>PVGzQ/ջv"O?*28>?or>-藿ա Ji2Ae,2RJ"$Zi"󃸾=oE i5=j:3C7okOҘ3j$ ~&Mlǂ/!w<-&ަwʻďPݳ߽75[#z@)L*IRDJekGrbϵB\a<B@BևOuSv,z s'營ܴŹR9?󯔌 JHHϨR(Bc(sc!TiE0 k|>ӥ8vNw!tsbEvCw>s;G#c 1ƲouG: BQRȹAפl, 9ּ-S&~=u׿͡o=|YJYRi I!ʟU?e'Ew=}= a]? Ȃ hax[z=gs=Zk~+-< r}o܆iNm;,~Ga*NI9:LR\icl $8 L*,8kEn) ݆?aB&I4qhp~r*(F\*mYoS<}ٻZque[d1}Z ?JѰboy/a*YBdRJ&:Tg=C'9wJ33L !< Aspk+@K; Y7[WӾ#f`豟ٹ_g!DJR%e0wdJ!I03Ol)ĺ;I?/ǰx83g~g% 6?k! H ?w>:'ߑF9sU~=7'\x|f'z7w^TQe2R򕄟 `g?mXtuΥ{ak!K4DѼ|zZGn] h^ꭏ(6[m_R%m[pz~(U)e5Pec=%+$SZ0 D F$ mE-ߵsd_=tmty/?|i)%qwoZ;Rkgvƛ"i'b w8"LIp5t0˷Tf/<vs;һ/B@vgjMj5MeY J)Ajy ]Ѝ]50J˝3x9o@4|R< bs= c07~/w_֥,,bc9Wsccۖ|}?~.*; rnQv͸jr_}4Me֙3b-sϽ $B"ڳ̡CWQs{y[9RX)($J)&ZiP$qu.W{EEA++mWE\-8wL<\4~#G/wʹx.O{ni8oP!H0o-vӻ v~١=SZJXj#6BltZYҳcДLP~M[ٲ3v{ HGpcO\ I n[YӁv&D^@FBș) B F9y=1|^&lz-bR8pf>c ;N:'>q"VRZkaZkby]˯`Ԭe=QR(K+r{5Rۮ‰?}}z|ιc/<ї^yՒR*TJs5[q )cQ˥wnC/ Y[\NwF͙ vcnn~w#GX9q-c,c%wux{߻P+}͊pذ +wCVkc/Wx|᧤Y.l EReeXe[8X ^9/wै^3/#w`eMgi.dqS!x{``ן|ⱥjZkŬ9k=u`4IByjOՆ˷һ 0 Lv P@ Jm܆֥}zNtIc1ק񝋭thTDji=nhz$)\E" Eco:&C{v{ҫX"`g~F5^{#6`oV0qɦ&] 1hۼwͬ_wCE/ b({k zcW9yTlc.4mo k6Z&kGeDU՛3[My{ަSSFͻdnkMIWM!4W^O<H^gኡyP4cI΅eq>}{uc[3漵cɾ5Cln1"|:蝦97`>^ҫ{ /E`c9sѫNtfag'jtz"Y[*) {M(Ľ[3{NC ,}sOdV([QgBwuBass_ 7ڼ( :jwt=CWJnm+>7gA3g['Emӓu-4~o9ⱗ˯z?<ŎZP\k ) ZS58G6v@)k1x1s0c ;I 5QcS T3Lr%c,c%dփ<|j5n^,xcɾܞ7Hڼ4hQT}oӢ5rb7, 6Wh!ת#{ >o2|Ϛꍁ79s$\.%~O6O6Ol_?|` o}-7Wz]=>.=y-Jxzs͍faNsUilw{w-Mzj F1SLY]}/ĻZ2}c+ۃ=̡xr^.;<|?s?[+񉓧W^y5_#*́<w yj ~=%]1&"vuW=m?/Jl' ?'A7W~+j@ZzJ79PzIDAT3k<8qRhm֊\k͌1TMM^Mu.jQ)PIՔPE)(rdbx֩ԾVZmy.se?({(:]3nfާ x+7Iφ_F }#m*P0ǐyzI!` "S$Xk,}+\Wg Ϗͥ\x/6ta>|иH(?{^DBZXDV9Qo Weo@1əKcz`$r3_gf{%RcӱJXֲ"n <׀f?BAA |.'-GF`yl!<3iE[ۊ'ܧstQt E  ȍ!:k>+.W׆<6k^ "+ YAO<:_^뤏H  =A.B'(O)+GB#zzzwҞ0Wϧ.|<AAl9hV  bA6[Ea                                               yHʬIENDB`pywps-4.5.1/docs/_static/pywps.svg000066400000000000000000001227121415166246000171760ustar00rootroot00000000000000 pywps-4.5.1/docs/api.rst000066400000000000000000000042521415166246000151460ustar00rootroot00000000000000############# PyWPS API Doc ############# .. module:: pywps Process ======= .. autoclass:: Process Exceptions you can raise in the process implementation to show a user-friendly error message. .. autoclass:: pywps.app.exceptions.ProcessError Inputs and outputs ================== .. autoclass:: pywps.validator.mode.MODE :members: :undoc-members: Most of the inputs nad outputs are derived from the `IOHandler` class .. autoclass:: pywps.inout.basic.IOHandler LiteralData ----------- .. autoclass:: LiteralInput .. autoclass:: LiteralOutput .. autoclass:: pywps.inout.literaltypes.AnyValue .. autoclass:: pywps.inout.literaltypes.AllowedValue .. autoclass:: pywps.inout.literaltypes.ValuesReference .. autodata:: pywps.inout.literaltypes.LITERAL_DATA_TYPES ComplexData ----------- .. autoclass:: ComplexInput .. autoclass:: ComplexOutput .. autoclass:: Format .. autodata:: pywps.inout.formats.FORMATS :annotation: List of out of the box supported formats. User can add custom formats to the array. .. autofunction:: pywps.validator.complexvalidator.validategml BoundingBoxData --------------- .. autoclass:: BoundingBoxInput .. autoclass:: BoundingBoxOutput Request and response objects ---------------------------- .. autodata:: pywps.response.status.WPS_STATUS :annotation: Process status information .. autoclass:: pywps.app.WPSRequest :members: .. attribute:: operation Type of operation requested by the client. Can be `getcapabilities`, `describeprocess` or `execute`. .. attribute:: http_request .. TODO link to werkzeug docs Original Werkzeug HTTPRequest object. .. attribute:: inputs .. TODO link to werkzeug docs A MultiDict object containing input values sent by the client. .. autoclass:: pywps.response.WPSResponse :members: .. attribute:: status Information about currently running process status :class:`pywps.response.status.STATUS` Processing ---------- .. autofunction:: pywps.processing.Process .. autoclass:: pywps.processing.Processing :members: .. autoclass:: pywps.processing.Job :members: Refer :ref:`exceptions` for their description. pywps-4.5.1/docs/api_rest.rst000066400000000000000000000241521415166246000162040ustar00rootroot00000000000000.. _api_rest: ################### PyWPS Rest API Doc ################### Since version 4.5, PyWPS includes an experimental implementation of the novel OGC API. This standard defines the OGC API - Processes API standard. This standard builds on the OGC Web Processing Service (WPS) 2.0 Standard and defines the processing interface to communicate over a RESTful protocol using JSON encodings. For more details about the standard please refer to https://github.com/opengeospatial/ogcapi-processes Defining the input/output format (JSON or XML) ================================================ WPS 1.0 standard defines input and outputs in XML format. OGC API - Processes: rest-api, json. PyWPS >= 4.5 allows inputs and outputs to be in both XML and JSON formats. The default format (mimetype) of the input/output is determinate by the URL: * Default XML - if the url starts with `/wps` * Default JSON - if the url starts with `/jobs` or `/processes` Please refer to `app.basic.parse_http_url` for full details about those defaults. GET request: ------------- The default mimetype (output format) can be set by adding `&f=json` or `&f=xml` parameter. GET GetCapabilities Request URL: .. code-block:: json http://localhost:5000/processes/?service=WPS http://localhost:5000/wps/?request=GetCapabilities&service=WPS&f=json GET GetCapabilities Response: .. code-block:: json { "pywps_version": "4.5.0", "version": "1.0.0", "title": "PyWPS WPS server", "abstract": "PyWPS WPS server server.", "keywords": [ "WPS", "PyWPS", ], "provider": { "name": "PyWPS Development team", "site": "https://github.com/geopython/pywps-flask", }, "serviceurl": "http://localhost:5000/wps", "languages": [ "en-US" ], "language": "en-US", "processes": [ { "class": "processes.sayhello:SayHello", "uuid": "None", "workdir": null, "version": "1.3.3.8", "identifier": "say_hello", "title": "Process Say Hello", "abstract": "Returns a literal string output with Hello plus the inputed name", "keywords": [], "metadata": [], "inputs": [ { "identifier": "name", "title": "Input name", "abstract": "", "keywords": [], "metadata": [], "type": "literal", "data_type": "string", "workdir": null, "allowed_values": [], "any_value": false, "mode": 1, "min_occurs": 1, "max_occurs": 1, "translations": null, "data": "World" } ], "outputs": [ { "identifier": "output", "title": "Output response", "abstract": "", "keywords": [], "data": null, "data_type": "string", "type": "literal", "uoms": [], "translations": null } ], "store_supported": "true", "status_supported": "true", "profile": [], "translations": null } ] } GET DescribeProcess Request URL: .. code-block:: json http://localhost:5000/processes/say_hello?service=WPS http://localhost:5000/wps/?request=DescribeProcess&service=WPS&identifier=say_hello&version=1.0.0&f=json GET DescribeProcess Response: .. code-block:: json { "pywps_version": "4.5.0", "processes": [ { "class": "processes.sayhello:SayHello", "uuid": "None", "workdir": null, "version": "1.3.3.8", "identifier": "say_hello", "title": "Process Say Hello", "abstract": "Returns a literal string output with Hello plus the inputed name", "keywords": [], "metadata": [], "inputs": [ { "identifier": "name", "title": "Input name", "abstract": "", "keywords": [], "metadata": [], "type": "literal", "data_type": "string", "workdir": null, "allowed_values": [], "any_value": false, "mode": 1, "min_occurs": 1, "max_occurs": 1, "translations": null, "data": "World" } ], "outputs": [ { "identifier": "output", "title": "Output response", "abstract": "", "keywords": [], "data": null, "data_type": "string", "type": "literal", "uoms": [], "translations": null } ], "store_supported": "true", "status_supported": "true", "profile": [], "translations": null } ], "language": "en-US" } GET Execute Request URL: .. code-block:: json http://localhost:5000/wps?/service=wps&version=1.0.0&request=execute&Identifier=say_hello&storeExecuteResponse=true&DataInputs=name=Dude&f=json GET Execute Response: .. code-block:: json { "status": { "status": "succeeded", "time": "2021-06-15T14:19:28Z", "percent_done": "100", "message": "PyWPS Process Process Say Hello finished" }, "outputs": { "output": "Hello Dude" } } GET Execute Request URL (Raw output): .. code-block:: json http://localhost:5000/wps?/service=wps&version=1.0.0&request=execute&Identifier=say_hello&storeExecuteResponse=true&DataInputs=name=Dude&RawDataOutput=output GET Execute Response: .. code-block:: json Hello Dude POST request: --------------- The default mimetype (input and output formats) can be changed by setting the following headers of a POST request to one following values `text/xml` or `application/json`: * `Content-Type` (format of the input) * `Accept` (format of the output) Example of a `Say Hello` POST request: POST Execute Request URL: .. code-block:: json http://localhost:5000/jobs POST Execute Request Body: .. code-block:: json { "identifier": "say_hello", "inputs": { "name": "Dude" } } POST Execute Response: .. code-block:: json { "status": { "status": "succeeded", "time": "2021-06-15T14:19:28Z", "percent_done": "100", "message": "PyWPS Process Process Say Hello finished" }, "outputs": { "output": "Hello Dude" } } Example of a `Say Hello` POST request with raw output: POST Execute Request Body: .. code-block:: json { "identifier": "say_hello", "outputs": "output", "inputs": { "name": "Dude" } } POST Execute Response: .. code-block:: json Hello Dude Alternatively, the `identifier` and optionally the raw output name can be encoded in the Request URL: POST Execute Request URL (with `identifier`): .. code-block:: json http://localhost:5000/jobs/say_hello POST Execute Request Body: .. code-block:: json { "name": "Dude" } POST Execute Response: .. code-block:: json { "status": { "status": "succeeded", "time": "2021-06-15T14:19:28Z", "percent_done": "100", "message": "PyWPS Process Process Say Hello finished" }, "outputs": { "output": "Hello Dude" } } POST Execute Request URL (with `identifier` and output name): .. code-block:: json http://localhost:5000/jobs/say_hello/output POST Execute Request Body: .. code-block:: json { "name": "Dude" } POST Execute Response: .. code-block:: json Hello Dude Example for a reference input: .. code-block:: json "raster": { "type": "reference", "href": "file:./path/to/data/data.tif" } Example for a BoundingBox input: (bbox default axis order is yx (EPSG:4326), i.e. miny, minx, maxy, maxx) .. code-block:: json "extent": { "type": "bbox", "bbox": [32, 34.7, 32.1, 34.8] } Example for a ComplexInput input: (the data is a standard GeoJSON) .. code-block:: json "cutline": { "type": "complex", "data": { "type": "FeatureCollection", "name": "Center", "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, "features": [ { "type": "Feature", "properties": {}, "geometry": { "type": "Polygon", "coordinates": [ [ [ 34.76844787397541, 32.07247233606565 ], [ 34.78658619364754, 32.07260143442631 ], [ 34.77780750512295, 32.09532274590172 ], [ 34.76844787397541, 32.07247233606565 ] ] ] } } ] } } The examples above show some `Literal`, 'Complex', `BoundingBox` inputs. Internally, PyWPS always keeps the inputs in JSON formats (also in previous versions) So potentially all input types that are supported in XML should also be supported in JSON, though only a small subset of them were tested in this preliminary implementation. Multiple inputs for the same parameter can be passed by using a list as the parameter value. pywps-4.5.1/docs/conf.py000066400000000000000000000040131415166246000151350ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import sys project = u'PyWPS' license = ('This work is licensed under a Creative Commons Attribution 4.0 ' 'International License') copyright = ('Copyright (C) 2014-2016 PyWPS Development Team, ' 'represented by Jachym Cepicky.') copyright += license with open('../VERSION.txt') as f: version = f.read().strip() release = version latex_logo = '_static/pywps.png' extensions = [ 'sphinx.ext.extlinks', 'sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.mathjax', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', 'pywps.ext_autodoc' ] exclude_patterns = ['_build'] source_suffix = '.rst' master_doc = 'index' pygments_style = 'sphinx' html_static_path = ['_static'] htmlhelp_basename = 'PyWPSdoc' # html_logo = 'pywps.png' html_theme = 'alabaster' # alabaster settings html_sidebars = { '**': [ 'about.html', 'navigation.html', 'searchbox.html', ] } html_theme_options = { 'show_related': True, 'travis_button': True, 'github_banner': True, 'github_user': 'geopython', 'github_repo': 'pywps', 'github_button': True, 'logo': 'pywps.png', 'logo_name': False } class Mock(object): def __init__(self, *args, **kwargs): pass def __call__(self, *args, **kwargs): return Mock() @classmethod def __getattr__(cls, name): if name in ('__file__', '__path__'): return '/dev/null' elif name[0] == name[0].upper(): return Mock else: return Mock() MOCK_MODULES = ['lxml', 'lxml.etree', 'lxml.builder'] # with open('../requirements.txt') as f: # MOCK_MODULES = f.read().splitlines() for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() todo_include_todos = True pywps-4.5.1/docs/configuration.rst000066400000000000000000000233201415166246000172410ustar00rootroot00000000000000.. _configuration: Configuration ============= PyWPS is configured using a configuration file. The file uses the `ConfigParser `_ format, with interpolation initialised using `os.environ`. .. versionadded:: 4.0.0 .. warning:: Compatibility with PyWPS 3.x: major changes have been made to the config file in order to allow for shared configurations with `PyCSW `_ and other projects. The configuration file has several sections: * `metadata:main` for the server metadata inputs * `server` for server configuration * `processing` for processing backend configuration * `logging` for logging configuration * `grass` for *optional* configuration to support `GRASS GIS `_ * `s3` for *optional* configuration to support AWS S3 storage PyWPS ships with a sample configuration file (``default-sample.cfg``). A similar file is also available in the `flask` service as described in :ref:`flask` section. Copy the file to ``default.cfg`` and edit the following: [metadata:main] --------------- The `[metadata:main]` section was designed according to the `PyCSW project configuration file `_. :identification_title: the title of the service :identification_abstract: some descriptive text about the service :identification_keywords: comma delimited list of keywords about the service :identification_keywords_type: keyword type as per the `ISO 19115 MD_KeywordTypeCode codelist `_). Accepted values are ``discipline``, ``temporal``, ``place``, ``theme``, ``stratum`` :identification_fees: fees associated with the service :identification_accessconstraints: access constraints associated with the service :provider_name: the name of the service provider :provider_url: the URL of the service provider :contact_name: the name of the provider contact :contact_position: the position title of the provider contact :contact_address: the address of the provider contact :contact_city: the city of the provider contact :contact_stateorprovince: the province or territory of the provider contact :contact_postalcode: the postal code of the provider contact :contact_country: the country of the provider contact :contact_phone: the phone number of the provider contact :contact_fax: the facsimile number of the provider contact :contact_email: the email address of the provider contact :contact_url: the URL to more information about the provider contact :contact_hours: the hours of service to contact the provider :contact_instructions: the how to contact the provider contact :contact_role: the role of the provider contact as per the `ISO 19115 CI_RoleCode codelist `_). Accepted values are ``author``, ``processor``, ``publisher``, ``custodian``, ``pointOfContact``, ``distributor``, ``user``, ``resourceProvider``, ``originator``, ``owner``, ``principalInvestigator`` .. _server-configuration: [server] -------- :url: the URL of the WPS service endpoint :language: a comma-separated list of ISO 639-1 language and ISO 3166-1 alpha2 country code of the service (e.g. ``en-CA``, ``fr-CA``, ``en-US``) :encoding: the content type encoding (e.g. ``ISO-8859-1``, see https://docs.python.org/2/library/codecs.html#standard-encodings). Default value is 'UTF-8' :parallelprocesses: maximum number of parallel running processes - set this number carefully. The effective number of parallel running processes is limited by the number of cores in the processor of the hosting machine. As well, speed and response time of hard drives impact ultimate processing performance. A reasonable number of parallel running processes is not higher than the number of processor cores. -1 for no limit. :maxrequestsize: maximal request size. 0 for no limit. :maxsingleinputsize: maximal request size for a single input. 0 for no limit. :maxprocesses: maximal number of requests being stored in queue, waiting till they can be processed (see ``parallelprocesses`` configuration option). -1 for no limit. :workdir: a directory to store all temporary files (which should be always deleted, once the process is finished). :outputpath: server path where to store output files. :outputurl: corresponding URL :allowedinputpaths: server paths which are allowed to be used by file URLs. A list of paths must be seperated by `:`. Example: `/var/lib/pywps/downloads:/var/lib/pywps/public` By default no input paths are allowed. :cleantempdir: flag to enable removal of process temporary workdir after process has finished. Default = `true`. .. note:: `outputpath` and `outputurl` must correspond. `outputpath` is the name of the resulting target directory, where all output data files are stored (with unique names). `outputurl` is the corresponding full URL, which is targeting to `outputpath` directory. Example: `outputpath=/var/www/wps/outputs` shall correspond with `outputurl=http://foo.bar/wps/outputs` :storagetype: The type of storage to use when storing status and results. Possible values are: ``file``, ``s3``. Defaults to ``file``. :storage_copy_function: When using file storage you can choose the copy function. Possible values are: * ``copy``: using ``shutil.copy2``, * ``move``: using ``shutil.move``, * ``link``: using ``os.link`` (hardlink). Default: ``copy``. [processing] ------------ :mode: the mode/backend used for processing. Possible values are: `default`, `multiprocessing` and `scheduler`. `default` is the same as `multiprocessing` and is the default value ... all processes are executed using the Python multiprocessing module on the same machine as the PyWPS service. `scheduler` is used to enable the job scheduler extension and process execution is delegated to a configured scheduler system like Slurm and Grid Engine. :path: path to the PyWPS `joblauncher` executable. This option is only used for the `scheduler` backend and is by default set automatically: `os.path.dirname(os.path.realpath(sys.argv[0]))` :drmaa_native_specification: option to set the DRMAA native specification, for example to limit number of CPUs and memory usage. Example: `--cpus-per-task=1 --mem=1024`. See DRMAA docs for details: https://github.com/natefoo/slurm-drmaa [logging] --------- :level: the logging level (see https://docs.python.org/3/library/logging.html#logging-levels) :format: the format string used by the logging `:Formatter:` (see https://docs.python.org/3/library/logging.html#logging.Formatter). For example: ``%(asctime)s] [%(levelname)s] %(message)s``. :file: the full file path to the log file for being able to see possible error messages. :database: Connection string to database where the login about requests/responses is to be stored. We are using `SQLAlchemy `_ please use the configuration string. The default is SQLite3 `:memory:` object, however this has `known issues `_ with async processing and should be avoided. [grass] ------- :gisbase: directory of the GRASS GIS instalation, refered as `GISBASE `_ [s3] ---- :bucket: Name of the bucket to store files in. e.g. ``my-wps-results`` :region: Region in which the bucket refered to above exists. e.g. ``us-east-1`` :public: Set this to ``true`` if public access to status and result files is desired. Defaults to ``false``. :prefix: Prefix to prepend to all file paths written to the S3 bucket by PyWPS. e.g. ``wps/results`` :encrypt: Set this to ``true`` if encryption at rest is desired. Defaults to ``false`` ----------- Sample file ----------- :: [server] encoding=utf-8 language=en-US url=http://localhost/wps maxoperations=30 maxinputparamlength=1024 maxsingleinputsize= maxrequestsize=3mb temp_path=/tmp/pywps/ processes_path= outputurl=/data/ outputpath=/tmp/outputs/ workdir= allowedinputpaths=/tmp storagetype=file [metadata:main] identification_title=PyWPS Processing Service identification_abstract=PyWPS is an implementation of the Web Processing Service standard from the Open Geospatial Consortium. PyWPS is written in Python. identification_keywords=PyWPS,WPS,OGC,processing identification_keywords_type=theme identification_fees=NONE identification_accessconstraints=NONE provider_name=Organization Name provider_url=https://pywps.org/ contact_name=Lastname, Firstname contact_position=Position Title contact_address=Mailing Address contact_city=City contact_stateorprovince=Administrative Area contact_postalcode=Zip or Postal Code contact_country=Country contact_phone=+xx-xxx-xxx-xxxx contact_fax=+xx-xxx-xxx-xxxx contact_email=Email Address contact_url=Contact URL contact_hours=Hours of Service contact_instructions=During hours of service. Off on weekends. contact_role=pointOfContact [processing] mode=default [logging] level=INFO file=logs/pywps.log database=sqlite:///logs/pywps-logs.sqlite3 format=%(asctime)s] [%(levelname)s] file=%(pathname)s line=%(lineno)s module=%(module)s function=%(funcName)s %(message)s [grass] gisbase=/usr/local/grass-7.3.svn/ [s3] bucket=my-org-wps region=us-east-1 prefix=appname/coolapp/ public=true encrypt=false pywps-4.5.1/docs/contributing.rst000066400000000000000000000000641415166246000171010ustar00rootroot00000000000000.. _contributing: .. include:: ../CONTRIBUTING.rst pywps-4.5.1/docs/demobuffer.py000066400000000000000000000104331415166246000163310ustar00rootroot00000000000000############################################################################### # # Copyright (C) 2014-2016 PyWPS Development Team, represented by # PyWPS Project Steering Committee # # 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. # ############################################################################### __author__ = 'Jachym Cepicky' from pywps import Process, LiteralInput, ComplexOutput, ComplexInput, Format from pywps.app.Common import Metadata from pywps.validator.mode import MODE from pywps.inout.formats import FORMATS from pywps.response.status import WPS_STATUS inpt_vector = ComplexInput( 'vector', 'Vector map', supported_formats=[Format('application/gml+xml')], mode=MODE.STRICT ) inpt_size = LiteralInput('size', 'Buffer size', data_type='float') out_output = ComplexOutput( 'output', 'HelloWorld Output', supported_formats=[Format('application/gml+xml')] ) inputs = [inpt_vector, inpt_size] outputs = [out_output] class DemoBuffer(Process): def __init__(self): super(DemoBuffer, self).__init__( _handler, identifier='demobuffer', version='1.0.0', title='Buffer', abstract='This process demonstrates, how to create any process in PyWPS environment', metadata=[Metadata('process metadata 1', 'http://example.org/1'), Metadata('process metadata 2', 'http://example.org/2')], inputs=inputs, outputs=outputs, store_supported=True, status_supported=True ) @staticmethod def _handler(request, response): """Handler method - this method obtains request object and response object and creates the buffer """ from osgeo import ogr # obtaining input with identifier 'vector' as file name input_file = request.inputs['vector'][0].file # obtaining input with identifier 'size' as data directly size = request.inputs['size'][0].data # open file the "gdal way" input_source = ogr.Open(input_file) input_layer = input_source.GetLayer() layer_name = input_layer.GetName() # create output file driver = ogr.GetDriverByName('GML') output_source = driver.CreateDataSource( layer_name, ["XSISCHEMAURI=http://schemas.opengis.net/gml/2.1.2/feature.xsd"]) output_layer = output_source.CreateLayer(layer_name, None, ogr.wkbUnknown) # get feature count count = input_layer.GetFeatureCount() index = 0 # make buffer for each feature while index < count: response._update_status(WPS_STATUS.STARTED, 'Buffering feature {}'.format(index), float(index) / count) # get the geometry input_feature = input_layer.GetNextFeature() input_geometry = input_feature.GetGeometryRef() # make the buffer buffer_geometry = input_geometry.Buffer(float(size)) # create output feature to the file output_feature = ogr.Feature(feature_def=output_layer.GetLayerDefn()) output_feature.SetGeometryDirectly(buffer_geometry) output_layer.CreateFeature(output_feature) output_feature.Destroy() index += 1 # set output format response.outputs['output'].data_format = FORMATS.GML # set output data as file name response.outputs['output'].file = layer_name return response pywps-4.5.1/docs/deployment.rst000066400000000000000000000237611415166246000165630ustar00rootroot00000000000000.. _deployment: Deployment to a production server ================================= As already described in the :ref:`installation` section, no specific deployment procedures are for PyWPS when using flask-based server. But this formula is not intended to be used in a production environment. For production, `sudo service apache2 restartApache httpd `_ or `nginx `_ servers are more advised. PyWPS is runs as a `WSGI `_ application on those servers. PyWPS relies on the `Werkzeug `_ library for this purpose. Deploying an individual PyWPS instance -------------------------------------- PyWPS should be installed in your computer (as per the :ref:`installation` section). As a following step, you can now create several instances of your WPS server. It is advisable for each PyWPS instance to have its own directory, where the WSGI file along with available processes should reside. Therefore create a new directory for the PyWPS instance:: $ sudo mkdir /path/to/pywps/ # create a directory for your processes too $ sudo mkdir /path/to/pywps/processes .. note:: In this configuration example it is assumed that there is only one instance of PyWPS on the server. Each instance is represented by a single `WSGI` script (written in Python), which: 1. Loads the configuration files 2. Serves processes 3. Takes care about maximum number of concurrent processes and similar Creating a PyWPS `WSGI` instance -------------------------------- An example WSGI script is distributed along with the pywps-flask service, as described in the :ref:`installation` section. The script is actually straightforward - in fact, it's a just wrapper around the PyWPS server with a list of processes and configuration files passed as arguments. Here is an example of a PyWPS WSGI script:: $ $EDITOR /path/to/pywps/pywps.wsgi .. code-block:: python :linenos: #!/usr/bin/env python3 from pywps.app.Service import Service # processes need to be installed in PYTHON_PATH from processes.sleep import Sleep from processes.ultimate_question import UltimateQuestion from processes.centroids import Centroids from processes.sayhello import SayHello from processes.feature_count import FeatureCount from processes.buffer import Buffer from processes.area import Area processes = [ FeatureCount(), SayHello(), Centroids(), UltimateQuestion(), Sleep(), Buffer(), Area() ] # Service accepts two parameters: # 1 - list of process instances # 2 - list of configuration files application = Service( processes, ['/path/to/pywps/pywps.cfg'] ) .. note:: The WSGI script is assuming that there are already some processes at hand that can be directly included. Also it assumes, that the configuration file already exists - which is not the case yet. The Configuration is described in next chapter (:ref:`configuration`), as well as process creation and deployment (:ref:`process`). Deployment on Apache2 httpd server ---------------------------------- First, the WSGI module must be installed and enabled:: $ sudo apt-get install libapache2-mod-wsgi $ sudo a2enmod wsgi You then can edit your site configuration file (`/etc/apache2/sites-enabled/yoursite.conf`) and add the following:: # PyWPS WSGIDaemonProcess pywps home=/path/to/pywps user=www-data group=www-data processes=2 threads=5 WSGIScriptAlias /pywps /path/to/pywps/pywps.wsgi process-group=pywps WSGIScriptReloading On WSGIProcessGroup pywps WSGIApplicationGroup %{GLOBAL} Require all granted .. note:: `WSGIScriptAlias` points to the `pywps.wsgi` script created before - it will be available under the url http://localhost/pywps .. note:: Please make sure that the `logs`, `workdir`, and `outputpath` directories are writeable to the Apache user. The `outputpath` directory need also be accessible from the URL mentioned in `outputurl` configuration. And of course restart the server:: $ sudo service apache2 restart Deployment on Nginx-Gunicorn ---------------------------- .. note:: We will use Greenunicorn for pyWPS deployment, since it is a very simple to configurate server. For difference between WSGI server consult: `WSGI comparison `_. uWSGU is more popular than gunicorn, best documentation is probably to be found at `Readthedocs `_. We need nginx and gunicorn server:: $ apt install nginx-full $ apt install gunicorn3 It is assumed that PyWPS is installed in your system (if not see: ref:`installation`) and we will use pywps-flask as installation example. First, cloning the pywps-flask example to the root / (you need to be sudoer or root to run the examples):: $ cd / $ git clone https://github.com/geopython/pywps-flask.git Second, preparing the WSGI script for gunicorn. It is necessary that the WSGI script located in the pywps-flask service is identified as a python module by gunicorn, this is done by creating a link with .py extention to the wsgi file:: $ cd /pywps-flask/wsgi $ ln -s ./pywps.wsgi ./pywps_app.py Gunicorn can already be tested by setting python path on the command options:: $ gunicorn3 -b 127.0.0.1:8081 --workers $((2*`nproc --all`)) --log-syslog --pythonpath /pywps-flask wsgi.pywps_app:application The command will start a gunicorn instance on the localhost IP and port 8081, logging to systlog (/var/log/syslog), using pywps process folder /pywps-flask/processes and loading module wsgi.pywps_app and object/function application for WSGI. .. note:: Gunicorn uses a prefork model where the master process forks processes (workers) that willl accept incomming connections. The --workers flag sets the number of processes, the default values is 1 but the recomended value is 2 or 4 times the number of CPU cores. Next step is to configure NGINX, by pointing to the WSGI server by changing the location paths of the default site file but editing file /etc/nginx/sites-enabled as follows::: server { listen 80 default_server; listen [::]:80 default_server; server_name _; #better to redirect / to wps application location / { return 301 /wps; } location /wps { # with try_files active there will be problems #try_files $uri $uri/ =404; proxy_set_header Host $host; proxy_redirect off; proxy_set_header X-NginX-Proxy true; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8081; } } It is likely that part of the proxy configuration is already set on the file /etc/nginx/proxy.conf. Of course the necessatyrestart of nginx :: $ service nginx restart The service will now be available on the IP of the server or localhost :: http://localhost/wps?request=GetCapabilities&service=wps The current gunicorn instance was launched by the user. In a production server it is necessary to set gunicorn as a service On ubuntu 16.04 the systemcltd system requires a service file that will start the gunicorn3 service. The service file (/lib/systemd/system/gunicorn.service) has to be configure as follows:: [Unit] Description=gunicorn3 daemon After=network.target [Service] User=www-data Group=www-data PIDFile=/var/run/gunicorn3.pid Environment=WORKERS=3 ExecStart=/usr/bin/gunicorn3 -b 127.0.0.1:8081 --preload --workers $WORKERS --log-syslog --pythonpath /pywps-flask wsgi.pywps_app:application ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s TERM $MAINPID [Install] WantedBy=multi-user.target And then enable the service and then reload the systemctl daemon:: $ systemctl enable gunicorn3.service $ systemctl daemon-reload $ systemctl restart gunicorn3.service And to check that everything is ok:: $ systemctl status gunicorn3.service .. note:: Todo NGIX + uWSGI .. _deployment-testing: Testing the deployment of a PyWPS instance ------------------------------------------ .. note:: For the purpose of this documentation, it is assumed that you've installed PyWPS using the `localhost` server domain name. As stated, before, PyWPS should be available at http://localhost/pywps, we now can visit the url (or use `wget`):: # the --content-error parameter makes sure, error response is displayed $ wget --content-error -O - "http://localhost/pywps" The result should be an XML-encoded error message. .. code-block:: xml service The server responded with the :py:class:`pywps.exceptions.MissingParameterValue` exception, telling us that the parameter `service` was not set. This is compliant with the OGC WPS standard, since each request mast have at least the `service` and `request` parameters. We can say for now, that this PyWPS instance is properly deployed on the server, since it returns proper exception report. We now have to configure the instance by editing the `pywps.cfg` file and adding some processes. pywps-4.5.1/docs/exceptions.rst000066400000000000000000000011101415166246000165440ustar00rootroot00000000000000.. _exceptions: Exceptions ========== .. module:: pywps.exceptions PyWPS will throw exceptions based on the error occurred. The exceptions will point out what is missing or what went wrong as accurately as possible. Here is the list of Exceptions and HTTP error codes associated with them: .. autoclass:: NoApplicableCode .. autoclass:: InvalidParameterValue .. autoclass:: MissingParameterValue .. autoclass:: FileSizeExceeded .. autoclass:: VersionNegotiationFailed .. autoclass:: OperationNotSupported .. autoclass:: StorageNotSupported .. autoclass:: NotEnoughStorage pywps-4.5.1/docs/extensions.rst000066400000000000000000000076211415166246000165770ustar00rootroot00000000000000.. _extensions: Extensions ========== PyWPS has extensions to enhance its usability in special uses cases, for example to run Web Processing Services at High Performance Compute (HPC) centers. These extensions are disabled by default. They need a modified configuration and have additional software packages. The extensions are: * Using batch job schedulers (distributed resource management) at HPC compute centers. * Using container solutions like `Docker `_ in a cloud computing infrastructure. Job Scheduler Extension ----------------------- By default PyWPS executes all processes on the same machine as the PyWPS service is running on. Using the PyWPS scheduler extension it becomes possible to delegate the execution of asynchronous processes to a scheduler system like `Slurm `_, `Grid Engine `_ and `TORQUE `_. By enabling this extension one can handle the processing workload using an existing scheduler system commonly found at High Performance Compute (HPC) centers. .. note:: The PyWPS process implementations are not changed by using the scheduler extension. To activate this extension you need to edit the ``pywps.cfg`` configuration file and make the following changes:: [processing] mode = scheduler The scheduler extension uses the `DRMAA`_ library to talk to the different scheduler systems. Install the additional Python dependencies using pip:: $ pip install -r requirements-processing.txt # drmaa If you are using the `conda `_ package manager you can install the dependencies with:: $ conda install drmaa dill The package `dill`_ is an enhanced version of the Python pickle module for serializing and de-serializing Python objects. .. warning:: In addition you need to install and configure the drmaa modules for your scheduler system on the machine PyWPS is running on. Follow the instructions given in the `DRMAA`_ documentation and by your scheduler system installation guide. .. note:: See an **example** on how to use this extension with a Slurm batch system in a `docker demo `_. .. note:: `COWS WPS `_ has a scheduler extension for Sun Grid Engine (SGE). --------------------------------------------- Interactions of PyWPS with a scheduler system --------------------------------------------- The PyWPS scheduler extension uses the Python `dill`_ library to dump and load the processing job to/from filesystem. The batch script executed on the scheduler system calls the PyWPS ``joblauncher`` script with the dumped job status and executes the job (no WPS service running on scheduler). The job status is updated on the filesystem. Both the PyWPS service and the ``joblauncher`` script use the same PyWPS configuration. The scheduler assumes that the PyWPS server has a shared filesystem with the scheduler system so that XML status documents and WPS outputs can be found at the same file location. See the interaction diagram how the communication between PyWPS and the scheduler works. .. figure:: _images/pywps-scheduler-extension_interactions.png Interaction diagram for PyWPS scheduler extension. The following image shows an example of using the scheduler extension with Slurm. .. figure:: _images/pywps-slurm-demo-architecture.png Example of PyWPS scheduler extension usage with Slurm. .. _DRMAA: https://pypi.python.org/pypi/drmaa .. _dill: https://pypi.python.org/pypi/dill Docker Container Extension --------------------------- .. todo:: This extension is on our wish list. In can be used to encapsulate and control the execution of a process. It enhances also the use case of Web Processing Services in a cloud computing infrastructure. pywps-4.5.1/docs/external-tools.rst000066400000000000000000000033101415166246000173470ustar00rootroot00000000000000PyWPS and external tools ======================== GRASS GIS --------- PyWPS can handle all the management needed to setup temporal GRASS GIS environemtn (GRASS DBASE, Location and Mapset) for you. You just need to configure it in the :class:`pywps.Process`, using the parameter ``grass_location``, which can have 2 possible values: ``epsg:[EPSG_CODE]`` New temporal location is created using the EPSG code given. PyWPS will create temporal directory as GRASS Location and remove it after the WPS Execute response is constructed. ``/path/to/grassdbase/location/`` Existing absolute path to GRASS Location directory. PyWPS will create temporal GRASS Mapset direcetory and remove it after the WPS Exceute response is constructed. Then you can use Python - GRASS interfaces in the execute method, to make the work. .. note:: Even PyWPS supports GRASS integration, the data still need to be imported using GRASS modules ``v.in.*`` or ``r.in.*`` and also they have to be exported manually at the end. .. code-block:: python def execute(request, response): from grass.script import core as grass grass.run_command('v.in.ogr', input=request.inputs["input"][0].file, ...) ... grass.run_command('v.out.ogr', input="myvector", ...) Also do not forget to set ``gisbase`` :ref:`configuration` option. OpenLayers WPS client --------------------- ZOO-Project ----------- `ZOO-Project `_ provides both a server (C) and client (JavaScript) framework. QGIS WPS Client --------------- The `QGIS WPS `_ client provides a plugin with WPS support for the QGIS Desktop GIS. pywps-4.5.1/docs/index.rst000066400000000000000000000020601415166246000154770ustar00rootroot00000000000000.. _index: Welcome to the PyWPS |release| documentation! ============================================= PyWPS is a server side implementation of the `OGC Web Processing Service (OGC WPS) standard `_, using the `Python `_ programming language. PyWPS is currently supporting WPS 1.0.0. Support for the version 2.0.0. of OGC WPS standard is presently being planned. Like the bicycle in the logo, PyWPS is: * simple to maintain * fast to drive * able to carry a lot * easy to hack **Mount your bike and setup your PyWPS instance!** .. todo:: * request queue management (probably linked from documentation) * inputs and outputs IOhandler class description (file, stream, ...) Contents: --------- .. toctree:: :maxdepth: 3 wps pywps install configuration process deployment migration external-tools extensions api api_rest contributing exceptions ================== Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` pywps-4.5.1/docs/install.rst000066400000000000000000000103101415166246000160330ustar00rootroot00000000000000.. _installation: Installation ============ .. note:: PyWPS is not tested on the MS Windows platform. Please join the development team if you need this platform to be supported. This is mainly because of the lack of a multiprocessing library. It is used to process asynchronous execution, i.e., when making requests storing the response document and updating a status document displaying the progress of execution. Dependencies and requirements ----------------------------- PyWPS runs on Python 2.7, 3.3 or higher. PyWPS is currently tested and developed on Linux (mostly Ubuntu). In the documentation we take this distribution as reference. Prior to installing PyWPS, Git and the Python bindings for GDAL must be installed in the system. In Debian based systems these packages can be installed with a tool like *apt*:: $ sudo apt-get install git python-gdal Alternatively, if GDAL is already installed on your system you can install the GDAL Python bindings via pip with:: $ pip install GDAL==1.10.0 --global-option=build_ext --global-option="-I/usr/include/gdal" Download and install -------------------- Using pip The easiest way to install PyWPS is using the Python Package Index (PIP). It fetches the source code from the repository and installs it automatically in the system. This might require superuser permissions (e.g. *sudo* in Debian based systems):: $ sudo pip install -e git+https://github.com/geopython/pywps.git@master#egg=pywps-dev .. todo:: * document Debian / Ubuntu package support Manual installation Manual installation of PyWPS requires `downloading `_ the source code followed by usage of the `setup.py` script. An example again for Debian based systems (note the usage of `sudo` for install):: $ tar zxf pywps-x.y.z.tar.gz $ cd pywps-x.y.z/ Then install the package dependencies using pip:: $ pip install -r requirements.txt $ pip install -r requirements-gdal.txt # for GDAL Python bindings (if python-gdal is not already installed by `apt-get`) $ pip install -r requirements-dev.txt # for developer tasks To install PyWPS system-wide run:: $ sudo python setup.py install For Developers Installation of the source code using Git and Python's virtualenv tool:: $ virtualenv my-pywps-env $ cd my-pywps-env $ source bin/activate $ git clone https://github.com/geopython/pywps.git $ cd pywps Then install the package dependencies using pip as described in the Manual installation section. To install PyWPS:: $ python setup.py install Note that installing PyWPS via a virtualenv environment keeps the installation of PyWPS and its dependencies isolated to the virtual environment and does not affect other parts of the system. This installation option is handy for development and / or users who may not have system-wide administration privileges. .. _flask: The Flask service and its sample processes ------------------------------------------ To use PyWPS the user must code processes and publish them through a service. An example service is available that makes up a good starting point for first time users. It launches a very simple built-in server (relying on the `Flask Python Microframework `_), which is good enough for testing but probably not appropriate for production. This example service can be cloned directly into the user area:: $ git clone https://github.com/geopython/pywps-flask.git It may be run right away through the `demo.py` script. First time users should start by studying the structure of this project and then code their own processes. There is also an example service Full more details please consult the :ref:`process` section. The example service contains some basic processes too, so you could get started with some examples (like `area`, `buffer`, `feature_count` and `grassbuffer`). These processes are to be taken just as inspiration and code documentation - most of them do not make any sense (e.g. `sayhello`). pywps-4.5.1/docs/max_operations.dia000066400000000000000000000112571415166246000173550ustar00rootroot00000000000000]r}Py򤁱/QԤf&I*-VšvGXpIyEj!ʔa^=\.l:fb |1H7d~?[}ݧO?wY2؟/ỏy/_2NVh^'NK;ӇIgO<&׫<̓Y:]Xt >'ӏ_\n?}jAHoL 'mJ d2xGVYZb!N<~}]}xf'&1bq1vfi\M4],6^׬~}:Ͻ?ڷ {m&%ĸtzMB{Kn'dZ}.[Z\/ڤVg$[)R\V\Aq͐ Vu h.+6JXRЖ2sX=[D Z1|5YU܁6i.y\|p&Gz|y1X揀m:擛M: F1x ܊m6r6iŁ@F%wYZLz!RKӎ+UU#Uq~_k|nbr뽟epoQvc`CxY]~:c9CB yH(/$0Ki#t)r! B,8C2$ e`\xC_SPVԋMEpWY29⮘G 9LH7 0a) LYrFwq^?BURDJI:q gVXͮlӫ凟Mч-^~9K>dtLtKbHMQOR,a#'c ǒO0RCJ VH!U+vHkKxD=_=v!~XkrAhj@"gvEp/H#o "X4"jh qċzb] ť-]u;|'jiYȍFD2K"M4JRYcXmP emDnӲbʰWKi:Ed~RzH%,>G=^%)_UWv*#d-!:ɽVwRC&[i c`Za[K]su-YvY2p-92цt-#s~eA]K2l2԰Q#v1?Mxҳ#bL#a4<8&|`2[Elw&4F0)Tp&Ep7[c w[z,z Ljl+±bYʨgZApEԠ? =P;zz-EԮB; II%ْ2ʔzֵΪ bu~HFrӢNМ)Zi6ZΥP.ڈkmbw3zvt*M'[/8=͹׶t:jez]R=Vz`z䌨T/R=ΨzVG-ų{H "3;]$w}!w<$;.F8"R(V!!+5nC^b o΃ra^:D!PE/uPkZ :]dDDE # S €#1gE鱙~*ks4Uu"noۊ4u~mdb6@8<2Bs^FriЂ(K#&t[C̷o<ҢjfY6;K BYaHFoӏw C #CnDϬ)YU:8fj #xfTvtKlPľ`'>c ALf} -=K$R~M0"`Sn~({fdV&3)YW #$2jEG@D@Fؼ #K! z(B1]"<0?HN=ʙ!9$ 1 1 B_L.\Ŀ-ѯgs=J"-)x`@* Dz*"$EHj܅I&IИ$]'eIT0#&5ˆIo܊3歨frW@?s(|Vsܖ3U?{+n[vST+l bn8Ruͷ_Tu l5ۏ&].凟[m!ɒ4>{~J wЕXmTbfWľ?m9gX#\ ) 8i &Y9h9b:s҄Ϝd85;)$E3C ;B /[2ޞd8 AՉ[yPnϖAj:Fm(2ޞc0f`QKnņmʭ1KȐiDQI֏"%?36.BTPm18Z3CfᒂZ+i ށZoNpGXU b2EA|]&sH,2p:T+|X{AK@ ~L[G~wXwCLQ$%FeԏōޯV_o@Z:RdVC [M7qPPpS $OTWҧC\v B~S@^VX <XAdL\G&eaP@&P@ @KENS O"bQqDM6#b1Eԁđs"!#E {aAȑ%)=e9;h;61&`r48ZTB ~Ln+˳WҗmLUo'?:";pM25NT;pSbAZsC YJ"L>"Sk^yS(F I@0'*ǥ/0T`p{;F0-d3WjHy&l\R!Xo XȬhN}$I t˰$43Ņ fd7A7EVq 499 . zo%i HFԮB$m+U8ϦXjaL9!isUҜ>(9|b]PՐ"C- 줖EH8G}Z1@ špjG^$3 o6!} aH av3JX3@鰽/bh<1>7J+ȇp/o'E/>M:Chb0RX@*T.h1&u Rc"HoA9'xlcL}Ƴ$2ǞTW{UEYB%ڙwV.U+: 5889Γ HMFQhuh:z،\:krWa:xmk+! 1"-2ym/v JbQGiM6jFj1sǼSbo Džoiz3CusТ]A"hE(Lň!F!p{YCK}.1t{V2dE[ڬP%`%{J V՗]2^~dxuK4CPP㎭1\ u%HBCdav!"8"qNpxb= -Cg]hRLBӎLپB(@+:U8,nqK- zQ$Wb9CNtL_cx-Vc}P~ceӇ)Rpywps-4.5.1/docs/migration.rst000066400000000000000000000076141415166246000163730ustar00rootroot00000000000000.. _migration: Migrating from PyWPS 3.x to 4.x =============================== The basic concept of PyWPS 3.x and 4.x remains the same: You deploy PyWPS once and can have many instances with set of processes. It's good practice to store processes in single files, although it's not required. .. note:: Unluckily, there is not automatic tool for conversion of processes nor compatibility module. If you would like to sponsor development of such module, please contact Project Steering Committee via PyWPS mailing list or members of PSC directly. Configuration file ------------------- Configuration file format remains the same (it's the one used by `configparser `_ module). The sections are shift a bit, so they are more alike another GeoPython project - `pycsw `_. See section :ref:`configuration`. Single process definition ------------------------- The main principle remains the same between 3.x and 4.x branches: You have to define process class `class` and it's `__init__` method with inputs and outputs. The former `execute()` method can now be any function and is assigned as `handler` attribute. `handler` function get's two arguments: `request` and `response`. In `requests`, all input data are stored, `response` will have output data assinged. The main difference between 3.x and 4.x is, *every input is list of inputs*. The reason for such behaviour is, that you, as user of PyWPS define input defined by type and identifier. When PyWPS process is turned to running job, there can be usually *more then one input with same identifier* defined. Therefore instead of calling:: def execute(self): ... # 3.x inputs input = self.my_input.getValue() you shall use first index of an input list:: def handler(request, response): ... # 4.X inputs input = request.inputs['my_input'][0].file Inputs and outputs data manipulation ------------------------------------ Btw, PyWPS Inputs do now have `file`, `data`, `url` and `stream` attributes. They are transparently converting one data-representation-type to another. You can read input data from file-like object using `stream` or get directly the data into variable with `input.data`. You can also save output data directly using `output.data = { ..... }`. See more in :ref:`process` Deployment ========== While PyWPS 3.x was usually deployed as CGI application, PyWPS 4.x is configured as `WSGI` application. PyWPS 4.x is distributed without any processes or sample deploy script. We provide such example in our `pywps-flask `_ project. .. note:: PYWPS_PROCESSES environment variable is gone, you have to assing processes to deploy script manually (or semi-automatically). For deployment script, standard WSGI application as used by `flask microframework `_ has to be defined, which get's two parameters: list of processes and configuration files:: from pywps.app.Service import Service from processes.buffer import Buffer processes = [Buffer()] application = Service(processes, ['wps.cfg']) Those 4 lines of code do deploy PyWPS with Buffer process. This gives you more flexible way, how to define processes, since you can pass new variables and config values to each process class instance during it's definition. Sample processes ================ For sample processes, please refer to `pywps-flask `_ project on GITHub. Needed steps summarization ========================== #. Fix configuration file #. Every processes needs new class and inputs and outputs definition #. In `execute` method, you just need to review inputs and outputs data assignment, but the core of the method should remain the same. #. Replace shell or python-based CGI script with Flask-based WSGI script pywps-4.5.1/docs/process.rst000066400000000000000000000513131415166246000160530ustar00rootroot00000000000000.. currentmodule:: pywps .. _process: Processes ######### .. versionadded:: 4.0.0 .. todo:: * Input validation * IOHandler PyWPS works with processes and services. A process is a Python `Class` containing an `handler` method and a list of inputs and outputs. A PyWPS service instance is then a collection of selected processes. PyWPS does not ship with any processes predefined - it's on you, as user of PyWPS to set up the processes of your choice. PyWPS is here to help you publishing your awesome geospatial operation on the web - it takes care of communication and security, you then have to add the content. .. note:: There are some example processes in the `PyWPS-Flask`_ project. Writing a Process ================= .. note:: At this place, you should prepare your environment for final :ref:`deployment`. At least, you should create a single directory with your processes, which is typically named `processes`:: $ mkdir processes In this directory, we will create single python scripts containing processes. Processes can be located *anywhere in the system* as long as their location is identified in the :envvar:`PYTHONPATH` environment variable, and can be imported in the final server instance. A processes is coded as a class inheriting from :class:`Process`. In the `PyWPS-Flask`_ server they are kept inside the *processes* folder, usually in separated files. The instance of a *Process* needs following attributes to be configured: :identifier: unique identifier of the process :title: corresponding title :inputs: list of process inputs :outputs: list of process outputs :handler: method which recieves :class:`pywps.app.WPSRequest` and :class:`pywps.response.WPSResponse` as inputs. Example vector buffer process ============================= As an example, we will create a *buffer* process - which will take a vector file as the input, create specified the buffer around the data (using `Shapely `_), and return back the result. Therefore, the process will have two inputs: * `ComplexData` input - the vector file * `LiteralData` input - the buffer size And it will have one output: * `ComplexData` output - the final buffer The process can be called `demobuffer` and we can now start coding it:: $ cd processes $ $EDITOR demobuffer.py At the beginning, we have to import the required classes and modules Here is a very basic example: .. literalinclude:: demobuffer.py :language: python :lines: 28-31 :linenos: :lineno-start: 28 As the next step, we define a list of inputs. The first input is :class:`pywps.ComplexInput` with the identifier `vector`, title `Vector map` and there is only one allowed format: GML. The next input is :class:`pywps.LiteralInput`, with the identifier `size` and the data type set to `float`: .. literalinclude:: demobuffer.py :language: python :lines: 33-40 :linenos: :lineno-start: 33 Next we define the output `output` as :class:`pywps.ComplexOutput`. This output supports GML format only. .. literalinclude:: demobuffer.py :language: python :lines: 42-46 :linenos: :lineno-start: 42 Next we create a new list variables for inputs and outputs. .. literalinclude:: demobuffer.py :language: python :lines: 48-49 :linenos: :lineno-start: 48 Next we define the *handler* method. In it, *geospatial analysis may happen*. The method gets a :class:`pywps.app.WPSRequest` and a :class:`pywps.response.WPSResponse` object as parameters. In our case, we calculate the buffer around each vector feature using `GDAL/OGR library `_. We will not got much into the details, what you should note is how to get input data from the :class:`pywps.app.WPSRequest` object and how to set data as outputs in the :class:`pywps.response.WPSResponse` object. .. literalinclude:: demobuffer.py :language: python :pyobject: _handler :emphasize-lines: 8-12, 50-54 :linenos: :lineno-start: 68 At the end, we put everything together and create new a `DemoBuffer` class with handler, inputs and outputs. It's based on :class:`pywps.Process`: .. literalinclude:: demobuffer.py :pyobject: DemoBuffer :language: python :linenos: :lineno-start: 51 Declaring inputs and outputs ============================ Clients need to know which inputs the processes expects. They can be declared as :class:`pywps.Input` objects in the :class:`Process` class declaration: .. code-block:: python from pywps import Process, LiteralInput, LiteralOutput class FooProcess(Process): def __init__(self): inputs = [ LiteralInput('foo', data_type='string'), ComplexInput('bar', [Format('text/xml')]) ] outputs = [ LiteralOutput('foo_output', data_type='string'), ComplexOutput('bar_output', [Format('JSON')]) ] super(FooProcess, self).__init__( ... inputs=inputs, outputs=outputs ) ... .. note:: A more generic description can be found in :ref:`wps` chapter. LiteralData ----------- * :class:`LiteralInput` * :class:`LiteralOutput` A simple value embedded in the request. The first argument is a name. The second argument is the type, one of `string`, `float`, `integer` or `boolean`. ComplexData ----------- * :class:`ComplexInput` * :class:`ComplexOutput` A large data object, for example a layer. ComplexData do have a `format` attribute as one of their key properties. It's either a list of supported formats or a single (already selected) format. It shall be an instance of the :class:`pywps.inout.formats.Format` class. ComplexData :class:`Format` and input validation ------------------------------------------------ The ComplexData needs as one of its parameters a list of supported data formats. They are derived from the :class:`Format` class. A :class:`Format` instance needs, among others, a `mime_type` parameter, a `validate` method -- which is used for input data validation -- and also a `mode` parameter -- defining how strict the validation should be (see :class:`pywps.validator.mode.MODE`). The `Validate` method is up to you, the user, to code. It requires two input paramers - `data_input` (a :class:`ComplexInput` object), and `mode`. This methid must return a `boolean` value indicating whether the input data are considered valid or not for given `mode`. You can draw inspiration from the :py:func:`pywps.validator.complexvalidator.validategml` method. The good news is: there are already predefined validation methods for the ESRI Shapefile, GML and GeoJSON formats, using GDAL/OGR. There is also an XML Schema validaton and a JSON schema validator - you just have to pick the propper supported formats from the :class:`pywps.inout.formats.FORMATS` list and set the validation mode to your :class:`ComplexInput` object. Even better news is: you can define custom validation functions and validate input data according to your needs. BoundingBoxData --------------- * :class:`BoundingBoxInput` * :class:`BoundingBoxOutput` BoundingBoxData contain information about the bounding box of the desired area and coordinate reference system. Interesting attributes of the BoundingBoxData are: `crs` current coordinate reference system `dimensions` number of dimensions `ll` pair of coordinates (or triplet) of the lower-left corner `ur` pair of coordinates (or triplet) of the upper-right corner Accessing the inputs and outputs in the `handler` method ======================================================== Handlers receive as input argument a :class:`WPSRequest` object. Input values are found in the `inputs` dictionary:: @staticmethod def _handler(request, response): name = request.inputs['name'][0].data response.outputs['output'].data = 'Hello world %s!' % name return response `inputs` is a plain Python dictionary. Most of the inputs and outputs are derived from the :class:`IOHandler` class. This enables the user to access the data in four different ways: `input.file` Returns a file name - you can access the data using the name of the file stored on the hard drive. `input.url` Return a link to the resource using either the ``file://`` or ``http://`` scheme. The target of the url is not downloaded to the PyWPS server until its content is explicitly accessed through either one of the ``file``, ``data`` or ``stream`` attributes. `input.data` Is the direct link to the data themselves. No need to create a file object on the hard drive or opening the file and closing it - PyWPS will do everything for you. `input.stream` Provides the IOStream of the data. No need for opening the file, you just have to `read()` the data. Because there could be multiple input values with the same identifier, the inputs are accessed with an index. For example:: request.inputs['file_input'][0].file request.inputs['data_input'][0].data request.inputs['stream_input'][0].stream url_input = request.inputs['url_input'][0] As mentioned, if an input is a link to a remote file (an ``http`` address), accessing the ``url`` attribute simply returns the url's string, but accessing any other attribute triggers the file's download:: url_input.url # returns the link as a string (no download) url_input.file # downloads target and returns the local path url_input.data # returns the content of the local copy PyWPS will persistently transform the input (and output) data to the desired form. You can also set the data for your `Output` object like `output.data = 1` or `output.file = "myfile.json"` - it works the same way. However, once the source type is set, it cannot be changed. That is, a `ComplexOutput` whose ``data`` attribute has been set once has read-only access to the three other attributes (``file``, ``stream`` and ``url``), while the ``data`` attribute can be freely modified. Progress and status report ========================== OGC WPS standard enables asynchronous process execution call, that is in particular useful, when the process execution takes longer time - process instance is set to background and WPS Execute Response document with `ProcessAccepted` messag is returned immediately to the client. The client has to check `statusLocation` URL, where the current status report is deployed, say every n-seconds or n-minutes (depends on calculation time). Content of the response is usually `percentDone` information about the progress along with `statusMessage` text information, what is currently happening. You can set process status any time in the `handler` using the :py:func:`WPSResponse.update_status` function. Returning large data ==================== WPS allows for a clever method of returning a large data file: instead of embedding the data in the response, it can be saved separately, and a URL is returned from where the data can be downloaded. In the current implementation, PyWPS saves the file in a folder specified in the configuration passed by the service (or in a default location). The URL returned is embedded in the XML response. This behaviour can be requested either by using a GET:: ...ResponseDocument=output=@asReference=true... Or a POST request:: ... output Some Output ... **output** is the identifier of the output the user wishes to have stored and accessible from a URL. The user may request as many outputs by reference as needed, but only *one* may be requested in RAW format. Returning multiple files ======================== When a process accepts a variable number of inputs, it often makes sense to return a variable number of outputs. The WPS standard does not however readily accommodate this. One pragmatic solution is to compress the files into a single output archive (e.g. zip file), but this proves to be awkward when the outputs are really just references to resources (URLs). In this case, another pragmatic solution is to return a simple text file storing the list of references. One issue with this is that it provides clients very little metadata about the file content. Although it would be fairly easy to define a json output file storing the properties and URLs of multiple files, it would require an ad-hoc implementation on the client side to parse the json and extract the urls metadata. Fortunately, the `metalink`_ standard already exists precisely to bundle references to multiples files. Metalink files are XML documents collecting a set of remote files. It was originally designed to describe the location of larges files stored on multiple mirrors or peer-to-peer networks. If one location goes down during download, metalink clients can switch to another mirror. Also, large files can be split into segments and downloaded concurrently from different locations, speeding up downloads. A metalink can also describe the location of files made for different operating systems and languages, with clients automatically selecting the most appropriate one. Metalink support in PyWPS includes: - `pywps.FORMATS.METALINK` and `pywps.FORMATS.META4` - helper classes :class:`MetaFile`, :class:`MetaLink` and :class:`MetaLink4` - validation of generated metalink files using XML schemas - size (bytes) and checksums (sha-256) for each file in the metalink document To use metalink in a process, define a :class:`ComplexOutput` with a metalink mimetype. Then after the handler has generated a list of file, instantiate one :class:`MetaFile` object for each output file, and append them to a :class:`MetaLink` or :class:`MetaLink4` instance. Finally, set the data property of the output to the xml generated by the `xml` property of the :class:`MetaLink` instance. .. note:: :class:`MetaLink` uses metalink standard version 3.0, while :class:`MetaLink4` uses version 4.0. Example process --------------- .. literalinclude:: ../tests/processes/metalinkprocess.py :language: python Process Exceptions ================== Any uncatched exception in the process execution will be handled by PyWPS and reported to the WPS client using an `ows:Exception`. PyWPS will only log the traceback and report a common error message like: *Process failed, please check server error log.* This sparse error message is used to avoid security issues by providing internal service information in an uncontrolled way. But in some cases you want to provide a user-friendly error message to give the user a hint of what went wrong with the processing job. In this case you can use the :class:`pywps.app.exceptions.ProcessError` exception. The error message will be send to the user encapsulated as `ows:Exception`. The :class:`pywps.app.exceptions.ProcessError` validates the error message to make sure it is not too long and it does not contain any suspicious characters. .. note:: By default a valid error message must have a length between 3 and 144 characters. Only alpha-numeric characters and a few special ones are allowed. The allowed special characters are: ".", ":", "!", "?", "=", ",", "-". .. note:: During the process development you might want to get a traceback shown in `ows:Exception`. This is possible by running PyWPS in debug mode. In `pywps.cfg` config file set:: [logging] level=DEBUG Example process --------------- .. literalinclude:: show_error.py :language: python Process deployment ================== In order for clients to invoke processes, a PyWPS :class:`Service` class must be present with the ability to listen for requests. An instance of this class must created, receiving instances of all the desired processes classes. In the *flask* example service the :class:`Service` class instance is created in the :class:`Server` class. :class:`Server` is a development server that relies on `Flask`_. The publication of processes is encapsulated in *demo.py*, where a main method passes a list of processes instances to the :class:`Server` class:: from pywps import Service from processes.helloworld import HelloWorld from processes.demobuffer import DemoBuffer ... processes = [ DemoBuffer(), ... ] server = Server(processes=processes) ... Running the dev server ====================== The :ref:`flask` server is a `WSGI application`_ that accepts incoming `Execute` requests and calls the appropriate process to handle them. It also answers `GetCapabilities` and `DescribeProcess` requests based on the process identifier and their inputs and outputs. .. _WSGI application: http://werkzeug.pocoo.org/docs/terms/#wsgi A host, a port, a config file and the processes can be passed as arguments to the :class:`Server` constructor. **host** and **port** will be **prioritised** if passed to the constructor, otherwise the contents of the config file (`pywps.cfg`) are used. Use the `run` method to start the server:: ... s = Server(host='localhost', processes=processes, config_file=config_file) s.run() ... To make the server visible from another computer, replace ``localhost`` with ``0.0.0.0``. Supporting multiple languages ============================= Supporting multiple languages requires: - Setting the `language` property in the server configuration (see :ref:`server-configuration`) - Adding translations to :class:`Process`, inputs and outputs objects The expected translations format is always the same. The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property:: from pywps import Process, LiteralInput, LiteralOutput class SayHello(Process): def __init__(self): inputs = [ LiteralInput( 'name', title='Input name', abstract='The name to say hello to.', translations={"fr-CA": {"abstract": "Le nom à saluer."}} ) ], outputs=[ LiteralOutput( 'response', title='Output response', abstract='The complete output message.', translations={"fr-CA": { "title": "La réponse", "abstract": "Le message complet." }} ) ], super().__init__( self._handler, identifier='say_hello', title='Process Say Hello', abstract='Returns a literal string output with Hello plus the inputed name', version='1.0', inputs=inputs, outputs=outputs, store_supported=True, status_supported=True, translations={"fr-CA": { "title": "Processus Dire Bonjour", "abstract": "Retourne une chaine de caractères qui dit bonjour au nom fournit en entrée." }}, ) def _handler(self, request, response): ... The translation will default to the untranslated attribute of the base object if the key is not provided in the `translations` dictionnary. Automated process documentation =============================== A :class:`Process` can be automatically documented with `Sphinx`_ using the `autoprocess` directive. The :class:`Process` object is instantiated and its content examined to create, behind the scenes, a docstring in the Numpy format. This lets developers embed the documentation directly in the code instead of having to describe each process manually. For example:: .. autoprocess:: pywps.tests.DocExampleProcess :docstring: :skiplines: 1 would yield .. autoprocess:: pywps.tests.DocExampleProcess :docstring: :skiplines: 1 The :option:`docstring` option fetches the :class:`Process` docstring and appends it after the Reference section. The first lines of this docstring can be skipped using the :option:`skiplines` option. To use the `autoprocess` directive, first add `'sphinx.ext.napoleon'` and `'pywps.ext_autodoc'` to the list of extensions in the Sphinx configuration file :file:`conf.py`. Then, insert `autoprocess` directives in your documentation source files, just as you would use an `autoclass` directive, and build the documentation. Note that for input and output parameters, the `title` is displayed only if no `abstract` is defined. In other words, if both `title` and `abstract` are given, only the `abstract` will be included in the documentation to avoid redundancy. .. _Flask: http://flask.pocoo.org .. _PyWPS-Flask: https://github.com/geopython/pywps-flask .. _Sphinx: http://sphinx-doc.org .. _`metalink`: http://www.metalinker.org/ pywps-4.5.1/docs/pywps.rst000066400000000000000000000033121415166246000155530ustar00rootroot00000000000000.. _pywps: PyWPS ===== .. todo:: * how are things organised * storage * dblog * relationship to grass gis PyWPS philosophy ---------------- PyWPS is simple, fast to run, has low requirements on system resources, is modular. PyWPS solves the problem of exposing geospatial calculations to the web, taking care of security, data download, request acceptance, process running and final response construction. Therefore PyWPS has a bicycle in its logo. Why is PyWPS there ------------------ Many scientific researchers and geospatial services provider need to setup system, where the geospatial operations would be calculated on the server, while the system resources could be exposed to clients. PyWPS is here, so that you could set up the server fast, deploy your awesome geospatial calculation and expose it to the world. PyWPS is written in Python with support for many geospatial tools out there, like GRASS GIS, R-Project or GDAL. Python is the most geo-positive scripting language out there, therefore all the best tools have their bindings to Python in their pocket. PyWPS History ------------- PyWPS started in 2006 as scholarship funded by `German Foundation for Environment `_. During the years, it grow to version 4.0.x. In 2015, we officially entered to `OSGeo `_ incubation process. In 2016, `Project Steering Committee `_ has started. PyWPS was originally hosted by the `Wald server `_, nowadays, we moved to `GeoPython group on GitHub `_. Since 2016, we also have new domain `PyWPS.org `_. You can find more at `history page `_. pywps-4.5.1/docs/show_error.py000066400000000000000000000023161415166246000164050ustar00rootroot00000000000000from pywps import Process, LiteralInput from pywps.app.Common import Metadata from pywps.app.exceptions import ProcessError import logging LOGGER = logging.getLogger("PYWPS") class ShowError(Process): def __init__(self): inputs = [ LiteralInput('message', 'Error Message', data_type='string', abstract='Enter an error message that will be returned.', default="This process failed intentionally.", min_occurs=1,)] super(ShowError, self).__init__( self._handler, identifier='show_error', title='Show a WPS Error', abstract='This process will fail intentionally with a WPS error message.', metadata=[ Metadata('User Guide', 'https://pywps.org/')], version='1.0', inputs=inputs, # outputs=outputs, store_supported=True, status_supported=True ) @staticmethod def _handler(request, response): response.update_status('PyWPS Process started.', 0) LOGGER.info("Raise intentionally an error ...") raise ProcessError(request.inputs['message'][0].data) pywps-4.5.1/docs/storage.rst000066400000000000000000000036131415166246000160410ustar00rootroot00000000000000.. currentmodule:: pywps .. _storage: Storage ####### .. todo:: * Local file storage In PyWPS, storage covers the storage of both the results that we want to return to the user and the storage of the execution status of each process. AWS S3 ------- Amazon Web Services Simple Storage Service (AWS S3) can be used to store both process execution status XML documents and process result files. By using S3 we can allow easy public read access to process status and results on S3 using a variety of tools including the web browser, the AWS SDK and the AWS CLI. For more information about AWS S3 please see https://aws.amazon.com/s3/ and for information about working with an S3 bucket see https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html Requirements ============= In order to work with S3 storage, you must first create an S3 bucket. https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html#create-bucket-intro PyWPS uses the boto3 library to send requests to AWS. In order to make requests boto3 requires credentials which grant read and write access to the S3 bucket. Please see the boto3 guide on credentials for options on how to configure the credentials for your application. https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html An example of an IAM policy that will allow PyWPS to read and write to the S3 Bucket is described here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_s3_rw-bucket.html ``{ "Version": "2012-10-17", "Statement": [ { "Sid": "ListObjectsInBucket", "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::bucket-name"] }, { "Sid": "AllObjectActions", "Effect": "Allow", "Action": "s3:*Object", "Resource": ["arn:aws:s3:::bucket-name/*"] } ] }`` pywps-4.5.1/docs/wps.rst000066400000000000000000000232401415166246000152040ustar00rootroot00000000000000.. _wps: OGC Web Processing Service (OGC WPS) ==================================== `OGC Web Processing Service `_ standard provides rules for standardizing how inputs and outputs (requests and responses) for geospatial processing services. The standard also defines how a client can request the execution of a process, and how the output from the process is handled. It defines an interface that facilitates the publishing of geospatial processes and clients discovery of and binding to those processes. The data required by the WPS can be delivered across a network or they can be available at the server. .. note:: This description is mainly refering to 1.0.0 version standard, since PyWPS implements this version only. There is also 2.0.0 version, which we are about to implement in near future. WPS is intended to be state-less protocol (like any OGC services). For every request-response action, the negotiation between the server and the client has to start. There is no official way, how to make the server "remember", what was before, there is no communication history between the server and the client. Process ------- A process `p` is a function that for each input returns a corresponding output: .. math:: p: X \rightarrow Y where `X` denotes the domain of arguments `x` and `Y` denotes the co-domain of values `y`. Within the specification, process arguments are referred to as *process inputs* and result values are referred to as *process outputs*. Processes that have no process inputs represent value generators that deliver constant or random process outputs. *Process* is just some geospatial operation, which has it's in- and outputs and which is deployed on the server. It can be something relatively simple (adding two raster maps together) or very complicated (climate change model). It can take short time (seconds) or long (days) to be calculated. Process is, what you, as PyWPS user, want to expose to other people and let their data processed. Every process has the following properties: Identifier Unique process identifier Title Human readable title Abstract Longer description of the process, what it does, how is it supposed to be used And a list of inputs and outputs. Data inputs and outputs ----------------------- OGC WPS defines 3 types of data inputs and outputs: *LiteralData*, *ComplexData* and *BoundingBoxData*. All data types do need to have following properties: Identifier Unique input identifier Title Human readable title Abstract Longer description of data input or output, so that the user could get oriented. minOccurs Minimal occurrence of the input (e.g. there can be more bands of raster file and they all can be passed as input using the same identifier) maxOccurs Maxium number of times, the input or output is present Depending on the data type (Literal, Complex, BoundingBox), other attributes might occur too. LiteralData ~~~~~~~~~~~ Literal data is any text string, usually short. It's used for passing single parameters like numbers or text parameters. WPS enables to the server, to define `allowedValues` - list or intervals of allowed values, as well as data type (integer, float, string). Additional attributes can be set, such as `units` or `encoding`. ComplexData ~~~~~~~~~~~ Complex data are usually raster or vector files, but basically any (usually file based) data, which are usually processed (or result of the process). The input can be specified more using `mimeType`, XML `schema` or `encoding` (such as `base64` for raster data. .. note:: PyWPS (like every server) supports limited list `mimeTypes`. In case you need some new format, just create pull request in our repository. Refer :const:`pywps.inout.formats.FORMATS` for more details. Usually, the minimum requirement for input data identification is `mimeType`. That usually is `application/gml+xml` for `GML `_-encoded vector files, `image/tiff; subtype=geotiff` for raster files. The input or output can also be result of any OGC OWS service. BoundingBoxData ~~~~~~~~~~~~~~~ .. todo:: add reference to OGC OWS Common spec BoundingBox data are specified in OGC OWS Common specification as two pairs of coordinate (for 2D and 3D space). They can either be encoded in WGS84 or EPSG code can be passed too. They are intended to be used as definition of the target region. .. note:: In real life, BoundingBox data are not that commonly used Passing data to process instance -------------------------------- There are typically 3 approaches to pass the input data from the client to the server: **Data are on the server already** In the first case, the data are already stored on the server (from the point of view of the client). This is the simplest case. **Data are send to the server along with the request** In this case, the data are directly part of the XML encoded document send via HTTP POST. Some clients/servers are expecting the data to be inserted in `CDATA` section. The data can be text based (JSON), XML based (GML) or even raster based - in this case, they are usually encoded using `base64 `_. **Reference link to target service is passed** Client does not have to pass the data itself, client can just send reference link to target data service (or file). In such case, for example OGC WFS `GetFeatureType` URL can be passed and server will download the data automatically. Although this is usually used for `ComplexData` input type, it can be used for literal and bounding box data too. Synchronous versus asynchronous process request ----------------------------------------------- There are two modes of process instance execution: Synchronous and asynchronous. Synchronous mode The client sends the `Execute` request to the server and waits with open server connection, till the process is calculated and final response is returned back. This is useful for fast calculations which do not take longer then a couple of seconds (`Apache2 httpd server uses 300 seconds `_ as default value for ConnectionTimeout). Asynchronous mode Client sends the `Execute` request with explicit request for asynchronous mode. If supported by the process (in PyWPS, we have a configuration for that), the server returns back `ProcessAccepted` response immediately with URL, where the client can regularly check for *process execution status*. .. note:: As you see, using WPS, the client has to apply *pull* method for the communication with the server. Client has to be the active element in the communication - server is just responding to clients request and is not actively *pushing* any information (like it would if e.g. web sockets would be implemented). Process status -------------- `Process status` is generic status of the process instance, reporting to the client, how does the calculation go. There are 4 types of process statuses ProcessAccepted Process was accepted by the server and the process execution will start soon. ProcessStarted Process calculation has started. The status also contains report about `percentDone` - calculation progress and `statusMessage` - text reporting current calculation state (example: *"Caculationg buffer"* - 33%). ProcessFinished Process instance performed the calculation successfully and the final `Execute` response is returned to the client and/or stored on final location ProcessFailed There was something wrong with the process instance and the server reports `server exception` (see :py:mod:`pywps.exceptions`) along with the message, what could possibly go wrong. Request encoding, HTTP GET and POST ----------------------------------- The request can be encoded either using key-value pairs (KVP) or an XML payload. Key-value pairs is usually sent via `HTTP GET request method `_ encoded directly in the URL. The keys and values are separated with `=` sign and each pair is separated with `&` sign (with `?` at the beginning of the request. Example could be the *get capabilities reques*:: http://server.domain/wps?service=WPS&request=GetCapabilities&version=1.0.0 In this example, there are 3 pairs of input parameter: `service`, `request` and `version` with values `WPS`, `GetCapabilities` and `1.0.0` respectively. XML payload is XML data sent via `HTTP POST request method `_. The XML document can be more rich, having more parameters, better to be parsed in complex structures. The Client can also encode entire datasets to the request, including raster (encoded using base64) or vector data (usually as GML file).:: 1.0.0 .. note:: Even it might be looking more complicated to use XML over KVP, for some complex request it usually is more safe and efficient to use XML encoding. The KVP way, especially for WPS Execute request can be tricky and lead to unpredictable errors. pywps-4.5.1/pywps/000077500000000000000000000000001415166246000140725ustar00rootroot00000000000000pywps-4.5.1/pywps/__init__.py000066400000000000000000000074521415166246000162130ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import os from lxml.builder import ElementMaker __version__ = "4.5.1" LOGGER = logging.getLogger('PYWPS') LOGGER.debug('setting core variables') PYWPS_INSTALL_DIR = os.path.dirname(os.path.abspath(__file__)) NAMESPACES = { 'xlink': "http://www.w3.org/1999/xlink", 'wps': "http://www.opengis.net/wps/{wps_version}", 'ows': "http://www.opengis.net/ows/{ows_version}", 'gml': "http://www.opengis.net/gml", 'xsi': "http://www.w3.org/2001/XMLSchema-instance" } E = ElementMaker() namespaces100 = {k: NAMESPACES[k].format(wps_version="1.0.0", ows_version="1.1") for k in NAMESPACES} namespaces200 = {k: NAMESPACES[k].format(wps_version="2.0", ows_version="2.0") for k in NAMESPACES} def get_ElementMakerForVersion(version): WPS = OWS = None if version == "1.0.0": WPS = ElementMaker(namespace=namespaces100['wps'], nsmap=namespaces100) OWS = ElementMaker(namespace=namespaces100['ows'], nsmap=namespaces100) elif version == "2.0.0": WPS = ElementMaker(namespace=namespaces200['wps'], nsmap=namespaces200) OWS = ElementMaker(namespace=namespaces200['ows'], nsmap=namespaces200) return WPS, OWS def get_version_from_ns(ns): if ns == "http://www.opengis.net/wps/1.0.0": return "1.0.0" elif ns == "http://www.opengis.net/wps/2.0": return "2.0.0" else: return None OGCTYPE = { 'measure': 'urn:ogc:def:dataType:OGC:1.1:measure', 'length': 'urn:ogc:def:dataType:OGC:1.1:length', 'scale': 'urn:ogc:def:dataType:OGC:1.1:scale', 'time': 'urn:ogc:def:dataType:OGC:1.1:time', 'date': 'urn:ogc:def:dataType:OGC:1.1:date', 'dateTime': 'urn:ogc:def:dataType:OGC:1.1:dateTime', 'gridLength': 'urn:ogc:def:dataType:OGC:1.1:gridLength', 'angle': 'urn:ogc:def:dataType:OGC:1.1:angle', 'lengthOrAngle': 'urn:ogc:def:dataType:OGC:1.1:lengthOrAngle', 'string': 'urn:ogc:def:dataType:OGC:1.1:string', 'positiveInteger': 'urn:ogc:def:dataType:OGC:1.1:positiveInteger', 'nonNegativeInteger': 'urn:ogc:def:dataType:OGC:1.1:nonNegativeInteger', 'boolean': 'urn:ogc:def:dataType:OGC:1.1:boolean', 'measureList': 'urn:ogc:def:dataType:OGC:1.1:measureList', 'lengthList': 'urn:ogc:def:dataType:OGC:1.1:lengthList', 'scaleList': 'urn:ogc:def:dataType:OGC:1.1:scaleList', 'angleList': 'urn:ogc:def:dataType:OGC:1.1:angleList', 'timeList': 'urn:ogc:def:dataType:OGC:1.1:timeList', 'gridLengthList': 'urn:ogc:def:dataType:OGC:1.1:gridLengthList', 'integerList': 'urn:ogc:def:dataType:OGC:1.1:integerList', 'positiveIntegerList': 'urn:ogc:def:dataType:OGC:1.1:positiveIntegerList', 'anyURI': 'urn:ogc:def:dataType:OGC:1.1:anyURI', 'integer': 'urn:ogc:def:dataType:OGC:1.1:integer', 'float': 'urn:ogc:def:dataType:OGC:1.1:float' } OGCUNIT = { 'degree': 'urn:ogc:def:uom:OGC:1.0:degree', 'degrees': 'urn:ogc:def:uom:OGC:1.0:degree', 'meter': 'urn:ogc:def:uom:OGC:1.0:metre', 'metre': 'urn:ogc:def:uom:OGC:1.0:metre', 'meteres': 'urn:ogc:def:uom:OGC:1.0:metre', 'meters': 'urn:ogc:def:uom:OGC:1.0:metre', 'unity': 'urn:ogc:def:uom:OGC:1.0:unity', 'feet': 'urn:ogc:def:uom:OGC:1.0:feet' } from pywps.app import Process, Service, WPSRequest from pywps.app.WPSRequest import get_inputs_from_xml, get_output_from_xml from pywps.inout.inputs import LiteralInput, ComplexInput, BoundingBoxInput from pywps.inout.outputs import LiteralOutput, ComplexOutput, BoundingBoxOutput from pywps.inout.formats import Format, FORMATS, get_format from pywps.inout import UOM if __name__ == "__main__": pass pywps-4.5.1/pywps/app/000077500000000000000000000000001415166246000146525ustar00rootroot00000000000000pywps-4.5.1/pywps/app/Common.py000066400000000000000000000046671415166246000164710ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging LOGGER = logging.getLogger("PYWPS") class Metadata(object): """ ows:Metadata content model. :param title: Metadata title, human readable string :param href: fully qualified URL :param role: fully qualified URL :param type_: fully qualified URL """ def __init__(self, title, href=None, role=None, type_='simple'): self.title = title self.href = href self.role = role self.type = type_ def __iter__(self): metadata = {"title": self.title} if self.href is not None: metadata['href'] = self.href if self.role is not None: metadata['role'] = self.role metadata['type'] = self.type yield metadata @property def json(self): """Get JSON representation of the metadata """ data = { 'title': self.title, 'href': self.href, 'role': self.role, 'type': self.type, } return data @classmethod def from_json(cls, json_input): instance = cls( title=json_input['title'], href=json_input['href'], role=json_input['role'], type_=json_input['type'], ) return instance def __eq__(self, other): return all([ self.title == other.title, self.href == other.href, self.role == other.role, self.type == other.type, ]) class MetadataUrl(Metadata): """Metadata subclass to allow anonymous links generation in documentation. Useful to avoid Sphinx "Duplicate explicit target name" warning. See https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#anonymous-hyperlinks. Meant to use in documentation only, not needed in the xml response, nor being serialized or deserialized to/from json. So that's why it is not directly in the base class. """ def __init__(self, title, href=None, role=None, type_='simple', anonymous=False): super().__init__(title, href=href, role=role, type_=type_) self.anonymous = anonymous "Whether to create anonymous link (boolean)." pywps-4.5.1/pywps/app/Process.py000066400000000000000000000506721415166246000166540ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import os from pywps.translations import lower_case_dict import sys import traceback import json import shutil from pywps import dblog from pywps.response import get_response from pywps.response.status import WPS_STATUS from pywps.response.execute import ExecuteResponse from pywps.app.WPSRequest import WPSRequest from pywps.inout.inputs import input_from_json from pywps.inout.outputs import output_from_json import pywps.configuration as config from pywps.exceptions import (StorageNotSupported, OperationNotSupported, ServerBusy, NoApplicableCode, InvalidParameterValue) from pywps.app.exceptions import ProcessError from pywps.inout.storage.builder import StorageBuilder from pywps.inout.outputs import ComplexOutput import importlib LOGGER = logging.getLogger("PYWPS") class Process(object): """ :param handler: A callable that gets invoked for each incoming request. It should accept a single :class:`pywps.app.WPSRequest` argument and return a :class:`pywps.app.WPSResponse` object. :param string identifier: Name of this process. :param string title: Human readable title of process. :param string abstract: Brief narrative description of the process. :param list keywords: Keywords that characterize a process. :param inputs: List of inputs accepted by this process. They should be :class:`~LiteralInput` and :class:`~ComplexInput` and :class:`~BoundingBoxInput` objects. :param outputs: List of outputs returned by this process. They should be :class:`~LiteralOutput` and :class:`~ComplexOutput` and :class:`~BoundingBoxOutput` objects. :param metadata: List of metadata advertised by this process. They should be :class:`pywps.app.Common.Metadata` objects. :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, handler, identifier, title, abstract='', keywords=[], profile=[], metadata=[], inputs=[], outputs=[], version='None', store_supported=False, status_supported=False, grass_location=None, translations=None): self.identifier = identifier self.handler = handler self.title = title self.abstract = abstract self.keywords = keywords self.metadata = metadata self.profile = profile self.version = version self.inputs = inputs self.outputs = outputs self.uuid = None self._status_store = None # self.status_location = '' # self.status_url = '' self.workdir = None self._grass_mapset = None self.grass_location = grass_location self.service = None self.translations = lower_case_dict(translations) if store_supported: self.store_supported = 'true' else: self.store_supported = 'false' if status_supported: self.status_supported = 'true' else: self.status_supported = 'false' @property def json(self): return { 'class': '{}:{}'.format(self.__module__, self.__class__.__name__), 'uuid': str(self.uuid), 'workdir': self.workdir, 'version': self.version, 'identifier': self.identifier, 'title': self.title, 'abstract': self.abstract, 'keywords': self.keywords, 'metadata': [m.json for m in self.metadata], 'inputs': [i.json for i in self.inputs], 'outputs': [o.json for o in self.outputs], 'store_supported': self.store_supported, 'status_supported': self.status_supported, 'profile': [p for p in self.profile], 'translations': self.translations, } @classmethod def from_json(cls, value): """init this process from json back again :param value: the json (not string) representation """ module, classname = value['class'].split(':') # instantiate subclass of Process new_process = getattr(importlib.import_module(module), classname)() new_process._set_uuid(value['uuid']) new_process.set_workdir(value['workdir']) return new_process def execute(self, wps_request, uuid): self._set_uuid(uuid) self._setup_status_storage() self.async_ = False response_cls = get_response("execute") wps_response = response_cls(wps_request, process=self, uuid=self.uuid) LOGGER.debug('Check if status storage and updating are supported by this process') if wps_request.store_execute == 'true': if self.store_supported != 'true': raise StorageNotSupported('Process does not support the storing of the execute response') if wps_request.status == 'true': if self.status_supported != 'true': raise OperationNotSupported('Process does not support the updating of status') wps_response.store_status_file = True self.async_ = True else: wps_response.store_status_file = False LOGGER.debug('Check if updating of status is not required then no need to spawn a process') wps_response = self._execute_process(self.async_, wps_request, wps_response) return wps_response def _set_uuid(self, uuid): """Set uuid and status location path and url """ self.uuid = uuid for inpt in self.inputs: inpt.uuid = uuid for outpt in self.outputs: outpt.uuid = uuid def _setup_status_storage(self): self._status_store = StorageBuilder.buildStorage() @property def status_store(self): if self._status_store is None: self._setup_status_storage() return self._status_store @property def status_location(self): return self.status_store.location(self.status_filename) @property def status_filename(self): return str(self.uuid) + '.xml' @property def status_url(self): return self.status_store.url(self.status_filename) def _execute_process(self, async_, wps_request, wps_response): """Uses :module:`pywps.processing` module for sending process to background BUT first, check for maxprocesses configuration value :param async_: run in asynchronous mode :return: wps_response or None """ maxparallel = int(config.get_config_value('server', 'parallelprocesses')) running, stored = dblog.get_process_counts() # async if async_: # run immedietly LOGGER.debug("Running processes: {} of {} allowed parallelprocesses".format(running, maxparallel)) LOGGER.debug("Stored processes: {}".format(stored)) if running < maxparallel or maxparallel == -1: wps_response._update_status(WPS_STATUS.ACCEPTED, "PyWPS Request accepted", 0) LOGGER.debug("Accepted request {}".format(self.uuid)) self._run_async(wps_request, wps_response) # try to store for later usage else: maxprocesses = int(config.get_config_value('server', 'maxprocesses')) if stored >= maxprocesses and maxprocesses != -1: raise ServerBusy('Maximum number of processes in queue reached. Please try later.') LOGGER.debug("Store process in job queue, uuid={}".format(self.uuid)) dblog.store_process(self.uuid, wps_request) wps_response._update_status(WPS_STATUS.ACCEPTED, 'PyWPS Process stored in job queue', 0) # not async else: if running >= maxparallel and maxparallel != -1: raise ServerBusy('Maximum number of parallel running processes reached. Please try later.') wps_response._update_status(WPS_STATUS.ACCEPTED, "PyWPS Request accepted", 0) wps_response = self._run_process(wps_request, wps_response) return wps_response # This function may not raise exception and must return a valid wps_response # Failure must be reported as wps_response.status = WPS_STATUS.FAILED def _run_async(self, wps_request, wps_response): import pywps.processing process = pywps.processing.Process( process=self, wps_request=wps_request, wps_response=wps_response) LOGGER.debug("Starting process for request: {}".format(self.uuid)) process.start() # This function may not raise exception and must return a valid wps_response # Failure must be reported as wps_response.status = WPS_STATUS.FAILED def _run_process(self, wps_request, wps_response): LOGGER.debug("Started processing request: {}".format(self.uuid)) try: self._set_grass(wps_request) # if required set HOME to the current working directory. if config.get_config_value('server', 'sethomedir') is True: os.environ['HOME'] = self.workdir LOGGER.info('Setting HOME to current working directory: {}'.format(os.environ['HOME'])) LOGGER.debug('ProcessID={}, HOME={}'.format(self.uuid, os.environ.get('HOME'))) wps_response._update_status(WPS_STATUS.STARTED, 'PyWPS Process started', 0) self.handler(wps_request, wps_response) # the user must update the wps_response. # Ensure process termination if wps_response.status != WPS_STATUS.SUCCEEDED and wps_response.status != WPS_STATUS.FAILED: # if (not wps_response.status_percentage) or (wps_response.status_percentage != 100): LOGGER.debug('Updating process status to 100% if everything went correctly') wps_response._update_status(WPS_STATUS.SUCCEEDED, f'PyWPS Process {self.title} finished', 100) except Exception as e: traceback.print_exc() LOGGER.debug('Retrieving file and line number where exception occurred') exc_type, exc_obj, exc_tb = sys.exc_info() found = False while not found: # search for the _handler method m_name = exc_tb.tb_frame.f_code.co_name if m_name == '_handler': found = True else: if exc_tb.tb_next is not None: exc_tb = exc_tb.tb_next else: # if not found then take the first exc_tb = sys.exc_info()[2] break fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] method_name = exc_tb.tb_frame.f_code.co_name # update the process status to display process failed msg = 'Process error: method={}.{}, line={}, msg={}'.format(fname, method_name, exc_tb.tb_lineno, e) LOGGER.error(msg) # In case of a ProcessError use the validated exception message. if isinstance(e, ProcessError): msg = "Process error: {}".format(e) # Only in debug mode we use the log message including the traceback ... elif config.get_config_value("logging", "level") != "DEBUG": # ... otherwise we use a sparse common error message. msg = 'Process failed, please check server error log' wps_response._update_status(WPS_STATUS.FAILED, msg, 100) finally: # The run of the next pending request if finished here, weather or not it successful self.launch_next_process() return wps_response def launch_next_process(self): """Look at the queue of async process, if the queue is not empty launch the next pending request. """ try: LOGGER.debug("Checking for stored requests") stored_request = dblog.pop_first_stored() if not stored_request: LOGGER.debug("No stored request found") return (uuid, request_json) = (stored_request.uuid, stored_request.request) request_json = request_json.decode('utf-8') LOGGER.debug("Launching the stored request {}".format(str(uuid))) new_wps_request = WPSRequest() new_wps_request.json = json.loads(request_json) process_identifier = new_wps_request.identifier process = self.service.prepare_process_for_execution(process_identifier) process._set_uuid(uuid) process._setup_status_storage() process.async_ = True process.setup_outputs_from_wps_request(new_wps_request) new_wps_response = ExecuteResponse(new_wps_request, process=process, uuid=uuid) new_wps_response.store_status_file = True process._run_async(new_wps_request, new_wps_response) except Exception as e: LOGGER.exception("Could not run stored process. {}".format(e)) def clean(self): """Clean the process working dir and other temporary files """ if config.get_config_value('server', 'cleantempdir'): LOGGER.info("Removing temporary working directory: {}".format(self.workdir)) try: if os.path.isdir(self.workdir): shutil.rmtree(self.workdir) if self._grass_mapset and os.path.isdir(self._grass_mapset): LOGGER.info("Removing temporary GRASS GIS mapset: {}".format(self._grass_mapset)) shutil.rmtree(self._grass_mapset) except Exception as err: LOGGER.error('Unable to remove directory: {}'.format(err)) else: LOGGER.warning('Temporary working directory is not removed: {}'.format(self.workdir)) def set_workdir(self, workdir): """Set working dir for all inputs and outputs this is the directory, where all the data are being stored to """ self.workdir = workdir for inpt in self.inputs: inpt.workdir = workdir for outpt in self.outputs: outpt.workdir = workdir def _set_grass(self, wps_request): """Handle given grass_location parameter of the constructor location is either directory name, 'epsg:1234' form or a georeferenced file in the first case, new temporary mapset within the location will be created in the second case, location will be created in self.workdir the mapset should be deleted automatically using self.clean() method """ if self.grass_location: import random import string from grass.script import core as grass from grass.script import setup as gsetup # HOME needs to be set - and that is usually not the case for httpd # server os.environ['HOME'] = self.workdir # GISRC envvariable needs to be set gisrc = open(os.path.join(self.workdir, 'GISRC'), 'w') gisrc.write("GISDBASE: {}\n".format(self.workdir)) gisrc.write("GUI: txt\n") gisrc.close() os.environ['GISRC'] = gisrc.name new_loc_args = dict() mapset_name = 'pywps_ms_{}'.format( ''.join(random.sample(string.ascii_letters, 5))) if self.grass_location.startswith('complexinput:'): # create new location from a georeferenced file ref_file_parameter = self.grass_location.split(':')[1] ref_file = wps_request.inputs[ref_file_parameter][0].file new_loc_args.update({'filename': ref_file}) elif self.grass_location.lower().startswith('epsg:'): # create new location from epsg code epsg = self.grass_location.lower().replace('epsg:', '') new_loc_args.update({'epsg': epsg}) if new_loc_args: dbase = self.workdir location = str() while os.path.isdir(os.path.join(dbase, location)): location = 'pywps_loc_{}'.format( ''.join(random.sample(string.ascii_letters, 5))) gsetup.init(os.environ['GISBASE'], dbase, location, 'PERMANENT') grass.create_location(dbase=dbase, location=location, **new_loc_args) LOGGER.debug('GRASS location based on {} created'.format( list(new_loc_args.keys())[0])) grass.run_command('g.mapset', mapset=mapset_name, flags='c', dbase=dbase, location=location, quiet=True) # create temporary mapset within existing location elif os.path.isdir(self.grass_location): from grass.pygrass.gis import make_mapset LOGGER.debug('Temporary mapset will be created') dbase = os.path.dirname(self.grass_location) location = os.path.basename(self.grass_location) grass.run_command('g.gisenv', set="GISDBASE={}".format(dbase)) grass.run_command('g.gisenv', set="LOCATION_NAME=%s" % location) while os.path.isdir(os.path.join(dbase, location, mapset_name)): mapset_name = 'pywps_ms_{}'.format( ''.join(random.sample(string.ascii_letters, 5))) make_mapset(mapset=mapset_name, location=location, gisdbase=dbase) grass.run_command('g.gisenv', set="MAPSET=%s" % mapset_name) else: raise NoApplicableCode('Location does exists or does not seem ' 'to be in "EPSG:XXXX" form nor is it existing directory: {}'.format(location)) # set _grass_mapset attribute - will be deleted once handler ends self._grass_mapset = mapset_name # final initialization LOGGER.debug('GRASS Mapset set to {}'.format(mapset_name)) LOGGER.debug('GRASS environment initialised') LOGGER.debug('GISRC {}, GISBASE {}, GISDBASE {}, LOCATION {}, MAPSET {}'.format( os.environ.get('GISRC'), os.environ.get('GISBASE'), dbase, location, os.path.basename(mapset_name))) def setup_outputs_from_wps_request(self, wps_request): # set as_reference to True for all the outputs specified as reference # if the output is not required to be raw if not wps_request.raw: for wps_outpt in wps_request.outputs: is_reference = wps_request.outputs[wps_outpt].get('asReference', 'false') mimetype = wps_request.outputs[wps_outpt].get('mimetype', '') if not isinstance(mimetype, str): mimetype = '' if is_reference.lower() == 'true': # check if store is supported if self.store_supported == 'false': raise StorageNotSupported( 'The storage of data is not supported for this process.') is_reference = True else: is_reference = False for outpt in self.outputs: if outpt.identifier == wps_outpt: outpt.as_reference = is_reference if isinstance(outpt, ComplexOutput) and mimetype: data_format = [f for f in outpt.supported_formats if f.mime_type == mimetype] if len(data_format) == 0: raise InvalidParameterValue( f"MimeType {mimetype} not valid") outpt.data_format = data_format[0] pywps-4.5.1/pywps/app/Service.py000077500000000000000000000352021415166246000166310ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import tempfile from typing import Sequence, Optional, Dict from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Request, Response from urllib.parse import urlparse from pywps.app.WPSRequest import WPSRequest import pywps.configuration as config from pywps.exceptions import MissingParameterValue, NoApplicableCode, InvalidParameterValue, FileSizeExceeded, \ StorageNotSupported, FileURLNotSupported from pywps.inout.inputs import ComplexInput, LiteralInput, BoundingBoxInput from pywps.dblog import log_request, store_status from pywps import response from pywps.response.status import WPS_STATUS from collections import deque, OrderedDict import os import sys import uuid import copy import shutil LOGGER = logging.getLogger("PYWPS") class Service(object): """ The top-level object that represents a WPS service. It's a WSGI application. :param processes: A list of :class:`~Process` objects that are provided by this service. :param cfgfiles: A list of configuration files """ def __init__(self, processes: Sequence = [], cfgfiles=None, preprocessors: Optional[Dict] = None): # ordered dict of processes self.processes = OrderedDict((p.identifier, p) for p in processes) self.preprocessors = preprocessors or dict() if cfgfiles: config.load_configuration(cfgfiles) if config.get_config_value('logging', 'file') and config.get_config_value('logging', 'level'): LOGGER.setLevel(getattr(logging, config.get_config_value('logging', 'level'))) if not LOGGER.handlers: # hasHandlers in Python 3.x fh = logging.FileHandler(config.get_config_value('logging', 'file')) fh.setFormatter(logging.Formatter(config.get_config_value('logging', 'format'))) LOGGER.addHandler(fh) else: # NullHandler | StreamHandler if not LOGGER.handlers: LOGGER.addHandler(logging.NullHandler()) def get_capabilities(self, wps_request, uuid): response_cls = response.get_response("capabilities") return response_cls(wps_request, uuid, version=wps_request.version, processes=self.processes) def describe(self, wps_request, uuid, identifiers): response_cls = response.get_response("describe") return response_cls(wps_request, uuid, processes=self.processes, identifiers=identifiers) def execute(self, identifier, wps_request, uuid): """Parse and perform Execute WPS request call :param identifier: process identifier string :param wps_request: pywps.WPSRequest structure with parsed inputs, still in memory :param uuid: string identifier of the request """ self._set_grass() process = self.prepare_process_for_execution(identifier) return self._parse_and_execute(process, wps_request, uuid) def prepare_process_for_execution(self, identifier): """Prepare the process identified by ``identifier`` for execution. """ try: process = self.processes[identifier] except KeyError: raise InvalidParameterValue("Unknown process '{}'".format(identifier), 'Identifier') # make deep copy of the process instance # so that processes are not overriding each other # just for execute process = copy.deepcopy(process) process.service = self workdir = os.path.abspath(config.get_config_value('server', 'workdir')) tempdir = tempfile.mkdtemp(prefix='pywps_process_', dir=workdir) process.set_workdir(tempdir) return process def _parse_and_execute(self, process, wps_request, uuid): """Parse and execute request """ LOGGER.debug('Checking if all mandatory inputs have been passed') data_inputs = {} for inpt in process.inputs: # Replace the dicts with the dict of Literal/Complex inputs # set the input to the type defined in the process. request_inputs = None if inpt.identifier in wps_request.inputs: request_inputs = wps_request.inputs[inpt.identifier] if not request_inputs: if inpt._default is not None: if not inpt.data_set and isinstance(inpt, ComplexInput): inpt._set_default_value() data_inputs[inpt.identifier] = [inpt.clone()] else: if isinstance(inpt, ComplexInput): data_inputs[inpt.identifier] = self.create_complex_inputs( inpt, request_inputs) elif isinstance(inpt, LiteralInput): data_inputs[inpt.identifier] = self.create_literal_inputs( inpt, request_inputs) elif isinstance(inpt, BoundingBoxInput): data_inputs[inpt.identifier] = self.create_bbox_inputs( inpt, request_inputs) for inpt in process.inputs: if inpt.identifier not in data_inputs: if inpt.min_occurs > 0: LOGGER.error('Missing parameter value: {}'.format(inpt.identifier)) raise MissingParameterValue( inpt.identifier, inpt.identifier) wps_request.inputs = data_inputs process.setup_outputs_from_wps_request(wps_request) wps_response = process.execute(wps_request, uuid) return wps_response def create_complex_inputs(self, source, inputs): """Create new ComplexInput as clone of original ComplexInput because of inputs can be more than one, take it just as Prototype. :param source: The process's input definition. :param inputs: The request input data. :return collections.deque: """ outinputs = deque(maxlen=source.max_occurs) for inpt in inputs: data_input = source.clone() frmt = data_input.supported_formats[0] if 'mimeType' in inpt: if inpt['mimeType']: frmt = data_input.get_format(inpt['mimeType']) else: frmt = data_input.data_format if frmt: data_input.data_format = frmt else: raise InvalidParameterValue( 'Invalid mimeType value {} for input {}'.format(inpt.get('mimeType'), source.identifier), 'mimeType') data_input.method = inpt.get('method', 'GET') data_input.process(inpt) outinputs.append(data_input) if len(outinputs) < source.min_occurs: description = "At least {} inputs are required. You provided {}.".format( source.min_occurs, len(outinputs), ) raise MissingParameterValue(description=description, locator=source.identifier) return outinputs def create_literal_inputs(self, source, inputs): """ Takes the http_request and parses the input to objects :return collections.deque: """ outinputs = deque(maxlen=source.max_occurs) for inpt in inputs: newinpt = source.clone() # set the input to the type defined in the process newinpt.uom = inpt.get('uom') data_type = inpt.get('datatype') if data_type: newinpt.data_type = data_type # get the value of the field newinpt.data = inpt.get('data') outinputs.append(newinpt) if len(outinputs) < source.min_occurs: description = "At least {} inputs are required. You provided {}.".format( source.min_occurs, len(outinputs), ) raise MissingParameterValue(description, locator=source.identifier) return outinputs def _set_grass(self): """Set environment variables needed for GRASS GIS support """ gisbase = config.get_config_value('grass', 'gisbase') if gisbase and os.path.isdir(gisbase): LOGGER.debug('GRASS GISBASE set to {}'.format(gisbase)) os.environ['GISBASE'] = gisbase os.environ['LD_LIBRARY_PATH'] = '{}:{}'.format( os.environ.get('LD_LIBRARY_PATH'), os.path.join(gisbase, 'lib')) os.putenv('LD_LIBRARY_PATH', os.environ.get('LD_LIBRARY_PATH')) os.environ['PATH'] = '{}:{}:{}'.format( os.environ.get('PATH'), os.path.join(gisbase, 'bin'), os.path.join(gisbase, 'scripts')) os.putenv('PATH', os.environ.get('PATH')) python_path = os.path.join(gisbase, 'etc', 'python') os.environ['PYTHONPATH'] = '{}:{}'.format(os.environ.get('PYTHONPATH'), python_path) os.putenv('PYTHONPATH', os.environ.get('PYTHONPATH')) sys.path.insert(0, python_path) def create_bbox_inputs(self, source, inputs): """ Takes the http_request and parses the input to objects :return collections.deque: """ outinputs = deque(maxlen=source.max_occurs) for inpt in inputs: newinpt = source.clone() newinpt.data = inpt.get('data') LOGGER.debug(f'newinpt bbox data={newinpt.data}') newinpt.crs = inpt.get('crs') newinpt.dimensions = inpt.get('dimensions') outinputs.append(newinpt) if len(outinputs) < source.min_occurs: description = "At least {} inputs are required. You provided {}.".format( source.min_occurs, len(outinputs), ) raise MissingParameterValue(description=description, locator=source.identifier) return outinputs # May not raise exceptions, this function must return a valid werkzeug.wrappers.Response. def call(self, http_request): try: # This try block handle Exception generated before the request is accepted. Once the request is accepted # a valid wps_reponse must exist. To report error use the wps_response using # wps_response._update_status(WPS_STATUS.FAILED, ...). # # We need this behaviour to handle the status file correctly, once the request is accepted, a # status file may be created and failure must be reported in this file instead of a raw ows:ExceptionReport # # Exeception from CapabilityResponse and DescribeResponse are always catched by this try ... except close # because they never have status. request_uuid = uuid.uuid1() environ_cfg = http_request.environ.get('PYWPS_CFG') if 'PYWPS_CFG' not in os.environ and environ_cfg: LOGGER.debug('Setting PYWPS_CFG to {}'.format(environ_cfg)) os.environ['PYWPS_CFG'] = environ_cfg wps_request = WPSRequest(http_request, self.preprocessors) LOGGER.info('Request: {}'.format(wps_request.operation)) if wps_request.operation in ['getcapabilities', 'describeprocess', 'execute']: log_request(request_uuid, wps_request) try: response = None if wps_request.operation == 'getcapabilities': response = self.get_capabilities(wps_request, request_uuid) response._update_status(WPS_STATUS.SUCCEEDED, '', 100) elif wps_request.operation == 'describeprocess': response = self.describe(wps_request, request_uuid, wps_request.identifiers) response._update_status(WPS_STATUS.SUCCEEDED, '', 100) elif wps_request.operation == 'execute': response = self.execute( wps_request.identifier, wps_request, request_uuid ) return response except Exception as e: # This ensure that logged request get terminated in case of exception while the request is not # accepted store_status(request_uuid, WPS_STATUS.FAILED, 'Request rejected due to exception', 100) raise e else: raise RuntimeError("Unknown operation {}".format(wps_request.operation)) except NoApplicableCode as e: return e except HTTPException as e: return NoApplicableCode(e.description, code=e.code) except Exception: msg = "No applicable error code, please check error log." return NoApplicableCode(msg, code=500) @Request.application def __call__(self, http_request): return self.call(http_request) def _build_input_file_name(href, workdir, extension=None): href = href or '' url_path = urlparse(href).path or '' file_name = os.path.basename(url_path).strip() or 'input' (prefix, suffix) = os.path.splitext(file_name) suffix = suffix or extension or '' if prefix and suffix: file_name = prefix + suffix input_file_name = os.path.join(workdir, file_name) # build tempfile in case of duplicates if os.path.exists(input_file_name): input_file_name = tempfile.mkstemp( suffix=suffix, prefix=prefix + '_', dir=workdir)[1] return input_file_name def _validate_file_input(href): href = href or '' parsed_url = urlparse(href) if parsed_url.scheme != 'file': raise FileURLNotSupported('Invalid URL scheme') file_path = parsed_url.path if not file_path: raise FileURLNotSupported('Invalid URL path') file_path = os.path.abspath(file_path) # build allowed paths list inputpaths = config.get_config_value('server', 'allowedinputpaths') allowed_paths = [os.path.abspath(p.strip()) for p in inputpaths.split(os.pathsep) if p.strip()] for allowed_path in allowed_paths: if file_path.startswith(allowed_path): LOGGER.debug("Accepted file url as input.") return raise FileURLNotSupported() def _extension(complexinput): extension = None if complexinput.data_format: extension = complexinput.data_format.extension return extension pywps-4.5.1/pywps/app/WPSRequest.py000066400000000000000000000753121415166246000172560ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import lxml from pywps import xml_util as etree from werkzeug.exceptions import MethodNotAllowed from pywps import get_ElementMakerForVersion import base64 import datetime from pywps.app.basic import get_xpath_ns, parse_http_url from pywps.inout.inputs import input_from_json from pywps.exceptions import NoApplicableCode, OperationNotSupported, MissingParameterValue, VersionNegotiationFailed, \ InvalidParameterValue, FileSizeExceeded from pywps import configuration from pywps.configuration import wps_strict from pywps import get_version_from_ns import json from urllib.parse import unquote LOGGER = logging.getLogger("PYWPS") default_version = '1.0.0' class WPSRequest(object): def __init__(self, http_request=None, preprocessors=None): self.http_request = http_request self.operation = None self.version = None self.api = None self.default_mimetype = None self.language = None self.identifier = None self.identifiers = None self.store_execute = None self.status = None self.lineage = None self.inputs = {} self.output_ids = None self.outputs = {} self.raw = None self.WPS = None self.OWS = None self.xpath_ns = None self.preprocessors = preprocessors or dict() self.preprocess_request = None self.preprocess_response = None if http_request: d = parse_http_url(http_request) self.operation = d.get('operation') self.identifier = d.get('identifier') self.output_ids = d.get('output_ids') self.api = d.get('api') self.default_mimetype = d.get('default_mimetype') request_parser = self._get_request_parser_method(http_request.method) request_parser() def _get_request_parser_method(self, method): if method == 'GET': return self._get_request elif method == 'POST': return self._post_request else: raise MethodNotAllowed() def _get_request(self): """HTTP GET request parser """ # service shall be WPS service = _get_get_param(self.http_request, 'service', None if wps_strict else 'wps') if service: if str(service).lower() != 'wps': raise InvalidParameterValue( 'parameter SERVICE [{}] not supported'.format(service), 'service') else: raise MissingParameterValue('service', 'service') self.operation = _get_get_param(self.http_request, 'request', self.operation) language = _get_get_param(self.http_request, 'language') self.check_and_set_language(language) request_parser = self._get_request_parser(self.operation) request_parser(self.http_request) def _post_request(self): """HTTP GET request parser """ # check if input file size was not exceeded maxsize = configuration.get_config_value('server', 'maxrequestsize') maxsize = configuration.get_size_mb(maxsize) * 1024 * 1024 if self.http_request.content_length > maxsize: raise FileSizeExceeded('File size for input exceeded.' ' Maximum request size allowed: {} megabytes'.format(maxsize / 1024 / 1024)) content_type = self.http_request.content_type or [] # or self.http_request.mimetype json_input = 'json' in content_type if not json_input: try: doc = etree.fromstring(self.http_request.get_data()) except Exception as e: raise NoApplicableCode(str(e)) operation = doc.tag version = get_version_from_ns(doc.nsmap[doc.prefix]) self.set_version(version) language = doc.attrib.get('language') self.check_and_set_language(language) request_parser = self._post_request_parser(operation) request_parser(doc) else: try: jdoc = json.loads(self.http_request.get_data()) except Exception as e: raise NoApplicableCode(str(e)) if self.identifier is not None: jdoc = {'inputs': jdoc} else: self.identifier = jdoc.get('identifier', None) self.operation = jdoc.get('operation', self.operation) preprocessor_tuple = self.preprocessors.get(self.identifier, None) if preprocessor_tuple: self.identifier = preprocessor_tuple[0] self.preprocess_request = preprocessor_tuple[1] self.preprocess_response = preprocessor_tuple[2] jdoc['operation'] = self.operation jdoc['identifier'] = self.identifier jdoc['api'] = self.api jdoc['default_mimetype'] = self.default_mimetype if self.preprocess_request is not None: jdoc = self.preprocess_request(jdoc, http_request=self.http_request) self.json = jdoc version = jdoc.get('version') self.set_version(version) language = jdoc.get('language') self.check_and_set_language(language) request_parser = self._post_json_request_parser() request_parser(jdoc) def _get_request_parser(self, operation): """Factory function returing propper parsing function """ wpsrequest = self def parse_get_getcapabilities(http_request): """Parse GET GetCapabilities request """ acceptedversions = _get_get_param(http_request, 'acceptversions') wpsrequest.check_accepted_versions(acceptedversions) wpsrequest.default_mimetype = _get_get_param(http_request, 'f', wpsrequest.default_mimetype) def parse_get_describeprocess(http_request): """Parse GET DescribeProcess request """ version = _get_get_param(http_request, 'version') wpsrequest.check_and_set_version(version) wpsrequest.identifiers = _get_get_param( http_request, 'identifier', wpsrequest.identifiers, aslist=True) if wpsrequest.identifiers is None and self.identifier is not None: wpsrequest.identifiers = [wpsrequest.identifier] wpsrequest.default_mimetype = _get_get_param(http_request, 'f', wpsrequest.default_mimetype) def parse_get_execute(http_request): """Parse GET Execute request """ version = _get_get_param(http_request, 'version') wpsrequest.check_and_set_version(version) wpsrequest.identifier = _get_get_param(http_request, 'identifier', wpsrequest.identifier) wpsrequest.store_execute = _get_get_param( http_request, 'storeExecuteResponse', 'false') wpsrequest.status = _get_get_param(http_request, 'status', 'false') wpsrequest.lineage = _get_get_param( http_request, 'lineage', 'false') wpsrequest.inputs = get_data_from_kvp( _get_get_param(http_request, 'DataInputs'), 'DataInputs') if self.inputs is None: self.inputs = {} # take responseDocument preferably raw, output_ids = False, _get_get_param(http_request, 'ResponseDocument') if output_ids is None: raw, output_ids = True, _get_get_param(http_request, 'RawDataOutput') if output_ids is not None: wpsrequest.raw, wpsrequest.output_ids = raw, output_ids elif wpsrequest.raw is None: wpsrequest.raw = wpsrequest.output_ids is not None wpsrequest.default_mimetype = _get_get_param(http_request, 'f', wpsrequest.default_mimetype) wpsrequest.outputs = get_data_from_kvp(wpsrequest.output_ids) or {} if wpsrequest.raw: # executeResponse XML will not be stored and no updating of # status wpsrequest.store_execute = 'false' wpsrequest.status = 'false' if operation: self.operation = operation.lower() else: if wps_strict: raise MissingParameterValue('Missing request value', 'request') self.operation = 'execute' if self.operation == 'getcapabilities': return parse_get_getcapabilities elif self.operation == 'describeprocess': return parse_get_describeprocess elif self.operation == 'execute': return parse_get_execute else: raise OperationNotSupported( 'Unknown request {}'.format(self.operation), operation) def _post_request_parser(self, tagname): """Factory function returning a proper parsing function according to tagname and sets self.operation to the correct operation """ wpsrequest = self def parse_post_getcapabilities(doc): """Parse POST GetCapabilities request """ acceptedversions = self.xpath_ns( doc, '/wps:GetCapabilities/ows:AcceptVersions/ows:Version') acceptedversions = ','.join( [v.text for v in acceptedversions]) wpsrequest.check_accepted_versions(acceptedversions) def parse_post_describeprocess(doc): """Parse POST DescribeProcess request """ version = doc.attrib.get('version') wpsrequest.check_and_set_version(version) wpsrequest.operation = 'describeprocess' wpsrequest.identifiers = [identifier_el.text for identifier_el in self.xpath_ns(doc, './ows:Identifier')] def parse_post_execute(doc): """Parse POST Execute request """ version = doc.attrib.get('version') wpsrequest.check_and_set_version(version) wpsrequest.operation = 'execute' identifier = self.xpath_ns(doc, './ows:Identifier') if not identifier: raise MissingParameterValue( 'Process identifier not set', 'Identifier') wpsrequest.identifier = identifier[0].text wpsrequest.lineage = 'false' wpsrequest.store_execute = 'false' wpsrequest.status = 'false' wpsrequest.inputs = get_inputs_from_xml(doc) wpsrequest.outputs = get_output_from_xml(doc) wpsrequest.raw = False if self.xpath_ns(doc, '/wps:Execute/wps:ResponseForm/wps:RawDataOutput'): wpsrequest.raw = True # executeResponse XML will not be stored wpsrequest.store_execute = 'false' # check if response document tag has been set then retrieve response_document = self.xpath_ns( doc, './wps:ResponseForm/wps:ResponseDocument') if len(response_document) > 0: wpsrequest.lineage = response_document[ 0].attrib.get('lineage', 'false') wpsrequest.store_execute = response_document[ 0].attrib.get('storeExecuteResponse', 'false') wpsrequest.status = response_document[ 0].attrib.get('status', 'false') if tagname == self.WPS.GetCapabilities().tag: self.operation = 'getcapabilities' return parse_post_getcapabilities elif tagname == self.WPS.DescribeProcess().tag: self.operation = 'describeprocess' return parse_post_describeprocess elif tagname == self.WPS.Execute().tag: self.operation = 'execute' return parse_post_execute else: raise InvalidParameterValue( 'Unknown request {}'.format(tagname), 'request') def _post_json_request_parser(self): """ Factory function returning a proper parsing function according to self.operation. self.operation is modified to be lowercase or the default 'execute' operation if self.operation is None """ wpsrequest = self def parse_json_post_getcapabilities(jdoc): """Parse POST GetCapabilities request """ acceptedversions = jdoc.get('acceptedversions') wpsrequest.check_accepted_versions(acceptedversions) def parse_json_post_describeprocess(jdoc): """Parse POST DescribeProcess request """ version = jdoc.get('version') wpsrequest.check_and_set_version(version) wpsrequest.identifiers = [identifier_el.text for identifier_el in self.xpath_ns(jdoc, './ows:Identifier')] def parse_json_post_execute(jdoc): """Parse POST Execute request """ version = jdoc.get('version') wpsrequest.check_and_set_version(version) wpsrequest.identifier = jdoc.get('identifier') if wpsrequest.identifier is None: raise MissingParameterValue( 'Process identifier not set', 'Identifier') wpsrequest.lineage = 'false' wpsrequest.store_execute = 'false' wpsrequest.status = 'false' wpsrequest.inputs = get_inputs_from_json(jdoc) if wpsrequest.output_ids is None: wpsrequest.output_ids = jdoc.get('outputs', {}) wpsrequest.raw = jdoc.get('raw', False) wpsrequest.raw, wpsrequest.outputs = get_output_from_dict(wpsrequest.output_ids, wpsrequest.raw) if wpsrequest.raw: # executeResponse XML will not be stored wpsrequest.store_execute = 'false' # todo: parse response_document like in the xml version? self.operation = 'execute' if self.operation is None else self.operation.lower() if self.operation == 'getcapabilities': return parse_json_post_getcapabilities elif self.operation == 'describeprocess': return parse_json_post_describeprocess elif self.operation == 'execute': return parse_json_post_execute else: raise InvalidParameterValue( 'Unknown request {}'.format(self.operation), 'request') def set_version(self, version): self.version = version self.xpath_ns = get_xpath_ns(version) self.WPS, self.OWS = get_ElementMakerForVersion(self.version) def check_accepted_versions(self, acceptedversions): """ :param acceptedversions: string """ version = None if acceptedversions: acceptedversions_array = acceptedversions.split(',') for aversion in acceptedversions_array: if _check_version(aversion): version = aversion else: version = '1.0.0' if version: self.check_and_set_version(version) else: raise VersionNegotiationFailed( 'The requested version "{}" is not supported by this server'.format(acceptedversions), 'version') def check_and_set_version(self, version, allow_default=True): """set this.version """ if not version: if allow_default: version = default_version else: raise MissingParameterValue('Missing version', 'version') if not _check_version(version): raise VersionNegotiationFailed( 'The requested version "{}" is not supported by this server'.format(version), 'version') else: self.set_version(version) def check_and_set_language(self, language): """set this.language """ supported_languages = configuration.get_config_value('server', 'language').split(',') supported_languages = [lang.strip() for lang in supported_languages] if not language: # default to the first supported language language = supported_languages[0] if language not in supported_languages: raise InvalidParameterValue( 'The requested language "{}" is not supported by this server'.format(language), 'language', ) self.language = language @property def json(self): """Return JSON encoded representation of the request """ class ExtendedJSONEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime.date) or isinstance(obj, datetime.time): encoded_object = obj.isoformat() else: encoded_object = json.JSONEncoder.default(self, obj) return encoded_object obj = { 'operation': self.operation, 'version': self.version, 'api': self.api, 'default_mimetype': self.default_mimetype, 'language': self.language, 'identifier': self.identifier, 'identifiers': self.identifiers, 'store_execute': self.store_execute, 'status': self.status, 'lineage': self.lineage, 'inputs': dict((i, [inpt.json for inpt in self.inputs[i]]) for i in self.inputs), 'outputs': self.outputs, 'raw': self.raw } return json.dumps(obj, allow_nan=False, cls=ExtendedJSONEncoder) @json.setter def json(self, value): """init this request from json back again :param value: the json (not string) representation """ self.operation = value.get('operation') self.version = value.get('version') self.api = value.get('api') self.default_mimetype = value.get('default_mimetype') self.language = value.get('language') self.identifier = value.get('identifier') self.identifiers = value.get('identifiers') self.store_execute = value.get('store_execute') self.status = value.get('status', False) self.lineage = value.get('lineage', False) self.outputs = value.get('outputs') self.raw = value.get('raw', False) self.inputs = {} for identifier in value.get('inputs', []): inpt_defs = value['inputs'][identifier] if not isinstance(inpt_defs, (list, tuple)): inpt_defs = [inpt_defs] self.inputs[identifier] = [] for inpt_def in inpt_defs: if not isinstance(inpt_def, dict): inpt_def = {"data": inpt_def} if 'identifier' not in inpt_def: inpt_def['identifier'] = identifier try: inpt = input_from_json(inpt_def) self.inputs[identifier].append(inpt) except Exception as e: LOGGER.warning(e) LOGGER.warning(f'skipping input: {identifier}') pass def get_inputs_from_xml(doc): the_inputs = {} version = get_version_from_ns(doc.nsmap[doc.prefix]) xpath_ns = get_xpath_ns(version) for input_el in xpath_ns(doc, '/wps:Execute/wps:DataInputs/wps:Input'): [identifier_el] = xpath_ns(input_el, './ows:Identifier') identifier = identifier_el.text if identifier not in the_inputs: the_inputs[identifier] = [] literal_data = xpath_ns(input_el, './wps:Data/wps:LiteralData') if literal_data: value_el = literal_data[0] inpt = {} inpt['identifier'] = identifier_el.text inpt['data'] = str(value_el.text) inpt['uom'] = value_el.attrib.get('uom', '') inpt['datatype'] = value_el.attrib.get('datatype', '') the_inputs[identifier].append(inpt) continue complex_data = xpath_ns(input_el, './wps:Data/wps:ComplexData') if complex_data: complex_data_el = complex_data[0] inpt = {} inpt['identifier'] = identifier_el.text inpt['mimeType'] = complex_data_el.attrib.get('mimeType', None) inpt['encoding'] = complex_data_el.attrib.get('encoding', '').lower() inpt['schema'] = complex_data_el.attrib.get('schema', '') inpt['method'] = complex_data_el.attrib.get('method', 'GET') if len(complex_data_el.getchildren()) > 0: value_el = complex_data_el[0] inpt['data'] = _get_dataelement_value(value_el) else: inpt['data'] = _get_rawvalue_value( complex_data_el.text, inpt['encoding']) the_inputs[identifier].append(inpt) continue reference_data = xpath_ns(input_el, './wps:Reference') if reference_data: reference_data_el = reference_data[0] inpt = {} inpt['identifier'] = identifier_el.text inpt[identifier_el.text] = reference_data_el.text inpt['href'] = reference_data_el.attrib.get( '{http://www.w3.org/1999/xlink}href', '') inpt['mimeType'] = reference_data_el.attrib.get('mimeType', None) inpt['method'] = reference_data_el.attrib.get('method', 'GET') header_element = xpath_ns(reference_data_el, './wps:Header') if header_element: inpt['header'] = _get_reference_header(header_element) body_element = xpath_ns(reference_data_el, './wps:Body') if body_element: inpt['body'] = _get_reference_body(body_element[0]) bodyreference_element = xpath_ns(reference_data_el, './wps:BodyReference') if bodyreference_element: inpt['bodyreference'] = _get_reference_bodyreference( bodyreference_element[0]) the_inputs[identifier].append(inpt) continue # Using OWSlib BoundingBox from owslib.ows import BoundingBox bbox_datas = xpath_ns(input_el, './wps:Data/wps:BoundingBoxData') if bbox_datas: for bbox_data in bbox_datas: bbox = BoundingBox(bbox_data) LOGGER.debug("parse bbox: minx={}, miny={}, maxx={},maxy={}".format( bbox.minx, bbox.miny, bbox.maxx, bbox.maxy)) inpt = {} inpt['identifier'] = identifier_el.text inpt['data'] = [bbox.minx, bbox.miny, bbox.maxx, bbox.maxy] inpt['crs'] = bbox.crs inpt['dimensions'] = bbox.dimensions the_inputs[identifier].append(inpt) return the_inputs def get_output_from_xml(doc): the_output = {} version = get_version_from_ns(doc.nsmap[doc.prefix]) xpath_ns = get_xpath_ns(version) if xpath_ns(doc, '/wps:Execute/wps:ResponseForm/wps:ResponseDocument'): for output_el in xpath_ns(doc, '/wps:Execute/wps:ResponseForm/wps:ResponseDocument/wps:Output'): [identifier_el] = xpath_ns(output_el, './ows:Identifier') outpt = {} outpt[identifier_el.text] = '' outpt['mimetype'] = output_el.attrib.get('mimeType', None) outpt['encoding'] = output_el.attrib.get('encoding', '') outpt['schema'] = output_el.attrib.get('schema', '') outpt['uom'] = output_el.attrib.get('uom', '') outpt['asReference'] = output_el.attrib.get('asReference', 'false') the_output[identifier_el.text] = outpt elif xpath_ns(doc, '/wps:Execute/wps:ResponseForm/wps:RawDataOutput'): for output_el in xpath_ns(doc, '/wps:Execute/wps:ResponseForm/wps:RawDataOutput'): [identifier_el] = xpath_ns(output_el, './ows:Identifier') outpt = {} outpt[identifier_el.text] = '' outpt['mimetype'] = output_el.attrib.get('mimeType', None) outpt['encoding'] = output_el.attrib.get('encoding', '') outpt['schema'] = output_el.attrib.get('schema', '') outpt['uom'] = output_el.attrib.get('uom', '') the_output[identifier_el.text] = outpt return the_output def get_inputs_from_json(jdoc): the_inputs = {} inputs_dict = jdoc.get('inputs', {}) for identifier, inpt_defs in inputs_dict.items(): if not isinstance(inpt_defs, (list, tuple)): inpt_defs = [inpt_defs] the_inputs[identifier] = [] for inpt_def in inpt_defs: if not isinstance(inpt_def, dict): inpt_def = {"data": inpt_def} data_type = inpt_def.get('type', 'literal') inpt = {'identifier': identifier} if data_type == 'literal': inpt['data'] = inpt_def.get('data') inpt['uom'] = inpt_def.get('uom', '') inpt['datatype'] = inpt_def.get('datatype', '') the_inputs[identifier].append(inpt) elif data_type == 'complex': inpt['mimeType'] = inpt_def.get('mimeType', None) inpt['encoding'] = inpt_def.get('encoding', '').lower() inpt['schema'] = inpt_def.get('schema', '') inpt['method'] = inpt_def.get('method', 'GET') inpt['data'] = _get_rawvalue_value(inpt_def.get('data', ''), inpt['encoding']) the_inputs[identifier].append(inpt) elif data_type == 'reference': inpt[identifier] = inpt_def inpt['href'] = inpt_def.get('href', '') inpt['mimeType'] = inpt_def.get('mimeType', None) inpt['method'] = inpt_def.get('method', 'GET') inpt['header'] = inpt_def.get('header', '') inpt['body'] = inpt_def.get('body', '') inpt['bodyreference'] = inpt_def.get('bodyreference', '') the_inputs[identifier].append(inpt) elif data_type == 'bbox': inpt['data'] = inpt_def['bbox'] inpt['crs'] = inpt_def.get('crs', 'urn:ogc:def:crs:EPSG::4326') inpt['dimensions'] = inpt_def.get('dimensions', 2) the_inputs[identifier].append(inpt) return the_inputs def get_output_from_dict(output_ids, raw): the_output = {} if isinstance(output_ids, dict): pass elif isinstance(output_ids, (tuple, list)): output_ids = {x: {} for x in output_ids} else: output_ids = {output_ids: {}} raw = True # single non-dict output means raw output for identifier, output_el in output_ids.items(): if isinstance(output_el, list): output_el = output_el[0] outpt = {} outpt[identifier] = '' outpt['mimetype'] = output_el.get('mimeType', None) outpt['encoding'] = output_el.get('encoding', '') outpt['schema'] = output_el.get('schema', '') outpt['uom'] = output_el.get('uom', '') if not raw: outpt['asReference'] = output_el.get('asReference', 'false') the_output[identifier] = outpt return raw, the_output def get_data_from_kvp(data, part=None): """Get execute DataInputs and ResponseDocument from URL (key-value-pairs) encoding :param data: key:value pair list of the datainputs and responseDocument parameter :param part: DataInputs or similar part of input url """ the_data = {} if data is None: return None for d in data.split(";"): try: io = {} fields = d.split('@') # First field is identifier and its value (identifier, val) = fields[0].split("=") io['identifier'] = identifier io['data'] = unquote(val) # Get the attributes of the data for attr in fields[1:]: (attribute, attr_val) = attr.split('=', 1) if attribute == 'xlink:href': io['href'] = unquote(attr_val) else: io[attribute] = unquote(attr_val) # Add the input/output with all its attributes and values to the # dictionary if part == 'DataInputs': if identifier not in the_data: the_data[identifier] = [] the_data[identifier].append(io) else: the_data[identifier] = io except Exception as e: LOGGER.warning(e) the_data[d] = {'identifier': d, 'data': ''} return the_data def _check_version(version): """ check given version """ if version not in ['1.0.0', '2.0.0']: return False else: return True def _get_get_param(http_request, key, default=None, aslist=False): """Returns value from the key:value pair, of the HTTP GET request, for example 'service' or 'request' :param http_request: http_request object :param key: key value you need to dig out of the HTTP GET request """ key = key.lower() value = default # http_request.args.keys will make + sign disappear in GET url if not # urlencoded for k in http_request.args.keys(): if k.lower() == key: value = http_request.args.get(k) if aslist: value = value.split(",") return value def _get_dataelement_value(value_el): """Return real value of XML Element (e.g. convert Element.FeatureCollection to String """ if isinstance(value_el, lxml.etree._Element): return etree.tostring(value_el, encoding=str) else: return value_el def _get_rawvalue_value(data, encoding=None): """Return real value of CDATA section""" try: LOGGER.debug("encoding={}".format(encoding)) if encoding is None or encoding == "": return data elif encoding == "utf-8": return data elif encoding == 'base64': return base64.b64decode(data) return base64.b64decode(data) except Exception: LOGGER.warning("failed to decode base64") return data def _get_reference_header(header_element): """Parses ReferenceInput Header element """ header = {} header['key'] = header_element.attrib('key') header['value'] = header_element.attrib('value') return header def _get_reference_body(body_element): """Parses ReferenceInput Body element """ body = None if len(body_element.getchildren()) > 0: value_el = body_element[0] body = _get_dataelement_value(value_el) else: body = _get_rawvalue_value(body_element.text) return body def _get_reference_bodyreference(referencebody_element): """Parse ReferenceInput BodyReference element """ return referencebody_element.attrib.get( '{http://www.w3.org/1999/xlink}href', '') pywps-4.5.1/pywps/app/__init__.py000066400000000000000000000010651415166246000167650ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps.app.Process import Process # noqa: F401 from pywps.app.Service import Service # noqa: F401 from pywps.app.WPSRequest import WPSRequest # noqa: F401 from pywps.app.WPSRequest import get_inputs_from_xml # noqa: F401 from pywps.app.WPSRequest import get_output_from_xml # noqa: F401 pywps-4.5.1/pywps/app/basic.py000066400000000000000000000123551415166246000163130ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ XML tools """ import logging from typing import Optional, Tuple from werkzeug.wrappers import Response import pywps.configuration as config LOGGER = logging.getLogger('PYWPS') def get_xpath_ns(version): """Get xpath namespace for specified WPS version currently 1.0.0 or 2.0.0 are supported """ def xpath_ns(ele, path): """Function, which will return xpath namespace for given element and xpath """ if version == "1.0.0": from pywps import namespaces100 nsp = namespaces100 elif version == "2.0.0": from pywps import namespaces200 nsp = namespaces200 return ele.xpath(path, namespaces=nsp) return xpath_ns def make_response(doc, content_type): """response serializer""" if not content_type: content_type = get_default_response_mimetype() response = Response(doc, content_type=content_type) response.status_percentage = 100 return response def get_default_response_mimetype(): default_mimetype = config.get_config_value('server', 'default_mimetype') return default_mimetype def get_json_indent(): json_ident = int(config.get_config_value('server', 'json_indent')) return json_ident if json_ident >= 0 else None def get_response_type(accept_mimetypes, default_mimetype) -> Tuple[bool, str]: """ This function determinate if the response should be JSON or XML based on the accepted mimetypes of the request and the default mimetype provided, which will be used in case both are equally accepted. :param accept_mimetypes: determinate which mimetypes are accepted :param default_mimetype: "text/xml", "application/json" :return: Tuple[bool, str] - bool - True: The response type is JSON, False: Otherwise - XML str - The output mimetype """ accept_json = \ accept_mimetypes.accept_json or \ accept_mimetypes.best is None or \ 'json' in accept_mimetypes.best.lower() accept_xhtml = \ accept_mimetypes.accept_xhtml or \ accept_mimetypes.best is None or \ 'xml' in accept_mimetypes.best.lower() if not default_mimetype: default_mimetype = get_default_response_mimetype() json_is_default = 'json' in default_mimetype or '*' in default_mimetype json_response = (accept_json and (not accept_xhtml or json_is_default)) or \ (json_is_default and accept_json == accept_xhtml) mimetype = 'application/json' if json_response else 'text/xml' if accept_xhtml else '' return json_response, mimetype def parse_http_url(http_request) -> dict: """ This function parses the request URL and extracts the following: default operation, process identifier, output_ids, default mimetype info that cannot be terminated from the URL will be None (default) The url is expected to be in the following format, all the levels are optional. [base_url]/[identifier]/[output_ids] :param http_request: the request URL :return: dict with the extracted info listed: base_url - [wps|processes|jobs|api/api_level] default_mimetype - determinate by the base_url part: XML - if the base url == 'wps', JSON - if the base URL in ['api'|'jobs'|'processes'] operation - also determinate by the base_url part: ['api'|'jobs'] -> 'execute' processes -> 'describeprocess' or 'getcapabilities' 'describeprocess' if identifier is present as the next item, 'getcapabilities' otherwise api - api level, only expected if base_url=='api' identifier - the process identifier output_ids - if exist then it selects raw output with the name output_ids """ operation = api = identifier = output_ids = default_mimetype = base_url = None if http_request: parts = str(http_request.path[1:]).split('/') i = 0 if len(parts) > i: base_url = parts[i].lower() if base_url == 'wps': default_mimetype = 'xml' elif base_url in ['api', 'processes', 'jobs']: default_mimetype = 'json' i += 1 if base_url == 'api': api = parts[i] i += 1 if len(parts) > i: identifier = parts[i] i += 1 if len(parts) > i: output_ids = parts[i] if not output_ids: output_ids = None if base_url in ['jobs', 'api']: operation = 'execute' elif base_url == 'processes': operation = 'describeprocess' if identifier else 'getcapabilities' d = {} if operation: d['operation'] = operation if identifier: d['identifier'] = identifier if output_ids: d['output_ids'] = output_ids if default_mimetype: d['default_mimetype'] = default_mimetype if api: d['api'] = api if base_url: d['base_url'] = base_url return d pywps-4.5.1/pywps/app/exceptions.py000066400000000000000000000037351415166246000174150ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ Process exceptions raised intentionally in processes to provide information for users. """ import re DEFAULT_ALLOWED_CHARS = ".:!?=,;-_/" import logging LOGGER = logging.getLogger('PYWPS') def format_message(text, min_length=3, max_length=300, allowed_chars=None): allowed_chars = allowed_chars or DEFAULT_ALLOWED_CHARS special = re.escape(allowed_chars) pattern = rf'[\w{special}]+' msg = ' '.join(re.findall(pattern, text)) msg.strip() if len(msg) >= min_length: msg = msg[:max_length] else: msg = '' return msg class ProcessError(Exception): """:class:`pywps.app.exceptions.ProcessError` is an :class:`Exception` you can intentionally raise in a process to provide a user-friendly error message. The error message gets formatted (3<= message length <=300) and only alpha numeric characters and a few special characters are allowed. """ default_msg = 'Sorry, process failed. Please check server error log.' def __init__(self, msg=None, min_length=3, max_length=300, allowed_chars=None): self.msg = msg self.min_length = min_length self.max_length = max_length self.allowed_chars = allowed_chars or DEFAULT_ALLOWED_CHARS def __str__(self): return self.message @property def message(self): try: msg = format_message( self.msg, min_length=self.min_length, max_length=self.max_length, allowed_chars=self.allowed_chars) except Exception as e: LOGGER.warning(f"process error formatting failed: {e}") msg = None if not msg: msg = self.default_msg return msg pywps-4.5.1/pywps/configuration.py000077500000000000000000000223401415166246000173170ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ Reads the PyWPS configuration file """ import logging import sys import os import tempfile import pywps import configparser __author__ = "Calin Ciociu" from pywps.util import file_uri RAW_OPTIONS = [('logging', 'format'), ] CONFIG = None LOGGER = logging.getLogger("PYWPS") wps_strict = True def get_config_value(section, option, default_value=''): """Get desired value from configuration files :param section: section in configuration files :type section: string :param option: option in the section :type option: string :returns: value found in the configuration file """ if not CONFIG: load_configuration() value = default_value if CONFIG.has_section(section): if CONFIG.has_option(section, option): raw = (section, option) in RAW_OPTIONS value = CONFIG.get(section, option, raw=raw) # Convert Boolean string to real Boolean values if value.lower() == "false": value = False elif value.lower() == "true": value = True return value def load_configuration(cfgfiles=None): """Load PyWPS configuration from configuration files. The later configuration file in the array overwrites configuration from the first. :param cfgfiles: list of configuration files """ global CONFIG LOGGER.info('loading configuration') CONFIG = configparser.ConfigParser(os.environ) LOGGER.debug('setting default values') CONFIG.add_section('server') CONFIG.set('server', 'encoding', 'utf-8') CONFIG.set('server', 'language', 'en-US') CONFIG.set('server', 'url', 'http://localhost/wps') CONFIG.set('server', 'maxprocesses', '30') CONFIG.set('server', 'maxsingleinputsize', '1mb') CONFIG.set('server', 'maxrequestsize', '3mb') CONFIG.set('server', 'temp_path', tempfile.gettempdir()) CONFIG.set('server', 'processes_path', '') outputpath = tempfile.gettempdir() CONFIG.set('server', 'outputurl', file_uri(outputpath)) CONFIG.set('server', 'outputpath', outputpath) # list of allowed input paths (file url input) seperated by ':' CONFIG.set('server', 'allowedinputpaths', '') CONFIG.set('server', 'workdir', tempfile.gettempdir()) CONFIG.set('server', 'parallelprocesses', '2') # If this flag is enabled it will set the HOME environment # for each process to its current workdir (a temp folder). CONFIG.set('server', 'sethomedir', 'false') # If this flag is enabled PyWPS will remove the process temporary workdir # after process has finished. CONFIG.set('server', 'cleantempdir', 'true') CONFIG.set('server', 'storagetype', 'file') # File storage outputs can be copied, moved or linked # from the workdir to the output folder. # Allowed functions: "copy", "move", "link" (default "copy") CONFIG.set('server', 'storage_copy_function', 'copy') # handles the default mimetype for requests. # available options: "text/xml", "application/json" CONFIG.set("server", "default_mimetype", "text/xml") # default json indentation for responses. CONFIG.set("server", "json_indent", "2") CONFIG.add_section('processing') CONFIG.set('processing', 'mode', 'default') CONFIG.set('processing', 'path', os.path.dirname(os.path.realpath(sys.argv[0]))) # https://github.com/natefoo/slurm-drmaa CONFIG.set('processing', 'drmaa_native_specification', '') CONFIG.add_section('logging') CONFIG.set('logging', 'file', '') CONFIG.set('logging', 'level', 'DEBUG') CONFIG.set('logging', 'database', 'sqlite:///:memory:') CONFIG.set('logging', 'prefix', 'pywps_') CONFIG.set('logging', 'format', '%(asctime)s] [%(levelname)s] file=%(pathname)s line=%(lineno)s module=%(module)s function=%(funcName)s %(message)s') # noqa CONFIG.add_section('metadata:main') CONFIG.set('metadata:main', 'identification_title', 'PyWPS Processing Service') CONFIG.set('metadata:main', 'identification_abstract', 'PyWPS is an implementation of the Web Processing Service standard from the Open Geospatial Consortium. PyWPS is written in Python.') # noqa CONFIG.set('metadata:main', 'identification_keywords', 'PyWPS,WPS,OGC,processing') CONFIG.set('metadata:main', 'identification_keywords_type', 'theme') CONFIG.set('metadata:main', 'identification_fees', 'NONE') CONFIG.set('metadata:main', 'identification_accessconstraints', 'NONE') CONFIG.set('metadata:main', 'provider_name', 'Organization Name') CONFIG.set('metadata:main', 'provider_url', 'https://pywps.org/') CONFIG.set('metadata:main', 'contact_name', 'Lastname, Firstname') CONFIG.set('metadata:main', 'contact_position', 'Position Title') CONFIG.set('metadata:main', 'contact_address', 'Mailing Address') CONFIG.set('metadata:main', 'contact_city', 'City') CONFIG.set('metadata:main', 'contact_stateorprovince', 'Administrative Area') CONFIG.set('metadata:main', 'contact_postalcode', 'Zip or Postal Code') CONFIG.set('metadata:main', 'contact_country', 'Country') CONFIG.set('metadata:main', 'contact_phone', '+xx-xxx-xxx-xxxx') CONFIG.set('metadata:main', 'contact_fax', '+xx-xxx-xxx-xxxx') CONFIG.set('metadata:main', 'contact_email', 'Email Address') CONFIG.set('metadata:main', 'contact_url', 'Contact URL') CONFIG.set('metadata:main', 'contact_hours', 'Hours of Service') CONFIG.set('metadata:main', 'contact_instructions', 'During hours of service. Off on weekends.') CONFIG.set('metadata:main', 'contact_role', 'pointOfContact') CONFIG.add_section('grass') CONFIG.set('grass', 'gisbase', '') CONFIG.add_section('s3') CONFIG.set('s3', 'bucket', '') CONFIG.set('s3', 'prefix', '') CONFIG.set('s3', 'public', 'false') CONFIG.set('s3', 'encrypt', 'false') CONFIG.set('s3', 'region', '') if not cfgfiles: cfgfiles = _get_default_config_files_location() if isinstance(cfgfiles, str): cfgfiles = [cfgfiles] if 'PYWPS_CFG' in os.environ: cfgfiles.append(os.environ['PYWPS_CFG']) loaded_files = CONFIG.read(cfgfiles, encoding='utf-8') if loaded_files: LOGGER.info('Configuration file(s) {} loaded'.format(loaded_files)) else: LOGGER.info('No configuration files loaded. Using default values') _check_config() def _check_config(): """Check some configuration values """ global CONFIG def checkdir(confid): confvalue = get_config_value('server', confid) if not os.path.isdir(confvalue): LOGGER.warning('server->{} configuration value {} is not directory'.format(confid, confvalue)) if not os.path.isabs(confvalue): LOGGER.warning('server->{} configuration value {} is not absolute path, making it absolute to {}'.format( confid, confvalue, os.path.abspath(confvalue))) CONFIG.set('server', confid, os.path.abspath(confvalue)) [checkdir(n) for n in ['workdir', 'outputpath']] def _get_default_config_files_location(): """Get the locations of the standard configuration files. These are Unix/Linux: 1. `/etc/pywps.cfg` 2. `$HOME/.pywps.cfg` Windows: 1. `pywps\\etc\\default.cfg` Both: 1. `$PYWPS_CFG environment variable` :returns: configuration files :rtype: list of strings """ is_win32 = sys.platform == 'win32' if is_win32: LOGGER.debug('Windows based environment') else: LOGGER.debug('UNIX based environment') if os.getenv("PYWPS_CFG"): LOGGER.debug('using PYWPS_CFG environment variable') # Windows or Unix if is_win32: PYWPS_INSTALL_DIR = os.path.abspath(os.path.join(os.getcwd(), os.path.dirname(sys.argv[0]))) cfgfiles = (os.getenv("PYWPS_CFG")) else: cfgfiles = (os.getenv("PYWPS_CFG")) else: LOGGER.debug('trying to estimate the default location') # Windows or Unix if is_win32: PYWPS_INSTALL_DIR = os.path.abspath(os.path.join(os.getcwd(), os.path.dirname(sys.argv[0]))) cfgfiles = (os.path.join(PYWPS_INSTALL_DIR, "pywps", "etc", "pywps.cfg")) else: homePath = os.getenv("HOME") if homePath: cfgfiles = (os.path.join(pywps.__path__[0], "etc", "pywps.cfg"), "/etc/pywps.cfg", os.path.join(os.getenv("HOME"), ".pywps.cfg")) else: cfgfiles = (os.path.join(pywps.__path__[0], "etc", "pywps.cfg"), "/etc/pywps.cfg") return cfgfiles def get_size_mb(mbsize): """Get real size of given object in Mb. """ size = mbsize.lower() import re units = re.compile("[gmkb].*") newsize = float(re.sub(units, '', size)) if size.find("g") > -1: newsize *= 1024 elif size.find("m") > -1: newsize *= 1 elif size.find("k") > -1: newsize /= 1024 else: newsize *= 1 LOGGER.debug('Calculated real size of {} is {}'.format(mbsize, newsize)) return newsize pywps-4.5.1/pywps/dblog.py000066400000000000000000000142371415166246000155420ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ Implementation of logging for PyWPS-4 """ import logging from pywps import configuration from pywps.exceptions import NoApplicableCode import sqlite3 import datetime import pickle import json import os from multiprocessing import Lock import sqlalchemy from sqlalchemy.ext.declarative import declarative_base from sqlalchemy import Column, Integer, String, VARCHAR, Float, DateTime, LargeBinary from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool, StaticPool LOGGER = logging.getLogger('PYWPS') _SESSION_MAKER = None _tableprefix = configuration.get_config_value('logging', 'prefix') _schema = configuration.get_config_value('logging', 'schema') Base = declarative_base() lock = Lock() class ProcessInstance(Base): __tablename__ = '{}requests'.format(_tableprefix) uuid = Column(VARCHAR(255), primary_key=True, nullable=False) pid = Column(Integer, nullable=False) operation = Column(VARCHAR(30), nullable=False) version = Column(VARCHAR(5), nullable=False) time_start = Column(DateTime(), nullable=False) time_end = Column(DateTime(), nullable=True) identifier = Column(VARCHAR(255), nullable=True) message = Column(String, nullable=True) percent_done = Column(Float, nullable=True) status = Column(Integer, nullable=True) class RequestInstance(Base): __tablename__ = '{}stored_requests'.format(_tableprefix) uuid = Column(VARCHAR(255), primary_key=True, nullable=False) request = Column(LargeBinary, nullable=False) def log_request(uuid, request): """Write OGC WPS request (only the necessary parts) to database logging system """ pid = os.getpid() operation = request.operation version = request.version time_start = datetime.datetime.now() identifier = _get_identifier(request) session = get_session() request = ProcessInstance( uuid=str(uuid), pid=pid, operation=operation, version=version, time_start=time_start, identifier=identifier) session.add(request) session.commit() session.close() # NoApplicableCode("Could commit to database: {}".format(e.message)) def get_process_counts(): """Returns running and stored process counts and """ session = get_session() stored_query = session.query(RequestInstance.uuid) running_count = ( session.query(ProcessInstance) .filter(ProcessInstance.percent_done < 100) .filter(ProcessInstance.percent_done > -1) .filter(~ProcessInstance.uuid.in_(stored_query)) .count() ) stored_count = stored_query.count() session.close() return running_count, stored_count def pop_first_stored(): """Gets the first stored process and delete it from the stored_requests table """ session = get_session() request = session.query(RequestInstance).first() if request: delete_count = session.query(RequestInstance).filter_by(uuid=request.uuid).delete() if delete_count == 0: LOGGER.debug("Another thread or process took the same stored request") request = None session.commit() return request def store_status(uuid, wps_status, message=None, status_percentage=None): """Writes response to database """ session = get_session() requests = session.query(ProcessInstance).filter_by(uuid=str(uuid)) if requests.count(): request = requests.one() request.time_end = datetime.datetime.now() request.message = str(message) request.percent_done = status_percentage request.status = wps_status session.commit() session.close() def _get_identifier(request): """Get operation identifier """ if request.operation == 'execute': return request.identifier elif request.operation == 'describeprocess': if request.identifiers: return ','.join(request.identifiers) else: return None else: return None def get_session(): """Get Connection for database """ LOGGER.debug('Initializing database connection') global _SESSION_MAKER if _SESSION_MAKER: return _SESSION_MAKER() with lock: database = configuration.get_config_value('logging', 'database') echo = True level = configuration.get_config_value('logging', 'level') level_name = logging.getLevelName(level) if isinstance(level_name, int) and level_name >= logging.INFO: echo = False try: if ":memory:" in database: engine = sqlalchemy.create_engine(database, echo=echo, connect_args={'check_same_thread': False}, poolclass=StaticPool) elif database.startswith("sqlite"): engine = sqlalchemy.create_engine(database, echo=echo, connect_args={'check_same_thread': False}, poolclass=NullPool) else: engine = sqlalchemy.create_engine(database, echo=echo, poolclass=NullPool) except sqlalchemy.exc.SQLAlchemyError as e: raise NoApplicableCode("Could not connect to database: {}".format(e.message)) Session = sessionmaker(bind=engine) ProcessInstance.metadata.create_all(engine) RequestInstance.metadata.create_all(engine) _SESSION_MAKER = Session return _SESSION_MAKER() def store_process(uuid, request): """Save given request under given UUID for later usage """ session = get_session() request_json = request.json # the BLOB type requires bytes on Python 3 request_json = request_json.encode('utf-8') request = RequestInstance(uuid=str(uuid), request=request_json) session.add(request) session.commit() session.close() pywps-4.5.1/pywps/dependencies.py000066400000000000000000000006241415166246000170740ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import warnings try: import netCDF4 # noqa except ImportError: warnings.warn('Complex validation requires netCDF4 support.') pywps-4.5.1/pywps/exceptions.py000066400000000000000000000126411415166246000166310ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ OGC OWS and WPS Exceptions Based on OGC OWS, WPS and http://lists.opengeospatial.org/pipermail/wps-dev/2013-October/000335.html """ import json from werkzeug.datastructures import MIMEAccept from werkzeug.http import parse_accept_header from werkzeug.wrappers import Response from werkzeug.exceptions import HTTPException from markupsafe import escape import logging from pywps import __version__ from pywps.app.basic import get_json_indent, get_response_type, parse_http_url __author__ = "Alex Morega & Calin Ciociu" LOGGER = logging.getLogger('PYWPS') class NoApplicableCode(HTTPException): """No applicable code exception implementation also Base exception class """ code = 500 locator = "" def __init__(self, description, locator="", code=400): self.code = code self.description = description self.locator = locator msg = 'Exception: code: {}, description: {}, locator: {}'.format(self.code, self.description, self.locator) LOGGER.exception(msg) HTTPException.__init__(self) @property def name(self): """The status name.""" return self.__class__.__name__ def get_description(self, environ=None): """Get the description.""" if self.description: return escape(self.description) else: return '' def get_response(self, environ=None): args = { 'version': __version__, 'code': self.code, 'locator': escape(self.locator), 'name': escape(self.name), 'description': self.get_description(environ) } accept_mimetypes = parse_accept_header(environ.get("HTTP_ACCEPT"), MIMEAccept) request = environ.get('werkzeug.request', None) default_mimetype = None if not request else request.args.get('f', None) if default_mimetype is None: default_mimetype = parse_http_url(request).get('default_mimetype') json_response, mimetype = get_response_type(accept_mimetypes, default_mimetype) if json_response: doc = json.dumps(args, indent=get_json_indent()) else: doc = str(( '\n' '\n' '\n' # noqa ' \n' ' {description}\n' ' \n' '' ).format(**args)) return Response(doc, self.code, mimetype=mimetype) class InvalidParameterValue(NoApplicableCode): """Invalid parameter value exception implementation """ code = 400 class MissingParameterValue(NoApplicableCode): """Missing parameter value exception implementation """ code = 400 class FileSizeExceeded(NoApplicableCode): """File size exceeded exception implementation """ code = 400 class VersionNegotiationFailed(NoApplicableCode): """Version negotiation exception implementation """ code = 400 class OperationNotSupported(NoApplicableCode): """Operation not supported exception implementation """ code = 501 class StorageNotSupported(NoApplicableCode): """Storage not supported exception implementation """ code = 400 class NotEnoughStorage(NoApplicableCode): """Not enough storage exception implementation """ code = 400 class FileStorageError(NoApplicableCode): """File storage exception implementation """ code = 400 class ServerBusy(NoApplicableCode): """Max number of operations exceeded """ code = 400 description = 'Maximum number of processes exceeded' def get_body(self, environ=None): """Get the XML body.""" args = { 'name': escape(self.name), 'description': self.get_description(environ) } return str(( '\n' '' # noqa '' '{description}' '' '' ).format(**args)) class FileURLNotSupported(NoApplicableCode): """File URL not supported exception implementation """ code = 400 description = 'File URL not supported as input.' def __init__(self, description="", locator="", code=400): description = description or self.description NoApplicableCode.__init__(self, description=description, locator=locator, code=code) class SchedulerNotAvailable(NoApplicableCode): """Job scheduler not available exception implementation """ code = 400 pywps-4.5.1/pywps/ext_autodoc.py000066400000000000000000000135631415166246000167720ustar00rootroot00000000000000# -*- coding: utf-8 -*- from sphinx.ext.autodoc import ClassDocumenter, bool_option from sphinx.ext.napoleon.docstring import NumpyDocstring from sphinx.util.docstrings import prepare_docstring from docutils.parsers.rst import directives from pywps import Process from pywps.app.Common import Metadata, MetadataUrl class ProcessDocumenter(ClassDocumenter): """Sphinx autodoc ClassDocumenter subclass that understands the pywps.Process class. The Process description, its inputs and docputs are converted to a numpy style docstring. Additional sections (Notes, References, Examples, etc.) can be added in the Process subclass docstring using the `docstring` option. Additionally, docstring lines can be skipped using the `skiplines` option. For, example, the following would append the SimpleProcess class docstring to the published docs, skipping the first line. .. autoprocess:: pywps.SimpleProcess :docstring: :skiplines: 1 """ directivetype = 'class' objtype = 'process' priority = ClassDocumenter.priority + 1 option_spec = {'skiplines': directives.nonnegative_int, 'docstring': bool_option} option_spec.update(ClassDocumenter.option_spec) @classmethod def can_document_member(cls, member, membername, isattr, parent): return isinstance(member, type) and issubclass(member, Process) def fmt_type(self, obj): """Input and output type formatting (type, default and allowed values). """ nmax = 10 doc = '' try: if getattr(obj, 'allowed_values', None): av = ', '.join(["'{}'".format(i.value) for i in obj.allowed_values[:nmax]]) if len(obj.allowed_values) > nmax: av += ', ...' doc += "{" + av + "}" elif getattr(obj, 'data_type', None): doc += obj.data_type elif getattr(obj, 'supported_formats', None): doc += ', '.join([':mimetype:`{}`'.format(f.mime_type) for f in obj.supported_formats]) elif getattr(obj, 'crss', None): doc += "[" + ', '.join(obj.crss[:nmax]) if len(obj.crss) > nmax: doc += ', ...' doc += "]" if getattr(obj, 'min_occurs', None) is not None: if obj.min_occurs == 0: doc += ', optional' if getattr(obj, 'default', None): doc += ', default:{0}'.format(obj.default) if getattr(obj, 'uoms', None): doc += ', units:[{}]'.format(', '.join([u.uom for u in obj.uoms])) except Exception as e: raise type(e)('{0} in {1} docstring'.format(e, self.object().identifier)) return doc def make_numpy_doc(self): """Numpy style docstring where meta data is scraped from the class instance. The numpy style is used because it supports multiple outputs. """ obj = self.object() # Description doc = list() doc.append(":program:`{}` {} (v{})".format(obj.identifier, obj.title, obj.version or '', )) doc.append('') doc.append(obj.abstract) doc.append('') # Inputs doc.append('Parameters') doc.append('----------') for i in obj.inputs: doc.append("{} : {}".format(i.identifier, self.fmt_type(i))) doc.append(" {}".format(i.abstract or i.title)) if i.metadata: doc[-1] += " ({})".format(', '.join(['`{} <{}>`_'.format(m.title, m.href) for m in i.metadata])) doc.append('') # Outputs doc.append("Returns") doc.append("-------") for i in obj.outputs: doc.append("{} : {}".format(i.identifier, self.fmt_type(i))) doc.append(" {}".format(i.abstract or i.title)) doc.extend(['', '']) # Metadata hasref = False ref = list() ref.append("References") ref.append("----------") ref.append('') for m in obj.metadata: if isinstance(m, Metadata): title, href = m.title, m.href elif type(m) == dict: title, href = m['title'], m['href'] else: title, href = None, None extra_underscore = "" if isinstance(m, MetadataUrl): extra_underscore = "_" if m.anonymous else "" if title and href: ref.append(" - `{} <{}>`_{}".format(title, href, extra_underscore)) hasref = True ref.append('') if hasref: doc += ref return doc def get_doc(self, encoding=None, ignore=1): """Overrides ClassDocumenter.get_doc to create the doc scraped from the Process object, then adds additional content from the class docstring. """ # Get the class docstring. This is a copy of the ClassDocumenter.get_doc method. Using "super" does weird stuff. docstring = self.get_attr(self.object, '__doc__', None) # make sure we have Unicode docstrings, then sanitize and split # into lines if isinstance(docstring, str): docstring = prepare_docstring(docstring, ignore) # Create the docstring by scraping info from the Process instance. pdocstrings = self.make_numpy_doc() if self.options.docstring and docstring is not None: # Add the sections from the class docstring itself. pdocstrings.extend(docstring[self.options.skiplines:]) # Parse using the Numpy docstring format. docstrings = NumpyDocstring(pdocstrings, self.env.config, self.env.app, what='class', obj=self.object, options=self.options) return [docstrings.lines()] def setup(app): app.add_autodocumenter(ProcessDocumenter) pywps-4.5.1/pywps/inout/000077500000000000000000000000001415166246000152305ustar00rootroot00000000000000pywps-4.5.1/pywps/inout/__init__.py000066400000000000000000000010071415166246000173370ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps.inout.inputs import LiteralInput, ComplexInput, BoundingBoxInput from pywps.inout.outputs import LiteralOutput, ComplexOutput, BoundingBoxOutput from pywps.inout.formats import Format, FORMATS, get_format from pywps.inout.basic import UOM pywps-4.5.1/pywps/inout/array_encode.py000066400000000000000000000010311415166246000202300ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from json import JSONEncoder class ArrayEncoder(JSONEncoder): def default(self, obj): if hasattr(obj, 'tolist'): # this will work for array.array and numpy.ndarray return obj.tolist() return JSONEncoder.default(self, obj) pywps-4.5.1/pywps/inout/basic.py000066400000000000000000001040771415166246000166740ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import json from pathlib import PurePath from pywps.inout.formats import Supported_Formats from pywps.inout.types import Translations from pywps.translations import lower_case_dict from io import StringIO import os from io import open import shutil import requests import tempfile import logging import pywps.configuration as config from pywps.inout.literaltypes import (LITERAL_DATA_TYPES, convert, make_allowedvalues, is_anyvalue, is_values_reference) from pywps import OGCUNIT from pywps.validator.mode import MODE from pywps.validator.base import emptyvalidator from pywps.validator import get_validator from pywps.validator.literalvalidator import (validate_value, validate_anyvalue, validate_allowed_values, validate_values_reference) from pywps.exceptions import NoApplicableCode, InvalidParameterValue, FileSizeExceeded, \ FileURLNotSupported from urllib.parse import urlparse import base64 from collections import namedtuple from copy import deepcopy from io import BytesIO import humanize import weakref _SOURCE_TYPE = namedtuple('SOURCE_TYPE', 'MEMORY, FILE, STREAM, DATA, URL') SOURCE_TYPE = _SOURCE_TYPE(0, 1, 2, 3, 4) LOGGER = logging.getLogger("PYWPS") def _is_textfile(filename): try: # use python-magic if available import magic is_text = 'text/' in magic.from_file(filename, mime=True) except ImportError: # read the first part of the file to check for a binary indicator. # This method won't detect all binary files. blocksize = 512 fh = open(filename, 'rb') is_text = b'\x00' not in fh.read(blocksize) fh.close() return is_text class UOM(object): """ :param uom: unit of measure """ def __init__(self, uom='', reference=None): self.uom = uom self.reference = reference if self.reference is None: self.reference = OGCUNIT[self.uom] @property def json(self): return {"reference": self.reference, "uom": self.uom} def __eq__(self, other): return self.uom == other.uom class NoneIOHandler(object): """Base class for implementation of IOHandler internal""" prop = None def __init__(self, ref): self._ref = weakref.ref(ref) @property def file(self): """Return filename.""" return None @property def data(self): """Read file and return content.""" return None @property def base64(self): """Return base64 encoding of data.""" return None @property def stream(self): """Return stream object.""" return None @property def mem(self): """Return memory object.""" return None @property def url(self): """Return url to file.""" return None @property def size(self): """Length of the linked content in octets.""" return None @property def post_data(self): raise NotImplementedError # Will raise an error if used on invalid object @post_data.setter def post_data(self, value): raise NotImplementedError class IOHandler(object): """Base IO handling class that handle multple IO types This class is created with NoneIOHandler that have no data inside. To initialise data you can set the `file`, `url`, `data` or `stream` attribute. If reset one of this attribute old data are lost and replaced by the new one. :param workdir: working directory, to save temporal file objects in. :param mode: ``MODE`` validation mode. `file` : str Filename on the local disk. `url` : str Link to an online resource. `stream` : FileIO A readable object. `data` : object A native python object (integer, string, float, etc) `base64` : str A base 64 encoding of the data. >>> # setting up >>> import os >>> from io import RawIOBase >>> from io import FileIO >>> >>> ioh_file = IOHandler(workdir=tmp) >>> assert isinstance(ioh_file, IOHandler) >>> >>> # Create test file input >>> fileobj = open(os.path.join(tmp, 'myfile.txt'), 'w') >>> fileobj.write('ASDF ASFADSF ASF ASF ASDF ASFASF') >>> fileobj.close() >>> >>> # testing file object on input >>> ioh_file.file = fileobj.name >>> assert ioh_file.file == fileobj.name >>> assert isinstance(ioh_file.stream, RawIOBase) >>> # skipped assert isinstance(ioh_file.memory_object, POSH) >>> >>> # testing stream object on input >>> ioh_stream = IOHandler(workdir=tmp) >>> assert ioh_stream.workdir == tmp >>> ioh_stream.stream = FileIO(fileobj.name,'r') >>> assert open(ioh_stream.file).read() == ioh_file.stream.read() >>> assert isinstance(ioh_stream.stream, RawIOBase) """ def __init__(self, workdir=None, mode=MODE.NONE): self._iohandler = NoneIOHandler(self) # Internal defaults for class and subclass properties. self._workdir = None # Set public defaults self.workdir = workdir self.valid_mode = mode # TODO: Clarify intent self.as_reference = False self.inpt = {} self.uuid = None # request identifier self.data_set = False def _check_valid(self): """Validate this input using given validator """ validate = self.validator if validate is not None: _valid = validate(self, self.valid_mode) if not _valid: self.data_set = False raise InvalidParameterValue('Input data not valid using ' 'mode {}'.format(self.valid_mode)) self.data_set = True @property def workdir(self): return self._workdir @workdir.setter def workdir(self, path): """Set working temporary directory for files to be stored in.""" if path is not None: if not os.path.exists(path): os.makedirs(path) self._workdir = path @property def validator(self): """Return the function suitable for validation This method should be overridden by class children :return: validating function """ return emptyvalidator @property def source_type(self): """Return the source type.""" # For backward compatibility only. source_type checks could be replaced by `isinstance`. return getattr(SOURCE_TYPE, self.prop.upper()) def _set_default_value(self, value=None, value_type=None): """Set default value based on input data type.""" value = value or getattr(self, '_default') value_type = value_type or getattr(self, '_default_type') if value: # only set default when a value is optional if self.min_occurs == 0: if value_type == SOURCE_TYPE.DATA: self.data = value elif value_type == SOURCE_TYPE.MEMORY: raise NotImplementedError elif value_type == SOURCE_TYPE.FILE: self.file = value elif value_type == SOURCE_TYPE.STREAM: self.stream = value elif value_type == SOURCE_TYPE.URL: self.url = value else: # when a value is requried the default value will be ignored LOGGER.warning( "The given default value will not be used" " because is is required to provide a value.") def _build_file_name(self, href=''): """Return a file name for the local system.""" url_path = urlparse(href).path or '' file_name = os.path.basename(url_path).strip() or 'input' (prefix, suffix) = os.path.splitext(file_name) suffix = suffix or self.extension if prefix and suffix: file_name = prefix + suffix input_file_name = os.path.join(self.workdir, file_name) # build tempfile in case of duplicates if os.path.exists(input_file_name): input_file_name = tempfile.mkstemp( suffix=suffix, prefix=prefix + '_', dir=self.workdir)[1] return input_file_name @property def extension(self): """Return the file extension for the data format, if set.""" if getattr(self, 'data_format', None): return self.data_format.extension else: return '' def clone(self): """Create copy of yourself """ return deepcopy(self) @property def base64(self): """Return raw data WARNING: may be bytes or str""" return self._iohandler.base64 @property def size(self): """Return object size in bytes. """ return self._iohandler.size @property def file(self): """Return a file name""" return self._iohandler.file @file.setter def file(self, value): self._iohandler = FileHandler(value, self) self._check_valid() @property def data(self): """Return raw data WARNING: may be bytes or str""" return self._iohandler.data def data_as_json(self): # applies json.loads if needed data = self._iohandler.data if data and not isinstance(self._iohandler, DataHandler) and self.extension in ['.geojson', 'json']: data = json.loads(data) self.data = data # switch to a DataHandler return data @data.setter def data(self, value): self._iohandler = DataHandler(value, self) self._check_valid() @property def stream(self): """Return stream of data WARNING: may be FileIO or StringIO""" return self._iohandler.stream @stream.setter def stream(self, value): self._iohandler = StreamHandler(value, self) self._check_valid() @property def url(self): """Return the url of data""" return self._iohandler.url @url.setter def url(self, value): self._iohandler = UrlHandler(value, self) self._check_valid() # FIXME: post_data is only related to url, this should be initialize with url setter @property def post_data(self): return self._iohandler.post_data # Will raise an arror if used on invalid object @post_data.setter def post_data(self, value): self._iohandler.post_data = value @property def prop(self): return self._iohandler.prop class FileHandler(NoneIOHandler): prop = 'file' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._data = None self._stream = None self._file = os.path.abspath(value) @property def file(self): """Return filename.""" return self._file @property def data(self): """Read file and return content.""" if self._data is None: openmode = self._openmode(self._ref()) kwargs = {} if 'b' in openmode else {'encoding': 'utf8'} with open(self.file, mode=openmode, **kwargs) as fh: self._data = fh.read() return self._data @property def base64(self): """Return base64 encoding of data.""" data = self.data.encode() if not isinstance(self.data, bytes) else self.data return base64.b64encode(data) @property def stream(self): """Return stream object.""" from io import FileIO if self._stream and not self._stream.closed: self._stream.close() self._stream = FileIO(self.file, mode='r', closefd=True) return self._stream @property def url(self): """Return url to file.""" result = PurePath(self.file).as_uri() return result @property def size(self): """Length of the linked content in octets.""" return os.stat(self.file).st_size def _openmode(self, base, data=None): openmode = 'r' # in Python 3 we need to open binary files in binary mode. checked = False if hasattr(base, 'data_format'): if base.data_format.encoding == 'base64': # binary, when the data is to be encoded to base64 openmode += 'b' checked = True elif 'text/' in base.data_format.mime_type: # not binary, when mime_type is 'text/' checked = True # when we can't guess it from the mime_type, we need to check the file. # mimetypes like application/xml and application/json are text files too. if not checked and not _is_textfile(self.file): openmode += 'b' return openmode class DataHandler(FileHandler): prop = 'data' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._file = None self._stream = None self._data = value def _openmode(self, data=None): openmode = 'w' if isinstance(data, bytes): # on Python 3 open the file in binary mode if the source is # bytes, which happens when the data was base64-decoded openmode += 'b' return openmode @property def data(self): """Return data.""" return self._data @property def file(self): """Return file name storing the data. Requesting the file attributes writes the data to a temporary file on disk. """ if self._file is None: self._file = self._ref()._build_file_name() openmode = self._openmode(self.data) kwargs = {} if 'b' in openmode else {'encoding': 'utf8'} with open(self._file, openmode, **kwargs) as fh: if isinstance(self.data, (bytes, str)): fh.write(self.data) else: json.dump(self.data, fh) return self._file @property def stream(self): """Return a stream representation of the data.""" if isinstance(self.data, bytes): return BytesIO(self.data) else: return StringIO(str(self.data)) class StreamHandler(DataHandler): prop = 'stream' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._file = None self._data = None self._stream = value @property def stream(self): """Return the stream.""" return self._stream @property def data(self): """Return the data from the stream.""" if self._data is None: self._data = self.stream.read() return self._data class UrlHandler(FileHandler): prop = 'url' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._file = None self._data = None self._stream = None self._url = value self._post_data = None @property def url(self): """Return the URL.""" return self._url @property def file(self): """Downloads URL and return file pointer. Checks if size is allowed before download. """ if self._file is not None: return self._file self._file = self._ref()._build_file_name(href=self.url) max_byte_size = self.max_size() # Create request try: reference_file = self._openurl(self.url, self.post_data) data_size = reference_file.headers.get('Content-Length', 0) except Exception as e: raise NoApplicableCode('File reference error: {}'.format(e)) error_message = 'File size for input "{}" exceeded. Maximum allowed: {}'.format( self._ref().inpt.get('identifier', '?'), humanize.naturalsize(max_byte_size)) if int(max_byte_size) > 0: if int(data_size) > int(max_byte_size): raise FileSizeExceeded(error_message) try: with open(self._file, 'wb') as f: data_size = 0 for chunk in reference_file.iter_content(chunk_size=1024): data_size += len(chunk) if int(max_byte_size) > 0: if int(data_size) > int(max_byte_size): raise FileSizeExceeded(error_message) f.write(chunk) except FileSizeExceeded: raise except Exception as e: raise NoApplicableCode(e) return self._file @property def post_data(self): return self._post_data @post_data.setter def post_data(self, value): self._post_data = value @property def size(self): """Get content-length of URL without download""" req = self._openurl(self.url) if req.ok: size = int(req.headers.get('content-length', '0')) else: size = 0 return size @staticmethod def _openurl(href, data=None): """Open given href. """ LOGGER.debug('Fetching URL {}'.format(href)) if data is not None: req = requests.post(url=href, data=data, stream=True) else: req = requests.get(url=href, stream=True) return req @staticmethod def max_size(): """Calculates maximal size for input file based on configuration and units. :return: maximum file size in bytes """ ms = config.get_config_value('server', 'maxsingleinputsize') byte_size = config.get_size_mb(ms) * 1024**2 return byte_size class SimpleHandler(IOHandler): """Data handler for Literal In- and Outputs >>> class Int_type(object): ... @staticmethod ... def convert(value): return int(value) >>> >>> class MyValidator(object): ... @staticmethod ... def validate(inpt): return 0 < inpt.data < 3 >>> >>> inpt = SimpleHandler(data_type = Int_type) >>> inpt.validator = MyValidator >>> >>> inpt.data = 1 >>> inpt.validator.validate(inpt) True >>> inpt.data = 5 >>> inpt.validator.validate(inpt) False """ def __init__(self, workdir=None, data_type=None, mode=MODE.NONE): IOHandler.__init__(self, workdir=workdir, mode=mode) if data_type not in LITERAL_DATA_TYPES: raise ValueError('data_type {} not in {}'.format(data_type, LITERAL_DATA_TYPES)) self.data_type = data_type @IOHandler.data.setter def data(self, value): """Set data value. Inputs are converted into target format. """ if self.data_type and value is not None: value = convert(self.data_type, value) IOHandler.data.fset(self, value) class BasicIO: """Basic Input/Output class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, min_occurs=1, max_occurs=1, metadata=[], translations=None): self.identifier = identifier self.title = title self.abstract = abstract self.keywords = keywords self.min_occurs = int(min_occurs) if min_occurs is not None else 0 self.max_occurs = int(max_occurs) if max_occurs is not None else None self.metadata = metadata self.translations = lower_case_dict(translations) class BasicLiteral: """Basic literal Input/Output class """ def __init__(self, data_type="integer", uoms=None): assert data_type in LITERAL_DATA_TYPES self.data_type = data_type # list of uoms self.uoms = [] # current uom self._uom = None # add all uoms (upcasting to UOM) if uoms is not None: for uom in uoms: if not isinstance(uom, UOM): uom = UOM(uom) self.uoms.append(uom) if self.uoms: # default/current uom self.uom = self.uoms[0] @property def uom(self): return self._uom @uom.setter def uom(self, uom): if uom is not None: self._uom = uom class BasicComplex(object): """Basic complex input/output class """ def __init__(self, data_format=None, supported_formats=None): self._data_format = data_format self._supported_formats = () if supported_formats: self.supported_formats = supported_formats if data_format: self.data_format = data_format elif self.supported_formats: # not an empty list, set the default/current format to the first self.data_format = supported_formats[0] def get_format(self, mime_type): """ :param mime_type: given mimetype :return: Format """ for frmt in self.supported_formats: if frmt.mime_type == mime_type: return frmt else: return None @property def validator(self): """Return the proper validator for given data_format """ return None if self.data_format is None else self.data_format.validate @property def supported_formats(self): return self._supported_formats @supported_formats.setter def supported_formats(self, supported_formats): """Setter of supported formats """ def set_format_validator(supported_format): if not supported_format.validate or \ supported_format.validate == emptyvalidator: supported_format.validate =\ get_validator(supported_format.mime_type) return supported_format self._supported_formats = tuple(map(set_format_validator, supported_formats)) @property def data_format(self): return self._data_format @data_format.setter def data_format(self, data_format): """self data_format setter """ if self._is_supported(data_format): self._data_format = data_format if not data_format.validate or data_format.validate == emptyvalidator: data_format.validate = get_validator(data_format.mime_type) else: raise InvalidParameterValue("Requested format {}, {}, {} not supported".format( data_format.mime_type, data_format.encoding, data_format.schema), 'mimeType') def _is_supported(self, data_format): if self.supported_formats: for frmt in self.supported_formats: if frmt.same_as(data_format): return True return False class BasicBoundingBox(object): """Basic BoundingBox input/output class """ def __init__(self, crss=None, dimensions=2): self._data = None self.crss = crss or ['epsg:4326'] self.crs = self.crss[0] self.dimensions = dimensions @property def data(self): return self._data @data.setter def data(self, value): if isinstance(value, list): self._data = [float(number) for number in value] elif isinstance(value, str): self._data = [float(number) for number in value.split(',')[:4]] else: self._data = None @property def ll(self): if self.data: return self.data[:2] return [] @property def ur(self): if self.data: return self.data[2:] return [] class LiteralInput(BasicIO, BasicLiteral, SimpleHandler): """LiteralInput input abstract class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, data_type="integer", workdir=None, allowed_values=None, uoms=None, mode=MODE.NONE, min_occurs=1, max_occurs=1, metadata=[], default=None, default_type=SOURCE_TYPE.DATA, translations=None): BasicIO.__init__(self, identifier=identifier, title=title, abstract=abstract, keywords=keywords, min_occurs=min_occurs, max_occurs=max_occurs, metadata=metadata, translations=translations, ) BasicLiteral.__init__(self, data_type, uoms) SimpleHandler.__init__(self, workdir, data_type, mode=mode) if default_type != SOURCE_TYPE.DATA: raise InvalidParameterValue("Source types other than data are not supported.") self.any_value = False self.values_reference = None self.allowed_values = [] if allowed_values: if not isinstance(allowed_values, (tuple, list)): allowed_values = [allowed_values] self.any_value = any(is_anyvalue(a) for a in allowed_values) for value in allowed_values: if is_values_reference(value): self.values_reference = value break self.allowed_values = make_allowedvalues(allowed_values) self._default = default self._default_type = default_type if default is not None: self.data = default @property def validator(self): """Get validator for any value as well as allowed_values :rtype: function """ if self.any_value: return validate_anyvalue elif self.values_reference: return validate_values_reference elif self.allowed_values: return validate_allowed_values else: return validate_value class LiteralOutput(BasicIO, BasicLiteral, SimpleHandler): """Basic LiteralOutput class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, data_type=None, workdir=None, uoms=None, validate=None, mode=MODE.NONE, translations=None): BasicIO.__init__(self, identifier, title, abstract, keywords, translations=translations) BasicLiteral.__init__(self, data_type, uoms) SimpleHandler.__init__(self, workdir=None, data_type=data_type, mode=mode) self._storage = None @property def storage(self): return self._storage @storage.setter def storage(self, storage): self._storage = storage @property def validator(self): """Get validator for any value as well as allowed_values """ return validate_anyvalue class BBoxInput(BasicIO, BasicBoundingBox, IOHandler): """Basic Bounding box input abstract class """ def __init__(self, identifier, title=None, abstract=None, keywords=[], crss=None, dimensions=None, workdir=None, mode=MODE.SIMPLE, min_occurs=1, max_occurs=1, metadata=[], default=None, default_type=SOURCE_TYPE.DATA, translations=None): BasicIO.__init__(self, identifier=identifier, title=title, abstract=abstract, keywords=keywords, min_occurs=min_occurs, max_occurs=max_occurs, metadata=metadata, translations=translations, ) BasicBoundingBox.__init__(self, crss, dimensions) IOHandler.__init__(self, workdir=workdir, mode=mode) if default_type != SOURCE_TYPE.DATA: raise InvalidParameterValue("Source types other than data are not supported.") self._default = default self._default_type = default_type self._set_default_value(default, default_type) class BBoxOutput(BasicIO, BasicBoundingBox, IOHandler): """Basic BoundingBox output class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, crss=None, dimensions=None, workdir=None, mode=MODE.NONE, translations=None): BasicIO.__init__(self, identifier, title, abstract, keywords, translations=translations) BasicBoundingBox.__init__(self, crss, dimensions) IOHandler.__init__(self, workdir=workdir, mode=mode) self._storage = None @property def storage(self): return self._storage @storage.setter def storage(self, storage): self._storage = storage class ComplexInput(BasicIO, BasicComplex, IOHandler): """Complex input abstract class >>> ci = ComplexInput() >>> ci.validator = 1 >>> ci.validator 1 """ def __init__(self, identifier, title=None, abstract=None, keywords=None, workdir=None, data_format=None, supported_formats=None, mode=MODE.NONE, min_occurs=1, max_occurs=1, metadata=[], default=None, default_type=SOURCE_TYPE.DATA, translations=None): BasicIO.__init__(self, identifier=identifier, title=title, abstract=abstract, keywords=keywords, min_occurs=min_occurs, max_occurs=max_occurs, metadata=metadata, translations=translations, ) IOHandler.__init__(self, workdir=workdir, mode=mode) BasicComplex.__init__(self, data_format, supported_formats) self._default = default self._default_type = default_type def file_handler(self, inpt): """ handler. Used when href is a file url.""" # check if file url is allowed self._validate_file_input(href=inpt.get('href')) # save the file reference input in workdir tmp_file = self._build_file_name(href=inpt.get('href')) try: inpt_file = urlparse(inpt.get('href')).path inpt_file = os.path.abspath(inpt_file) os.symlink(inpt_file, tmp_file) LOGGER.debug("Linked input file {} to {}.".format(inpt_file, tmp_file)) except Exception: # TODO: handle os.symlink on windows # raise NoApplicableCode("Could not link file reference: {}".format(e)) LOGGER.warn("Could not link file reference") shutil.copy2(inpt_file, tmp_file) return tmp_file def url_handler(self, inpt): # That could possibly go into the data property... if inpt.get('method') == 'POST': if 'body' in inpt: self.post_data = inpt.get('body') elif 'bodyreference' in inpt: self.post_data = requests.get(url=inpt.get('bodyreference')).text else: raise AttributeError("Missing post data content.") return inpt.get('href') def process(self, inpt): """Subclass with the appropriate handler given the data input.""" href = inpt.get('href', None) self.inpt = inpt if href: if urlparse(href).scheme == 'file': self.file = self.file_handler(inpt) else: # No file download occurs here. The file content will # only be retrieved when the file property is accessed. self.url = self.url_handler(inpt) else: self.data = inpt.get('data') @staticmethod def _validate_file_input(href): href = href or '' parsed_url = urlparse(href) if parsed_url.scheme != 'file': raise FileURLNotSupported('Invalid URL scheme') file_path = parsed_url.path if not file_path: raise FileURLNotSupported('Invalid URL path') file_path = os.path.abspath(file_path) # build allowed paths list inputpaths = config.get_config_value('server', 'allowedinputpaths') allowed_paths = [os.path.abspath(p.strip()) for p in inputpaths.split(os.pathsep) if p.strip()] for allowed_path in allowed_paths: if file_path.startswith(allowed_path): LOGGER.debug("Accepted file url as input.") return raise FileURLNotSupported() class ComplexOutput(BasicIO, BasicComplex, IOHandler): """Complex output abstract class >>> # temporary configuration >>> import ConfigParser >>> from pywps.storage import * >>> config = ConfigParser.RawConfigParser() >>> config.add_section('FileStorage') >>> config.set('FileStorage', 'target', './') >>> config.add_section('server') >>> config.set('server', 'outputurl', 'http://foo/bar/filestorage') >>> >>> # create temporary file >>> tiff_file = open('file.tiff', 'w') >>> tiff_file.write("AA") >>> tiff_file.close() >>> >>> co = ComplexOutput() >>> co.file ='file.tiff' >>> fs = FileStorage(config) >>> co.storage = fs >>> >>> url = co.url # get url, data are stored >>> >>> co.stream.read() # get data - nothing is stored 'AA' """ def __init__(self, identifier, title=None, abstract=None, keywords=None, workdir=None, data_format=None, supported_formats: Supported_Formats = None, mode=MODE.NONE, translations: Translations = None): BasicIO.__init__(self, identifier, title, abstract, keywords, translations=translations) IOHandler.__init__(self, workdir=workdir, mode=mode) BasicComplex.__init__(self, data_format, supported_formats) self._storage = None @property def storage(self): return self._storage @storage.setter def storage(self, storage): # don't set storage twice if self._storage is None: self._storage = storage # TODO: refactor ? def get_url(self): """Return URL pointing to data """ # TODO: it is not obvious that storing happens here (_, _, url) = self.storage.store(self) # url = self.storage.url(self) return url pywps-4.5.1/pywps/inout/formats/000077500000000000000000000000001415166246000167035ustar00rootroot00000000000000pywps-4.5.1/pywps/inout/formats/__init__.py000066400000000000000000000163041415166246000210200ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """List of know mimetypes""" # List of known complex data formats # you can use any other, but these are widely known and supported by popular # software packages # based on Web Processing Service Best Practices Discussion Paper, OGC 12-029 # http://opengeospatial.org/standards/wps from collections import namedtuple import mimetypes from typing import Optional, Sequence, Union _FORMATS = namedtuple('FORMATS', 'GEOJSON, JSON, SHP, GML, GPX, METALINK, META4, KML, KMZ, GEOTIFF,' 'WCS, WCS100, WCS110, WCS20, WFS, WFS100,' 'WFS110, WFS20, WMS, WMS130, WMS110,' 'WMS100, TEXT, DODS, NETCDF, NCML, LAZ, LAS, ZIP,' 'XML, CSV') class Format(object): """Input/output format specification Predefined Formats are stored in :class:`pywps.inout.formats.FORMATS` :param str mime_type: mimetype definition :param str schema: xml schema definition :param str encoding: base64 or not :param function validate: function, which will perform validation. e.g. :param number mode: validation mode :param str extension: file extension """ def __init__(self, mime_type, schema=None, encoding=None, validate=None, extension=None): """Constructor """ self._mime_type = None self._encoding = None self._schema = None self._extension = None self.mime_type = mime_type self.encoding = encoding self.schema = schema self.validate = validate self.extension = extension @property def mime_type(self): """Get format mime type :rtype: String """ return self._mime_type @mime_type.setter def mime_type(self, mime_type): """Set format mime type """ try: # support Format('GML') frmt = getattr(FORMATS, mime_type) self._mime_type = frmt.mime_type except AttributeError: # if we don't have this as a shortcut, assume it's a real mime type self._mime_type = mime_type except NameError: # TODO: on init of FORMATS, FORMATS is not available. Clean up code! self._mime_type = mime_type @property def encoding(self): """Get format encoding :rtype: String """ if self._encoding: return self._encoding else: return '' @encoding.setter def encoding(self, encoding): """Set format encoding """ self._encoding = encoding @property def schema(self): """Get format schema :rtype: String """ if self._schema: return self._schema else: return '' @schema.setter def schema(self, schema): """Set format schema """ self._schema = schema @property def extension(self): """Get format extension :rtype: String """ if self._extension: return self._extension else: return '' @extension.setter def extension(self, extension): """Set format extension """ self._extension = extension def same_as(self, frmt): """Check input frmt, if it seems to be the same as self """ if not isinstance(frmt, Format): return False return all([frmt.mime_type == self.mime_type, frmt.encoding == self.encoding, frmt.schema == self.schema]) def __eq__(self, other): return self.same_as(other) @property def json(self): """Get format as json :rtype: dict """ return { 'mime_type': self.mime_type, 'encoding': self.encoding, 'schema': self.schema, 'extension': self.extension } @json.setter def json(self, jsonin): """Set format from json :param jsonin: """ self.mime_type = jsonin['mime_type'] self.encoding = jsonin['encoding'] self.schema = jsonin['schema'] self.extension = jsonin['extension'] Supported_Formats = Optional[Sequence[Union[str, Format]]] FORMATS = _FORMATS( Format('application/geo+json', extension='.geojson'), Format('application/json', extension='.json'), Format('application/x-zipped-shp', extension='.zip', encoding='base64'), Format('application/gml+xml', extension='.gml'), Format('application/gpx+xml', extension='.gpx'), Format('application/metalink+xml; version=3.0', extension='.metalink', schema="metalink/3.0/metalink.xsd"), Format('application/metalink+xml; version=4.0', extension='.meta4', schema="metalink/4.0/metalink4.xsd"), Format('application/vnd.google-earth.kml+xml', extension='.kml'), Format('application/vnd.google-earth.kmz', extension='.kmz', encoding='base64'), Format('image/tiff; subtype=geotiff', extension='.tiff', encoding='base64'), Format('application/x-ogc-wcs', extension='.xml'), Format('application/x-ogc-wcs; version=1.0.0', extension='.xml'), Format('application/x-ogc-wcs; version=1.1.0', extension='.xml'), Format('application/x-ogc-wcs; version=2.0', extension='.xml'), Format('application/x-ogc-wfs', extension='.xml'), Format('application/x-ogc-wfs; version=1.0.0', extension='.xml'), Format('application/x-ogc-wfs; version=1.1.0', extension='.xml'), Format('application/x-ogc-wfs; version=2.0', extension='.xml'), Format('application/x-ogc-wms', extension='.xml'), Format('application/x-ogc-wms; version=1.3.0', extension='.xml'), Format('application/x-ogc-wms; version=1.1.0', extension='.xml'), Format('application/x-ogc-wms; version=1.0.0', extension='.xml'), Format('text/plain', extension='.txt'), Format('application/x-ogc-dods', extension='.nc'), Format('application/x-netcdf', extension='.nc', encoding='base64'), Format('application/ncML+xml', extension='.ncml', schema="ncml/2.2/ncml-2.2.xsd"), Format('application/octet-stream', extension='.laz'), Format('application/octet-stream', extension='.las'), Format('application/zip', extension='.zip', encoding='base64'), Format('application/xml', extension='.xml'), Format('text/csv', extension='.csv'), ) def _get_mimetypes(): """Add FORMATS to system wide mimetypes """ mimetypes.init() for pywps_format in FORMATS: mimetypes.add_type(pywps_format.mime_type, pywps_format.extension, True) _get_mimetypes() def get_format(frmt, validator=None): """Return Format instance based on given pywps.inout.FORMATS keyword """ # TODO this should be probably removed, it's used only in tests outfrmt = None if frmt in FORMATS._asdict(): outfrmt = FORMATS._asdict()[frmt] outfrmt.validate = validator return outfrmt else: return Format('None', validate=validator) pywps-4.5.1/pywps/inout/inputs.py000066400000000000000000000370461415166246000171360ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import re from pywps import xml_util as etree from pywps.app.Common import Metadata from pywps.exceptions import InvalidParameterValue from pywps.inout.formats import Format from pywps.inout import basic from copy import deepcopy from pywps.validator.mode import MODE from pywps.inout.literaltypes import AnyValue, NoValue, ValuesReference, AllowedValue CDATA_PATTERN = re.compile(r'') class BoundingBoxInput(basic.BBoxInput): """ :param string identifier: The name of this input. :param string title: Human readable title :param string abstract: Longer text description :param crss: List of supported coordinate reference system (e.g. ['EPSG:4326']) :param list keywords: Keywords that characterize this input. :param int dimensions: 2 or 3 :param str workdir: working directory, to save temporary file objects in. :param list metadata: TODO :param int min_occurs: how many times this input occurs :param int max_occurs: how many times this input occurs :param metadata: List of metadata advertised by this process. They should be :class:`pywps.app.Common.Metadata` objects. :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, identifier, title, crss=None, abstract='', keywords=[], dimensions=2, workdir=None, metadata=[], min_occurs=1, max_occurs=1, mode=MODE.NONE, default=None, default_type=basic.SOURCE_TYPE.DATA, translations=None): basic.BBoxInput.__init__(self, identifier, title=title, crss=crss, abstract=abstract, keywords=keywords, dimensions=dimensions, workdir=workdir, metadata=metadata, min_occurs=min_occurs, max_occurs=max_occurs, mode=mode, default=default, default_type=default_type, translations=translations) self.as_reference = False @property def json(self): """Get JSON representation of the input """ return { 'identifier': self.identifier, 'title': self.title, 'abstract': self.abstract, 'keywords': self.keywords, 'type': 'bbox', 'crs': self.crs, 'crss': self.crss, 'metadata': [m.json for m in self.metadata], 'bbox': self.data, 'll': self.ll, 'ur': self.ur, 'dimensions': self.dimensions, 'workdir': self.workdir, 'mode': self.valid_mode, 'min_occurs': self.min_occurs, 'max_occurs': self.max_occurs, 'translations': self.translations, } @classmethod def from_json(cls, json_input): instance = cls( identifier=json_input['identifier'], title=json_input.get('title'), abstract=json_input.get('abstract'), crss=json_input.get('crss'), keywords=json_input.get('keywords'), metadata=[Metadata.from_json(data) for data in json_input.get('metadata', [])], dimensions=json_input.get('dimensions'), workdir=json_input.get('workdir'), mode=json_input.get('mode'), min_occurs=json_input.get('min_occurs'), max_occurs=json_input.get('max_occurs'), translations=json_input.get('translations'), ) instance.data = json_input['bbox'] return instance def clone(self): """Create copy of yourself """ return deepcopy(self) class ComplexInput(basic.ComplexInput): """ Complex data input :param str identifier: The name of this input. :param str title: Title of the input :param pywps.inout.formats.Format supported_formats: List of supported formats :param pywps.inout.formats.Format data_format: default data format :param str abstract: Input abstract :param list keywords: Keywords that characterize this input. :param str workdir: working directory, to save temporary file objects in. :param list metadata: TODO :param int min_occurs: minimum occurrence :param int max_occurs: maximum occurrence :param pywps.validator.mode.MODE mode: validation mode (none to strict) :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, identifier, title, supported_formats, data_format=None, abstract='', keywords=[], workdir=None, metadata=[], min_occurs=1, max_occurs=1, mode=MODE.NONE, default=None, default_type=basic.SOURCE_TYPE.DATA, translations=None): """constructor""" basic.ComplexInput.__init__(self, identifier, title=title, supported_formats=supported_formats, data_format=data_format, abstract=abstract, keywords=keywords, workdir=workdir, metadata=metadata, min_occurs=min_occurs, max_occurs=max_occurs, mode=mode, default=default, default_type=default_type, translations=translations) self.as_reference = False self.method = '' @property def json(self): """Get JSON representation of the input """ data = { 'identifier': self.identifier, 'title': self.title, 'abstract': self.abstract, 'keywords': self.keywords, 'metadata': [m.json for m in self.metadata], 'type': 'complex', 'data_format': self.data_format.json, 'asreference': self.as_reference, 'supported_formats': [frmt.json for frmt in self.supported_formats], 'workdir': self.workdir, 'mode': self.valid_mode, 'min_occurs': self.min_occurs, 'max_occurs': self.max_occurs, 'translations': self.translations, } if self.prop == 'file': data['file'] = self.file elif self.prop == 'url': data["href"] = self.url elif self.prop == 'data': data = self._json_data(data) elif self.prop == 'stream': # we store the stream in the data property data = self._json_data(data) if self.data_format: if self.data_format.mime_type: data['mimetype'] = self.data_format.mime_type if self.data_format.encoding: data['encoding'] = self.data_format.encoding if self.data_format.schema: data['schema'] = self.data_format.schema return data @classmethod def from_json(cls, json_input): data_format = json_input.get('data_format') if data_format is not None: data_format = Format( schema=data_format.get('schema'), extension=data_format.get('extension'), mime_type=data_format.get('mime_type', ""), encoding=data_format.get('encoding') ) instance = cls( identifier=json_input['identifier'], title=json_input.get('title'), abstract=json_input.get('abstract'), keywords=json_input.get('keywords', []), workdir=json_input.get('workdir'), metadata=[Metadata.from_json(data) for data in json_input.get('metadata', [])], data_format=data_format, supported_formats=[ Format( schema=infrmt.get('schema'), extension=infrmt.get('extension'), mime_type=infrmt.get('mime_type'), encoding=infrmt.get('encoding') ) for infrmt in json_input.get('supported_formats', []) ], mode=json_input.get('mode', MODE.NONE), translations=json_input.get('translations'), ) instance.as_reference = json_input.get('asreference', False) if json_input.get('file'): instance.file = json_input['file'] elif json_input.get('href'): instance.url = json_input['href'] elif json_input.get('data'): data = json_input['data'] # remove cdata tag if it exists (issue #553) if isinstance(data, str): match = CDATA_PATTERN.match(data) if match: data = match.group(1) instance.data = data return instance def _json_data(self, data): """Return Data node """ if self.data: if self.data_format.mime_type in ["application/xml", "application/gml+xml", "text/xml"]: # Note that in a client-server round trip, the original and returned file will not be identical. data_doc = etree.parse(self.file) data["data"] = etree.tostring(data_doc, pretty_print=True).decode('utf-8') else: if self.data_format.encoding == 'base64': data["data"] = self.base64.decode('utf-8') else: # Otherwise we assume all other formats are unsafe and need to be enclosed in a CDATA tag. if isinstance(self.data, bytes): out = self.data.encode(self.data_format.encoding or 'utf-8') else: out = self.data data["data"] = ''.format(out) return data def clone(self): """Create copy of yourself """ return deepcopy(self) class LiteralInput(basic.LiteralInput): """ :param str identifier: The name of this input. :param str title: Title of the input :param pywps.inout.literaltypes.LITERAL_DATA_TYPES data_type: data type :param str workdir: working directory, to save temporary file objects in. :param str abstract: Input abstract :param list keywords: Keywords that characterize this input. :param list metadata: TODO :param str uoms: units :param int min_occurs: minimum occurence :param int max_occurs: maximum occurence :param pywps.validator.mode.MODE mode: validation mode (none to strict) :param pywps.inout.literaltypes.AnyValue allowed_values: or :py:class:`pywps.inout.literaltypes.AllowedValue` object :param metadata: List of metadata advertised by this process. They should be :class:`pywps.app.Common.Metadata` objects. :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, identifier, title=None, data_type=None, workdir=None, abstract='', keywords=[], metadata=[], uoms=None, min_occurs=1, max_occurs=1, mode=MODE.SIMPLE, allowed_values=None, default=None, default_type=basic.SOURCE_TYPE.DATA, translations=None): """Constructor """ data_type = data_type or 'string' basic.LiteralInput.__init__(self, identifier, title=title, data_type=data_type, workdir=workdir, abstract=abstract, keywords=keywords, metadata=metadata, uoms=uoms, min_occurs=min_occurs, max_occurs=max_occurs, mode=mode, allowed_values=allowed_values, default=default, default_type=default_type, translations=translations) self.as_reference = False @property def json(self): """Get JSON representation of the input """ data = { 'identifier': self.identifier, 'title': self.title, 'abstract': self.abstract, 'keywords': self.keywords, 'metadata': [m.json for m in self.metadata], 'type': 'literal', 'data_type': self.data_type, 'workdir': self.workdir, 'allowed_values': [value.json for value in self.allowed_values], 'any_value': self.any_value, 'mode': self.valid_mode, 'min_occurs': self.min_occurs, 'max_occurs': self.max_occurs, 'translations': self.translations, # other values not set in the constructor } if self.values_reference: data['values_reference'] = self.values_reference.json if self.uoms: data["uoms"] = [uom.json for uom in self.uoms] if self.uom: data["uom"] = self.uom.json if self.data is not None: data['data'] = str(self.data) return data @classmethod def from_json(cls, json_input): allowed_values = [] for allowed_value in json_input.get('allowed_values', []): if allowed_value['type'] == 'anyvalue': allowed_values.append(AnyValue()) elif allowed_value['type'] == 'novalue': allowed_values.append(NoValue()) elif allowed_value['type'] == 'valuesreference': allowed_values.append(ValuesReference.from_json(allowed_value)) elif allowed_value['type'] == 'allowedvalue': allowed_values.append(AllowedValue.from_json(allowed_value)) json_input_copy = deepcopy(json_input) json_input_copy['allowed_values'] = allowed_values json_input_copy['uoms'] = [ basic.UOM(uom['uom'], uom['reference']) for uom in json_input.get('uoms', []) ] data = json_input_copy.pop('data', None) uom = json_input_copy.pop('uom', None) metadata = json_input_copy.pop('metadata', []) json_input_copy.pop('type', None) json_input_copy.pop('any_value', None) json_input_copy.pop('values_reference', None) instance = cls(**json_input_copy) instance.metadata = [Metadata.from_json(d) for d in metadata] instance.data = data if uom: instance.uom = basic.UOM(uom['uom'], uom['reference']) return instance def clone(self): """Create copy of yourself """ return deepcopy(self) def input_from_json(json_data): data_type = json_data.get('type', 'literal') if data_type in ['complex', 'reference']: inpt = ComplexInput.from_json(json_data) elif data_type == 'literal': inpt = LiteralInput.from_json(json_data) elif data_type == 'bbox': inpt = BoundingBoxInput.from_json(json_data) else: raise InvalidParameterValue("Input type not recognized: {}".format(data_type)) return inpt pywps-4.5.1/pywps/inout/literaltypes.py000066400000000000000000000275011415166246000203300ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Literaltypes are used for LiteralInputs, to make sure, input data are OK """ from urllib.parse import urlparse from dateutil.parser import parse as date_parser import datetime from pywps.exceptions import InvalidParameterValue from pywps.validator.allowed_value import RANGECLOSURETYPE from pywps.validator.allowed_value import ALLOWEDVALUETYPE import logging LOGGER = logging.getLogger('PYWPS') LITERAL_DATA_TYPES = ('float', 'boolean', 'integer', 'string', 'positiveInteger', 'anyURI', 'time', 'date', 'dateTime', 'scale', 'angle', 'nonNegativeInteger', None) # currently we are supporting just ^^^ data types, feel free to add support for # more # 'measure', 'angleList', # 'angle', 'integerList', # 'positiveIntegerList', # 'lengthOrAngle', 'gridLength', # 'measureList', 'lengthList', # 'gridLengthList', 'scaleList', 'timeList', # 'nonNegativeInteger', 'length' class AnyValue(object): """Specifies that any value is allowed for this quantity. """ @property def value(self): return None @property def json(self): return { 'type': 'anyvalue', } def __eq__(self, other): return isinstance(other, AnyValue) and self.json == other.json class NoValue(object): """No value allowed NOTE: not really implemented """ @property def value(self): return None @property def json(self): return {'type': 'novalue'} def __eq__(self, other): return isinstance(other, NoValue) and self.json == other.json class ValuesReference(object): """Reference to list of all valid values and/or ranges of values for this quantity. NOTE: Validation of values is not implemented. :param: reference: URL from which this set of ranges and values can be retrieved :param: values_form: Reference to a description of the mimetype, encoding, and schema used for this set of values and ranges. """ def __init__(self, reference=None, values_form=None): self.reference = reference self.values_form = values_form if not self.reference: raise InvalidParameterValue("values reference is missing.") @property def value(self): return None @property def json(self): return { 'type': 'valuesreference', 'reference': self.reference, 'values_form': self.values_form } @classmethod def from_json(cls, json_input): instance = cls( reference=json_input['reference'], values_form=json_input['values_form'], ) return instance def __eq__(self, other): return isinstance(other, ValuesReference) and self.json == other.json class AllowedValue(object): """List of all valid values and/or ranges of values for this quantity. The values are evaluated in literal validator functions :param pywps.validator.allowed_value.ALLOWEDVALUETYPE allowed_type: VALUE or RANGE :param value: single value :param minval: minimal value in case of Range :param maxval: maximal value in case of Range :param spacing: spacing in case of Range :param pywps.input.literaltypes.RANGECLOSURETYPE range_closure: """ def __init__(self, allowed_type=None, value=None, minval=None, maxval=None, spacing=None, range_closure=RANGECLOSURETYPE.CLOSED): self.allowed_type = allowed_type self.value = value self.minval = minval self.maxval = maxval self.spacing = spacing self.range_closure = range_closure if not self.allowed_type: # automatically set allowed_type: RANGE or VALUE if self.minval or self.maxval or self.spacing: self.allowed_type = ALLOWEDVALUETYPE.RANGE else: self.allowed_type = ALLOWEDVALUETYPE.VALUE def __eq__(self, other): return isinstance(other, AllowedValue) and self.json == other.json @property def json(self): value = self.value if hasattr(value, 'json'): value = value.json return { 'type': 'allowedvalue', 'allowed_type': self.allowed_type, 'value': value, 'minval': self.minval, 'maxval': self.maxval, 'spacing': self.spacing, 'range_closure': self.range_closure } @classmethod def from_json(cls, json_input): instance = cls( allowed_type=json_input['allowed_type'], value=json_input['value'], minval=json_input['minval'], maxval=json_input['maxval'], spacing=json_input['spacing'], range_closure=json_input['range_closure'] ) return instance ALLOWED_VALUES_TYPES = (AllowedValue, AnyValue, NoValue, ValuesReference) def get_converter(convertor): """function for decoration of convert """ def decorator_selector(data_type, data): convert = None if data_type in LITERAL_DATA_TYPES: if data_type == 'string': convert = convert_string elif data_type == 'integer': convert = convert_integer elif data_type == 'float': convert = convert_float elif data_type == 'boolean': convert = convert_boolean elif data_type == 'positiveInteger': convert = convert_positiveInteger elif data_type == 'anyURI': convert = convert_anyURI elif data_type == 'time': convert = convert_time elif data_type == 'date': convert = convert_date elif data_type == 'dateTime': convert = convert_datetime elif data_type == 'scale': convert = convert_scale elif data_type == 'angle': convert = convert_angle elif data_type == 'nonNegativeInteger': convert = convert_positiveInteger else: raise InvalidParameterValue( "Invalid data_type value of LiteralInput " "set to '{}'".format(data_type)) try: return convert(data) except ValueError: raise InvalidParameterValue( "Could not convert value '{}' to format '{}'".format( data, data_type)) return decorator_selector @get_converter def convert(data_type, data): """Convert data to target value """ return data_type, data def convert_boolean(inpt): """Return boolean value from input boolean input >>> convert_boolean('1') True >>> convert_boolean('-1') True >>> convert_boolean('FaLsE') False >>> convert_boolean('FaLsEx') True >>> convert_boolean(0) False """ val = False if str(inpt).lower() in ['false', 'f']: val = False else: try: val = int(inpt) if val == 0: val = False else: val = True except Exception: val = True return val def convert_float(inpt): """Return float value from inpt >>> convert_float('1') 1.0 """ return float(inpt) def convert_integer(inpt): """Return integer value from input inpt >>> convert_integer('1.0') 1 """ return int(float(inpt)) def convert_string(inpt): """Return string value from input lit_input >>> convert_string(1) '1' """ return str(inpt) def convert_positiveInteger(inpt): """Return value of input""" inpt = convert_integer(inpt) if inpt < 0: raise InvalidParameterValue( 'The value "{}" is not of type positiveInteger'.format(inpt)) else: return inpt def convert_anyURI(inpt): """Return value of input :rtype: url components """ inpt = convert_string(inpt) components = urlparse(inpt) if (components[0] and components[1]) or components[0] == 'file': return components else: raise InvalidParameterValue( 'The value "{}" does not seem to be of type anyURI'.format(inpt)) def convert_time(inpt): """Return value of input time formating assumed according to ISO standard: https://www.w3.org/TR/xmlschema-2/#time Examples: 12:00:00 :rtype: datetime.time object """ if not isinstance(inpt, datetime.time): inpt = convert_datetime(inpt).time() return inpt def convert_date(inpt): """Return value of input date formating assumed according to ISO standard: https://www.w3.org/TR/xmlschema-2/#date Examples: 2016-09-20 :rtype: datetime.date object """ if not isinstance(inpt, datetime.date): inpt = convert_datetime(inpt).date() return inpt def convert_datetime(inpt): """Return value of input dateTime formating assumed according to ISO standard: * http://www.w3.org/TR/NOTE-datetime * https://www.w3.org/TR/xmlschema-2/#dateTime Examples: 2016-09-20T12:00:00, 2012-12-31T06:30:00Z, 2017-01-01T18:00:00+01:00 :rtype: datetime.datetime object """ # TODO: %z directive works only with python 3 # time_format = '%Y-%m-%dT%H:%M:%S%z' # time_format = '%Y-%m-%dT%H:%M:%S%Z' # inpt = time.strptime(convert_string(inpt), time_format) if not isinstance(inpt, datetime.datetime): inpt = convert_string(inpt) inpt = date_parser(inpt) return inpt def convert_scale(inpt): """Return value of input""" return convert_float(inpt) def convert_angle(inpt): """Return value of input return degrees """ inpt = convert_float(inpt) return inpt % 360 def make_allowedvalues(allowed_values): """convert given value list to AllowedValue objects :return: list of pywps.inout.literaltypes.AllowedValue """ new_allowedvalues = [] if not isinstance(allowed_values, (tuple, list)): allowed_values = [allowed_values] for value in allowed_values: if value in ALLOWED_VALUES_TYPES: # value is equal to one of the allowed classes objects new_allowedvalues.append(value()) elif isinstance(value, ALLOWED_VALUES_TYPES): # value is an instance of one of the allowed classes new_allowedvalues.append(value) elif type(value) == tuple or type(value) == list: spacing = None if len(value) == 2: minval = value[0] maxval = value[1] else: minval = value[0] spacing = value[1] maxval = value[2] new_allowedvalues.append( AllowedValue(minval=minval, maxval=maxval, spacing=spacing) ) else: new_allowedvalues.append(AllowedValue(value=value)) return new_allowedvalues def is_anyvalue(value): """Check for any value object of given value """ is_av = False if value is AnyValue: is_av = True elif value is None: is_av = True elif isinstance(value, AnyValue): is_av = True elif str(value).lower() == 'anyvalue': is_av = True return is_av def is_values_reference(value): """Check for ValuesReference in given value """ check = False if value is ValuesReference: check = True elif value is None: check = False elif isinstance(value, ValuesReference): check = True elif str(value).lower() == 'valuesreference': check = True return check pywps-4.5.1/pywps/inout/outputs.py000066400000000000000000000507031415166246000173320ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ WPS Output classes """ from typing import Optional, Sequence, Dict, Union from pywps import xml_util as etree import os import re from pywps.app.Common import Metadata from pywps.exceptions import InvalidParameterValue from pywps.inout import basic from pywps.inout.storage.file import FileStorageBuilder from pywps.inout.types import Translations from pywps.validator.mode import MODE from pywps import configuration as config from pywps.inout.formats import Format, Supported_Formats, FORMATS class BoundingBoxOutput(basic.BBoxOutput): """ :param identifier: The name of this input. :param str title: Title of the input :param str abstract: Input abstract :param crss: List of supported coordinate reference system (e.g. ['EPSG:4326']) :param int dimensions: number of dimensions (2 or 3) :param int min_occurs: minimum occurence :param int max_occurs: maximum occurence :param pywps.validator.mode.MODE mode: validation mode (none to strict) :param metadata: List of metadata advertised by this process. They should be :class:`pywps.app.Common.Metadata` objects. :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, identifier, title, crss, abstract='', keywords=[], dimensions=2, metadata=[], min_occurs='1', max_occurs='1', as_reference=False, mode=MODE.NONE, translations=None): basic.BBoxOutput.__init__(self, identifier, title=title, abstract=abstract, keywords=keywords, crss=crss, dimensions=dimensions, mode=mode, translations=translations) self.metadata = metadata self.min_occurs = min_occurs self.max_occurs = max_occurs self.as_reference = as_reference @property def json(self): """Get JSON representation of the output """ return { 'identifier': self.identifier, 'title': self.title, 'abstract': self.abstract, 'keywords': self.keywords, 'min_occurs': self.min_occurs, 'max_occurs': self.max_occurs, 'metadata': self.metadata, 'type': 'bbox', 'crs': self.crs, 'crss': self.crss, 'dimensions': self.dimensions, 'bbox': self.data, 'll': self.ll, 'ur': self.ur, 'workdir': self.workdir, 'mode': self.valid_mode, 'translations': self.translations, } @classmethod def from_json(cls, json_output): instance = cls( identifier=json_output['identifier'], title=json_output['title'], abstract=json_output['abstract'], keywords=json_output['keywords'], min_occurs=json_output['min_occurs'], max_occurs=json_output['max_occurs'], metadata=[Metadata.from_json(data) for data in json_output.get('metadata', [])], crss=json_output['crss'], dimensions=json_output['dimensions'], mode=json_output['mode'], translations=json_output.get('translations'), ) instance.data = json_output['bbox'] instance.workdir = json_output['workdir'] return instance class ComplexOutput(basic.ComplexOutput): """ :param identifier: The name of this output. :param title: Readable form of the output name. :param supported_formats: List of supported formats. The first format in the list will be used as the default. :type supported_formats: (pywps.inout.formats.Format, ) :param str abstract: Description of the output :param pywps.validator.mode.MODE mode: validation mode (none to strict) :param metadata: List of metadata advertised by this process. They should be :class:`pywps.app.Common.Metadata` objects. :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, identifier: str, title: str, supported_formats: Supported_Formats = None, data_format=None, abstract: str = '', keywords=[], workdir=None, metadata: Optional[Sequence[Metadata]] = None, as_reference=False, mode: MODE = MODE.NONE, translations: Translations = None): if metadata is None: metadata = [] basic.ComplexOutput.__init__(self, identifier, title=title, data_format=data_format, abstract=abstract, keywords=keywords, workdir=workdir, supported_formats=supported_formats, mode=mode, translations=translations) self.metadata = metadata self.as_reference = as_reference self.storage = None @property def json(self): """Get JSON representation of the output """ data = { "identifier": self.identifier, "title": self.title, "abstract": self.abstract, 'keywords': self.keywords, 'type': 'complex', 'supported_formats': [frmt.json for frmt in self.supported_formats], 'asreference': self.as_reference, 'data_format': self.data_format.json if self.data_format else None, 'file': self.file if self.prop == 'file' else None, 'workdir': self.workdir, 'mode': self.valid_mode, 'min_occurs': self.min_occurs, 'max_occurs': self.max_occurs, 'translations': self.translations, } if self.data_format: if self.data_format.mime_type: data['mimetype'] = self.data_format.mime_type if self.data_format.encoding: data['encoding'] = self.data_format.encoding if self.data_format.schema: data['schema'] = self.data_format.schema if self.as_reference: data = self._json_reference(data) else: data = self._json_data(data) return data @classmethod def from_json(cls, json_output): instance = cls( identifier=json_output['identifier'], title=json_output.get('title'), abstract=json_output.get('abstract'), keywords=json_output.get('keywords', []), workdir=json_output.get('workdir'), metadata=[Metadata.from_json(data) for data in json_output.get('metadata', [])], data_format=Format( schema=json_output['data_format'].get('schema'), extension=json_output['data_format'].get('extension'), mime_type=json_output['data_format']['mime_type'], encoding=json_output['data_format'].get('encoding') ), supported_formats=[ Format( schema=infrmt.get('schema'), extension=infrmt.get('extension'), mime_type=infrmt['mime_type'], encoding=infrmt.get('encoding') ) for infrmt in json_output['supported_formats'] ], mode=json_output.get('mode', MODE.NONE), translations=json_output.get('translations'), ) instance.as_reference = json_output.get('asreference', False) if json_output.get('file'): instance.file = json_output['file'] return instance def _json_reference(self, data): """Return Reference node """ data["type"] = "reference" # get_url will create the file and return the url for it if self.prop == 'url': data["href"] = self.url elif self.prop is not None: self.storage = FileStorageBuilder().build() data["href"] = self.get_url() return data def _json_data(self, data): """Return Data node """ data["type"] = "complex" if self.data: if self.data_format.mime_type in [FORMATS.GEOJSON.mime_type, FORMATS.JSON.mime_type]: data["data"] = self.data elif self.data_format.mime_type in ["application/xml", "application/gml+xml", "text/xml"]: # Note that in a client-server round trip, the original and returned file will not be identical. data_doc = etree.parse(self.file) data["data"] = etree.tostring(data_doc, pretty_print=True).decode('utf-8') else: if self.data_format.encoding == 'base64': data["data"] = self.base64.decode('utf-8') else: # Match only data that are safe CDATA pattern. CDATA_PATTERN = re.compile(r'^).)*\]\]>$') # Otherwise we assume all other formats are unsafe and need to be enclosed in a CDATA tag. if isinstance(self.data, bytes): # Try to inline data as text but if fail encode is in base64 if self.data_format.encoding == 'utf-8': out = self.data.decode('utf-8') # If data is already enclosed with CDATA pattern, do not add it twice if CDATA_PATTERN.match(out): data["data"] = out else: # Check if the data does not contain ]]> patern if is safe to use CDATA # other wise we fallback to base64 encoding. if not re.search('\\]\\]>', out): data["data"] = ''.format(out) else: data['encoding'] = 'base64' # override the unsafe encoding data["data"] = self.base64.decode('utf-8') else: data['encoding'] = 'base64' # override the unsafe encoding data["data"] = self.base64.decode('utf-8') else: out = str(self.data) # If data is already enclose with CDATApatern do not add it twise if CDATA_PATTERN.match(out): data["data"] = out else: # Check if the data does not contain ]]> patern if is safe to use CDATA # other wise we fallback to base64 encoding. if not re.search('\\]\\]>', out): data["data"] = ''.format(out) else: data['encoding'] = 'base64' # override the unsafe encoding data["data"] = self.base64.decode('utf-8') return data class LiteralOutput(basic.LiteralOutput): """ :param identifier: The name of this output. :param str title: Title of the input :param pywps.inout.literaltypes.LITERAL_DATA_TYPES data_type: data type :param str abstract: Input abstract :param str uoms: units :param pywps.validator.mode.MODE mode: validation mode (none to strict) :param metadata: List of metadata advertised by this process. They should be :class:`pywps.app.Common.Metadata` objects. :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code, and the nested mapping contains translated strings accessible by a string property. e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}} """ def __init__(self, identifier, title, data_type='string', abstract='', keywords=[], metadata=[], uoms=None, mode=MODE.SIMPLE, translations=None): if uoms is None: uoms = [] basic.LiteralOutput.__init__(self, identifier, title=title, abstract=abstract, keywords=keywords, data_type=data_type, uoms=uoms, mode=mode, translations=translations) self.metadata = metadata @property def json(self): """Get JSON representation of the output """ data = { "identifier": self.identifier, "title": self.title, "abstract": self.abstract, "keywords": self.keywords, "data": self.data, "data_type": self.data_type, "type": "literal", "uoms": [u.json for u in self.uoms], "translations": self.translations, } if self.uom: data["uom"] = self.uom.json return data @classmethod def from_json(cls, json_output): uoms = [ basic.UOM(uom['uom'], uom['reference']) for uom in json_output.get('uoms', []) ] uom = json_output.get('uom') instance = cls( identifier=json_output['identifier'], title=json_output['title'], data_type=json_output['data_type'], abstract=json_output['abstract'], keywords=json_output['keywords'], uoms=uoms, translations=json_output.get('translations'), ) instance.data = json_output.get('data') if uom: instance.uom = basic.UOM(uom['uom'], uom['reference']) return instance class MetaFile: """MetaFile object.""" def __init__(self, identity=None, description=None, fmt=None): """Create a `MetaFile` object. :param str identity: human readable identity. :param str description: human readable file description. :param pywps.FORMAT fmt: file mime type. The content of each metafile is set like `ComplexOutputs`, ie using either the `data`, `file`, `stream` or `url` properties. The metalink document is created by a `MetaLink` instance, which holds a number of `MetaFile` instances. """ self._size = None self._output = ComplexOutput( identifier=identity or '', title=description or '', as_reference=True, supported_formats=[fmt, ], ) def _set_workdir(self, workdir): self._output.workdir = workdir @property def hash(self): """Text construct that conveys a cryptographic hash for a file. All hashes are encoded in lowercase hexadecimal format. Hashes are used to verify the integrity of a complete file or portion of a file to determine if the file has been transferred without any errors. """ import hashlib m = hashlib.sha256() with open(self.file, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): m.update(chunk) return m.hexdigest() @property def identity(self): """Human-readable identity.""" return self._output.identifier @property def name(self): """Indicate a specific file in a document describing multiple files.""" js = self._output.json (_, name) = os.path.split(js.get('href', 'http:///')) return name @property def size(self): """Size of the linked content in bytes.""" if self._size is None: self._size = self._output.size return self._size @size.setter def size(self, value): """Set size to avoid size calculation.""" self._size = int(value) @property def urls(self): js = self._output.json return [js.get('href', ''), ] @property def mediatype(self): """Multipurpose Internet Mail Extensions (MIME) media type [RFC4288] of the metadata file available at the IRI.""" return self._output.data_format.mime_type @property def data(self): return self._output.data @data.setter def data(self, value): self._output.data = value @property def file(self): return self._output.file @file.setter def file(self, value): self._output.file = value @property def url(self): return self._output.url @url.setter def url(self, value): self._output.url = value @property def stream(self): return self._output.stream @stream.setter def stream(self, value): self._output.stream = value def __str__(self): out = "MetaFile {}:".format(self.name) for url in self.urls: out += "\n\t{}".format(url) return out def __repr__(self): return "".format(self.name) class MetaLink: _xml_template = 'metalink/3.0/main.xml' # Specs: https://www.metalinker.org/Metalink_3.0_Spec.pdf def __init__(self, identity=None, description=None, publisher=None, files=(), workdir=None, checksums=False): """Create a MetaLink v3.0 instance. :param str identity: human readable identity. :param str description: human readable file description. :param str publisher: The name of the file's publisher. :param tuple files: Sequence of files to include in Metalink. Can also be added using `append`. :param str workdir: Work directory to store temporary files. :param bool checksums: Whether to compute checksums on files. To use, first append `MetaFile` instances, then write the metalink using the `xml` property. Methods: - `append`: add a `MetaFile` instance """ self.identity = identity self.description = description self.workdir = workdir self.publisher = publisher self.files = [] self.checksums = checksums for file in files: self.append(file) self._load_template() def append(self, file): """Append a `MetaFile` instance.""" if not isinstance(file, MetaFile): raise ValueError("file must be a MetaFile instance.") file._set_workdir(self.workdir) self.files.append(file) @property def xml(self): return self._template.render(meta=self) @property def origin(self): """IRI where the Metalink Document was originally published. If the dynamic attribute of metalink:origin is "true", then updated versions of the Metalink can be found at this IRI. """ return "" @property def published(self): """Date construct indicating an instant in time associated with an event early in the life cycle of the entry.""" import datetime return datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") @property def generator(self): """Generating agent name and version used to generate a Metalink Document, for debugging and other purposes.""" import pywps return "PyWPS/{}".format(pywps.__version__) @property def url(self): """Return the server URL.""" return config.get_config_value('server', 'url') def _load_template(self): from pywps.response import RelEnvironment from jinja2 import PackageLoader template_env = RelEnvironment( loader=PackageLoader('pywps', 'templates'), trim_blocks=True, lstrip_blocks=True, autoescape=True, ) self._template = template_env.get_template(self._xml_template) class MetaLink4(MetaLink): _xml_template = 'metalink/4.0/main.xml' # Specs: https://tools.ietf.org/html/rfc5854 def output_from_json(json_data): data_type = json_data.get('type', 'literal') if data_type == 'complex': output = ComplexOutput.from_json(json_data) elif data_type == 'literal': output = LiteralOutput.from_json(json_data) elif data_type == 'bbox': output = BoundingBoxOutput.from_json(json_data) else: raise InvalidParameterValue("Output type not recognized: {}".format(data_type)) return output pywps-4.5.1/pywps/inout/storage/000077500000000000000000000000001415166246000166745ustar00rootroot00000000000000pywps-4.5.1/pywps/inout/storage/__init__.py000066400000000000000000000056531415166246000210160ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import os from abc import ABCMeta, abstractmethod LOGGER = logging.getLogger('PYWPS') class STORE_TYPE: PATH = 0 S3 = 1 # TODO: cover with tests class StorageAbstract(object, metaclass=ABCMeta): """Data storage abstract class """ @abstractmethod def store(self, output): """ :param output: of type IOHandler :returns: (type, store, url) where type - is type of STORE_TYPE - number store - string describing storage - file name, database connection url - url, where the data can be downloaded """ raise NotImplementedError @abstractmethod def write(self, data, destination, data_format=None): """ :param data: data to write to storage :param destination: identifies the destination to write to storage generally a file name which can be interpreted by the implemented Storage class in a manner of its choosing :param data_format: Optional parameter of type pywps.inout.formats.FORMAT describing the format of the data to write. :returns: url where the data can be downloaded """ raise NotImplementedError @abstractmethod def url(self, destination): """ :param destination: the name of the output to calculate the url for :returns: URL where file_name can be reached """ raise NotImplementedError @abstractmethod def location(self, destination): """ Provides a location for the specified destination. This may be any path, pathlike object, db connection string, URL, etc and it is not guaranteed to be accessible on the local file system :param destination: the name of the output to calculate the location for :returns: location where file_name can be found """ raise NotImplementedError class CachedStorage(StorageAbstract): def __init__(self): self._cache = {} def store(self, output): if output.identifier not in self._cache: self._cache[output.identifier] = self._do_store(output) return self._cache[output.identifier] def _do_store(self, output): raise NotImplementedError class DummyStorage(StorageAbstract): """Dummy empty storage implementation, does nothing Default instance, for non-reference output request >>> store = DummyStorage() >>> assert store.store """ def __init__(self): """ """ def store(self, output): pass pywps-4.5.1/pywps/inout/storage/builder.py000066400000000000000000000022201415166246000206700ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from .s3 import S3StorageBuilder from .file import FileStorageBuilder import pywps.configuration as wpsConfig STORAGE_MAP = { 's3': S3StorageBuilder, 'file': FileStorageBuilder } class StorageBuilder: """ Class to construct other storage classes using the server configuration to determine the appropriate type. Will default to using FileStorage if the specified type cannot be found """ @staticmethod def buildStorage(): """ :returns: A StorageAbstract conforming object for storing outputs that has been configured using the server configuration """ storage_type = wpsConfig.get_config_value('server', 'storagetype').lower() if storage_type not in STORAGE_MAP: return FileStorageBuilder().build() else: return STORAGE_MAP[storage_type]().build() pywps-4.5.1/pywps/inout/storage/file.py000066400000000000000000000153111415166246000201660ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import os from urllib.parse import urljoin from pywps.exceptions import NotEnoughStorage, FileStorageError from pywps import configuration as config from pywps.inout.basic import IOHandler from . import CachedStorage from .implementationbuilder import StorageImplementationBuilder from . import STORE_TYPE LOGGER = logging.getLogger('PYWPS') class FileStorageBuilder(StorageImplementationBuilder): def build(self): file_path = config.get_config_value('server', 'outputpath') base_url = config.get_config_value('server', 'outputurl') copy_function = config.get_config_value('server', 'storage_copy_function') return FileStorage(file_path, base_url, copy_function=copy_function) def _build_output_name(output): (prefix, suffix) = os.path.splitext(output.file) if not suffix: suffix = output.data_format.extension _, file_name = os.path.split(prefix) output_name = file_name + suffix return (output_name, suffix) class FileStorage(CachedStorage): """File storage implementation, stores data to file system >>> import ConfigParser >>> config = ConfigParser.RawConfigParser() >>> config.add_section('FileStorage') >>> config.set('FileStorage', 'target', './') >>> config.add_section('server') >>> config.set('server', 'outputurl', 'http://foo/bar/filestorage') >>> >>> store = FileStorage() >>> >>> class FakeOutput(object): ... def __init__(self): ... self.file = self._get_file() ... def _get_file(self): ... tiff_file = open('file.tiff', 'w') ... tiff_file.close() ... return 'file.tiff' >>> fake_out = FakeOutput() >>> (type, path, url) = store.store(fake_out) >>> type == STORE_TYPE.PATH True """ def __init__(self, output_path, output_url, copy_function=None): """ """ CachedStorage.__init__(self) self.target = output_path self.output_url = output_url self.copy_function = copy_function def _do_store(self, output): """Copy output to final storage location. - Create output directory - Check available file space - Create output file name, taking care of possible duplicates - Copy / link output in work directory to output directory - Return store type, output path and output URL """ import platform import math import tempfile import uuid file_name = output.file request_uuid = output.uuid or uuid.uuid1() # Create a target folder for each request target = os.path.join(self.target, str(request_uuid)) if not os.path.exists(target): os.makedirs(target) # st.blksize is not available in windows, skips the validation on windows if platform.system() != 'Windows': file_block_size = os.stat(file_name).st_blksize # get_free_space delivers the numer of free blocks, not the available size! avail_size = get_free_space(self.target) * file_block_size file_size = os.stat(file_name).st_size # calculate space used according to block size actual_file_size = math.ceil(file_size / float(file_block_size)) * file_block_size if avail_size < actual_file_size: raise NotEnoughStorage('Not enough space in {} to store {}'.format(self.target, file_name)) # build output name output_name, suffix = _build_output_name(output) # build tempfile in case of duplicates if os.path.exists(os.path.join(target, output_name)): output_name = tempfile.mkstemp(suffix=suffix, prefix=file_name + '_', dir=target)[1] full_output_name = os.path.join(target, output_name) LOGGER.info(f'Storing file output to {full_output_name} ({self.copy_function}).') try: self.copy(output.file, full_output_name, self.copy_function) except Exception: LOGGER.exception(f"Could not copy {output_name}.") raise FileStorageError("Could not copy output file.") just_file_name = os.path.basename(output_name) url = self.url("{}/{}".format(request_uuid, just_file_name)) LOGGER.info('File output URI: {}'.format(url)) return STORE_TYPE.PATH, output_name, url @staticmethod def copy(src, dst, copy_function=None): """Copy file from source to destination using `copy_function`. Values of `copy_function` (default=`copy`): * copy: using `shutil.copy2` * move: using `shutil.move` * link: using `os.link` (hardlink) """ import shutil if copy_function == 'move': shutil.move(src, dst) elif copy_function == 'link': try: os.link(src, dst) except Exception: LOGGER.warning("Could not create hardlink. Fallback to copy.") FileStorage.copy(src, dst) else: shutil.copy2(src, dst) def write(self, data, destination, data_format=None): """ Write data to self.target """ if not os.path.exists(os.path.dirname(self.target)): os.makedirs(self.target) full_output_name = os.path.join(self.target, destination) with open(full_output_name, "w") as file: file.write(data) return self.url(destination) def url(self, destination): if isinstance(destination, IOHandler): output_name, _ = _build_output_name(destination) just_file_name = os.path.basename(output_name) dst = f"{destination.uuid}/{just_file_name}" else: dst = destination # make sure base url ends with '/' baseurl = self.output_url.rstrip('/') + '/' url = urljoin(baseurl, dst) return url def location(self, destination): return os.path.join(self.target, destination) def get_free_space(folder): """ Return folder/drive free space (in bytes) """ import platform if platform.system() == 'Windows': import ctypes free_bytes = ctypes.c_ulonglong(0) ctypes.windll.kernel32.GetDiskFreeSpaceExW(ctypes.c_wchar_p(folder), None, None, ctypes.pointer(free_bytes)) free_space = free_bytes.value else: free_space = os.statvfs(folder).f_bfree LOGGER.debug('Free space: {}'.format(free_space)) return free_space pywps-4.5.1/pywps/inout/storage/implementationbuilder.py000066400000000000000000000013261415166246000236440ustar00rootroot00000000000000################################################################## # Copyright 2019 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from abc import ABCMeta, abstractmethod class StorageImplementationBuilder(object, metaclass=ABCMeta): """ Storage implementations should implement this class and build method then import and register the build class into the StorageBuilder. """ @abstractmethod def build(self): """ :returns: An object which implements the StorageAbstract class """ raise NotImplementedError pywps-4.5.1/pywps/inout/storage/s3.py000066400000000000000000000140461415166246000176000ustar00rootroot00000000000000################################################################## # Copyright 2019 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import pywps.configuration as wpsConfig from . import StorageAbstract from .implementationbuilder import StorageImplementationBuilder from . import STORE_TYPE import os import logging LOGGER = logging.getLogger('PYWPS') class S3StorageBuilder(StorageImplementationBuilder): def build(self): bucket = wpsConfig.get_config_value('s3', 'bucket') prefix = wpsConfig.get_config_value('s3', 'prefix') public_access = wpsConfig.get_config_value('s3', 'public') encrypt = wpsConfig.get_config_value('s3', 'encrypt') region = wpsConfig.get_config_value('s3', 'region') return S3Storage(bucket, prefix, public_access, encrypt, region) def _build_s3_file_path(prefix, filename): if prefix: path = prefix.rstrip('/') + '/' + filename.lstrip('/') else: path = filename.lstrip('/') return path def _build_extra_args(public=False, encrypt=False, mime_type=''): extraArgs = dict() if public: extraArgs['ACL'] = 'public-read' if encrypt: extraArgs['ServerSideEncryption'] = 'AES256' extraArgs['ContentType'] = mime_type return extraArgs class S3Storage(StorageAbstract): """ Implements a simple class to store files on AWS S3 Can optionally set the outputs to be publically readable and can also encrypt files at rest """ def __init__(self, bucket, prefix, public_access, encrypt, region): self.bucket = bucket self.public = public_access self.encrypt = encrypt self.prefix = prefix self.region = region def _wait_for(self, filename): import boto3 client = boto3.client('s3', region_name=self.region) waiter = client.get_waiter('object_exists') waiter.wait(Bucket=self.bucket, Key=filename) def uploadData(self, data, filename, extraArgs): """ :param data: Data to upload to S3 :param filename: name of the file to upload to s3 will be appened to the configured prefix :returns: url to access the uploaded file Creates or updates a file on S3 in the bucket specified in the server configuration. The key of the created object will be equal to the configured prefix with the destination parameter appended. """ import boto3 s3 = boto3.resource('s3', region_name=self.region) s3.Object(self.bucket, filename).put(Body=data, **extraArgs) LOGGER.debug('S3 Put: {} into bucket {}'.format(self.bucket, filename)) # Ensure object is available before returning URL self._wait_for(filename) # Create s3 URL url = self.url(filename) return url def uploadFileToS3(self, filename, extraArgs): """ :param filename: Path to file on local filesystem :returns: url to access the uploaded file Uploads a file from the local filesystem to AWS S3 """ url = '' with open(filename, "rb") as data: s3_path = _build_s3_file_path(self.prefix, os.path.basename(filename)) url = self.uploadData(data, s3_path, extraArgs) return url def store(self, output): """ :param output: Of type IOHandler :returns: tuple(STORE_TYPE.S3, uploaded filename, url to access the uploaded file) Stores an IOHandler object to AWS S3 and returns the storage type, string and a URL to access the uploaded object """ filename = output.file s3_path = _build_s3_file_path(self.prefix, os.path.basename(filename)) extraArgs = _build_extra_args( public=self.public, encrypt=self.encrypt, mime_type=output.data_format.mime_type) url = self.uploadFileToS3(filename, extraArgs) return (STORE_TYPE.S3, s3_path, url) def write(self, data, destination, data_format=None): """ :param data: Data that will be written to S3. Can be binary or text :param destination: Filename of object that will be created / updated on S3. :param data_format: Format of the data. Will set the mime_type of the file on S3. If not set, no mime_type will be set. Creates or updates a file on S3 in the bucket specified in the server configuration. The key of the created object will be equal to the configured prefix with the destination parameter appended. """ # Get MimeType from format if it exists mime_type = data_format.mime_type if data_format is not None else '' s3_path = _build_s3_file_path(self.prefix, destination) extraArgs = _build_extra_args( public=self.public, encrypt=self.encrypt, mime_type=mime_type) return self.uploadData(data, s3_path, extraArgs) def url(self, destination): """ :param destination: File of object to create a URL for. This should not include any prefix configured in the server configuration. :returns: URL for accessing an object in S3 using a HTTPS GET request """ import boto3 client = boto3.client('s3', region_name=self.region) url = '{}/{}/{}'.format(client.meta.endpoint_url, self.bucket, destination) LOGGER.debug('S3 URL calculated as: {}'.format(url)) return url def location(self, destination): """ :param destination: File of object to create a location for. This should not include any prefix configured in the server configuration. :returns: URL for accessing an object in S3 using a HTTPS GET request """ return self.url(destination) pywps-4.5.1/pywps/inout/types.py000066400000000000000000000005431415166246000167500ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from typing import Optional, Dict Translations = Optional[Dict[str, Dict[str, str]]] pywps-4.5.1/pywps/processing/000077500000000000000000000000001415166246000162465ustar00rootroot00000000000000pywps-4.5.1/pywps/processing/__init__.py000066400000000000000000000022401415166246000203550ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import pywps.configuration as config from pywps.processing.basic import MultiProcessing from pywps.processing.scheduler import Scheduler # api only from pywps.processing.basic import Processing # noqa: F401 from pywps.processing.job import Job # noqa: F401 import logging LOGGER = logging.getLogger("PYWPS") MULTIPROCESSING = 'multiprocessing' SCHEDULER = 'scheduler' DEFAULT = MULTIPROCESSING def Process(process, wps_request, wps_response): """ Factory method (looking like a class) to return the configured processing class. :return: instance of :class:`pywps.processing.Processing` """ mode = config.get_config_value("processing", "mode") LOGGER.info("Processing mode: {}".format(mode)) if mode == SCHEDULER: process = Scheduler(process, wps_request, wps_response) else: process = MultiProcessing(process, wps_request, wps_response) return process pywps-4.5.1/pywps/processing/basic.py000066400000000000000000000021751415166246000177060ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps.processing.job import Job class Processing(object): """ :class:`Processing` is an interface for running jobs. """ def __init__(self, process, wps_request, wps_response): self.job = Job(process, wps_request, wps_response) def start(self): raise NotImplementedError("Needs to be implemented in subclass.") def cancel(self): raise NotImplementedError("Needs to be implemented in subclass.") class MultiProcessing(Processing): """ :class:`MultiProcessing` is the default implementation to run jobs using the :module:`multiprocessing` module. """ def start(self): import multiprocessing process = multiprocessing.Process( target=getattr(self.job.process, self.job.method), args=(self.job.wps_request, self.job.wps_response) ) process.start() pywps-4.5.1/pywps/processing/job.py000066400000000000000000000110011415166246000173630ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import os import tempfile import pywps.configuration as config from pywps import Process, WPSRequest from pywps.response.execute import ExecuteResponse import json import logging LOGGER = logging.getLogger("PYWPS") class Job(object): """ :class:`Job` represents a processing job. """ def __init__(self, process, wps_request, wps_response): self.process = process self.method = '_run_process' self.wps_request = wps_request self.wps_response = wps_response @property def name(self): return self.process.identifier @property def workdir(self): return self.process.workdir @property def uuid(self): return self.process.uuid @property def json(self): """Return JSON encoded representation of the request """ obj = { 'process': self.process.json, 'wps_request': self.wps_request.json, } return json.dumps(obj, allow_nan=False) @classmethod def from_json(cls, value): """init this request from json back again :param value: the json (not string) representation """ process = Process.from_json(value['process']) wps_request = WPSRequest() wps_request.json = json.loads(value['wps_request']) wps_response = ExecuteResponse( wps_request=wps_request, uuid=process.uuid, process=process) wps_response.store_status_file = True new_job = Job( process=Process.from_json(value['process']), wps_request=wps_request, wps_response=wps_response) return new_job def dump(self): LOGGER.debug('dump job ...') filename = tempfile.mkstemp(prefix='job_', suffix='.dump', dir=self.workdir)[1] with open(filename, 'w') as fp: fp.write(self.json) LOGGER.debug("dumped job status to {}".format(filename)) return filename return None @classmethod def load(cls, filename): LOGGER.debug('load job ...') with open(filename, 'r') as fp: job = Job.from_json(json.load(fp)) return job return None def run(self): getattr(self.process, self.method)(self.wps_request, self.wps_response) class JobLauncher(object): """ :class:`JobLauncher` is a command line tool to launch a job from a file with a dumped job state. Example call: ``joblauncher -c /etc/pywps.cfg job-1001.dump`` """ def create_parser(self): import argparse parser = argparse.ArgumentParser(prog="joblauncher") parser.add_argument("-c", "--config", help="Path to pywps configuration.") parser.add_argument("filename", help="File with dumped pywps job object.") return parser def run(self, args): if args.config: LOGGER.debug("using pywps_cfg={}".format(args.config)) os.environ['PYWPS_CFG'] = args.config self._run_job(args.filename) def _run_job(self, filename): job = Job.load(filename) # init config if 'PYWPS_CFG' in os.environ: config.load_configuration(os.environ['PYWPS_CFG']) # update PATH os.environ['PATH'] = "{0}:{1}".format( config.get_config_value('processing', 'path'), os.environ.get('PATH')) # cd into workdir os.chdir(job.workdir) # init logger ... code copied from app.Service if config.get_config_value('logging', 'file') and config.get_config_value('logging', 'level'): LOGGER.setLevel(getattr(logging, config.get_config_value('logging', 'level'))) if not LOGGER.handlers: # hasHandlers in Python 3.x fh = logging.FileHandler(config.get_config_value('logging', 'file')) fh.setFormatter(logging.Formatter(config.get_config_value('logging', 'format'))) LOGGER.addHandler(fh) else: # NullHandler if not LOGGER.handlers: LOGGER.addHandler(logging.NullHandler()) job.run() def launcher(): """ Run job launcher command line. """ job_launcher = JobLauncher() parser = job_launcher.create_parser() args = parser.parse_args() job_launcher.run(args) pywps-4.5.1/pywps/processing/scheduler.py000066400000000000000000000060441415166246000206020ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import os import pywps.configuration as config from pywps.processing.basic import Processing from pywps.exceptions import SchedulerNotAvailable from pywps.response.status import WPS_STATUS import logging LOGGER = logging.getLogger("PYWPS") class Scheduler(Processing): """ :class:`Scheduler` is processing implementation to run jobs on schedulers like slurm, grid-engine and torque. It uses the drmaa python library as client to launch jobs on a scheduler system. See: http://drmaa-python.readthedocs.io/en/latest/index.html """ def start(self): self.job.wps_response._update_status(WPS_STATUS.ACCEPTED, 'Submitting job ...', 0) # run remote pywps process jobid = self.run_job() self.job.wps_response._update_status(WPS_STATUS.ACCEPTED, 'Your job has been submitted with ID {}'.format(jobid), 0) def run_job(self): LOGGER.info("Submitting job ...") try: import drmaa with drmaa.Session() as session: # dump job to file dump_filename = self.job.dump() if not dump_filename: raise Exception("Could not dump job status.") # prepare remote command jt = session.createJobTemplate() jt.remoteCommand = os.path.join( config.get_config_value('processing', 'path'), 'joblauncher') if os.getenv("PYWPS_CFG"): import shutil cfg_file = os.path.join(self.job.workdir, "pywps.cfg") shutil.copy2(os.getenv('PYWPS_CFG'), cfg_file) LOGGER.debug("Copied pywps config: {}".format(cfg_file)) jt.args = ['-c', cfg_file, dump_filename] else: jt.args = [dump_filename] drmaa_native_specification = config.get_config_value('processing', 'drmaa_native_specification') if drmaa_native_specification: jt.nativeSpecification = drmaa_native_specification jt.joinFiles = False jt.errorPath = ":{}".format(os.path.join(self.job.workdir, "job-error.txt")) jt.outputPath = ":{}".format(os.path.join(self.job.workdir, "job-output.txt")) # run job jobid = session.runJob(jt) LOGGER.info('Your job has been submitted with ID {}'.format(jobid)) # show status LOGGER.info('Job status: {}'.format(session.jobStatus(jobid))) # Cleaning up session.deleteJobTemplate(jt) except Exception as e: raise SchedulerNotAvailable("Could not submit job: {}".format(str(e))) return jobid pywps-4.5.1/pywps/resources/000077500000000000000000000000001415166246000161045ustar00rootroot00000000000000pywps-4.5.1/pywps/resources/__init__.py000066400000000000000000000004141415166246000202140ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## pywps-4.5.1/pywps/resources/schemas/000077500000000000000000000000001415166246000175275ustar00rootroot00000000000000pywps-4.5.1/pywps/resources/schemas/__init__.py000066400000000000000000000004141415166246000216370ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## pywps-4.5.1/pywps/resources/schemas/wps_all.xsd000066400000000000000000000006501415166246000217110ustar00rootroot00000000000000 pywps-4.5.1/pywps/response/000077500000000000000000000000001415166246000157305ustar00rootroot00000000000000pywps-4.5.1/pywps/response/__init__.py000066400000000000000000000051621415166246000200450ustar00rootroot00000000000000from abc import abstractmethod from typing import TYPE_CHECKING if TYPE_CHECKING: from pywps import WPSRequest from pywps.dblog import store_status from pywps.response.status import WPS_STATUS from pywps.translations import get_translation from jinja2 import Environment, PackageLoader import os class RelEnvironment(Environment): """Override join_path() to enable relative template paths.""" def join_path(self, template, parent): return os.path.dirname(parent) + '/' + template def get_response(operation): from .capabilities import CapabilitiesResponse from .describe import DescribeResponse from .execute import ExecuteResponse if operation == "capabilities": return CapabilitiesResponse elif operation == "describe": return DescribeResponse elif operation == "execute": return ExecuteResponse class WPSResponse(object): def __init__(self, wps_request: 'WPSRequest', uuid=None, version="1.0.0"): self.wps_request = wps_request self.uuid = uuid self.message = '' self.status = WPS_STATUS.ACCEPTED self.status_percentage = 0 self.doc = None self.content_type = None self.version = version self.template_env = RelEnvironment( loader=PackageLoader('pywps', 'templates'), trim_blocks=True, lstrip_blocks=True, autoescape=True, ) self.template_env.globals.update(get_translation=get_translation) def _update_status(self, status, message, status_percentage): """ Update status report of currently running process instance :param str message: Message you need to share with the client :param int status_percentage: Percent done (number betwen <0-100>) :param pywps.response.status.WPS_STATUS status: process status - user should usually ommit this parameter """ self.message = message self.status = status self.status_percentage = status_percentage store_status(self.uuid, self.status, self.message, self.status_percentage) @abstractmethod def _construct_doc(self): ... def get_response_doc(self): try: self.doc, self.content_type = self._construct_doc() except Exception as e: if hasattr(e, "description"): msg = e.description else: msg = e self._update_status(WPS_STATUS.FAILED, msg, 100) raise e else: self._update_status(WPS_STATUS.SUCCEEDED, "Response generated", 100) return self.doc, self.content_type pywps-4.5.1/pywps/response/capabilities.py000066400000000000000000000101731415166246000207350ustar00rootroot00000000000000import json from werkzeug.wrappers import Request import pywps.configuration as config from pywps.app.basic import make_response, get_response_type, get_json_indent from pywps.response import WPSResponse from pywps import __version__ from pywps.exceptions import NoApplicableCode import os class CapabilitiesResponse(WPSResponse): def __init__(self, wps_request, uuid, version, **kwargs): super(CapabilitiesResponse, self).__init__(wps_request, uuid, version) self.processes = kwargs["processes"] @property def json(self): """Convert the response to JSON structure """ processes = [p.json for p in self.processes.values()] return { 'pywps_version': __version__, 'version': self.version, 'title': config.get_config_value('metadata:main', 'identification_title'), 'abstract': config.get_config_value('metadata:main', 'identification_abstract'), 'keywords': config.get_config_value('metadata:main', 'identification_keywords').split(","), 'keywords_type': config.get_config_value('metadata:main', 'identification_keywords_type').split(","), 'fees': config.get_config_value('metadata:main', 'identification_fees'), 'accessconstraints': config.get_config_value( 'metadata:main', 'identification_accessconstraints' ).split(','), 'profile': config.get_config_value('metadata:main', 'identification_profile'), 'provider': { 'name': config.get_config_value('metadata:main', 'provider_name'), 'site': config.get_config_value('metadata:main', 'provider_url'), 'individual': config.get_config_value('metadata:main', 'contact_name'), 'position': config.get_config_value('metadata:main', 'contact_position'), 'voice': config.get_config_value('metadata:main', 'contact_phone'), 'fascimile': config.get_config_value('metadata:main', 'contaact_fax'), 'address': { 'delivery': config.get_config_value('metadata:main', 'deliveryPoint'), 'city': config.get_config_value('metadata:main', 'contact_city'), 'state': config.get_config_value('metadata:main', 'contact_stateorprovince'), 'postalcode': config.get_config_value('metadata:main', 'contact_postalcode'), 'country': config.get_config_value('metadata:main', 'contact_country'), 'email': config.get_config_value('metadata:main', 'contact_email') }, 'url': config.get_config_value('metadata:main', 'contact_url'), 'hours': config.get_config_value('metadata:main', 'contact_hours'), 'instructions': config.get_config_value('metadata:main', 'contact_instructions'), 'role': config.get_config_value('metadata:main', 'contact_role') }, 'serviceurl': config.get_config_value('server', 'url'), 'languages': config.get_config_value('server', 'language').split(','), 'language': self.wps_request.language, 'processes': processes } @staticmethod def _render_json_response(jdoc): return jdoc def _construct_doc(self): doc = self.json json_response, mimetype = get_response_type( self.wps_request.http_request.accept_mimetypes, self.wps_request.default_mimetype) if json_response: doc = json.dumps(self._render_json_response(doc), indent=get_json_indent()) else: template = self.template_env.get_template(self.version + '/capabilities/main.xml') doc = template.render(**doc) return doc, mimetype @Request.application def __call__(self, request): # This function must return a valid response. try: doc, content_type = self.get_response_doc() return make_response(doc, content_type=content_type) except NoApplicableCode as e: return e except Exception as e: return NoApplicableCode(str(e)) pywps-4.5.1/pywps/response/describe.py000066400000000000000000000050711415166246000200650ustar00rootroot00000000000000import json from werkzeug.wrappers import Request import pywps.configuration as config from pywps.app.basic import make_response, get_response_type, get_json_indent from pywps.exceptions import NoApplicableCode from pywps.exceptions import MissingParameterValue from pywps.exceptions import InvalidParameterValue from pywps.response import WPSResponse from pywps import __version__ import os class DescribeResponse(WPSResponse): def __init__(self, wps_request, uuid, **kwargs): super(DescribeResponse, self).__init__(wps_request, uuid) self.identifiers = None if "identifiers" in kwargs: self.identifiers = kwargs["identifiers"] self.processes = kwargs["processes"] @property def json(self): processes = [] if 'all' in (ident.lower() for ident in self.identifiers): processes = (self.processes[p].json for p in self.processes) else: for identifier in self.identifiers: if identifier not in self.processes: msg = "Unknown process {}".format(identifier) raise InvalidParameterValue(msg, "identifier") else: processes.append(self.processes[identifier].json) return { 'pywps_version': __version__, 'processes': processes, 'language': self.wps_request.language, } @staticmethod def _render_json_response(jdoc): return jdoc def _construct_doc(self): if not self.identifiers: raise MissingParameterValue('Missing parameter value "identifier"', 'identifier') doc = self.json json_response, mimetype = get_response_type( self.wps_request.http_request.accept_mimetypes, self.wps_request.default_mimetype) if json_response: doc = json.dumps(self._render_json_response(doc), indent=get_json_indent()) else: template = self.template_env.get_template(self.version + '/describe/main.xml') max_size = int(config.get_size_mb(config.get_config_value('server', 'maxsingleinputsize'))) doc = template.render(max_size=max_size, **doc) return doc, mimetype @Request.application def __call__(self, request): # This function must return a valid response. try: doc, content_type = self.get_response_doc() return make_response(doc, content_type=content_type) except NoApplicableCode as e: return e except Exception as e: return NoApplicableCode(str(e)) pywps-4.5.1/pywps/response/execute.py000077500000000000000000000263631415166246000177610ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import json import logging import time from werkzeug.wrappers import Request from pywps import get_ElementMakerForVersion from pywps.app.basic import get_response_type, get_json_indent, get_default_response_mimetype from pywps.exceptions import NoApplicableCode import pywps.configuration as config from werkzeug.wrappers import Response from pywps.inout.array_encode import ArrayEncoder from pywps.response.status import WPS_STATUS from pywps.response import WPSResponse from pywps.inout.formats import FORMATS from pywps.inout.outputs import ComplexOutput import urllib.parse as urlparse from urllib.parse import urlencode LOGGER = logging.getLogger("PYWPS") WPS, OWS = get_ElementMakerForVersion("1.0.0") class ExecuteResponse(WPSResponse): def __init__(self, wps_request, uuid, **kwargs): """constructor :param pywps.app.WPSRequest.WPSRequest wps_request: :param pywps.app.Process.Process process: :param uuid: string this request uuid """ super(ExecuteResponse, self).__init__(wps_request, uuid) self.process = kwargs["process"] self.outputs = {o.identifier: o for o in self.process.outputs} self.store_status_file = False # override WPSResponse._update_status def _update_status(self, status, message, status_percentage, clean=True): """ Updates status report of currently running process instance: * Updates the status document. * Updates the status file (if requested). * Cleans the working directory when process has finished. This method is *only* called by pywps internally. """ super(ExecuteResponse, self)._update_status(status, message, status_percentage) LOGGER.debug("_update_status: status={}, clean={}".format(status, clean)) self._update_status_doc() if self.store_status_file: self._update_status_file() if clean: if self.status == WPS_STATUS.SUCCEEDED or self.status == WPS_STATUS.FAILED: LOGGER.debug("clean workdir: status={}".format(status)) self.process.clean() def update_status(self, message, status_percentage=None): """ Update status report of currently running process instance. This method is *only* called by the user provided process. The status is handled internally in pywps. :param str message: Message you need to share with the client :param int status_percentage: Percent done (number betwen <0-100>) """ if status_percentage is None: status_percentage = self.status_percentage self._update_status(self.status, message, status_percentage, False) def _update_status_doc(self): try: # rebuild the doc self.doc, self.content_type = self._construct_doc() except Exception as e: raise NoApplicableCode('Building Response Document failed with : {}'.format(e)) def _update_status_file(self): # TODO: check if file/directory is still present, maybe deleted in mean time try: # update the status xml file self.process.status_store.write( self.doc, self.process.status_filename, data_format=FORMATS.XML) except Exception as e: raise NoApplicableCode('Writing Response Document failed with : {}'.format(e)) def _process_accepted(self): percent = int(self.status_percentage) if percent > 99: percent = 99 return { "status": "accepted", "time": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.localtime()), "percent_done": str(percent), "message": self.message } def _process_started(self): data = self._process_accepted() data.update({ "status": "started", }) return data def _process_paused(self): data = self._process_accepted() data.update({ "status": "paused", }) return data def _process_succeeded(self): data = self._process_accepted() data.update({ "status": "succeeded", "percent_done": "100" }) return data def _process_failed(self): data = self._process_accepted() data.update({ "status": "failed", "code": "NoApplicableCode", "locator": "None", }) return data def _get_serviceinstance(self): url = config.get_config_value("server", "url") params = {'request': 'GetCapabilities', 'service': 'WPS'} url_parts = list(urlparse.urlparse(url)) query = dict(urlparse.parse_qsl(url_parts[4])) query.update(params) url_parts[4] = urlencode(query) return urlparse.urlunparse(url_parts).replace("&", "&") @property def json(self): data = {} data["language"] = self.wps_request.language data["service_instance"] = self._get_serviceinstance() data["process"] = self.process.json if self.store_status_file: if self.process.status_location: data["status_location"] = self.process.status_url if self.status == WPS_STATUS.ACCEPTED: self.message = 'PyWPS Process {} accepted'.format(self.process.identifier) data["status"] = self._process_accepted() elif self.status == WPS_STATUS.STARTED: data["status"] = self._process_started() elif self.status == WPS_STATUS.FAILED: # check if process failed and display fail message data["status"] = self._process_failed() elif self.status == WPS_STATUS.PAUSED: # TODO: handle paused status data["status"] = self._process_paused() elif self.status == WPS_STATUS.SUCCEEDED: data["status"] = self._process_succeeded() # Process outputs XML data["outputs"] = [self.outputs[o].json for o in self.outputs] # lineage: add optional lineage when process has finished if self.status in [WPS_STATUS.SUCCEEDED, WPS_STATUS.FAILED]: # DataInputs and DataOutputs definition XML if lineage=true if self.wps_request.lineage == 'true': data["lineage"] = True try: # TODO: stored process has ``pywps.inout.basic.LiteralInput`` # instead of a ``pywps.inout.inputs.LiteralInput``. data["input_definitions"] = [self.wps_request.inputs[i][0].json for i in self.wps_request.inputs] except Exception as e: LOGGER.error("Failed to update lineage for input parameter. {}".format(e)) data["output_definitions"] = [self.outputs[o].json for o in self.outputs] return data @staticmethod def _render_json_response(jdoc): response = dict() response['status'] = jdoc['status'] out = jdoc['process']['outputs'] d = {} for val in out: id = val.get('identifier') if id is None: continue type = val.get('type') key = 'bbox' if type == 'bbox' else 'data' if key in val: d[id] = val[key] response['outputs'] = d return response def _construct_doc(self): if self.status == WPS_STATUS.SUCCEEDED and \ hasattr(self.wps_request, 'preprocess_response') and \ self.wps_request.preprocess_response: self.outputs = self.wps_request.preprocess_response(self.outputs, request=self.wps_request, http_request=self.wps_request.http_request) doc = self.json try: json_response, mimetype = get_response_type( self.wps_request.http_request.accept_mimetypes, self.wps_request.default_mimetype) except Exception: mimetype = get_default_response_mimetype() json_response = 'json' in mimetype if json_response: doc = json.dumps(self._render_json_response(doc), cls=ArrayEncoder, indent=get_json_indent()) else: template = self.template_env.get_template(self.version + '/execute/main.xml') doc = template.render(**doc) return doc, mimetype @Request.application def __call__(self, request): accept_json_response, accepted_mimetype = get_response_type( self.wps_request.http_request.accept_mimetypes, self.wps_request.default_mimetype) if self.wps_request.raw: if self.status == WPS_STATUS.FAILED: return NoApplicableCode(self.message) else: wps_output_identifier = next(iter(self.wps_request.outputs)) # get the first key only wps_output_value = self.outputs[wps_output_identifier] response = wps_output_value.data if response is None: return NoApplicableCode("Expected output was not generated") suffix = '' # if isinstance(wps_output_value, ComplexOutput): data_format = None if hasattr(wps_output_value, 'output_format'): # this is set in the response, thus should be more precise data_format = wps_output_value.output_format elif hasattr(wps_output_value, 'data_format'): # this is set in the process' response _handler function, thus could have a few supported formats data_format = wps_output_value.data_format if data_format is not None: mimetype = data_format.mime_type if data_format.extension is not None: suffix = data_format.extension else: # like LitearlOutput mimetype = self.wps_request.outputs[wps_output_identifier].get('mimetype', None) if not isinstance(response, (str, bytes, bytearray)): if not mimetype: mimetype = accepted_mimetype json_response = mimetype and 'json' in mimetype if json_response: mimetype = 'application/json' suffix = '.json' response = json.dumps(response, cls=ArrayEncoder, indent=get_json_indent()) else: response = str(response) if not mimetype: mimetype = None return Response(response, mimetype=mimetype, headers={'Content-Disposition': 'attachment; filename="{}"' .format(wps_output_identifier + suffix)}) else: if not self.doc: return NoApplicableCode("Output was not generated") return Response(self.doc, mimetype=accepted_mimetype) pywps-4.5.1/pywps/response/status.py000066400000000000000000000002721415166246000176260ustar00rootroot00000000000000from collections import namedtuple _WPS_STATUS = namedtuple('WPSStatus', ['UNKNOWN', 'ACCEPTED', 'STARTED', 'PAUSED', 'SUCCEEDED', 'FAILED']) WPS_STATUS = _WPS_STATUS(0, 1, 2, 3, 4, 5) pywps-4.5.1/pywps/schemas/000077500000000000000000000000001415166246000155155ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/geojson/000077500000000000000000000000001415166246000171615ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/geojson/README000066400000000000000000000001261415166246000200400ustar00rootroot00000000000000This schema comes from https://github.com/fge/sample-json-schemas/tree/master/geojson pywps-4.5.1/pywps/schemas/geojson/bbox.json000066400000000000000000000004611415166246000210070ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-04/schema#", "id": "http://json-schema.org/geojson/bbox.json#", "description": "A bounding box as defined by GeoJSON", "FIXME": "unenforceable constraint: even number of elements in array", "type": "array", "items": { "type": "number" } }pywps-4.5.1/pywps/schemas/geojson/crs.json000066400000000000000000000032471415166246000206510ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-04/schema#", "title": "crs", "description": "a Coordinate Reference System object", "type": [ "object", "null" ], "required": [ "type", "properties" ], "properties": { "type": { "type": "string" }, "properties": { "type": "object" } }, "additionalProperties": false, "oneOf": [ { "$ref": "#/definitions/namedCrs" }, { "$ref": "#/definitions/linkedCrs" } ], "definitions": { "namedCrs": { "properties": { "type": { "enum": [ "name" ] }, "properties": { "required": [ "name" ], "additionalProperties": false, "properties": { "name": { "type": "string", "FIXME": "semantic validation necessary" } } } } }, "linkedObject": { "type": "object", "required": [ "href" ], "properties": { "href": { "type": "string", "format": "uri", "FIXME": "spec says \"dereferenceable\", cannot enforce that" }, "type": { "type": "string", "description": "Suggested values: proj4, ogjwkt, esriwkt" } } }, "linkedCrs": { "properties": { "type": { "enum": [ "link" ] }, "properties": { "$ref": "#/definitions/linkedObject" } } } } } pywps-4.5.1/pywps/schemas/geojson/geojson.json000066400000000000000000000043111415166246000215170ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-04/schema#", "id": "http://json-schema.org/geojson/geojson.json#", "title": "Geo JSON object", "description": "Schema for a Geo JSON object", "type": "object", "required": [ "type" ], "properties": { "crs": { "$ref": "http://json-schema.org/geojson/crs.json#" }, "bbox": { "$ref": "http://json-schema.org/geojson/bbox.json#" } }, "oneOf": [ { "$ref": "http://json-schema.org/geojson/geometry.json#" }, { "$ref": "#/definitions/geometryCollection" }, { "$ref": "#/definitions/feature" }, { "$ref": "#/definitions/featureCollection" } ], "definitions": { "geometryCollection": { "title": "GeometryCollection", "description": "A collection of geometry objects", "required": [ "geometries" ], "properties": { "type": { "enum": [ "GeometryCollection" ] }, "geometries": { "type": "array", "items": { "$ref": "http://json-schema.org/geojson/geometry.json#" } } } }, "feature": { "title": "Feature", "description": "A Geo JSON feature object", "required": [ "geometry", "properties" ], "properties": { "type": { "enum": [ "Feature" ] }, "geometry": { "oneOf": [ { "type": "null" }, { "$ref": "http://json-schema.org/geojson/geometry.json#" } ] }, "properties": { "type": [ "object", "null" ] }, "id": { "FIXME": "may be there, type not known (string? number?)" } } }, "featureCollection": { "title": "FeatureCollection", "description": "A Geo JSON feature collection", "required": [ "features" ], "properties": { "type": { "enum": [ "FeatureCollection" ] }, "features": { "type": "array", "items": { "$ref": "#/definitions/feature" } } } } } } pywps-4.5.1/pywps/schemas/geojson/geometry.json000066400000000000000000000055121415166246000217120ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-04/schema#", "id": "http://json-schema.org/geojson/geometry.json#", "title": "geometry", "description": "One geometry as defined by GeoJSON", "type": "object", "required": [ "type", "coordinates" ], "oneOf": [ { "title": "Point", "properties": { "type": { "enum": [ "Point" ] }, "coordinates": { "$ref": "#/definitions/position" } } }, { "title": "MultiPoint", "properties": { "type": { "enum": [ "MultiPoint" ] }, "coordinates": { "$ref": "#/definitions/positionArray" } } }, { "title": "LineString", "properties": { "type": { "enum": [ "LineString" ] }, "coordinates": { "$ref": "#/definitions/lineString" } } }, { "title": "MultiLineString", "properties": { "type": { "enum": [ "MultiLineString" ] }, "coordinates": { "type": "array", "items": { "$ref": "#/definitions/lineString" } } } }, { "title": "Polygon", "properties": { "type": { "enum": [ "Polygon" ] }, "coordinates": { "$ref": "#/definitions/polygon" } } }, { "title": "MultiPolygon", "properties": { "type": { "enum": [ "MultiPolygon" ] }, "coordinates": { "type": "array", "items": { "$ref": "#/definitions/polygon" } } } } ], "definitions": { "position": { "description": "A single position", "type": "array", "minItems": 2, "items": [ { "type": "number" }, { "type": "number" } ], "additionalItems": false }, "positionArray": { "description": "An array of positions", "type": "array", "items": { "$ref": "#/definitions/position" } }, "lineString": { "description": "An array of two or more positions", "allOf": [ { "$ref": "#/definitions/positionArray" }, { "minItems": 2 } ] }, "linearRing": { "description": "An array of four positions where the first equals the last", "allOf": [ { "$ref": "#/definitions/positionArray" }, { "minItems": 4 } ] }, "polygon": { "description": "An array of linear rings", "type": "array", "items": { "$ref": "#/definitions/linearRing" } } } }pywps-4.5.1/pywps/schemas/metalink/000077500000000000000000000000001415166246000173215ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/metalink/3.0/000077500000000000000000000000001415166246000176215ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/metalink/3.0/metalink.xsd000066400000000000000000000261211415166246000221470ustar00rootroot00000000000000 Date and time formatted according to the RFC822, Section 5. Using the regex definition of RFC822 date by Sam Ruby at http://www.intertwingly.net/blog/1360.html. pywps-4.5.1/pywps/schemas/metalink/4.0/000077500000000000000000000000001415166246000176225ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/metalink/4.0/metalink4.xsd000066400000000000000000000177231415166246000222440ustar00rootroot00000000000000 pywps-4.5.1/pywps/schemas/metalink/4.0/xml.xsd000066400000000000000000000211001415166246000211340ustar00rootroot00000000000000

About the XML namespace

This schema document describes the XML namespace, in a form suitable for import by other schema documents.

See http://www.w3.org/XML/1998/namespace.html and http://www.w3.org/TR/REC-xml for information about this namespace.

Note that local names in this namespace are intended to be defined only by the World Wide Web Consortium or its subgroups. The names currently defined in this namespace are listed below. They should not be used with conflicting semantics by any Working Group, specification, or document instance.

See further below in this document for more information about how to refer to this schema document from your own XSD schema documents and about the namespace-versioning policy governing this schema document.

lang (as an attribute name)

denotes an attribute whose value is a language code for the natural language of the content of any element; its value is inherited. This name is reserved by virtue of its definition in the XML specification.

Notes

Attempting to install the relevant ISO 2- and 3-letter codes as the enumerated possible values is probably never going to be a realistic possibility.

See BCP 47 at http://www.rfc-editor.org/rfc/bcp/bcp47.txt and the IANA language subtag registry at http://www.iana.org/assignments/language-subtag-registry for further information.

The union allows for the 'un-declaration' of xml:lang with the empty string.

space (as an attribute name)

denotes an attribute whose value is a keyword indicating what whitespace processing discipline is intended for the content of the element; its value is inherited. This name is reserved by virtue of its definition in the XML specification.

base (as an attribute name)

denotes an attribute whose value provides a URI to be used as the base for interpreting any relative URIs in the scope of the element on which it appears; its value is inherited. This name is reserved by virtue of its definition in the XML Base specification.

See http://www.w3.org/TR/xmlbase/ for information about this attribute.

id (as an attribute name)

denotes an attribute whose value should be interpreted as if declared to be of type ID. This name is reserved by virtue of its definition in the xml:id specification.

See http://www.w3.org/TR/xml-id/ for information about this attribute.

Father (in any context at all)

denotes Jon Bosak, the chair of the original XML Working Group. This name is reserved by the following decision of the W3C XML Plenary and XML Coordination groups:

In appreciation for his vision, leadership and dedication the W3C XML Plenary on this 10th day of February, 2000, reserves for Jon Bosak in perpetuity the XML name "xml:Father".

About this schema document

This schema defines attributes and an attribute group suitable for use by schemas wishing to allow xml:base, xml:lang, xml:space or xml:id attributes on elements they define.

To enable this, such a schema must import this schema for the XML namespace, e.g. as follows:

          <schema . . .>
           . . .
           <import namespace="http://www.w3.org/XML/1998/namespace"
                      schemaLocation="http://www.w3.org/2001/xml.xsd"/>
     

or

           <import namespace="http://www.w3.org/XML/1998/namespace"
                      schemaLocation="http://www.w3.org/2009/01/xml.xsd"/>
     

Subsequently, qualified reference to any of the attributes or the group defined below will have the desired effect, e.g.

          <type . . .>
           . . .
           <attributeGroup ref="xml:specialAttrs"/>
     

will define a type which will schema-validate an instance element with any of those attributes.

Versioning policy for this schema document

In keeping with the XML Schema WG's standard versioning policy, this schema document will persist at http://www.w3.org/2009/01/xml.xsd.

At the date of issue it can also be found at http://www.w3.org/2001/xml.xsd.

The schema document at that URI may however change in the future, in order to remain compatible with the latest version of XML Schema itself, or with the XML namespace itself. In other words, if the XML Schema or XML namespaces change, the version of this document at http://www.w3.org/2001/xml.xsd will change accordingly; the version at http://www.w3.org/2009/01/xml.xsd will not change.

Previous dated (and unchanging) versions of this schema document are at:

pywps-4.5.1/pywps/schemas/ncml/000077500000000000000000000000001415166246000164465ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/ncml/2.2/000077500000000000000000000000001415166246000167475ustar00rootroot00000000000000pywps-4.5.1/pywps/schemas/ncml/2.2/ncml-2.2.xsd000066400000000000000000000254241415166246000207260ustar00rootroot00000000000000 pywps-4.5.1/pywps/templates/000077500000000000000000000000001415166246000160705ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/1.0.0/000077500000000000000000000000001415166246000165245ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/1.0.0/capabilities/000077500000000000000000000000001415166246000211555ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/1.0.0/capabilities/main.xml000066400000000000000000000114041415166246000226230ustar00rootroot00000000000000 {{ title }} {{ abstract }} {% for keyword in keywords %} {{ keyword }} {% endfor %} {% for keyword in keywords_type %} {{ keyword }} {% endfor %} WPS 1.0.0 2.0.0 {{ fees }} {% for ac in accessconstraints %} {{ ac }} {% endfor %} {{ provider.name }} {{ provider.individual }} {{ provider.position }} {{ provider.voice }} {{ provider.fascimile }} {{ provider.address.delivery }} {{ provider.address.city }} {{ provider.address.administrativearea }} {{ provider.address.postalcode }} {{ provider.address.country }} {{ provider.address.email }} {% for process in processes %} {{ process.identifier }} {{ get_translation(process, "title", language) }} {{ get_translation(process, "abstract", language) }} {% if process.keywords %} {% for keyword in process.keywords %} {{ keyword }} {% endfor %} {% endif %} {% for metadata in process.metadata %} {% endfor %} {% endfor %} {{ languages[0] }} {% for lang in languages %} {{ lang }} {% endfor %} {# #} pywps-4.5.1/pywps/templates/1.0.0/describe/000077500000000000000000000000001415166246000203045ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/1.0.0/describe/bbox.xml000066400000000000000000000004601415166246000217600ustar00rootroot00000000000000 {{ put.crs }} {% for c in put.crss %} {{ c }} {% endfor %} pywps-4.5.1/pywps/templates/1.0.0/describe/complex.xml000066400000000000000000000022731415166246000225010ustar00rootroot00000000000000 {{ put.data_format.mime_type }} {% if put.data_format.encoding %} {{ put.data_format.encoding }} {% endif %} {% if put.data_format.schema %} {{ put.data_format.schema }} {% endif %} {% for format in put.supported_formats %} {{ format.mime_type }} {% if put.data_format.encoding %} {{ format.encoding }} {% endif %} {% if put.data_format.schema %} {{ format.schema }} {% endif %} {% endfor %} pywps-4.5.1/pywps/templates/1.0.0/describe/literal.xml000066400000000000000000000035521415166246000224670ustar00rootroot00000000000000 {{ put.data_type }} {% if put.uom %} {{ put.uom.uom }} {% for uom in put.uoms %} {{ put.uom.uom }} {% endfor %} {% endif %} {% if put.any_value %} {% elif put.values_reference %} {% elif put.allowed_values %} {% for value in put.allowed_values %} {% if value.allowed_type == "value" %} {{ value.value }} {% else %} {{ value.minval }} {{ value.maxval }} {% if value.spacing %} {{ value.spacing }} {% endif %} {% endif %} {% endfor %} {% endif %} {% if put.data is defined and put.data is not none %} {{ put.data }} {% endif %} pywps-4.5.1/pywps/templates/1.0.0/describe/main.xml000066400000000000000000000066301415166246000217570ustar00rootroot00000000000000 {% for process in processes %} {{ process.identifier }} {{ get_translation(process, "title", language) }} {{ get_translation(process, "abstract", language) }} {% for metadata in process.metadata %} {% endfor %} {% for profile in profiles %} {{ profile }} {% endfor %} {% if process.inputs %} {% for put in process.inputs %} {{ put.identifier }} {{ get_translation(put, "title", language) }} {{ get_translation(put, "abstract", language) }} {% if put.type == "complex" %} {% include 'complex.xml' %} {% elif put.type == "literal" %} {% include 'literal.xml' %} {% elif put.type == "bbox" %} {% include 'bbox.xml' %} {% endif %} {% endfor %} {% endif %} {% if process.outputs %} {% for put in process.outputs %} {{ put.identifier }} {{ get_translation(put, "title", language) }} {{ get_translation(put, "abstract", language) }} {% if put.type in ["complex", "reference"] %} {% include 'complex.xml' %} {% elif put.type == "literal" %} {% include 'literal.xml' %} {% elif put.type == "bbox" %} {% include 'bbox.xml' %} {% endif %} {% endfor %} {% endif %} {% endfor %} pywps-4.5.1/pywps/templates/1.0.0/execute/000077500000000000000000000000001415166246000201665ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/1.0.0/execute/main.xml000066400000000000000000000134071415166246000216410ustar00rootroot00000000000000 {{ process.identifier }} {{ get_translation(process, "title", language) }} {{ get_translation(process, "abstract", language) }} {% if profile %} {{ process.profile }} {% endif %} {% if wsdl %} {% endif %} {% if status.status == "accepted" %} {{ status.message }} {% elif status.status == "started" %} {{ status.message }} {% elif status.status == "paused" %} {{ status.message }} {% elif status.status == "succeeded" %} {{ status.message }} {% elif status.status == "failed" %} {{ status.message }} {% endif %} {% if lineage %} {% if input_definitions %} {% for input in input_definitions %} {{ input.identifier }} {{ get_translation(input, "title", language) }} {{ get_translation(input, "abstract", language) }} {% if input.type == "complex" %} {{ input.data | safe }} {% elif input.type == "literal" %} {{ input.data }} {% elif input.type == "bbox" %} {% for c in input.ll %} {{ c }} {% endfor %} {% for c in input.ur %} {{ c }} {% endfor %} {% elif input.type == "reference" %} {% endif %} {% endfor %} {% endif %} {% if output_definitions %} {% for output in output_definitions %} {% if output.type in ["complex", "reference"] %} {% else %} {% endif %} {{ output.identifier }} {{ get_translation(output, "title", language) }} {{ get_translation(output, "abstract", language) }} {% endfor %} {% endif %} {% endif %} {% if outputs %} {% for output in outputs %} {{ output.identifier }} {{ get_translation(output, "title", language) }} {{ get_translation(output, "abstract", language) }} {% if output.type == "reference" %} {% elif output.type == "complex" %} {{ output.data | safe }} {% elif output.type == "literal" %} {{ output.data }} {% elif output.type == "bbox" %} {% for c in output.ll %} {{ c }} {% endfor %} {% for c in output.ur %} {{ c }} {% endfor %} {% endif %} {% endfor %} {% endif %} pywps-4.5.1/pywps/templates/2.0.0/000077500000000000000000000000001415166246000165255ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/2.0.0/capabilities/000077500000000000000000000000001415166246000211565ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/2.0.0/capabilities/main.xml000066400000000000000000000106151415166246000226270ustar00rootroot00000000000000 {{ title }} {{ abstract }} {% for keyword in keywords %} {{ keyword }}{% endfor %} WPS 2.0.0 {{ fees }} {% for ac in accessconstraints %}{{ ac }} {% endfor %} {{ provider.name }} {{ provider.individual }} {{ provider.position }} {{ provider.voice }} {{ provider.fascimile }} {{ provider.address.delivery }} {{ provider.address.city }} {{ provider.address.administrativearea }} {{ provider.address.postalcode }} {{ provider.address.country }} {{ provider.address.email }} {% for process in processes %} process.title process.identifier {{ process.abstract }}{% for metadata in process.metadata %} {% endfor %} {% for keyword in process.keywords %} {{ keyword }}{% endfor %} {% endfor %} pywps-4.5.1/pywps/templates/metalink/000077500000000000000000000000001415166246000176745ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/metalink/3.0/000077500000000000000000000000001415166246000201745ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/metalink/3.0/main.xml000066400000000000000000000024231415166246000216430ustar00rootroot00000000000000 {% if meta.identity %} {{ meta.identity }} {% endif %} {% if meta.description %} {{ meta.description }} {% endif %} {% if meta.publisher %} {{ meta.publisher }} {{ meta.url }} {% endif %} {% for file in meta.files %} {% if file.identity %} {{ file.identity }} {% endif %} {% if file.description %} {{ file.description }} {% endif %} {% if file.size %} {{ file.size }} {% endif %} {% if meta.checksums %} {{ file.hash }} {% endif %} {% for url in file.urls %} {{ url }} {% endfor %} {% endfor %} pywps-4.5.1/pywps/templates/metalink/4.0/000077500000000000000000000000001415166246000201755ustar00rootroot00000000000000pywps-4.5.1/pywps/templates/metalink/4.0/main.xml000066400000000000000000000015721415166246000216500ustar00rootroot00000000000000 {{ meta.published }} {{ meta.generator }} {% for file in meta.files %} {% if file.identity %} {{ file.identity }} {% endif %} {% if file.description %} {{ file.description }} {% endif %} {% if file.size %} {{ file.size }} {% endif %} {% if meta.checksums %} {{ file.hash }} {% endif %} {% for url in file.urls %} {{ url }} {% endfor %} {% endfor %} pywps-4.5.1/pywps/tests.py000066400000000000000000000166511415166246000156170ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import json import tempfile from pathlib import Path import lxml from pywps import xml_util as etree import requests from werkzeug.test import Client from werkzeug.wrappers import Response from pywps import __version__ from pywps import Process from pywps.inout import LiteralInput, LiteralOutput, ComplexInput, ComplexOutput, BoundingBoxInput, BoundingBoxOutput from pywps.inout import Format from pywps.app.Common import Metadata, MetadataUrl import re import logging logging.disable(logging.CRITICAL) def service_ok(url, timeout=5): try: resp = requests.get(url, timeout=timeout) if 'html' in resp.headers['content-type']: ok = False else: ok = resp.ok except requests.exceptions.ReadTimeout: ok = False except requests.exceptions.ConnectTimeout: ok = False except Exception: ok = False return ok class DocExampleProcess(Process): """This first line is going to be skipped by the :skiplines:1 option. Notes ----- This is additional documentation that can be added following the Numpy docstring convention. """ def __init__(self): inputs = [ LiteralInput( 'literal_input', "Literal input title", 'integer', abstract="Literal input value abstract.", min_occurs=0, max_occurs=1, uoms=['meters', 'feet'], default=1 ), LiteralInput('date_input', 'The title is shown when no abstract is provided.', 'date', allowed_values=['2000-01-01', '2018-01-01']), ComplexInput('complex_input', 'Complex input title', [Format('application/json'), Format('application/x-netcdf')], abstract="Complex input abstract.", ), BoundingBoxInput('bb_input', 'BoundingBox input title', ['EPSG:4326', ], metadata=[Metadata('EPSG.io', 'http://epsg.io/'), ]), ] outputs = [ LiteralOutput( 'literal_output', 'Literal output title', 'boolean', abstract='Boolean output abstract.' ), ComplexOutput('complex_output', 'Complex output', [Format('text/plain'), ], ), BoundingBoxOutput('bb_output', 'BoundingBox output title', ['EPSG:4326', ]) ] super(DocExampleProcess, self).__init__( self._handler, identifier='doc_example_process_identifier', title="Process title", abstract="Multiline process abstract.", version="4.0", metadata=[Metadata('PyWPS docs', 'https://pywps.org'), Metadata('NumPy docstring conventions', 'https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt'), MetadataUrl('Duplicate label', 'http://one.example.com', anonymous=True), MetadataUrl('Duplicate label', 'http://two.example.com', anonymous=True), ], inputs=inputs, outputs=outputs, ) def _handler(self, request, response): pass class WpsClient(Client): def post_xml(self, *args, **kwargs): doc = kwargs.pop('doc') data = etree.tostring(doc, pretty_print=True) kwargs['data'] = data return self.post(*args, **kwargs) def post_json(self, *args, **kwargs): doc = kwargs.pop('doc') # data = json.dumps(doc, indent=2) # kwargs['data'] = data kwargs['json'] = doc # kwargs['content_type'] = 'application/json' # input is json, redundant as it's deducted from the json kwarg # kwargs['mimetype'] = 'application/json' # output is json kwargs['environ_base'] = {'HTTP_ACCEPT': 'application/json'} # output is json return self.post(*args, **kwargs) class WpsTestResponse(Response): def __init__(self, *args): super(WpsTestResponse, self).__init__(*args) if re.match(r'text/xml(;\s*charset=.*)?', self.headers.get('Content-Type')): self.xml = etree.fromstring(self.get_data()) def xpath(self, path): version = self.xml.attrib["version"] if version == "2.0.0": from pywps import namespaces200 namespaces = namespaces200 else: from pywps import namespaces100 namespaces = namespaces100 return self.xml.xpath(path, namespaces=namespaces) def xpath_text(self, path): return ' '.join(e.text for e in self.xpath(path)) def client_for(service): return WpsClient(service, WpsTestResponse) def assert_response_accepted(resp): assert resp.status_code == 200 assert re.match(r'text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) success = resp.xpath_text('/wps:ExecuteResponse' '/wps:Status' '/wps:ProcessAccepted') assert success is not None # TODO: assert status URL is present def assert_process_started(resp): assert resp.status_code == 200 assert re.match(r'text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) success = resp.xpath_text('/wps:ExecuteResponse' '/wps:Status' 'ProcessStarted') # Is it still like this in PyWPS-4 ? assert success.split[0] == "processstarted" def assert_response_success_json(resp, expected_data): assert resp.status_code == 200 content_type = resp.headers['Content-Type'] expected_contect_type = 'application/json' re_content_type = rf'{expected_contect_type}(;\s*charset=.*)?' assert re.match(re_content_type, content_type) data = json.loads(resp.data) success = data['status']['status'] assert success == 'succeeded' if expected_data: outputs = data['outputs'] assert outputs == expected_data def assert_response_success(resp): assert resp.status_code == 200 content_type = resp.headers['Content-Type'] expected_contect_type = 'text/xml' re_content_type = rf'{expected_contect_type}(;\s*charset=.*)?' assert re.match(re_content_type, content_type) success = resp.xpath('/wps:ExecuteResponse/wps:Status/wps:ProcessSucceeded') assert len(success) == 1 def assert_process_exception(resp, code=None): assert resp.status_code == 400 assert re.match(r'text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) elem = resp.xpath('/ows:ExceptionReport' '/ows:Exception') assert elem[0].attrib['exceptionCode'] == code def assert_pywps_version(resp): # get first child of root element root_firstchild = resp.xpath('/*')[0].getprevious() assert isinstance(root_firstchild, lxml.etree._Comment) tokens = root_firstchild.text.split() assert len(tokens) == 2 assert tokens[0] == 'PyWPS' assert tokens[1] == __version__ def assert_wps_version(response, version="1.0.0"): elem = response.xpath('/wps:Capabilities' '/ows:ServiceIdentification' '/ows:ServiceTypeVersion') found_version = elem[0].text assert version == found_version tmp = Path(tempfile.mkdtemp()) with open(tmp / "out.xml", "wb") as out: out.writelines(response.response) pywps-4.5.1/pywps/translations.py000066400000000000000000000030141415166246000171630ustar00rootroot00000000000000def get_translation(obj, attribute, language): """Get the translation from an object, for an attribute. The `obj` object is expected to have an attribute or key named `translations` and its value should be of type `dict[str,dict[str,str]]`. If the translation can't be found in the translations mapping, get the attribute on the object itself and raise :py:exc:`AttributeError` if it can't be found. The language property is converted to lowercase (see :py:func:`lower_case_dict` which must have been called on the translations first. :param str attribute: The attribute to get :param str language: The RFC 4646 language code """ language = language.lower() try: return obj.translations[language][attribute] except (AttributeError, KeyError, TypeError): pass try: return obj["translations"][language][attribute] except (AttributeError, KeyError, TypeError): pass if hasattr(obj, attribute): return getattr(obj, attribute) try: return obj[attribute] except (TypeError, AttributeError): pass raise AttributeError( "Can't find translation '{}' for object type '{}'".format(attribute, type(obj).__name__) ) def lower_case_dict(translations=None): """Returns a new dict, with its keys converted to lowercase. :param dict[str, Any] translations: A dictionnary to be converted. """ if translations is None: return return {k.lower(): v for k, v in translations.items()} pywps-4.5.1/pywps/util.py000066400000000000000000000012501415166246000154170ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import platform from typing import Union from pathlib import Path from urllib.parse import urlparse is_windows = platform.system() == 'Windows' def file_uri(path: Union[str, Path]) -> str: path = Path(path) path = path.as_uri() return str(path) def uri_to_path(uri) -> str: p = urlparse(uri) path = p.path if is_windows: path = str(Path(path)).lstrip('\\') return path pywps-4.5.1/pywps/validator/000077500000000000000000000000001415166246000160575ustar00rootroot00000000000000pywps-4.5.1/pywps/validator/__init__.py000066400000000000000000000037441415166246000202000ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Validating functions for various inputs """ import logging from pywps.validator.complexvalidator import validategml, validateshapefile, validatejson, validategeojson, \ validategeotiff, validatenetcdf, validatedods, validategpx from pywps.validator.base import emptyvalidator LOGGER = logging.getLogger('PYWPS') _VALIDATORS = { 'application/geo+json': validategeojson, 'application/json': validatejson, 'application/x-zipped-shp': validateshapefile, 'application/gml+xml': validategml, 'application/gpx+xml': validategpx, 'image/tiff; subtype=geotiff': validategeotiff, 'application/x-netcdf': validatenetcdf, 'application/x-ogc-dods': validatedods, 'application/xogc-wcs': emptyvalidator, 'application/x-ogc-wcs; version=1.0.0': emptyvalidator, 'application/x-ogc-wcs; version=1.1.0': emptyvalidator, 'application/x-ogc-wcs; version=2.0': emptyvalidator, 'application/x-ogc-wfs': emptyvalidator, 'application/x-ogc-wfs; version=1.0.0': emptyvalidator, 'application/x-ogc-wfs; version=1.1.0': emptyvalidator, 'application/x-ogc-wfs; version=2.0': emptyvalidator, 'application/x-ogc-wms': emptyvalidator, 'application/x-ogc-wms; version=1.3.0': emptyvalidator, 'application/x-ogc-wms; version=1.1.0': emptyvalidator, 'application/x-ogc-wms; version=1.0.0': emptyvalidator } def get_validator(identifier): """Return validator function for given mime_type identifier can be either full mime_type or data type identifier """ if identifier in _VALIDATORS: LOGGER.debug('validator: {}'.format(_VALIDATORS[identifier])) return _VALIDATORS[identifier] else: LOGGER.debug('empty validator') return emptyvalidator pywps-4.5.1/pywps/validator/allowed_value.py000066400000000000000000000012221415166246000212510ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from collections import namedtuple _ALLOWEDVALUETYPE = namedtuple('ALLOWEDVALUETYPE', 'VALUE, RANGE') _RANGELCLOSURETYPE = namedtuple('RANGECLOSURETYPE', 'OPEN, CLOSED,' 'OPENCLOSED, CLOSEDOPEN') ALLOWEDVALUETYPE = _ALLOWEDVALUETYPE('value', 'range') RANGECLOSURETYPE = _RANGELCLOSURETYPE( 'open', 'closed', 'open-closed', 'closed-open' ) pywps-4.5.1/pywps/validator/base.py000066400000000000000000000007651415166246000173530ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps.validator.mode import MODE def emptyvalidator(data_input, mode): """Empty validator will return always false for security reason """ if mode <= MODE.NONE: return True else: return False pywps-4.5.1/pywps/validator/complexvalidator.py000066400000000000000000000306561415166246000220200ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Validator classes are used for ComplexInputs, to validate the content """ import logging from pywps.validator.mode import MODE from pywps.inout.formats import FORMATS from lxml.etree import XMLSchema from pywps import xml_util as etree from urllib.request import urlopen import mimetypes import os LOGGER = logging.getLogger('PYWPS') def validategml(data_input, mode): """GML validation function :param data_input: :class:`ComplexInput` :param pywps.validator.mode.MODE mode: This function validates GML input based on given validation mode. Following happens, if `mode` parameter is given: `MODE.NONE` it will return always `True` `MODE.SIMPLE` the mimetype will be checked `MODE.STRICT` `Fiona` is used for getting the proper format. `MODE.VERYSTRICT` the :class:`lxml.etree` is used along with given input `schema` and the GML file is properly validated against given schema. """ LOGGER.info('validating GML; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.GML.mime_type} if mode >= MODE.STRICT: try: import fiona data_source = fiona.open(data_input.file) passed = (data_source.driver == "GML") except (ModuleNotFoundError, ImportError): passed = False if mode >= MODE.VERYSTRICT: try: schema_url = data_input.data_format.schema gmlschema_doc = etree.parse(urlopen(schema_url)) gmlschema = XMLSchema(gmlschema_doc) passed = gmlschema.validate(etree.parse(data_input.stream)) except Exception as e: LOGGER.warning(e) passed = False return passed def validategpx(data_input, mode): """GPX validation function :param data_input: :class:`ComplexInput` :param pywps.validator.mode.MODE mode: This function validates GPX input based on given validation mode. Following happens, if `mode` parameter is given: `MODE.NONE` it will return always `True` `MODE.SIMPLE` the mimetype will be checked `MODE.STRICT` `Fiona` is used for getting the proper format. `MODE.VERYSTRICT` the :class:`lxml.etree` is used along with given input `schema` and the GPX file is properly validated against given schema. """ LOGGER.info('validating GPX; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.GPX.mime_type} if mode >= MODE.STRICT: try: import fiona data_source = fiona.open(data_input.file) passed = (data_source.driver == "GPX") except (ModuleNotFoundError, ImportError): passed = False if mode >= MODE.VERYSTRICT: try: schema_url = data_input.data_format.schema gpxschema_doc = etree.parse(urlopen(schema_url)) gpxschema = XMLSchema(gpxschema_doc) passed = gpxschema.validate(etree.parse(data_input.stream)) except Exception as e: LOGGER.warning(e) passed = False return passed def validatexml(data_input, mode): """XML validation function :param data_input: :class:`ComplexInput` :param pywps.validator.mode.MODE mode: This function validates XML input based on given validation mode. Following happens, if `mode` parameter is given: `MODE.NONE` it will return always `True` `MODE.SIMPLE` the mimetype will be checked `MODE.STRICT` and `MODE.VERYSTRICT` the :class:`lxml.etree` is used along with given input `schema` and the XML file is properly validated against given schema. """ LOGGER.info('validating XML; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.GML.mime_type} if mode >= MODE.STRICT: # TODO: Raise the actual validation exception to make it easier to spot the error. # xml = etree.parse(data_input.file) # schema.assertValid(xml) try: fn = os.path.join(_get_schemas_home(), data_input.data_format.schema) schema_doc = etree.parse(fn) schema = XMLSchema(schema_doc) passed = schema.validate(etree.parse(data_input.file)) except Exception as e: LOGGER.warning(e) passed = False return passed def validatejson(data_input, mode): """JSON validation function :param data_input: :class:`ComplexInput` :param pywps.validator.mode.MODE mode: This function validates JSON input based on given validation mode. Following happens, if `mode` parameter is given: `MODE.NONE` No validation, returns `True`. `MODE.SIMPLE` Returns `True` if the mime type is correct. `MODE.STRICT` Returns `True` if the content can be interpreted as a json object. """ LOGGER.info('validating JSON; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.JSON.mime_type} if mode >= MODE.STRICT: import json try: with open(data_input.file) as f: json.load(f) passed = True except ValueError: passed = False return passed def validategeojson(data_input, mode): """GeoJSON validation example >>> import StringIO >>> class FakeInput(object): ... json = open('point.geojson','w') ... json.write('''{"type":"Feature", "properties":{}, "geometry":{"type":"Point", "coordinates":[8.5781228542328, 22.87500500679]}, "crs":{"type":"name", "properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}}}''') # noqa ... json.close() ... file = 'point.geojson' >>> class fake_data_format(object): ... mimetype = 'application/geojson' >>> fake_input = FakeInput() >>> fake_input.data_format = fake_data_format() >>> validategeojson(fake_input, MODE.SIMPLE) True """ LOGGER.info('validating GeoJSON; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.GEOJSON.mime_type} if mode >= MODE.STRICT: try: import fiona data_source = fiona.open(data_input.file) passed = (data_source.driver == "GeoJSON") except (ModuleNotFoundError, ImportError): passed = False if mode >= MODE.VERYSTRICT: import jsonschema import json # this code comes from # https://github.com/om-henners/GeoJSON_Validation/blob/master/geojsonvalidation/geojson_validation.py schema_home = os.path.join(_get_schemas_home(), "geojson") base_schema = os.path.join(schema_home, "geojson.json") with open(base_schema) as fh: geojson_base = json.load(fh) with open(os.path.join(schema_home, "crs.json")) as fh: crs_json = json.load(fh) with open(os.path.join(schema_home, "bbox.json")) as fh: bbox_json = json.load(fh) with open(os.path.join(schema_home, "geometry.json")) as fh: geometry_json = json.load(fh) cached_json = { "http://json-schema.org/geojson/crs.json": crs_json, "http://json-schema.org/geojson/bbox.json": bbox_json, "http://json-schema.org/geojson/geometry.json": geometry_json } resolver = jsonschema.RefResolver( "http://json-schema.org/geojson/geojson.json", geojson_base, store=cached_json) validator = jsonschema.Draft4Validator(geojson_base, resolver=resolver) try: validator.validate(json.loads(data_input.stream.read())) passed = True except jsonschema.ValidationError: passed = False return passed def validateshapefile(data_input, mode): """ESRI Shapefile validation example """ LOGGER.info('validating Shapefile; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.SHP.mime_type} if mode >= MODE.STRICT: try: import fiona sf = fiona.open(data_input.file) passed = (sf.driver == "ESRI Shapefile") except (ModuleNotFoundError, ImportError): passed = False return passed def validategeotiff(data_input, mode): """GeoTIFF validation example """ LOGGER.info('Validating Shapefile; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.GEOTIFF.mime_type} if mode >= MODE.STRICT: try: from geotiff import GeoTiff data_source = GeoTiff(data_input.file) passed = (data_source.crs_code > 0) except (ModuleNotFoundError, ImportError): passed = False return passed def validatenetcdf(data_input, mode): """netCDF validation. """ LOGGER.info('Validating netCDF; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.file (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.NETCDF.mime_type} if mode >= MODE.STRICT: try: from pywps.dependencies import netCDF4 as nc nc.Dataset(data_input.file) passed = True except ImportError as e: passed = False LOGGER.exception("ImportError while validating netCDF4 file {}:\n {}".format(data_input.file, e)) except IOError as e: passed = False LOGGER.exception("IOError while validating netCDF4 file {}:\n {}".format(data_input.file, e)) return passed def validatedods(data_input, mode): """OPeNDAP validation. """ LOGGER.info('Validating OPeNDAP; Mode: {}'.format(mode)) passed = False if mode >= MODE.NONE: passed = True if mode >= MODE.SIMPLE: name = data_input.url (mtype, encoding) = mimetypes.guess_type(name, strict=False) passed = data_input.data_format.mime_type in {mtype, FORMATS.DODS.mime_type} if mode >= MODE.STRICT: try: from pywps.dependencies import netCDF4 as nc nc.Dataset(data_input.url) passed = True except ImportError as e: passed = False LOGGER.exception("ImportError while validating OPeNDAP link {}:\n {}".format(data_input.url, e)) except IOError as e: passed = False LOGGER.exception("IOError while validating OPeNDAP link {}:\n {}".format(data_input.url, e)) return passed def _get_schemas_home(): """Get path to schemas directory """ schema_dir = os.path.join( os.path.abspath( os.path.dirname(__file__) ), os.path.pardir, "schemas") LOGGER.debug('Schemas directory: {}'.format(schema_dir)) return schema_dir pywps-4.5.1/pywps/validator/literalvalidator.py000066400000000000000000000076331415166246000220040ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ Validator classes used for LiteralInputs """ import logging from decimal import Decimal from pywps.inout.literaltypes import AnyValue, NoValue, ValuesReference from pywps.validator.mode import MODE from pywps.validator.allowed_value import ALLOWEDVALUETYPE, RANGECLOSURETYPE LOGGER = logging.getLogger('PYWPS') def validate_value(data_input, mode): """Validate a literal value of type string, integer etc. TODO: not fully implemented """ if mode == MODE.NONE: passed = True else: LOGGER.debug('validating literal value.') data_input.data # TODO: we currently rely only on the data conversion in `pywps.inout.literaltypes.convert` passed = True LOGGER.debug('validation result: {}'.format(passed)) return passed def validate_anyvalue(data_input, mode): """Just placeholder, anyvalue is always valid """ return True def validate_values_reference(data_input, mode): """Validate values reference TODO: not fully implemented """ if mode == MODE.NONE: passed = True else: LOGGER.debug('validating values reference.') data_input.data # TODO: we don't validate if the data is within the reference values passed = True LOGGER.debug('validation result: {}'.format(passed)) return passed def validate_allowed_values(data_input, mode): """Validate allowed values """ passed = False if mode == MODE.NONE: passed = True else: data = data_input.data LOGGER.debug('validating allowed values: {} in {}'.format(data, data_input.allowed_values)) for value in data_input.allowed_values: if isinstance(value, (AnyValue, NoValue, ValuesReference)): # AnyValue, NoValue and ValuesReference always pass validation # NoValue and ValuesReference are not implemented passed = True elif value.allowed_type == ALLOWEDVALUETYPE.VALUE: passed = _validate_value(value, data) elif value.allowed_type == ALLOWEDVALUETYPE.RANGE: passed = _validate_range(value, data) if passed is True: break LOGGER.debug('validation result: {}'.format(passed)) return passed def _validate_value(value, data): """Validate data against given value directly :param value: list or tupple with allowed data :param data: the data itself (string or number) """ passed = False if data == value.value: passed = True return passed def _validate_range(interval, data): """Validate data against given range """ passed = False LOGGER.debug('validating range: {} in {}'.format(data, interval)) if interval.minval <= data <= interval.maxval: if interval.spacing: spacing = abs(interval.spacing) diff = data - interval.minval passed = Decimal(str(diff)) % Decimal(str(spacing)) == 0 else: passed = True if passed: if interval.range_closure == RANGECLOSURETYPE.OPEN: passed = (interval.minval < data < interval.maxval) elif interval.range_closure == RANGECLOSURETYPE.CLOSED: passed = (interval.minval <= data <= interval.maxval) elif interval.range_closure == RANGECLOSURETYPE.OPENCLOSED: passed = (interval.minval < data <= interval.maxval) elif interval.range_closure == RANGECLOSURETYPE.CLOSEDOPEN: passed = (interval.minval <= data < interval.maxval) else: passed = False LOGGER.debug('validation result: {}'.format(passed)) return passed pywps-4.5.1/pywps/validator/mode.py000066400000000000000000000006361415166246000173620ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Validation modes """ class MODE(): """Validation mode enumeration """ NONE = 0 SIMPLE = 1 STRICT = 2 VERYSTRICT = 3 pywps-4.5.1/pywps/xml_util.py000066400000000000000000000004061415166246000163010ustar00rootroot00000000000000from lxml import etree as _etree PARSER = _etree.XMLParser( resolve_entities=False, ) tostring = _etree.tostring def fromstring(text): return _etree.fromstring(text, parser=PARSER) def parse(source): return _etree.parse(source, parser=PARSER) pywps-4.5.1/requirements-dev.txt000066400000000000000000000001101415166246000167400ustar00rootroot00000000000000coverage coveralls pytest flake8 pylint Sphinx twine wheel bump2version pywps-4.5.1/requirements-extra.txt000066400000000000000000000000101415166246000173040ustar00rootroot00000000000000netCDF4 pywps-4.5.1/requirements-processing.txt000066400000000000000000000000061415166246000203420ustar00rootroot00000000000000drmaa pywps-4.5.1/requirements-s3.txt000066400000000000000000000000051415166246000165120ustar00rootroot00000000000000boto3pywps-4.5.1/requirements.txt000066400000000000000000000001551415166246000161750ustar00rootroot00000000000000jinja2 jsonschema lxml owslib python-dateutil requests SQLAlchemy werkzeug MarkupSafe humanize geotiff fiona pywps-4.5.1/setup.cfg000066400000000000000000000006611415166246000145340ustar00rootroot00000000000000[bumpversion] current_version = 4.5.1 commit = False tag = False parse = (?P\d+)\.(?P\d+).(?P\d+) serialize = {major}.{minor}.{patch} [flake8] ignore = F401,E402,W606 max-line-length = 120 exclude = tests [bumpversion:file:pywps/__init__.py] search = __version__ = "{current_version}" replace = __version__ = "{new_version}" [bumpversion:file:VERSION.txt] search = {current_version} replace = {new_version} pywps-4.5.1/setup.py000066400000000000000000000043051415166246000144240ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import sys try: from setuptools import setup except ImportError: from distutils.core import setup from setuptools import find_packages with open('VERSION.txt') as ff: VERSION = ff.read().strip() DESCRIPTION = ('PyWPS is an implementation of the Web Processing Service ' 'standard from the Open Geospatial Consortium. PyWPS is ' 'written in Python.') with open('README.md') as ff: LONG_DESCRIPTION = ff.read() KEYWORDS = 'PyWPS WPS OGC processing' with open('requirements.txt') as f: INSTALL_REQUIRES = f.read().splitlines() CONFIG = { 'name': 'pywps', 'version': VERSION, 'description': DESCRIPTION, 'long_description': LONG_DESCRIPTION, 'long_description_content_type': 'text/markdown', 'keywords': KEYWORDS, 'license': 'MIT', 'platforms': 'all', 'author': 'Jachym Cepicky', 'author_email': 'jachym.cepicky@gmail.com', 'maintainer': 'Jachym Cepicky', 'maintainer_email': 'jachym.cepicky@gmail.com', 'url': 'https://pywps.org', 'download_url': 'https://github.com/geopython/pywps', 'classifiers': [ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'Intended Audience :: Science/Research', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", 'Topic :: Scientific/Engineering :: GIS' ], 'install_requires': INSTALL_REQUIRES, 'python_requires': '>=3.6, <4', 'packages': find_packages(exclude=["docs", "tests.*", "tests"]), 'include_package_data': True, 'scripts': [], 'entry_points': { 'console_scripts': [ 'joblauncher=pywps.processing.job:launcher', ]}, } setup(**CONFIG) pywps-4.5.1/tests/000077500000000000000000000000001415166246000140525ustar00rootroot00000000000000pywps-4.5.1/tests/__init__.py000066400000000000000000000056621415166246000161740ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import sys import unittest import os import subprocess import tempfile import configparser import pywps.configuration as config from tests import test_capabilities from tests import test_describe from tests import test_execute from tests import test_exceptions from tests import test_inout from tests import test_literaltypes from tests import validator from tests import test_ows from tests import test_formats from tests import test_dblog from tests import test_wpsrequest from tests import test_service from tests import test_process from tests import test_processing from tests import test_assync from tests import test_grass_location from tests import test_storage from tests import test_filestorage from tests import test_s3storage from tests.validator import test_complexvalidators from tests.validator import test_literalvalidators def find_grass(): """Check whether GRASS is installed and return path to its GISBASE.""" startcmd = ['grass', '--config', 'path'] try: p = subprocess.Popen(startcmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except: return None out, _ = p.communicate() str_out = out.decode("utf-8") gisbase = str_out.rstrip(os.linesep) return gisbase def config_grass(gisbase): """Configure PyWPS to allow GRASS commands.""" conf = configparser.ConfigParser() conf.add_section('grass') conf.set('grass', 'gisbase', gisbase) conf.set('grass', 'gui', 'text') _, conf_path = tempfile.mkstemp() with open(conf_path, 'w') as c: conf.write(c) config.load_configuration(conf_path) def load_tests(loader=None, tests=None, pattern=None): """Load tests """ gisbase = find_grass() if gisbase: config_grass(gisbase) return unittest.TestSuite([ test_capabilities.load_tests(), test_execute.load_tests(), test_describe.load_tests(), test_inout.load_tests(), test_exceptions.load_tests(), test_ows.load_tests(), test_literaltypes.load_tests(), test_complexvalidators.load_tests(), test_literalvalidators.load_tests(), test_formats.load_tests(), test_dblog.load_tests(), test_wpsrequest.load_tests(), test_service.load_tests(), test_process.load_tests(), test_processing.load_tests(), test_assync.load_tests(), test_grass_location.load_tests(), test_storage.load_tests(), test_filestorage.load_tests(), test_s3storage.load_tests(), ]) if __name__ == "__main__": result = unittest.TextTestRunner(verbosity=2).run(load_tests()) if not result.wasSuccessful(): sys.exit(1) pywps-4.5.1/tests/data/000077500000000000000000000000001415166246000147635ustar00rootroot00000000000000pywps-4.5.1/tests/data/geotiff/000077500000000000000000000000001415166246000164065ustar00rootroot00000000000000pywps-4.5.1/tests/data/geotiff/dem.tiff000066400000000000000000014157461415166246000200470ustar00rootroot00000000000000II*:55@S 0 H x*U~~~~~~~~}}}}}}}}||||||||{{{{{{{{zzzzzzzzyyyyyyyyxxxxxxxxwwwwwwwwvvvvvvvvuuuuuuuuttttttttssssssrrrrrrrrqqqqqqqqppppppppoooooooonnnnnnnnmmmmmmmmllllllllkkkkkkkkjjjjjjjjiiiiiiiihhhhhhhhggggggffffffffeeeeeeeeddddddddccccccccbbbbbbbbaaaaaaaa````````________^^^^^^^^]]]]]]]]\\\\\\\\[[[[[[[[ZZZZZZYYYYYYYYXXXXXXXXWWWWWWWWVVVVVVVVUUUUUUUUTTTTTTTTSSSSSSSSRRRRRRRRQQQQQQQQPPPPPPPPOOOOOOOONNNNNNNNMMMMMMLLLLLLLLKKKKKKKKJJJJJJJJIIIIIIIIHHHHHHHHGGGGGGGGFFFFFFFFEEEEEEEEDDDDDDDDCCCCCCCCBBBBBBBBAAAAAAAA@@@@@@????????>>>>>>>>========<<<<<<<<;;;;;;;;::::::::999999998888888877777777666666665555555544444433333333222222221111111100000000////////........--------,,,,,,,,++++++++********))))))))((((((((''''''&&&&&&&&%%%%%%%%$$$$$$$$########""""""""!!!!!!!!   !!""##$$&&''((**++,,--//0011334455668899::;;==>>??AABBCCDDFFGGHHIIKKLLMMOOPPQQRRTTUUVVWWYYZZ[[]]^^__``bbccddffgghhiikkllmmnnppqqrrttuuvvwwyyzz{{||~~~~~~}}}}||||{{{{zzzzyyyyxxxxwwwwvvvvuuuuuuttttssssrrrrqqqqppppoooonnnnmmmmllllkkkkkkjjjjiiiihhhhggggffffeeeeddddccccbbbbaaaa``````____^^^^]]]]\\\\[[[[ZZZZYYYYXXXXWWWWVVVVUUUUUUTTTTSSSSRRRRQQQQPPPPOOOONNNNMMMM~~~~~~}}}}}}||||||{{{{{{zzzzzzyyyyyyyyxxxxxxwwwwwwvvvvvvuuuuuuttttttssssssrrrrrrrrqqqqqqppppppoooooonnnnnnmmmmmmllllllkkkkkkkkjjjjjjiiiiiihhhhhhggggggffffffeeeeeeeeddddddccccccbbbbbbaaaaaa``````______^^^^^^^^]]]]]]\\\\\\[[[[[[ZZZZZZYYYYYYXXXXXXWWWWWWWWVVVVVVUUUUUUTTTTTTSSSSSSRRRRRRQQQQQQQQPPPPPPOOOOOONNNNNNMMMMMMLLLLLLKKKKKKJJJJJJJJIIIIIIHHHHHHGGGGGGFFFFFFEEEEEEDDDDDDCCCCCCCCBBBBBBAAAAAA@@@@@@??????>>>>>>========<<<<<<;;;;;;::::::9999998888887777776666666655555544444433~~~~~~~~}}}}}}}}||||||||{{{{{{{{zzzzzzzzyyyyyyyyxxxxxxxxwwwwwwwwvvvvvvvvuuuuuuuuttttttttssssssrrrrrrrrqqqqqqqqppppppppoooooooonnnnnnnnmmmmmmmmllllllllkkkkkkkkjjjjjjjjiiiiiiiihhhhhhhhggggggffffffffeeeeeeeeddddddddccccccccbbbbbbbbaaaaaaaa````````________^^^^^^^^]]]]]]]]\\\\\\\\[[[[[[[[ZZZZZZYYYYYYYYXXXXXXXXWWWWWWWWVVVVVVVVUUUUUUUUTTTTTTTTSSSSSSSSRRRRRRRRQQQQQQQQPPPPPPPPOOOOOOOONNNNNNNNMMMMMMLLLLLLLLKKKKKKKKJJJJJJJJIIIIIIIIHHHHHHHHGGGGGGGGFFFFFFFFEEEEEEEEDDDDDDDDCCCCCCCCBBBBBBBBAAAAAAAA@@@@@@????????>>>>>>>>========<<<<<<<<;;;;;;;;::::::::999999998888888877777777666666665555555544444433333333222222221111111100000000////////........--------,,,,,,,,++++++++********))))))))((((((((''''''&&&&&&&&%%%%%%%%$$$$$$$$########""""""""!!!!!!!!    !!!!!!""""""""######$$$$$$%%%%%%&&&&&&''''''(((((((())))))******++++++,,,,,,--------......//////0000001111112222223333333344444455555566666677777788888899999999::::::;;;;;;<<<<<<======>>>>>>??????????????>>>>>>>>>>>>==============<<<<<<<<<<<<;;;;;;;;;;;;::::::::::::::999999999999888888888888777777777777776666666666665555555555554444444444444433333333333322222222222211111111111111000000000000////////////..............------------,,,,,,,,,,,,++++++++++++++************))))))))))))))((((((((((((''''''''''''&&&&&&&&&&&&&&%%%%%%%%%%%%$$$$$$$$$$$$##############""""""""""""!!!!!!!!!!!!  5 0.000000e+00 1.000000e+03 255 255 255 0 255 0 1.000000e+03 1.200000e+03 0 255 0 255 255 0 1.200000e+03 1.400000e+03 255 255 0 255 127 0 1.400000e+03 1.600000e+03 255 127 0 191 127 63 1.600000e+03 2.000000e+03 191 127 63 0 0 0 >@>@p_"ARA!!# Yh )#UTM Zone 13, Northern Hemisphere|clark66|                                                  "%$#"! #'+*)(%!      "',000.+'#"     %+157752..*&#   &.6<>>=;73/*'$#!%.7>ACB?;73/,)(&#  $-7?DGGD@<8420.+'#   !*6?FJKHDA=98640,'#    '3>FKMLIEB>=<:50,(#  %0;DKOPMKGDDB?:61-(#  $.8AJQSRPMKJGD@;72-($ #-6@JRVVTRQPMIE@<72,'!    )3=HQWYXVVUQMJE@;5/*%     %.8CMV[[YZYVQNJE?93/)#    !)2>ISZ]\\\ZVSOID>72,&       %.8DOW[]]__[XSNHA;4.(#       )3?JRWZ[_a^\WQKD=71+%      #-:DLRUX[^^^ZTNG@;5.)#    (4>EFKNRUX[YUOHA<61,%  *28?DGLPTWXTOIB=950*$$*19>BFJORTRNJEB>:6/*$ #+28;@DHKNNKHEEB>:3-(# $+04:>BEGHFCACB>:3-'" "',38;>@A@>=??;82+# %,036998779975/)   %)+.110/./00.(! !$&))'%"#&&$!"           pywps-4.5.1/tests/data/gml/000077500000000000000000000000001415166246000155425ustar00rootroot00000000000000pywps-4.5.1/tests/data/gml/point.gfs000066400000000000000000000006551415166246000174020ustar00rootroot00000000000000 point point 1 1 -1.25967 -1.25967 0.20258 0.20258 pywps-4.5.1/tests/data/gml/point.gml000066400000000000000000000015331415166246000173760ustar00rootroot00000000000000 -1.2596685082872930.2025782688766113 -1.2596685082872930.2025782688766113 -1.259668508287293,0.202578268876611 pywps-4.5.1/tests/data/json/000077500000000000000000000000001415166246000157345ustar00rootroot00000000000000pywps-4.5.1/tests/data/json/point.geojson000066400000000000000000000003001415166246000204440ustar00rootroot00000000000000{"type":"Feature", "properties":{}, "geometry":{"type":"Point", "coordinates":[8.5781228542328, 22.87500500679]}, "crs":{"type":"name", "properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}}} pywps-4.5.1/tests/data/netcdf/000077500000000000000000000000001415166246000162265ustar00rootroot00000000000000pywps-4.5.1/tests/data/netcdf/time.nc000066400000000000000000000055221415166246000175120ustar00rootroot00000000000000HDF  R `OHDR "  Y __NCProperties7version=1|netcdflibversion=4.6.1|hdf5libversion=1.10.1timeKOHDR   ?@4 4G GP 0CLASSDIMENSION_SCALE %NAMEtime 4 _Netcdf4Dimid  5unitsdays since 1900-01-01 ,standard_nametime?@@@@@@ @"@OCHK )title Test file2>pywps-4.5.1/tests/data/point.xsd000066400000000000000000000027451415166246000166440ustar00rootroot00000000000000 pywps-4.5.1/tests/data/shp/000077500000000000000000000000001415166246000155555ustar00rootroot00000000000000pywps-4.5.1/tests/data/shp/point.dbf000066400000000000000000000001141415166246000173570ustar00rootroot00000000000000_A idN 1pywps-4.5.1/tests/data/shp/point.prj000066400000000000000000000002171415166246000174230ustar00rootroot00000000000000GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]]pywps-4.5.1/tests/data/shp/point.qpj000066400000000000000000000004011415166246000174150ustar00rootroot00000000000000GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]] pywps-4.5.1/tests/data/shp/point.shp000066400000000000000000000002001415166246000174120ustar00rootroot00000000000000' @.3ء٨?`]Uz ?.3ء٨?`]Uz ? .3ء٨?`]Uz ?pywps-4.5.1/tests/data/shp/point.shp.zip000066400000000000000000000020531415166246000202230ustar00rootroot00000000000000PKEDu4, point.shpUT R=TS^=TSux c`Pb^0300zo,>!V6C?2Å,PKED$ *l point.shxUT R=TSL=TSux c`Pb^0300zo,>!V6C?20b.PK DcDCO  point.xsdUT 0SOSux TMo0 WJ)t[un00}8~ev`ݰȧG>>J˫F+ KkJ:+v+MUwjl‹GМ wW\@Icr5Z|a](/bϠ1ؿot~~ޖV#T\@uv<(,SJ$lXm&ums,ĭ[5͋Y1g;Rv}eK΀kDL ꘈC[$c*v6%lK+.NZAn_*2& `4EsxNS$!W{q\e6GtrAYכv-MEIHܶN$\% 9i"oXV,;vL"V[iphUxx?]+N 3>1U`5;ܹ8X^cK,HFEZoBvS4ose^:e2M)E8 -Eݬӏ1<_@\J'\xc6ܥ#vYv̿);ثoPKEDu4, point.shpUTR=TSux PKED$ *l opoint.shxUTR=TSux PK DcDCO  point.xsdUT0Sux PK(pywps-4.5.1/tests/data/shp/point.shx000066400000000000000000000001541415166246000174320ustar00rootroot00000000000000' 6.3ء٨?`]Uz ?.3ء٨?`]Uz ?2 pywps-4.5.1/tests/data/text/000077500000000000000000000000001415166246000157475ustar00rootroot00000000000000pywps-4.5.1/tests/data/text/unsafe.txt000066400000000000000000000000641415166246000177710ustar00rootroot00000000000000< Bunch of characters that would break XML <> & "" 'pywps-4.5.1/tests/processes/000077500000000000000000000000001415166246000160605ustar00rootroot00000000000000pywps-4.5.1/tests/processes/__init__.py000066400000000000000000000047211415166246000201750ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps import Process from pywps.inout import LiteralInput, LiteralOutput from pywps.inout.literaltypes import ValuesReference class SimpleProcess(Process): identifier = "simpleprocess" def __init__(self): self.add_input(LiteralInput()) class UltimateQuestion(Process): def __init__(self): super(UltimateQuestion, self).__init__( self._handler, identifier='ultimate_question', title='Ultimate Question', outputs=[LiteralOutput('outvalue', 'Output Value', data_type='string')]) @staticmethod def _handler(request, response): response.outputs['outvalue'].data = '42' return response class Greeter(Process): def __init__(self): super(Greeter, self).__init__( self.greeter, identifier='greeter', title='Greeter', inputs=[LiteralInput('name', 'Input name', data_type='string')], outputs=[LiteralOutput('message', 'Output message', data_type='string')] ) @staticmethod def greeter(request, response): name = request.inputs['name'][0].data assert type(name) is str response.outputs['message'].data = "Hello {}!".format(name) return response class InOut(Process): def __init__(self): super(InOut, self).__init__( self.inout, identifier='inout', title='In and Out', inputs=[ LiteralInput('string', 'String', data_type='string'), LiteralInput('time', 'Time', data_type='time', default='12:00:00'), LiteralInput('ref_value', 'Referenced Value', data_type='string', allowed_values=ValuesReference(reference="https://en.wikipedia.org/w/api.php?action=opensearch&search=scotland&format=json"), # noqa default='Scotland',), ], outputs=[ LiteralOutput('string', 'Output', data_type='string') ] ) @staticmethod def inout(request, response): a_string = request.inputs['string'][0].data response.outputs['string'].data = "".format(a_string) return response pywps-4.5.1/tests/processes/metalinkprocess.py000066400000000000000000000033671415166246000216460ustar00rootroot00000000000000from pywps import Process, LiteralInput, ComplexOutput, FORMATS from pywps.inout.outputs import MetaLink4, MetaFile class MultipleOutputs(Process): def __init__(self): inputs = [ LiteralInput('count', 'Number of output files', abstract='The number of generated output files.', data_type='integer', default=2)] outputs = [ ComplexOutput('output', 'Metalink4 output', abstract='A metalink file storing URIs to multiple files', as_reference=True, supported_formats=[FORMATS.META4]) ] super(MultipleOutputs, self).__init__( self._handler, identifier='multiple-outputs', title='Multiple Outputs', abstract='Produces multiple files and returns a document' ' with references to these files.', inputs=inputs, outputs=outputs, store_supported=True, status_supported=True ) def _handler(self, request, response): max_outputs = request.inputs['count'][0].data ml = MetaLink4('test-ml-1', 'MetaLink with links to text files.', workdir=self.workdir) for i in range(max_outputs): # Create a MetaFile instance, which instantiates a ComplexOutput object. mf = MetaFile('output_{}'.format(i), 'Test output', format=FORMATS.TEXT) mf.data = 'output: {}'.format(i) # or mf.file = or mf.url = ml.append(mf) # The `xml` property of the Metalink4 class returns the metalink content. response.outputs['output'].data = ml.xml return response pywps-4.5.1/tests/requests/000077500000000000000000000000001415166246000157255ustar00rootroot00000000000000pywps-4.5.1/tests/requests/wps_describeprocess_request.xml000066400000000000000000000011361415166246000242700ustar00rootroot00000000000000 intersection union pywps-4.5.1/tests/requests/wps_execute_request-boundingbox.xml000066400000000000000000000014771415166246000250770ustar00rootroot00000000000000 BBox bbox Bounding box 189000 834000 285000 962000 pywps-4.5.1/tests/requests/wps_execute_request-complexvalue.xml000066400000000000000000000046161415166246000252630ustar00rootroot00000000000000 Reclassification InputLayer The layer which's values shall be reclassified BufferDistance Distance which people will walk to get to a playground. 0 119 A 120 B Outlayer Reclassified Layer. Layer classified into two classes, where class A is less than or equal 120 and class B is more than 120. pywps-4.5.1/tests/requests/wps_execute_request-responsedocument-1.xml000066400000000000000000000033351415166246000263070ustar00rootroot00000000000000 Buffer InputPolygon Playground area BufferDistance Distance which people will walk to get to a playground. 400 BufferedPolygon Area serviced by playground. Area within which most users of this playground will live. pywps-4.5.1/tests/requests/wps_execute_request-responsedocument-2.xml000066400000000000000000000040161415166246000263050ustar00rootroot00000000000000 Buffer InputPolygon Playground area BufferDistance Distance which people will walk to get to a playground. 400 BufferedPolygon Area serviced by playground. Area within which most users of this playground will live. pywps-4.5.1/tests/requests/wps_execute_request_extended-responsedocument.xml000066400000000000000000000051221415166246000300250ustar00rootroot00000000000000 Buffer InputPolygon Playground area BufferDistance Distance which people will walk to get to a playground . 400 BufferZoneWidth Defining buffer zone width 0 100 100 400 BufferedPolygon Area serviced by playground. Area within which most users of this playground will live plus the buffer. pywps-4.5.1/tests/requests/wps_execute_request_rawdataoutput.xml000066400000000000000000000026441415166246000255440ustar00rootroot00000000000000 Buffer InputPolygon Playground area BufferDistance Distance which people will walk to get to a playground. 400 BufferedPolygon pywps-4.5.1/tests/requests/wps_getcapabilities_request.xml000066400000000000000000000011031415166246000242340ustar00rootroot00000000000000 1.0.0 pywps-4.5.1/tests/test_app_exceptions.py000066400000000000000000000031731415166246000205100ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest from pywps.app.exceptions import format_message, ProcessError, DEFAULT_ALLOWED_CHARS class AppExceptionsTest(unittest.TestCase): def setUp(self): pass def test_format_message(self): assert format_message('no data available') == 'no data available' assert format_message(' no data available! ') == 'no data available!' assert format_message('no') == '' assert format_message('no data available', max_length=7) == 'no data' assert format_message('no &data% available') == 'no data available' assert format_message(DEFAULT_ALLOWED_CHARS) == DEFAULT_ALLOWED_CHARS def test_process_error(self): assert ProcessError(' no &data available!').message == 'no data available!' assert ProcessError('no', min_length=2).message == 'no' assert ProcessError('0 data available', max_length=6).message == '0 data' assert ProcessError('no data? not available!', allowed_chars='?').message == 'no data? not available' assert ProcessError('').message == 'Sorry, process failed. Please check server error log.' assert ProcessError(1234).message == 'Sorry, process failed. Please check server error log.' try: raise ProcessError('no data!!') except ProcessError as e: assert f"{e}" == 'no data!!' else: assert False pywps-4.5.1/tests/test_assync.py000066400000000000000000000043571415166246000167740ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest import time from pywps import Service, Process, LiteralInput, LiteralOutput from pywps import get_ElementMakerForVersion from pywps.tests import client_for, assert_response_accepted VERSION = "1.0.0" WPS, OWS = get_ElementMakerForVersion(VERSION) def create_sleep(): def sleep(request, response): seconds = request.inputs['seconds'][0].data assert isinstance(seconds, float) step = seconds / 3 for i in range(3): # How is status working in version 4 ? #self.status.set("Waiting...", i * 10) time.sleep(step) response.outputs['finished'].data = "True" return response return Process(handler=sleep, identifier='sleep', title='Sleep', inputs=[ LiteralInput('seconds', title='Seconds', data_type='float') ], outputs=[ LiteralOutput('finished', title='Finished', data_type='boolean') ] ) class ExecuteTest(unittest.TestCase): def test_assync(self): client = client_for(Service(processes=[create_sleep()])) request_doc = WPS.Execute( OWS.Identifier('sleep'), WPS.DataInputs( WPS.Input( OWS.Identifier('seconds'), WPS.Data( WPS.LiteralData( "0.3" ) ) ) ), version="1.0.0" ) resp = client.post_xml(doc=request_doc) assert_response_accepted(resp) # TODO: # . extract the status URL from the response # . send a status request def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExecuteTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_assync_inout.py000066400000000000000000000037631415166246000202120ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps import Service, Process, LiteralInput, ComplexOutput from pywps import FORMATS from pywps import get_ElementMakerForVersion from pywps.tests import client_for VERSION = "1.0.0" WPS, OWS = get_ElementMakerForVersion(VERSION) def create_inout(): def inout(request, response): response.outputs['text'].data = request.inputs['text'][0].data return response return Process(handler=inout, identifier='inout', title='InOut', inputs=[ LiteralInput('text', 'Text', data_type='string') ], outputs=[ ComplexOutput( 'text', title='Text', supported_formats=[FORMATS.TEXT, ] ), ], store_supported=True, status_supported=True ) def test_assync_inout(): client = client_for(Service(processes=[create_inout()])) request_doc = WPS.Execute( OWS.Identifier('inout'), WPS.DataInputs( WPS.Input( OWS.Identifier('text'), WPS.Data( WPS.LiteralData( "Hello World" ) ) ) ), WPS.ResponseForm( WPS.ResponseDocument( WPS.Output( OWS.Identifier("text") ), ), ), version="1.0.0" ) resp = client.post_xml(doc=request_doc) assert resp.status_code == 200 # TODO: # . extract the status URL from the response # . send a status request pywps-4.5.1/tests/test_capabilities.py000066400000000000000000000156151415166246000201240ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest from pywps import configuration from pywps.app import Process, Service from pywps.app.Common import Metadata from pywps import get_ElementMakerForVersion from pywps.tests import client_for, assert_wps_version WPS, OWS = get_ElementMakerForVersion("1.0.0") class BadRequestTest(unittest.TestCase): def test_bad_http_verb(self): client = client_for(Service()) resp = client.put('') assert resp.status_code == 405 # method not allowed def test_bad_request_type_with_get(self): client = client_for(Service()) resp = client.get('?Request=foo') assert resp.status_code == 400 def test_bad_service_type_with_get(self): client = client_for(Service()) resp = client.get('?service=foo') exception = resp.xpath('/ows:ExceptionReport' '/ows:Exception') assert resp.status_code == 400 assert exception[0].attrib['exceptionCode'] == 'InvalidParameterValue' def test_bad_request_type_with_post(self): client = client_for(Service()) request_doc = WPS.Foo() resp = client.post_xml('', doc=request_doc) assert resp.status_code == 400 class CapabilitiesTest(unittest.TestCase): def setUp(self): def pr1(): pass def pr2(): pass self.client = client_for( Service( processes=[ Process( pr1, "pr1", "Process 1", abstract="Process 1", keywords=["kw1a", "kw1b"], metadata=[Metadata("pr1 metadata")], ), Process( pr2, "pr2", "Process 2", keywords=["kw2a"], metadata=[Metadata("pr2 metadata")], ), ] ) ) def check_capabilities_response(self, resp): assert resp.status_code == 200 assert resp.headers['Content-Type'] == 'text/xml' title = resp.xpath_text('/wps:Capabilities' '/ows:ServiceIdentification' '/ows:Title') assert title != '' names = resp.xpath_text('/wps:Capabilities' '/wps:ProcessOfferings' '/wps:Process' '/ows:Identifier') assert sorted(names.split()) == ['pr1', 'pr2'] keywords = resp.xpath('/wps:Capabilities' '/wps:ProcessOfferings' '/wps:Process' '/ows:Keywords' '/ows:Keyword') assert len(keywords) == 3 metadatas = resp.xpath('/wps:Capabilities' '/wps:ProcessOfferings' '/wps:Process' '/ows:Metadata') assert len(metadatas) == 2 def test_get_request(self): resp = self.client.get('?Request=GetCapabilities&service=WpS') self.check_capabilities_response(resp) # case insesitive check resp = self.client.get('?request=getcapabilities&service=wps') self.check_capabilities_response(resp) def test_post_request(self): request_doc = WPS.GetCapabilities() resp = self.client.post_xml(doc=request_doc) self.check_capabilities_response(resp) def test_get_bad_version(self): resp = self.client.get('?request=getcapabilities&service=wps&acceptversions=2001-123') exception = resp.xpath('/ows:ExceptionReport' '/ows:Exception') assert resp.status_code == 400 assert exception[0].attrib['exceptionCode'] == 'VersionNegotiationFailed' def test_post_bad_version(self): acceptedVersions_doc = OWS.AcceptVersions(OWS.Version('2001-123')) request_doc = WPS.GetCapabilities(acceptedVersions_doc) resp = self.client.post_xml(doc=request_doc) exception = resp.xpath('/ows:ExceptionReport' '/ows:Exception') assert resp.status_code == 400 assert exception[0].attrib['exceptionCode'] == 'VersionNegotiationFailed' def test_version(self): resp = self.client.get('?service=WPS&request=GetCapabilities&version=1.0.0') assert_wps_version(resp) def test_version2(self): resp = self.client.get('?service=WPS&request=GetCapabilities&acceptversions=2.0.0') assert_wps_version(resp, version="2.0.0") class CapabilitiesTranslationsTest(unittest.TestCase): def setUp(self): configuration.load_configuration() configuration.CONFIG.set('server', 'language', 'en-US,fr-CA') self.client = client_for( Service( processes=[ Process( lambda: None, "pr1", "Process 1", abstract="Process 1", translations={"fr-CA": {"title": "Processus 1", "abstract": "Processus 1"}}, ), Process( lambda: None, "pr2", "Process 2", abstract="Process 2", translations={"fr-CA": {"title": "Processus 2"}}, ), ] ) ) def tearDown(self): configuration.CONFIG.set('server', 'language', 'en-US') def test_get_translated(self): resp = self.client.get('?Request=GetCapabilities&service=wps&language=fr-CA') assert resp.xpath('/wps:Capabilities/@xml:lang')[0] == "fr-CA" default = resp.xpath_text('/wps:Capabilities/wps:Languages/wps:Default/ows:Language') assert default == 'en-US' supported = resp.xpath('/wps:Capabilities/wps:Languages/wps:Supported/ows:Language/text()') assert supported == ["en-US", "fr-CA"] processes = list(resp.xpath('//wps:ProcessOfferings')[0]) assert [e.text for e in processes[0]] == ['pr1', 'Processus 1', 'Processus 1'] assert [e.text for e in processes[1]] == ['pr2', 'Processus 2', 'Process 2'] def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(BadRequestTest), loader.loadTestsFromTestCase(CapabilitiesTest), loader.loadTestsFromTestCase(CapabilitiesTranslationsTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_complexdata_io.py000066400000000000000000000114541415166246000204600ustar00rootroot00000000000000"""Test embedding different file formats and different encodings within the tag.""" import unittest import os from pywps import get_ElementMakerForVersion from pywps.app.basic import get_xpath_ns from pywps import Service, Process, ComplexInput, ComplexOutput, FORMATS from pywps.tests import client_for, assert_response_success from owslib.wps import WPSExecution, ComplexDataInput from pywps import xml_util as etree VERSION = "1.0.0" WPS, OWS = get_ElementMakerForVersion(VERSION) xpath_ns = get_xpath_ns(VERSION) def get_resource(path): return os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data', path) test_fmts = {'json': (get_resource('json/point.geojson'), FORMATS.JSON), 'geojson': (get_resource('json/point.geojson'), FORMATS.GEOJSON), 'netcdf': (get_resource('netcdf/time.nc'), FORMATS.NETCDF), 'geotiff': (get_resource('geotiff/dem.tiff'), FORMATS.GEOTIFF), 'gml': (get_resource('gml/point.gml'), FORMATS.GML), 'shp': (get_resource('shp/point.shp.zip'), FORMATS.SHP), 'txt': (get_resource('text/unsafe.txt'), FORMATS.TEXT), } def create_fmt_process(name, fn, fmt): """Create a dummy process comparing the input file on disk and the data that was passed in the request.""" def handler(request, response): # Load output from file and convert to data response.outputs['complex'].file = fn o = response.outputs['complex'].data # Get input data from the request i = request.inputs['complex'][0].data assert i == o return response return Process(handler=handler, identifier='test-fmt', title='Complex fmt test process', inputs=[ComplexInput('complex', 'Complex input', supported_formats=(fmt, ))], outputs=[ComplexOutput('complex', 'Complex output', supported_formats=(fmt, ))]) def get_data(fn, encoding=None): """Read the data from file and encode.""" import base64 mode = 'rb' if encoding == 'base64' else 'r' with open(fn, mode) as fp: data = fp.read() if encoding == 'base64': data = base64.b64encode(data) if isinstance(data, bytes): return data.decode('utf-8') else: return data class RawInput(unittest.TestCase): def make_request(self, name, fn, fmt): """Create XML request embedding encoded data.""" data = get_data(fn, fmt.encoding) doc = WPS.Execute( OWS.Identifier('test-fmt'), WPS.DataInputs( WPS.Input( OWS.Identifier('complex'), WPS.Data( WPS.ComplexData(data, mimeType=fmt.mime_type, encoding=fmt.encoding)))), version='1.0.0') return doc def compare_io(self, name, fn, fmt): """Start the dummy process, post the request and check the response matches the input data.""" # Note that `WPSRequest` calls `get_inputs_from_xml` which converts base64 input to bytes # See `_get_rawvalue_value` client = client_for(Service(processes=[create_fmt_process(name, fn, fmt)])) data = get_data(fn, fmt.encoding) wps = WPSExecution() doc = wps.buildRequest('test-fmt', inputs=[('complex', ComplexDataInput(data, mimeType=fmt.mime_type, encoding=fmt.encoding))], mode='sync') resp = client.post_xml(doc=doc) assert_response_success(resp) wps.parseResponse(resp.xml) out = wps.processOutputs[0].data[0] if 'gml' in fmt.mime_type: xml_orig = etree.tostring(etree.fromstring(data.encode('utf-8'))).decode('utf-8') xml_out = etree.tostring(etree.fromstring(out.decode('utf-8'))).decode('utf-8') # Not equal because the output includes additional namespaces compared to the origin. # self.assertEqual(xml_out, xml_orig) else: self.assertEqual(out.strip(), data.strip()) def test_json(self): key = 'json' self.compare_io(key, *test_fmts[key]) def test_geojson(self): key = 'geojson' self.compare_io(key, *test_fmts[key]) def test_geotiff(self): key = 'geotiff' self.compare_io(key, *test_fmts[key]) def test_netcdf(self): key = 'netcdf' self.compare_io(key, *test_fmts[key]) def test_gml(self): key = 'gml' self.compare_io(key, *test_fmts[key]) def test_shp(self): key = 'shp' self.compare_io(key, *test_fmts[key]) def test_txt(self): key = 'txt' self.compare_io(key, *test_fmts[key]) pywps-4.5.1/tests/test_dblog.py000066400000000000000000000036061415166246000165570ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for dblog """ import unittest from pywps import configuration from pywps.dblog import get_session from pywps.dblog import ProcessInstance class DBLogTest(unittest.TestCase): """DBGLog test cases""" def setUp(self): self.database = configuration.get_config_value('logging', 'database') def test_0_dblog(self): """Test pywps.formats.Format class """ session = get_session() self.assertTrue(session) def test_db_content(self): session = get_session() null_time_end = session.query(ProcessInstance).filter(ProcessInstance.time_end == None) self.assertEqual(null_time_end.count(), 0, 'There are no unfinished processes loged') null_status = session.query(ProcessInstance).filter(ProcessInstance.status == None) self.assertEqual(null_status.count(), 0, 'There are no processes without status loged') null_percent = session.query(ProcessInstance).filter(ProcessInstance.percent_done == None) self.assertEqual(null_percent.count(), 0, 'There are no processes without percent loged') null_percent = session.query(ProcessInstance).filter(ProcessInstance.percent_done < 100) self.assertEqual(null_percent.count(), 0, 'There are no unfinished processes') def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(DBLogTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_describe.py000066400000000000000000000411341415166246000172460ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest import pytest from collections import namedtuple from pywps import Process, Service, LiteralInput, ComplexInput, BoundingBoxInput from pywps import LiteralOutput, ComplexOutput, BoundingBoxOutput from pywps import get_ElementMakerForVersion, OGCTYPE, Format, NAMESPACES, OGCUNIT from pywps.inout.literaltypes import LITERAL_DATA_TYPES from pywps.app.basic import get_xpath_ns from pywps.app.Common import Metadata from pywps.inout.literaltypes import AllowedValue from pywps.validator.allowed_value import ALLOWEDVALUETYPE from pywps import configuration from pywps.tests import assert_pywps_version, client_for ProcessDescription = namedtuple('ProcessDescription', ['identifier', 'inputs', 'metadata']) WPS, OWS = get_ElementMakerForVersion("1.0.0") def get_data_type(el): if el.text in LITERAL_DATA_TYPES: return el.text raise RuntimeError("Can't parse data type") def get_default_value(el): default = None xpath = xpath_ns(el, './DefaultValue') if xpath: default = xpath[0].text return default xpath_ns = get_xpath_ns("1.0.0") def get_describe_result(resp): assert resp.status_code == 200 assert resp.headers['Content-Type'] == 'text/xml' result = [] for desc_el in resp.xpath('/wps:ProcessDescriptions/ProcessDescription'): [identifier_el] = xpath_ns(desc_el, './ows:Identifier') inputs = [] metadata = [] for metadata_el in xpath_ns(desc_el, './ows:Metadata'): metadata.append(metadata_el.attrib['{http://www.w3.org/1999/xlink}title']) for input_el in xpath_ns(desc_el, './DataInputs/Input'): [input_identifier_el] = xpath_ns(input_el, './ows:Identifier') input_identifier = input_identifier_el.text literal_data_el_list = xpath_ns(input_el, './LiteralData') complex_data_el_list = xpath_ns(input_el, './ComplexData') if literal_data_el_list: [literal_data_el] = literal_data_el_list [data_type_el] = xpath_ns(literal_data_el, './ows:DataType') default = get_default_value(literal_data_el) data_type = get_data_type(data_type_el) inputs.append((input_identifier, 'literal', data_type, default)) elif complex_data_el_list: [complex_data_el] = complex_data_el_list formats = [] for format_el in xpath_ns(complex_data_el, './Supported/Format'): [mimetype_el] = xpath_ns(format_el, './ows:MimeType') formats.append({'mime_type': mimetype_el.text}) inputs.append((input_identifier, 'complex', formats, None)) else: raise RuntimeError("Can't parse input description") result.append(ProcessDescription(identifier_el.text, inputs, metadata)) return result class DescribeProcessTest(unittest.TestCase): def setUp(self): def hello(request): pass def ping(request): pass processes = [ Process(hello, 'hello', 'Process Hello', metadata=[ Metadata('hello metadata', 'http://example.org/hello', role='http://www.opengis.net/spec/wps/2.0/def/process/description/documentation')]), Process(ping, 'ping', 'Process Ping', metadata=[Metadata('ping metadata', 'http://example.org/ping')]), ] self.client = client_for(Service(processes=processes)) def test_get_request_all_args(self): resp = self.client.get('?Request=DescribeProcess&service=wps&version=1.0.0&identifier=all') identifiers = [desc.identifier for desc in get_describe_result(resp)] metadata = [desc.metadata for desc in get_describe_result(resp)] assert 'ping' in identifiers assert 'hello' in identifiers assert_pywps_version(resp) assert 'hello metadata' in [item for sublist in metadata for item in sublist] def test_get_request_zero_args(self): resp = self.client.get('?Request=DescribeProcess&version=1.0.0&service=wps') assert resp.status_code == 400 def test_get_request_nonexisting_process_args(self): resp = self.client.get('?Request=DescribeProcess&version=1.0.0&service=wps&identifier=NONEXISTINGPROCESS') assert resp.status_code == 400 def test_post_request_zero_args(self): request_doc = WPS.DescribeProcess() resp = self.client.post_xml(doc=request_doc) assert resp.status_code == 400 def test_get_one_arg(self): resp = self.client.get('?service=wps&version=1.0.0&Request=DescribeProcess&identifier=hello') assert [pr.identifier for pr in get_describe_result(resp)] == ['hello'] def test_post_one_arg(self): request_doc = WPS.DescribeProcess( OWS.Identifier('hello'), version='1.0.0' ) resp = self.client.post_xml(doc=request_doc) assert resp.status_code == 200 def test_post_two_args(self): request_doc = WPS.DescribeProcess( OWS.Identifier('hello'), OWS.Identifier('ping'), version='1.0.0' ) resp = self.client.post_xml(doc=request_doc) result = get_describe_result(resp) # print(b"\n".join(resp.response).decode("utf-8")) assert [pr.identifier for pr in result] == ['hello', 'ping'] class DescribeProcessTranslationsTest(unittest.TestCase): def setUp(self): configuration.get_config_value('server', 'language') configuration.CONFIG.set('server', 'language', 'en-US,fr-CA') self.client = client_for( Service( processes=[ Process( lambda: None, "pr1", "Process 1", abstract="Process 1", inputs=[ LiteralInput( 'input1', title='Input name', abstract='Input abstract', translations={"fr-CA": {"title": "Nom de l'input", "abstract": "Description"}} ) ], outputs=[ LiteralOutput( 'output1', title='Output name', abstract='Output abstract', translations={"fr-CA": {"title": "Nom de l'output", "abstract": "Description"}} ) ], translations={"fr-CA": {"title": "Processus 1", "abstract": "Processus 1"}}, ), Process( lambda: None, "pr2", "Process 2", abstract="Process 2", inputs=[ LiteralInput( 'input1', title='Input name', abstract='Input abstract', translations={"fr-CA": {"abstract": "Description"}} ) ], outputs=[ LiteralOutput( 'output1', title='Output name', abstract='Output abstract', translations={"fr-CA": {"abstract": "Description"}} ) ], translations={"fr-CA": {"title": "Processus 2"}}, ), ] ) ) def tearDown(self): configuration.CONFIG.set('server', 'language', 'en-US') def test_get_describe_translations(self): resp = self.client.get('?Request=DescribeProcess&service=wps&version=1.0.0&identifier=all&language=fr-CA') assert resp.xpath('/wps:ProcessDescriptions/@xml:lang')[0] == "fr-CA" titles = [e.text for e in resp.xpath('//ProcessDescription/ows:Title')] abstracts = [e.text for e in resp.xpath('//ProcessDescription/ows:Abstract')] assert titles == ['Processus 1', 'Processus 2'] assert abstracts == ['Processus 1', 'Process 2'] input_titles = [e.text for e in resp.xpath('//Input/ows:Title')] input_abstracts = [e.text for e in resp.xpath('//Input/ows:Abstract')] assert input_titles == ["Nom de l'input", "Input name"] assert input_abstracts == ["Description", "Description"] output_titles = [e.text for e in resp.xpath('//Output/ows:Title')] output_abstracts = [e.text for e in resp.xpath('//Output/ows:Abstract')] assert output_titles == ["Nom de l'output", "Output name"] assert output_abstracts == ["Description", "Description"] class DescribeProcessInputTest(unittest.TestCase): def describe_process(self, process): client = client_for(Service(processes=[process])) resp = client.get('?service=wps&version=1.0.0&Request=DescribeProcess&identifier={}'.format(process.identifier)) [result] = get_describe_result(resp) return result def test_one_literal_string_input(self): def hello(request): pass hello_process = Process( hello, 'hello', 'Process Hello', inputs=[LiteralInput('the_name', 'Input name')], metadata=[ Metadata('process metadata 1', 'http://example.org/1'), Metadata('process metadata 2', 'http://example.org/2')] ) result = self.describe_process(hello_process) assert result.inputs == [('the_name', 'literal', 'string', None)] assert result.metadata == ['process metadata 1', 'process metadata 2'] def test_one_literal_integer_input(self): def hello(request): pass hello_process = Process(hello, 'hello', 'Process Hello', inputs=[LiteralInput('the_number', 'Input number', data_type='positiveInteger')]) result = self.describe_process(hello_process) assert result.inputs == [('the_number', 'literal', 'positiveInteger', None)] def test_one_literal_integer_default_zero(self): def hello(request): pass hello_process = Process(hello, 'hello', 'Process Hello', inputs=[LiteralInput('the_number', 'Input number', data_type='integer', default=0)]) result = self.describe_process(hello_process) assert result.inputs == [('the_number', 'literal', 'integer', "0")] class InputDescriptionTest(unittest.TestCase): def test_literal_integer_input(self): literal = LiteralInput('foo', 'Literal foo', data_type='positiveInteger', keywords=['kw1', 'kw2'], uoms=['metre']) data = literal.json assert data["identifier"] == "foo" assert data["data_type"] == "positiveInteger" def test_literal_allowed_values_input(self): """Test all around allowed_values """ literal = LiteralInput( 'foo', 'Foo', data_type='integer', uoms=['metre'], allowed_values=( 1, 2, (5, 10), (12, 4, 24), AllowedValue( allowed_type=ALLOWEDVALUETYPE.RANGE, minval=30, maxval=33, range_closure='closed-open') ) ) data = literal.json assert len(data["allowed_values"]) == 5 assert data["allowed_values"][0]["value"] == 1 assert data["allowed_values"][0]["range_closure"] == "closed" def test_complex_input_identifier(self): complex_in = ComplexInput('foo', 'Complex foo', keywords=['kw1', 'kw2'], supported_formats=[Format('bar/baz')]) data = complex_in.json assert data["identifier"] == "foo" assert len(data["keywords"]) == 2 def test_complex_input_default_and_supported(self): complex_in = ComplexInput( 'foo', 'Complex foo', default='default', supported_formats=[ Format('a/b'), Format('c/d') ], ) data = complex_in.json assert len(data["supported_formats"]) == 2 assert data["data_format"]["mime_type"] == "a/b" def test_bbox_input(self): bbox = BoundingBoxInput('bbox', 'BBox foo', keywords=['kw1', 'kw2'], crss=["EPSG:4326", "EPSG:3035"]) data = bbox.json assert data["identifier"] == "bbox" assert len(data["keywords"]) == 2 assert len(data["crss"]) == 2 assert data["crss"][0] == "EPSG:4326" assert data["dimensions"] == 2 class OutputDescriptionTest(unittest.TestCase): @pytest.mark.skip(reason="not working") def test_literal_output(self): literal = LiteralOutput('literal', 'Literal foo', abstract='Description', keywords=['kw1', 'kw2'], uoms=['metre']) doc = literal.describe_xml() [output] = xpath_ns(doc, '/Output') [identifier] = xpath_ns(doc, '/Output/ows:Identifier') [abstract] = xpath_ns(doc, '/Output/ows:Abstract') [keywords] = xpath_ns(doc, '/Output/ows:Keywords') kws = xpath_ns(keywords, './ows:Keyword') [data_type] = xpath_ns(doc, '/Output/LiteralOutput/ows:DataType') [uoms] = xpath_ns(doc, '/Output/LiteralOutput/UOMs') [default_uom] = xpath_ns(uoms, './Default/ows:UOM') supported_uoms = xpath_ns(uoms, './Supported/ows:UOM') assert output is not None assert identifier.text == 'literal' assert abstract.text == 'Description' assert keywords is not None assert len(kws) == 2 assert data_type.attrib['{{{}}}reference'.format(NAMESPACES['ows'])] == OGCTYPE['string'] assert uoms is not None assert default_uom.text == 'metre' assert default_uom.attrib['{{{}}}reference'.format(NAMESPACES['ows'])] == OGCUNIT['metre'] assert len(supported_uoms) == 1 @pytest.mark.skip(reason="not working") def test_complex_output(self): complexo = ComplexOutput('complex', 'Complex foo', [Format('GML')], keywords=['kw1', 'kw2']) doc = complexo.describe_xml() [outpt] = xpath_ns(doc, '/Output') [default] = xpath_ns(doc, '/Output/ComplexOutput/Default/Format/MimeType') supported = xpath_ns(doc, '/Output/ComplexOutput/Supported/Format/MimeType') assert default.text == 'application/gml+xml' assert len(supported) == 1 [keywords] = xpath_ns(doc, '/Output/ows:Keywords') kws = xpath_ns(keywords, './ows:Keyword') assert keywords is not None assert len(kws) == 2 @pytest.mark.skip(reason="not working") def test_bbox_output(self): bbox = BoundingBoxOutput('bbox', 'BBox foo', keywords=['kw1', 'kw2'], crss=["EPSG:4326"]) doc = bbox.describe_xml() [outpt] = xpath_ns(doc, '/Output') [default_crs] = xpath_ns(doc, './BoundingBoxOutput/Default/CRS') supported = xpath_ns(doc, './BoundingBoxOutput/Supported/CRS') assert default_crs.text == 'EPSG:4326' assert len(supported) == 1 [keywords] = xpath_ns(doc, '/Output/ows:Keywords') kws = xpath_ns(keywords, './ows:Keyword') assert keywords is not None assert len(kws) == 2 def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(DescribeProcessTest), loader.loadTestsFromTestCase(DescribeProcessInputTest), loader.loadTestsFromTestCase(InputDescriptionTest), loader.loadTestsFromTestCase(DescribeProcessTranslationsTest), loader.loadTestsFromTestCase(OutputDescriptionTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_exceptions.py000066400000000000000000000044471415166246000176550ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest from pywps import Service, get_ElementMakerForVersion from pywps.app.basic import get_xpath_ns from pywps.tests import assert_pywps_version, client_for import re VERSION="1.0.0" WPS, OWS = get_ElementMakerForVersion(VERSION) xpath_ns = get_xpath_ns(VERSION) class ExceptionsTest(unittest.TestCase): def setUp(self): self.client = client_for(Service(processes=[])) def test_invalid_parameter_value(self): resp = self.client.get('?service=wms') exception_el = resp.xpath('/ows:ExceptionReport/ows:Exception')[0] assert exception_el.attrib['exceptionCode'] == 'InvalidParameterValue' assert resp.status_code == 400 assert re.match('text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) assert_pywps_version(resp) def test_missing_parameter_value(self): resp = self.client.get() exception_el = resp.xpath('/ows:ExceptionReport/ows:Exception')[0] assert exception_el.attrib['exceptionCode'] == 'MissingParameterValue' assert resp.status_code == 400 assert re.match('text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) def test_missing_request(self): resp = self.client.get("?service=wps") exception_el = resp.xpath('/ows:ExceptionReport/ows:Exception/ows:ExceptionText')[0] # should mention something about a request assert 'request' in exception_el.text assert re.match('text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) def test_bad_request(self): resp = self.client.get("?service=wps&request=xyz") exception_el = resp.xpath('/ows:ExceptionReport/ows:Exception')[0] assert exception_el.attrib['exceptionCode'] == 'OperationNotSupported' assert re.match('text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExceptionsTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_execute.py000066400000000000000000000736401415166246000171370ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest import pytest from pywps import xml_util as etree import json import tempfile import os.path from pywps import Service, Process, LiteralOutput, LiteralInput,\ BoundingBoxOutput, BoundingBoxInput, Format, ComplexInput, ComplexOutput, FORMATS from pywps.validator.base import emptyvalidator from pywps.validator.complexvalidator import validategml from pywps.exceptions import InvalidParameterValue from pywps import get_inputs_from_xml from pywps import E, get_ElementMakerForVersion from pywps.app.basic import get_xpath_ns from pywps.tests import client_for, assert_response_success, assert_response_success_json from pywps import configuration from io import StringIO try: import netCDF4 except ImportError: WITH_NC4 = False else: WITH_NC4 = True DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') VERSION = "1.0.0" WPS, OWS = get_ElementMakerForVersion(VERSION) xpath_ns = get_xpath_ns(VERSION) def create_ultimate_question(): def handler(request, response): response.outputs['outvalue'].data = '42' return response return Process(handler=handler, identifier='ultimate_question', title='Ultimate Question', outputs=[LiteralOutput('outvalue', 'Output Value', data_type='string')]) def create_greeter(): def greeter(request, response): name = request.inputs['name'][0].data assert isinstance(name, str) response.outputs['message'].data = "Hello {}!".format(name) return response return Process(handler=greeter, identifier='greeter', title='Greeter', inputs=[LiteralInput('name', 'Input name', data_type='string')], outputs=[LiteralOutput('message', 'Output message', data_type='string')]) def create_translated_greeter(): def greeter(request, response): name = request.inputs['name'][0].data response.outputs['message'].data = "Hello {}!".format(name) return response return Process( handler=greeter, identifier='greeter', title='Greeter', abstract='Say hello', inputs=[ LiteralInput( 'name', 'Input name', data_type='string', abstract='Input description', translations={"fr-CA": {"title": "Nom", "abstract": "Description"}}, ) ], outputs=[ LiteralOutput( 'message', 'Output message', data_type='string', abstract='Output description', translations={"fr-CA": {"title": "Message de retour", "abstract": "Description"}}, ) ], translations={"fr-CA": {"title": "Salutations", "abstract": "Dire allô"}}, ) def create_bbox_process(): def bbox_process(request, response): coords = request.inputs['mybbox'][0].data assert isinstance(coords, list) assert len(coords) == 4 assert coords[0] == 15.0 response.outputs['outbbox'].data = coords return response return Process(handler=bbox_process, identifier='my_bbox_process', title='Bbox process', inputs=[BoundingBoxInput('mybbox', 'Input name', ["EPSG:4326"])], outputs=[BoundingBoxOutput('outbbox', 'Output message', ["EPSG:4326"])]) def create_complex_proces(mime_type: str = 'gml'): def complex_proces(request, response): response.outputs['complex'].data = request.inputs['complex'][0].data_as_json() return response if mime_type == 'gml': frmt = Format(mime_type='application/gml', extension=".gml") # this is unknown mimetype elif mime_type == 'geojson': frmt = FORMATS.GEOJSON else: raise Exception(f'Unknown mime type {mime_type}') return Process(handler=complex_proces, identifier='my_complex_process', title='Complex process', inputs=[ ComplexInput( 'complex', 'Complex input', min_occurs=0, default="DEFAULT COMPLEX DATA", supported_formats=[frmt]) ], outputs=[ ComplexOutput( 'complex', 'Complex output', supported_formats=[frmt]) ]) def create_complex_nc_process(): def complex_proces(request, response): from pywps.dependencies import netCDF4 as nc url = request.inputs['dods'][0].url with nc.Dataset(url) as D: response.outputs['conventions'].data = D.Conventions response.outputs['outdods'].url = url response.outputs['ncraw'].file = os.path.join(DATA_DIR, 'netcdf', 'time.nc') response.outputs['ncraw'].data_format = FORMATS.NETCDF return response return Process(handler=complex_proces, identifier='my_opendap_process', title='Opendap process', inputs=[ ComplexInput( 'dods', 'Opendap input', supported_formats=[Format('DODS'), Format('NETCDF')], # mode=MODE.STRICT ) ], outputs=[ LiteralOutput( 'conventions', 'NetCDF convention', ), ComplexOutput('outdods', 'Opendap output', supported_formats=[FORMATS.DODS, ], as_reference=True), ComplexOutput('ncraw', 'NetCDF raw data output', supported_formats=[FORMATS.NETCDF, ], as_reference=False) ]) def create_mimetype_process(): def _handler(request, response): response.outputs['mimetype'].data = response.outputs['mimetype'].data_format.mime_type return response frmt_txt = Format(mime_type='text/plain') frmt_txt2 = Format(mime_type='text/plain+test') return Process(handler=_handler, identifier='get_mimetype_process', title='Get mimeType process', inputs=[], outputs=[ ComplexOutput( 'mimetype', 'mimetype of requested output', supported_formats=[frmt_txt, frmt_txt2]) ]) def create_metalink_process(): from .processes.metalinkprocess import MultipleOutputs return MultipleOutputs() def get_output(doc): """Return the content of LiteralData, Reference or ComplexData.""" output = {} for output_el in xpath_ns(doc, '/wps:ExecuteResponse' '/wps:ProcessOutputs/wps:Output'): [identifier_el] = xpath_ns(output_el, './ows:Identifier') lit_el = xpath_ns(output_el, './wps:Data/wps:LiteralData') if lit_el != []: output[identifier_el.text] = lit_el[0].text ref_el = xpath_ns(output_el, './wps:Reference') if ref_el != []: output[identifier_el.text] = ref_el[0].attrib['href'] data_el = xpath_ns(output_el, './wps:Data/wps:ComplexData') if data_el != []: if data_el[0].text: output[identifier_el.text] = data_el[0].text else: # XML children ch = list(data_el[0])[0] output[identifier_el.text] = etree.tostring(ch) return output class ExecuteTest(unittest.TestCase): """Test for Exeucte request KVP request""" @pytest.mark.xfail(reason="test.opendap.org is offline") def test_dods(self): if not WITH_NC4: self.skipTest('netCDF4 not installed') my_process = create_complex_nc_process() service = Service(processes=[my_process]) href = "http://test.opendap.org:80/opendap/netcdf/examples/sresa1b_ncar_ccsm3_0_run1_200001.nc" """ # Is this how the request should be written ? request_doc = WPS.Execute( OWS.Identifier('my_opendap_process'), WPS.DataInputs( WPS.Input( OWS.Identifier('dods'), WPS.Reference( WPS.Body('request body'), {'{http://www.w3.org/1999/xlink}href': href}, method='POST' ) #WPS.Data(WPS.ComplexData(href=href, mime_type='application/x-ogc-dods')) # This form is not supported yet. Should it be ? ) ), version='1.0.0' ) resp = client.post_xml(doc=request_doc) assert_response_success(resp) """ class FakeRequest(): identifier = 'my_opendap_process' service = 'wps' operation = 'execute' version = '1.0.0' raw = True, inputs = {'dods': [{ 'identifier': 'dods', 'href': href, }]} store_execute = False lineage = False outputs = ['conventions'] language = "en-US" request = FakeRequest() resp = service.execute('my_opendap_process', request, 'fakeuuid') self.assertEqual(resp.outputs['conventions'].data, 'CF-1.0') self.assertEqual(resp.outputs['outdods'].url, href) self.assertTrue(resp.outputs['outdods'].as_reference) self.assertFalse(resp.outputs['ncraw'].as_reference) with open(os.path.join(DATA_DIR, 'netcdf', 'time.nc'), 'rb') as f: data = f.read() self.assertEqual(resp.outputs['ncraw'].data, data) def test_input_parser(self): """Test input parsing """ my_process = create_complex_proces() service = Service(processes=[my_process]) self.assertEqual(len(service.processes), 1) self.assertTrue(service.processes['my_complex_process']) class FakeRequest(): identifier = 'complex_process' service = 'wps' operation = 'execute' version = '1.0.0' inputs = {'complex': [{ 'identifier': 'complex', 'mimeType': 'text/gml', 'data': 'the data' }]} language = "en-US" request = FakeRequest() try: service.execute('my_complex_process', request, 'fakeuuid') except InvalidParameterValue as e: self.assertEqual(e.locator, 'mimeType') request.inputs['complex'][0]['mimeType'] = 'application/gml' parsed_inputs = service.create_complex_inputs(my_process.inputs[0], request.inputs['complex']) # TODO parse outputs and their validators too self.assertEqual(parsed_inputs[0].data_format.validate, emptyvalidator) request.inputs['complex'][0]['mimeType'] = 'application/xml+gml' try: parsed_inputs = service.create_complex_inputs(my_process.inputs[0], request.inputs['complex']) except InvalidParameterValue as e: self.assertEqual(e.locator, 'mimeType') try: my_process.inputs[0].data_format = Format(mime_type='application/xml+gml') except InvalidParameterValue as e: self.assertEqual(e.locator, 'mimeType') frmt = Format(mime_type='application/xml+gml', validate=validategml) self.assertEqual(frmt.validate, validategml) my_process.inputs[0].supported_formats = [frmt] my_process.inputs[0].data_format = Format(mime_type='application/xml+gml') parsed_inputs = service.create_complex_inputs(my_process.inputs[0], request.inputs['complex']) self.assertEqual(parsed_inputs[0].data_format.validate, validategml) def test_input_default(self): """Test input parsing """ my_process = create_complex_proces() service = Service(processes=[my_process]) self.assertEqual(len(service.processes), 1) self.assertTrue(service.processes['my_complex_process']) class FakeRequest(): identifier = 'complex_process' service = 'wps' operation = 'execute' version = '1.0.0' inputs = {} raw = False outputs = {} store_execute = False lineage = False language = "en-US" request = FakeRequest() response = service.execute('my_complex_process', request, 'fakeuuid') self.assertEqual(response.outputs['complex'].data, 'DEFAULT COMPLEX DATA') def test_output_mimetype(self): """Test input parsing """ my_process = create_mimetype_process() service = Service(processes=[my_process]) self.assertEqual(len(service.processes), 1) self.assertTrue(service.processes['get_mimetype_process']) class FakeRequest(): def __init__(self, mimetype): self.outputs = {'mimetype': { 'identifier': 'mimetype', 'mimetype': mimetype, 'data': 'the data' }} identifier = 'get_mimetype_process' service = 'wps' operation = 'execute' version = '1.0.0' inputs = {} raw = False store_execute = False lineage = False language = "en-US" # valid mimetype request = FakeRequest('text/plain+test') response = service.execute('get_mimetype_process', request, 'fakeuuid') self.assertEqual(response.outputs['mimetype'].data, 'text/plain+test') # non valid mimetype request = FakeRequest('text/xml') with self.assertRaises(InvalidParameterValue): response = service.execute('get_mimetype_process', request, 'fakeuuid') def test_metalink(self): client = client_for(Service(processes=[create_metalink_process()])) resp = client.get('?Request=Execute&identifier=multiple-outputs') assert resp.status_code == 400 def test_missing_process_error(self): client = client_for(Service(processes=[create_ultimate_question()])) resp = client.get('?Request=Execute&identifier=foo') assert resp.status_code == 400 def test_get_with_no_inputs(self): client = client_for(Service(processes=[create_ultimate_question()])) resp = client.get('?service=wps&version=1.0.0&Request=Execute&identifier=ultimate_question') assert_response_success(resp) assert get_output(resp.xml) == {'outvalue': '42'} def test_post_with_no_inputs(self): request = { 'identifier': 'ultimate_question', 'version': '1.0.0' } result = {'outvalue': '42'} client = client_for(Service(processes=[create_ultimate_question()])) request_doc = WPS.Execute( OWS.Identifier(request['identifier']), version=request['version'] ) resp = client.post_xml(doc=request_doc) assert_response_success(resp) assert get_output(resp.xml) == result resp = client.post_json(doc=request) assert_response_success_json(resp, result) def test_post_with_string_input(self): request = { 'identifier': 'greeter', 'version': '1.0.0', 'inputs': { 'name': 'foo' }, } result = {'message': "Hello foo!"} client = client_for(Service(processes=[create_greeter()])) request_doc = WPS.Execute( OWS.Identifier(request['identifier']), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Data(WPS.LiteralData(request['inputs']['name'])) ) ), version=request['version'] ) resp = client.post_xml(doc=request_doc) assert_response_success(resp) assert get_output(resp.xml) == result resp = client.post_json(doc=request) assert_response_success_json(resp, result) def test_bbox(self): client = client_for(Service(processes=[create_bbox_process()])) request_doc = WPS.Execute( OWS.Identifier('my_bbox_process'), WPS.DataInputs( WPS.Input( OWS.Identifier('mybbox'), WPS.Data(WPS.BoundingBoxData( OWS.LowerCorner('15.0 50.0'), OWS.UpperCorner('16.0 51.0'), )) ) ), version='1.0.0' ) resp = client.post_xml(doc=request_doc) assert_response_success(resp) [output] = xpath_ns(resp.xml, '/wps:ExecuteResponse' '/wps:ProcessOutputs/wps:Output') self.assertEqual('outbbox', xpath_ns( output, './ows:Identifier')[0].text) lower_corner = xpath_ns(output, './wps:Data/wps:BoundingBoxData/ows:LowerCorner')[0].text lower_corner = lower_corner.strip().replace(' ', ' ') self.assertEqual('15.0 50.0', lower_corner) upper_corner = xpath_ns(output, './wps:Data/wps:BoundingBoxData/ows:UpperCorner')[0].text upper_corner = upper_corner.strip().replace(' ', ' ') self.assertEqual('16.0 51.0', upper_corner) def test_bbox_rest(self): client = client_for(Service(processes=[create_bbox_process()])) bbox = [15.0, 50.0, 16.0, 51.0] request = dict( identifier='my_bbox_process', version='1.0.0', inputs={ 'mybbox': { "type": "bbox", 'bbox': bbox, } }, ) result = {'outbbox': bbox} resp = client.post_json(doc=request) assert_response_success_json(resp, result) def test_geojson_input_rest(self): geojson = os.path.join(DATA_DIR, 'json', 'point.geojson') with open(geojson) as f: p = json.load(f) my_process = create_complex_proces('geojson') client = client_for(Service(processes=[my_process])) request = dict( identifier='my_complex_process', version='1.0.0', inputs={ 'complex': { "type": "complex", 'data': p } }, ) result = {'complex': p} resp = client.post_json(doc=request) assert_response_success_json(resp, result) def test_output_response_dataType(self): client = client_for(Service(processes=[create_greeter()])) request_doc = WPS.Execute( OWS.Identifier('greeter'), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Data(WPS.LiteralData('foo')) ) ), version='1.0.0' ) resp = client.post_xml(doc=request_doc) el = next(resp.xml.iter('{http://www.opengis.net/wps/1.0.0}LiteralData')) assert el.attrib['dataType'] == 'string' class AllowedInputReferenceExecuteTest(unittest.TestCase): def setUp(self): self.allowedinputpaths = configuration.get_config_value('server', 'allowedinputpaths') configuration.CONFIG.set('server', 'allowedinputpaths', DATA_DIR) def tearDown(self): configuration.CONFIG.set('server', 'allowedinputpaths', self.allowedinputpaths) def test_geojson_input_reference_rest(self): geojson = os.path.join(DATA_DIR, 'json', 'point.geojson') with open(geojson) as f: p = json.load(f) my_process = create_complex_proces('geojson') client = client_for(Service(processes=[my_process])) request = dict( identifier='my_complex_process', version='1.0.0', inputs={ 'complex': { "type": "reference", "href": f"file:{geojson}" } }, ) result = {'complex': p} resp = client.post_json(doc=request) assert_response_success_json(resp, result) class ExecuteTranslationsTest(unittest.TestCase): def setUp(self): configuration.get_config_value('server', 'language') configuration.CONFIG.set('server', 'language', 'en-US,fr-CA') def tearDown(self): configuration.CONFIG.set('server', 'language', 'en-US') def test_translations(self): client = client_for(Service(processes=[create_translated_greeter()])) request_doc = WPS.Execute( OWS.Identifier('greeter'), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Data(WPS.LiteralData('foo')) ) ), WPS.ResponseForm( WPS.ResponseDocument( lineage='true', ) ), version='1.0.0', language='fr-CA', ) resp = client.post_xml(doc=request_doc) assert resp.xpath('/wps:ExecuteResponse/@xml:lang')[0] == "fr-CA" process_title = [e.text for e in resp.xpath('//wps:Process/ows:Title')] assert process_title == ["Salutations"] process_abstract = [e.text for e in resp.xpath('//wps:Process/ows:Abstract')] assert process_abstract == ["Dire allô"] input_titles = [e.text for e in resp.xpath('//wps:Input/ows:Title')] assert input_titles == ["Nom"] input_abstract = [e.text for e in resp.xpath('//wps:Input/ows:Abstract')] assert input_abstract == ["Description"] output_titles = [e.text for e in resp.xpath('//wps:OutputDefinitions/wps:Output/ows:Title')] assert output_titles == ["Message de retour"] output_abstract = [e.text for e in resp.xpath('//wps:OutputDefinitions/wps:Output/ows:Abstract')] assert output_abstract == ["Description"] output_titles = [e.text for e in resp.xpath('//wps:ProcessOutputs/wps:Output/ows:Title')] assert output_titles == ["Message de retour"] output_abstract = [e.text for e in resp.xpath('//wps:ProcessOutputs/wps:Output/ows:Abstract')] assert output_abstract == ["Description"] class ExecuteXmlParserTest(unittest.TestCase): """Tests for Execute request XML Parser """ def test_empty(self): request_doc = WPS.Execute(OWS.Identifier('foo')) assert get_inputs_from_xml(request_doc) == {} def test_one_string(self): request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Data(WPS.LiteralData('foo'))), WPS.Input( OWS.Identifier('name'), WPS.Data(WPS.LiteralData('bar'))) )) rv = get_inputs_from_xml(request_doc) self.assertTrue('name' in rv) self.assertEqual(len(rv['name']), 2) self.assertEqual(rv['name'][0]['data'], 'foo') self.assertEqual(rv['name'][1]['data'], 'bar') def test_two_strings(self): request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('name1'), WPS.Data(WPS.LiteralData('foo'))), WPS.Input( OWS.Identifier('name2'), WPS.Data(WPS.LiteralData('bar'))))) rv = get_inputs_from_xml(request_doc) self.assertEqual(rv['name1'][0]['data'], 'foo') self.assertEqual(rv['name2'][0]['data'], 'bar') def test_complex_input(self): the_data = E.TheData("hello world") request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Data( WPS.ComplexData(the_data, mimeType='text/foobar'))))) rv = get_inputs_from_xml(request_doc) self.assertEqual(rv['name'][0]['mimeType'], 'text/foobar') rv_doc = etree.parse(StringIO(rv['name'][0]['data'])).getroot() self.assertEqual(rv_doc.tag, 'TheData') self.assertEqual(rv_doc.text, 'hello world') def test_complex_input_raw_value(self): the_data = '{ "plot":{ "Version" : "0.1" } }' request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('json'), WPS.Data( WPS.ComplexData(the_data, mimeType='application/json'))))) rv = get_inputs_from_xml(request_doc) self.assertEqual(rv['json'][0]['mimeType'], 'application/json') json_data = json.loads(rv['json'][0]['data']) self.assertEqual(json_data['plot']['Version'], '0.1') def test_complex_input_base64_value(self): the_data = 'eyAicGxvdCI6eyAiVmVyc2lvbiIgOiAiMC4xIiB9IH0=' request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('json'), WPS.Data( WPS.ComplexData(the_data, encoding='base64', mimeType='application/json'))))) rv = get_inputs_from_xml(request_doc) self.assertEqual(rv['json'][0]['mimeType'], 'application/json') json_data = json.loads(rv['json'][0]['data'].decode()) self.assertEqual(json_data['plot']['Version'], '0.1') def test_bbox_input(self): request_doc = WPS.Execute( OWS.Identifier('request'), WPS.DataInputs( WPS.Input( OWS.Identifier('bbox'), WPS.Data( WPS.BoundingBoxData( OWS.LowerCorner('40 50'), OWS.UpperCorner('60 70')))))) rv = get_inputs_from_xml(request_doc) bbox = rv['bbox'][0] # assert isinstance(bbox, BoundingBox) assert bbox['data'] == ['40', '50', '60', '70'] # assert bbox.minx == '40' # assert bbox.miny == '50' # assert bbox.maxx == '60' # assert bbox.maxy == '70' def test_reference_post_input(self): request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Reference( WPS.Body('request body'), {'{http://www.w3.org/1999/xlink}href': 'http://foo/bar/service'}, method='POST' ) ) ) ) rv = get_inputs_from_xml(request_doc) self.assertEqual(rv['name'][0]['href'], 'http://foo/bar/service') self.assertEqual(rv['name'][0]['method'], 'POST') self.assertEqual(rv['name'][0]['body'], 'request body') def test_reference_post_bodyreference_input(self): request_doc = WPS.Execute( OWS.Identifier('foo'), WPS.DataInputs( WPS.Input( OWS.Identifier('name'), WPS.Reference( WPS.BodyReference( {'{http://www.w3.org/1999/xlink}href': 'http://foo/bar/reference'}), {'{http://www.w3.org/1999/xlink}href': 'http://foo/bar/service'}, method='POST' ) ) ) ) rv = get_inputs_from_xml(request_doc) self.assertEqual(rv['name'][0]['href'], 'http://foo/bar/service') self.assertEqual(rv['name'][0]['bodyreference'], 'http://foo/bar/reference') def test_build_input_file_name(self): from pywps.inout.basic import ComplexInput h = ComplexInput('ci') h.workdir = workdir = tempfile.mkdtemp() self.assertEqual( h._build_file_name('http://path/to/test.txt'), os.path.join(workdir, 'test.txt')) self.assertEqual( h._build_file_name('http://path/to/test'), os.path.join(workdir, 'test')) self.assertEqual( h._build_file_name('file://path/to/.config'), os.path.join(workdir, '.config')) self.assertEqual( h._build_file_name('https://path/to/test.txt?token=abc&expires_at=1234567'), os.path.join(workdir, 'test.txt')) h.supported_formats = [FORMATS.TEXT, ] h.data_format = FORMATS.TEXT self.assertEqual( h._build_file_name('http://path/to/test'), os.path.join(workdir, 'test.txt')) open(os.path.join(workdir, 'duplicate.html'), 'a').close() inpt_filename = h._build_file_name('http://path/to/duplicate.html') self.assertTrue(inpt_filename.startswith(os.path.join(workdir, 'duplicate_'))) self.assertTrue(inpt_filename.endswith('.html')) def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExecuteTest), loader.loadTestsFromTestCase(ExecuteTranslationsTest), loader.loadTestsFromTestCase(ExecuteXmlParserTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_filestorage.py000066400000000000000000000071511415166246000177730ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pathlib import Path from pywps.inout.storage.file import FileStorageBuilder, FileStorage, _build_output_name from pywps.inout.storage import STORE_TYPE from pywps.inout.basic import ComplexOutput from pywps.util import file_uri from pywps import configuration, FORMATS from urllib.parse import urlparse import tempfile import os import unittest class FileStorageTests(unittest.TestCase): def setUp(self): self.tmp_dir = tempfile.mkdtemp() def test_build_output_name(self): storage = FileStorageBuilder().build() output = ComplexOutput('testme', 'Test', supported_formats=[FORMATS.TEXT], workdir=self.tmp_dir) output.data = "Hello World!" output_name, suffix = _build_output_name(output) self.assertEqual(output.file, str(Path(self.tmp_dir) / 'input.txt')) self.assertEqual(output_name, 'input.txt') self.assertEqual(suffix, '.txt') def test_store(self): configuration.CONFIG.set('server', 'outputpath', self.tmp_dir) storage = FileStorageBuilder().build() output = ComplexOutput('testme', 'Test', supported_formats=[FORMATS.TEXT], workdir=self.tmp_dir) output.data = "Hello World!" store_type, store_str, url = storage.store(output) self.assertEqual(store_type, STORE_TYPE.PATH) self.assertEqual(store_str, 'input.txt') with open(Path(self.tmp_dir) / store_str) as f: self.assertEqual(f.read(), "Hello World!") def test_write(self): configuration.CONFIG.set('server', 'outputpath', self.tmp_dir) configuration.CONFIG.set('server', 'outputurl', file_uri(self.tmp_dir)) storage = FileStorageBuilder().build() output = ComplexOutput('testme', 'Test', supported_formats=[FORMATS.TEXT], workdir=self.tmp_dir) output.data = "Hello World!" url = storage.write(output.data, 'foo.txt') fname = Path(self.tmp_dir) / 'foo.txt' self.assertEqual(url, file_uri(fname)) with open(fname) as f: self.assertEqual(f.read(), "Hello World!") def test_url(self): configuration.CONFIG.set('server', 'outputpath', self.tmp_dir) configuration.CONFIG.set('server', 'outputurl', file_uri(self.tmp_dir)) storage = FileStorageBuilder().build() output = ComplexOutput('testme', 'Test', supported_formats=[FORMATS.TEXT], workdir=self.tmp_dir) output.data = "Hello World!" output.uuid = '595129f0-1a6c-11ea-a30c-acde48001122' url = storage.url(output) fname = Path(self.tmp_dir) / '595129f0-1a6c-11ea-a30c-acde48001122' / 'input.txt' self.assertEqual(file_uri(fname), url) file_name = 'test.txt' url = storage.url(file_name) fname = Path(self.tmp_dir) / 'test.txt' self.assertEqual(file_uri(fname), url) def test_location(self): configuration.CONFIG.set('server', 'outputpath', self.tmp_dir) storage = FileStorageBuilder().build() file_name = 'test.txt' loc = storage.location(file_name) fname = Path(self.tmp_dir) / 'test.txt' self.assertEqual(str(fname), loc) def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(FileStorageTests) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_formats.py000066400000000000000000000071721415166246000171450ustar00rootroot00000000000000"""Unit tests for Formats """ ################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest from pywps.inout.formats import Format, get_format, FORMATS from pywps.app.basic import get_xpath_ns xpath_ns = get_xpath_ns("1.0.0") class FormatsTest(unittest.TestCase): """Formats test cases""" def setUp(self): def validate(self, inpt, level=None): """fake validate method """ return True self.validate = validate def tearDown(self): pass def test_format_class(self): """Test pywps.formats.Format class """ frmt = Format('mimetype', schema='halloworld', encoding='asdf', validate=self.validate) self.assertEqual(frmt.mime_type, 'mimetype') self.assertEqual(frmt.schema, 'halloworld') self.assertEqual(frmt.encoding, 'asdf') self.assertTrue(frmt.validate('the input', 1)) describeel = frmt.json self.assertEqual(describeel["mime_type"], 'mimetype') self.assertEqual(describeel["encoding"], 'asdf') self.assertEqual(describeel["schema"], 'halloworld') frmt2 = get_format('GML') self.assertFalse(frmt.same_as(frmt2)) def test_getformat(self): """test for pypws.inout.formats.get_format function """ frmt = get_format('GML', self.validate) self.assertTrue(frmt.mime_type, FORMATS.GML.mime_type) self.assertTrue(frmt.validate('ahoj', 1)) frmt2 = get_format('GML') self.assertTrue(frmt.same_as(frmt2)) def test_format_equal_types(self): """Test that equality check returns the expected bool and doesn't raise when types mismatch. """ frmt = get_format('GML') self.assertTrue(isinstance(frmt, Format)) try: res = frmt.same_as("GML") # not a Format type except AssertionError: self.fail("Comparing a format to another type should not raise") except Exception: self.fail("Unexpected error, test failed for unknown reason") self.assertFalse(res, "Equality check with other type should be False") frmt_other = get_format('GML') self.assertTrue(frmt == frmt_other, "Same formats should return True") def test_json_out(self): """Test json export """ frmt = get_format('GML') outjson = frmt.json self.assertEqual(outjson['schema'], '') self.assertEqual(outjson['extension'], '.gml') self.assertEqual(outjson['mime_type'], 'application/gml+xml') self.assertEqual(outjson['encoding'], '') def test_json_in(self): """Test json import """ injson = {} injson['schema'] = 'elcepelce' injson['extension'] = '.gml' injson['mime_type'] = 'application/gml+xml' injson['encoding'] = 'utf-8' frmt = Format(injson['mime_type']) frmt.json = injson self.assertEqual(injson['schema'], frmt.schema) self.assertEqual(injson['extension'], frmt.extension) self.assertEqual(injson['mime_type'], frmt.mime_type) self.assertEqual(injson['encoding'], frmt.encoding) def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(FormatsTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_grass_location.py000066400000000000000000000074541415166246000205040ustar00rootroot00000000000000################################################################## # Copyright 2019 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest import pywps.configuration as config from pywps import Service, Process, ComplexInput, get_format, \ get_ElementMakerForVersion from pywps.tests import client_for, assert_response_success WPS, OWS = get_ElementMakerForVersion("1.0.0") def grass_epsg_based_location(): """Return a Process creating a GRASS location based on an EPSG code.""" def epsg_location(request, response): """Check whether the EPSG of a mapset corresponds the specified one.""" from grass.script import parse_command g_proj = parse_command('g.proj', flags='g') assert g_proj['epsg'] == '5514', \ 'Error in creating a GRASS location based on an EPSG code' return response return Process(handler=epsg_location, identifier='my_epsg_based_location', title='EPSG location', grass_location="EPSG:5514") def grass_file_based_location(): """Return a Process creating a GRASS location from a georeferenced file.""" def file_location(request, response): """Check whether the datum of a mapset corresponds the file one.""" from grass.script import parse_command g_proj = parse_command('g.proj', flags='g') assert g_proj['datum'] == 'wgs84', \ 'Error in creating a GRASS location based on a file' return response inputs = [ComplexInput(identifier='input1', supported_formats=[get_format('GEOTIFF')], title="Name of input vector map")] return Process(handler=file_location, identifier='my_file_based_location', title='File location', inputs=inputs, grass_location="complexinput:input1") class GRASSTests(unittest.TestCase): """Test creating GRASS locations and mapsets in different ways.""" def setUp(self): """Skip test if GRASS is not installed on the machine.""" if not config.CONFIG.get('grass', 'gisbase'): self.skipTest('GRASS lib not found') def test_epsg_based_location(self): """Test whether the EPSG of a mapset corresponds the specified one.""" my_process = grass_epsg_based_location() client = client_for(Service(processes=[my_process])) request_doc = WPS.Execute( OWS.Identifier('my_epsg_based_location'), version='1.0.0' ) resp = client.post_xml(doc=request_doc) assert_response_success(resp) def test_file_based_location(self): """Test whether the datum of a mapset corresponds the file one.""" my_process = grass_file_based_location() client = client_for(Service(processes=[my_process])) href = 'http://demo.mapserver.org/cgi-bin/wfs?service=WFS&' \ 'version=1.1.0&request=GetFeature&typename=continents&' \ 'maxfeatures=1' request_doc = WPS.Execute( OWS.Identifier('my_file_based_location'), WPS.DataInputs( WPS.Input( OWS.Identifier('input1'), WPS.Reference( {'{http://www.w3.org/1999/xlink}href': href}))), version='1.0.0') resp = client.post_xml(doc=request_doc) assert_response_success(resp) def load_tests(loader=None, tests=None, pattern=None): """Load tests.""" if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(GRASSTests), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_inout.py000066400000000000000000000774201415166246000166330ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for IOs """ ################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import requests import os import tempfile import unittest import json from pywps import inout import base64 from pywps import Format, FORMATS from pywps.app.Common import Metadata from pywps.validator import get_validator from pywps.inout.basic import IOHandler, SOURCE_TYPE, SimpleHandler, BBoxInput, BBoxOutput, \ ComplexInput, ComplexOutput, LiteralOutput, LiteralInput, _is_textfile from pywps.util import uri_to_path from pywps.inout.literaltypes import convert, AllowedValue, AnyValue from pywps.inout.outputs import MetaFile, MetaLink, MetaLink4 from io import StringIO from urllib.parse import urlparse from pywps.validator.base import emptyvalidator from pywps.exceptions import InvalidParameterValue from pywps.validator.mode import MODE from pywps.inout.basic import UOM from pywps.inout.storage.file import FileStorageBuilder from pywps.tests import service_ok from pywps.translations import get_translation DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') def get_data_format(mime_type): return Format(mime_type=mime_type, validate=get_validator(mime_type)) class IOHandlerTest(unittest.TestCase): """IOHandler test cases""" def setUp(self): tmp_dir = tempfile.mkdtemp() self.iohandler = IOHandler(workdir=tmp_dir) self._value = 'lalala' def tearDown(self): pass def test_basic_IOHandler(self): """Test basic IOHandler""" self.assertTrue(os.path.isdir(self.iohandler.workdir)) def test_validator(self): """Test available validation function """ self.assertEqual(self.iohandler.validator, emptyvalidator) def _test_outout(self, source_type, suffix=''): """Test all outputs""" self.assertEqual(source_type, self.iohandler.source_type, 'Source type properly set') self.assertEqual(self._value, self.iohandler.data, 'Data obtained') if self.iohandler.source_type == SOURCE_TYPE.URL: self.assertEqual('http', urlparse(self.iohandler.url).scheme) else: self.assertEqual('file', urlparse(self.iohandler.url).scheme) if self.iohandler.source_type == SOURCE_TYPE.STREAM: source = StringIO(str(self._value)) self.iohandler.stream = source file_path = self.iohandler.file self.assertTrue(file_path.endswith(suffix)) file_handler = open(file_path) self.assertEqual(self._value, file_handler.read(), 'File obtained') file_handler.close() if self.iohandler.source_type == SOURCE_TYPE.STREAM: source = StringIO(str(self._value)) self.iohandler.stream = source stream_val = self.iohandler.stream.read() self.iohandler.stream.close() if isinstance(stream_val, bytes): self.assertEqual(self._value, stream_val.decode('utf-8'), 'Stream obtained') else: self.assertEqual(self._value, stream_val, 'Stream obtained') if self.iohandler.source_type == SOURCE_TYPE.STREAM: source = StringIO(str(self._value)) self.iohandler.stream = source # self.assertEqual(stream_val, self.iohandler.memory_object, # 'Memory object obtained') def test_data(self): """Test data input IOHandler""" self.iohandler.data = self._value self.iohandler.data_format = Format('foo', extension='.foo') self._test_outout(SOURCE_TYPE.DATA, '.foo') def test_stream(self): """Test stream input IOHandler""" source = StringIO(str(self._value)) self.iohandler.stream = source self._test_outout(SOURCE_TYPE.STREAM) def test_file(self): """Test file input IOHandler""" (fd, tmp_file) = tempfile.mkstemp() source = tmp_file file_handler = open(tmp_file, 'w') file_handler.write(self._value) file_handler.close() self.iohandler.file = source self._test_outout(SOURCE_TYPE.FILE) with self.assertRaises(TypeError): self.iohandler[0].data = '5' def test_url(self): if not service_ok('https://demo.mapserver.org'): self.skipTest("mapserver is unreachable") wfsResource = 'http://demo.mapserver.org/cgi-bin/wfs?' \ 'service=WFS&version=1.1.0&' \ 'request=GetFeature&' \ 'typename=continents&maxfeatures=2' self._value = requests.get(wfsResource).text self.iohandler.url = wfsResource self._test_outout(SOURCE_TYPE.URL) def test_workdir(self): """Test workdir""" workdir = tempfile.mkdtemp() self.iohandler.workdir = workdir self.assertTrue(os.path.isdir(self.iohandler.workdir)) # make another workdir = tempfile.mkdtemp() self.iohandler.workdir = workdir self.assertTrue(os.path.isdir(self.iohandler.workdir)) def test_memory(self): """Test data input IOHandler""" self.skipTest('Memory object not implemented') def test_data_bytes(self): self._value = b'aa' self.iohandler.data = self._value self.assertEqual(self.iohandler.source_type, SOURCE_TYPE.DATA, 'Source type properly set') # test the data handle self.assertEqual(self._value, self.iohandler.data, 'Data obtained') # test the file handle file_handler = open(self.iohandler.file, 'rb') self.assertEqual(self._value, file_handler.read(), 'File obtained') file_handler.close() # test the stream handle stream_data = self.iohandler.stream.read() self.iohandler.stream.close() self.assertEqual(self._value, stream_data, 'Stream obtained') def test_is_textfile(self): geotiff = os.path.join(DATA_DIR, 'geotiff', 'dem.tiff') self.assertFalse(_is_textfile(geotiff)) gml = os.path.join(DATA_DIR, 'gml', 'point.gml') self.assertTrue(_is_textfile(gml)) geojson = os.path.join(DATA_DIR, 'json', 'point.geojson') self.assertTrue(_is_textfile(geojson)) class ComplexInputTest(unittest.TestCase): """ComplexInput test cases""" def setUp(self): self.tmp_dir = tempfile.mkdtemp() data_format = get_data_format('application/json') self.complex_in = inout.inputs.ComplexInput(identifier="complexinput", title='MyComplex', abstract='My complex input', keywords=['kw1', 'kw2'], workdir=self.tmp_dir, supported_formats=[data_format]) self.complex_in.data = "Hallo world!" def test_validator(self): self.assertEqual(self.complex_in.data_format.validate, get_validator('application/json')) self.assertEqual(self.complex_in.validator, get_validator('application/json')) frmt = get_data_format('application/json') def my_validate(): return True frmt.validate = my_validate self.assertNotEqual(self.complex_in.validator, frmt.validate) def test_contruct(self): self.assertIsInstance(self.complex_in, ComplexInput) def test_data_format(self): self.assertIsInstance(self.complex_in.supported_formats[0], Format) class SerializationComplexInputTest(unittest.TestCase): """ComplexInput test cases""" def setUp(self): self.tmp_dir = tempfile.mkdtemp() def make_complex_input(self): complex = inout.inputs.ComplexInput( identifier="complexinput", title='MyComplex', abstract='My complex input', keywords=['kw1', 'kw2'], workdir=self.tmp_dir, supported_formats=[get_data_format('application/json')], metadata=[Metadata("special data")], default="/some/file/path", default_type=SOURCE_TYPE.FILE, translations={"fr-CA": {"title": "Mon input", "abstract": "Une description"}}, ) complex.as_reference = False complex.method = "GET" complex.max_size = 1000 return complex def assert_complex_equals(self, complex_1, complex_2): self.assertEqual(complex_1.identifier, complex_2.identifier) self.assertEqual(complex_1.title, complex_2.title) self.assertEqual(complex_1.supported_formats, complex_2.supported_formats) self.assertEqual(complex_1.data_format, complex_2.data_format) self.assertEqual(complex_1.abstract, complex_2.abstract) self.assertEqual(complex_1.keywords, complex_2.keywords) self.assertEqual(complex_1.workdir, complex_2.workdir) self.assertEqual(complex_1.metadata, complex_2.metadata) self.assertEqual(complex_1.max_occurs, complex_2.max_occurs) self.assertEqual(complex_1.valid_mode, complex_2.valid_mode) self.assertEqual(complex_1.as_reference, complex_2.as_reference) self.assertEqual(complex_1.translations, complex_2.translations) def test_complex_input_file(self): complex = self.make_complex_input() some_file = os.path.join(self.tmp_dir, "some_file.txt") with open(some_file, "w") as f: f.write("some data") complex.file = some_file complex2 = inout.inputs.ComplexInput.from_json(complex.json) self.assert_complex_equals(complex, complex2) self.assertEqual(complex.prop, 'file') self.assertEqual(complex2.prop, 'file') self.assertEqual(complex.file, complex2.file) self.assertEqual(complex.data, complex2.data) def test_complex_input_data(self): complex = self.make_complex_input() complex.data = "some data" # the data is enclosed by a CDATA tag in json assert complex.json['data'] == '' # dump to json and load it again complex2 = inout.inputs.ComplexInput.from_json(complex.json) self.assert_complex_equals(complex, complex2) self.assertEqual(complex.prop, 'data') self.assertEqual(complex2.prop, 'data') self.assertEqual(complex.data, complex2.data) def test_complex_input_stream(self): complex = self.make_complex_input() complex.stream = StringIO("{'name': 'test', 'input1': ']]'}") # the data is enclosed by a CDATA tag in json assert complex.json['data'] == "" # dump to json and load it again complex2 = inout.inputs.ComplexInput.from_json(complex.json) # the serialized stream becomes a data type self.assertEqual(complex.prop, 'stream') self.assertEqual(complex2.prop, 'data') self.assert_complex_equals(complex, complex2) self.assertEqual(complex.data, complex2.data) def test_complex_input_url(self): complex = self.make_complex_input() complex.url = "http://test.opendap.org:80/opendap/netcdf/examples/sresa1b_ncar_ccsm3_0_run1_200001.nc" complex2 = inout.inputs.ComplexInput.from_json(complex.json) self.assert_complex_equals(complex, complex2) self.assertEqual(complex.prop, 'url') class SerializationLiteralInputTest(unittest.TestCase): """LiteralInput test cases""" def setUp(self): self.tmp_dir = tempfile.mkdtemp() def make_literal_input(self): literal = inout.inputs.LiteralInput( identifier="complexinput", title='MyComplex', data_type='string', workdir=self.tmp_dir, abstract="some description", keywords=['kw1', 'kw2'], metadata=[Metadata("special data")], uoms=['metre', 'unity'], min_occurs=2, max_occurs=5, mode=MODE.STRICT, allowed_values=[AllowedValue(value='something'), AllowedValue(value='something else'), AnyValue()], default="something else", default_type=SOURCE_TYPE.DATA, ) literal.data = 'something' literal.uom = UOM('unity') literal.as_reference = False return literal def assert_literal_equals(self, literal_1, literal_2): self.assertEqual(literal_1.identifier, literal_2.identifier) self.assertEqual(literal_1.title, literal_2.title) self.assertEqual(literal_1.data_type, literal_2.data_type) self.assertEqual(literal_1.workdir, literal_2.workdir) self.assertEqual(literal_1.abstract, literal_2.abstract) self.assertEqual(literal_1.keywords, literal_2.keywords) self.assertEqual(literal_1.metadata, literal_2.metadata) self.assertEqual(literal_1.uoms, literal_2.uoms) self.assertEqual(literal_1.min_occurs, literal_2.min_occurs) self.assertEqual(literal_1.max_occurs, literal_2.max_occurs) self.assertEqual(literal_1.valid_mode, literal_2.valid_mode) self.assertEqual(literal_1.allowed_values, literal_2.allowed_values) self.assertEqual(literal_1.any_value, literal_2.any_value) self.assertTrue(literal_1.any_value) self.assertEqual(literal_1.as_reference, literal_2.as_reference) self.assertEqual(literal_1.data, literal_2.data) def test_literal_input(self): literal = self.make_literal_input() literal2 = inout.inputs.LiteralInput.from_json(literal.json) self.assert_literal_equals(literal, literal2) class SerializationBoundingBoxInputTest(unittest.TestCase): """LiteralInput test cases""" def setUp(self): self.tmp_dir = tempfile.mkdtemp() def make_bbox_input(self): bbox = inout.inputs.BoundingBoxInput( identifier="bbox", title='BBox', crss=['epsg:3857', 'epsg:4326'], abstract="some description", keywords=['kw1', 'kw2'], dimensions=2, workdir=self.tmp_dir, metadata=[Metadata("bbox")], min_occurs=2, max_occurs=5, mode=MODE.NONE, default="0,50,20,70", default_type=SOURCE_TYPE.DATA, ) bbox.as_reference = False return bbox def assert_bbox_equals(self, bbox_1, bbox_2): self.assertEqual(bbox_1.identifier, bbox_2.identifier) self.assertEqual(bbox_1.title, bbox_2.title) self.assertEqual(bbox_1.crss, bbox_2.crss) self.assertEqual(bbox_1.abstract, bbox_2.abstract) self.assertEqual(bbox_1.keywords, bbox_2.keywords) self.assertEqual(bbox_1.dimensions, bbox_2.dimensions) self.assertEqual(bbox_1.workdir, bbox_2.workdir) self.assertEqual(bbox_1.metadata, bbox_2.metadata) self.assertEqual(bbox_1.min_occurs, bbox_2.min_occurs) self.assertEqual(bbox_1.max_occurs, bbox_2.max_occurs) self.assertEqual(bbox_1.valid_mode, bbox_2.valid_mode) self.assertEqual(bbox_1.as_reference, bbox_2.as_reference) self.assertEqual(bbox_1.ll, bbox_2.ll) self.assertEqual(bbox_1.ur, bbox_2.ur) def test_bbox_input(self): bbox = self.make_bbox_input() bbox2 = inout.inputs.BoundingBoxInput.from_json(bbox.json) self.assert_bbox_equals(bbox, bbox2) class DodsComplexInputTest(unittest.TestCase): """ComplexInput test cases""" def setUp(self): self.tmp_dir = tempfile.mkdtemp() data_format = get_data_format('application/x-ogc-dods') self.complex_in = ComplexInput(identifier="complexinput", title='MyComplex', abstract='My complex input', keywords=['kw1', 'kw2'], workdir=self.tmp_dir, data_format=data_format, supported_formats=[data_format, get_data_format('application/x-netcdf')]) self.complex_in.href = "http://test.opendap.org:80/opendap/netcdf/examples/sresa1b_ncar_ccsm3_0_run1_200001.nc" def test_validator(self): self.assertEqual(self.complex_in.data_format.validate, get_validator('application/x-ogc-dods')) self.assertEqual(self.complex_in.validator, get_validator('application/x-ogc-dods')) frmt = get_data_format('application/x-ogc-dods') def my_validate(): return True frmt.validate = my_validate self.assertNotEqual(self.complex_in.validator, frmt.validate) def test_contruct(self): self.assertIsInstance(self.complex_in, ComplexInput) class ComplexOutputTest(unittest.TestCase): """ComplexOutput test cases""" def setUp(self): tmp_dir = tempfile.mkdtemp() data_format = get_data_format('application/json') self.complex_out = inout.outputs.ComplexOutput( identifier="complexoutput", title='Complex Output', workdir=tmp_dir, data_format=data_format, supported_formats=[data_format], mode=MODE.NONE, translations={"fr-CA": {"title": "Mon output", "abstract": "Une description"}}, ) self.complex_out_nc = inout.outputs.ComplexOutput( identifier="netcdf", title="NetCDF output", workdir=tmp_dir, data_format=get_data_format('application/x-netcdf'), supported_formats=[get_data_format('application/x-netcdf')], mode=MODE.NONE) self.data = json.dumps({'a': 1, 'unicodé': 'éîïç', }) self.ncfile = os.path.join(DATA_DIR, 'netcdf', 'time.nc') self.test_fn = os.path.join(self.complex_out.workdir, 'test.json') with open(self.test_fn, 'w') as f: f.write(self.data) def test_contruct(self): self.assertIsInstance(self.complex_out, ComplexOutput) def test_data_format(self): self.assertIsInstance(self.complex_out.data_format, Format) def test_storage(self): class Storage(object): pass storage = Storage() self.complex_out.store = storage self.assertEqual(self.complex_out.store, storage) def test_validator(self): self.assertEqual(self.complex_out.validator, get_validator('application/json')) self.assertEqual(self.complex_out_nc.validator, get_validator('application/x-netcdf')) def test_file_handler(self): self.complex_out.file = self.test_fn self.assertEqual(self.complex_out.data, self.data) with self.complex_out.stream as s: self.assertEqual(s.read(), bytes(self.data, encoding='utf8')) path = uri_to_path(self.complex_out.url) with open(path) as f: self.assertEqual(f.read(), self.data) def test_file_handler_netcdf(self): self.complex_out_nc.file = self.ncfile self.complex_out_nc.base64 def test_data_handler(self): self.complex_out.data = self.data with open(self.complex_out.file) as f: self.assertEqual(f.read(), self.data) def test_base64(self): self.complex_out.data = self.data b = self.complex_out.base64 self.assertEqual(base64.b64decode(b).decode(), self.data) def test_url_handler(self): wfsResource = 'http://demo.mapserver.org/cgi-bin/wfs?' \ 'service=WFS&version=1.1.0&' \ 'request=GetFeature&' \ 'typename=continents&maxfeatures=2' self.complex_out.url = wfsResource self.complex_out.storage = FileStorageBuilder().build() url = self.complex_out.get_url() self.assertEqual('file', urlparse(url).scheme) def test_json(self): new_output = inout.outputs.ComplexOutput.from_json(self.complex_out.json) self.assertEqual(new_output.identifier, 'complexoutput') self.assertEqual( new_output.translations, {"fr-ca": {"title": "Mon output", "abstract": "Une description"}}, 'translations does not exist' ) class SimpleHandlerTest(unittest.TestCase): """SimpleHandler test cases""" def setUp(self): data_type = 'integer' self.simple_handler = SimpleHandler(data_type=data_type) def test_contruct(self): self.assertIsInstance(self.simple_handler, SimpleHandler) def test_data_type(self): self.assertEqual(convert(self.simple_handler.data_type, '1'), 1) class LiteralInputTest(unittest.TestCase): """LiteralInput test cases""" def setUp(self): self.literal_input = inout.inputs.LiteralInput( identifier="literalinput", title="Literal Input", data_type='integer', mode=2, allowed_values=(1, 2, (3, 3, 12)), default=6, uoms=(UOM("metre"), UOM("km / h", "custom reference")), translations={"fr-CA": {"title": "Mon input", "abstract": "Une description"}}, ) def test_contruct(self): self.assertIsInstance(self.literal_input, LiteralInput) self.assertEqual(len(self.literal_input.allowed_values), 3) self.assertIsInstance(self.literal_input.allowed_values[0], AllowedValue) self.assertIsInstance(self.literal_input.allowed_values[2], AllowedValue) self.assertEqual(self.literal_input.allowed_values[2].spacing, 3) self.assertEqual(self.literal_input.allowed_values[2].minval, 3) self.assertEqual(self.literal_input.data, 6, "Default value set to 6") def test_valid(self): self.assertEqual(self.literal_input.data, 6) self.literal_input.data = 1 self.assertEqual(self.literal_input.data, 1) with self.assertRaises(InvalidParameterValue): self.literal_input.data = 5 with self.assertRaises(InvalidParameterValue): self.literal_input.data = "a" with self.assertRaises(InvalidParameterValue): self.literal_input.data = 15 self.literal_input.data = 6 self.assertEqual(self.literal_input.data, 6) def test_json_out(self): self.literal_input.data = 9 out = self.literal_input.json self.assertEqual(out['uoms'][0]["uom"], "metre") self.assertEqual(out['uoms'][1]["uom"], "km / h") self.assertEqual(out['uoms'][1]["reference"], "custom reference") self.assertEqual(out['uom']["uom"], "metre") self.assertFalse(out['workdir'], 'Workdir exist') self.assertEqual(out['data_type'], 'integer', 'Data type is integer') self.assertFalse(out['abstract'], 'abstract exist') self.assertFalse(out['keywords'], 'keywords exist') self.assertTrue(out['title'], 'title does not exist') self.assertEqual(out['data'], '9', 'data set') self.assertEqual(out['mode'], MODE.STRICT, 'Mode set') self.assertEqual(out['identifier'], 'literalinput', 'identifier set') self.assertEqual(out['type'], 'literal', 'it\'s literal input') self.assertEqual(len(out['allowed_values']), 3, '3 allowed values') self.assertEqual(out['allowed_values'][0]['value'], 1, 'allowed value 1') self.assertEqual( out['translations'], {"fr-ca": {"title": "Mon input", "abstract": "Une description"}}, 'translations does not exist' ) def test_json_out_datetime(self): inpt = inout.inputs.LiteralInput( identifier="datetime", title="Literal Input", mode=2, data_type='dateTime') inpt.data = "2017-04-20T12:30:00" out = inpt.json self.assertEqual(out['data'], '2017-04-20 12:30:00', 'datetime set') def test_json_out_time(self): inpt = inout.inputs.LiteralInput( identifier="time", title="Literal Input", mode=2, data_type='time') inpt.data = "12:30:00" out = inpt.json self.assertEqual(out['data'], '12:30:00', 'time set') def test_json_out_date(self): inpt = inout.inputs.LiteralInput( identifier="date", title="Literal Input", mode=2, data_type='date') inpt.data = "2017-04-20" out = inpt.json self.assertEqual(out['data'], '2017-04-20', 'date set') def test_translations(self): title_fr = get_translation(self.literal_input, "title", "fr-CA") assert title_fr == "Mon input" abstract_fr = get_translation(self.literal_input, "abstract", "fr-CA") assert abstract_fr == "Une description" identifier = get_translation(self.literal_input, "identifier", "fr-CA") assert identifier == self.literal_input.identifier class LiteralOutputTest(unittest.TestCase): """LiteralOutput test cases""" def setUp(self): self.literal_output = inout.outputs.LiteralOutput( "literaloutput", data_type="integer", title="Literal Output", uoms=[UOM("km / h", "custom reference"), UOM("m / s", "other reference")], translations={"fr-CA": {"title": "Mon output", "abstract": "Une description"}}, ) def test_contruct(self): self.assertIsInstance(self.literal_output, LiteralOutput) def test_storage(self): class Storage(object): pass storage = Storage() self.literal_output.store = storage self.assertEqual(self.literal_output.store, storage) def test_json(self): new_output = inout.outputs.LiteralOutput.from_json(self.literal_output.json) self.assertEqual(new_output.identifier, 'literaloutput') self.assertEqual(new_output.uom, self.literal_output.uom) self.assertEqual(new_output.uoms, self.literal_output.uoms) self.assertEqual( new_output.translations, {"fr-ca": {"title": "Mon output", "abstract": "Une description"}}, 'translations does not exist' ) class BBoxInputTest(unittest.TestCase): """BountingBoxInput test cases""" def setUp(self): self.bbox_input = inout.inputs.BoundingBoxInput( "bboxinput", title="BBox input", dimensions=2, translations={"fr-CA": {"title": "Mon input", "abstract": "Une description"}}, ) self.bbox_input.data = [0, 1, 2, 4] def test_contruct(self): self.assertIsInstance(self.bbox_input, BBoxInput) def test_json_out(self): out = self.bbox_input.json self.assertTrue(out['identifier'], 'identifier exists') self.assertTrue(out['title'], 'title does not exist') self.assertFalse(out['abstract'], 'abstract set') self.assertEqual(out['type'], 'bbox', 'type set') # self.assertTupleEqual(out['bbox'], ([0, 1], [2, 4]), 'data are there') self.assertEqual(out['bbox'], [0, 1, 2, 4], 'data are there') self.assertEqual(out['dimensions'], 2, 'Dimensions set') self.assertEqual( out['translations'], {"fr-ca": {"title": "Mon input", "abstract": "Une description"}}, 'translations does not exist' ) class BBoxOutputTest(unittest.TestCase): """BoundingBoxOutput test cases""" def setUp(self): self.bbox_out = inout.outputs.BoundingBoxOutput( "bboxoutput", title="BBox output", dimensions=2, crss=['epsg:3857', 'epsg:4326'], translations={"fr-CA": {"title": "Mon output", "abstract": "Une description"}}, ) def test_contruct(self): self.assertIsInstance(self.bbox_out, BBoxOutput) def test_storage(self): class Storage(object): pass storage = Storage() self.bbox_out.store = storage self.assertEqual(self.bbox_out.store, storage) def test_json(self): new_bbox = inout.outputs.BoundingBoxOutput.from_json(self.bbox_out.json) self.assertEqual(new_bbox.identifier, 'bboxoutput') self.assertEqual( new_bbox.translations, {"fr-ca": {"title": "Mon output", "abstract": "Une description"}}, 'translations does not exist' ) class TestMetaLink(unittest.TestCase): tmp_dir = tempfile.mkdtemp() def metafile(self): mf = MetaFile('identifier', 'title', fmt=FORMATS.JSON) mf.data = json.dumps({'a': 1}) mf._set_workdir(self.tmp_dir) return mf def metafile_with_url(self): mf = MetaFile('identifier', 'title', fmt=FORMATS.JSON) mf.url = "https://pywps.org/" mf._set_workdir(self.tmp_dir) return mf def test_metafile(self): mf = self.metafile() self.assertEqual('identifier', mf.identity) def metalink(self): ml = MetaLink(identity='unittest', description='desc', files=(self.metafile(), ), workdir=self.tmp_dir) return ml def metalink4(self): ml = MetaLink4(identity='unittest', description='desc', files=(self.metafile(), ), workdir=self.tmp_dir) return ml def test_metalink(self): from pywps.validator.complexvalidator import validatexml out = inout.outputs.ComplexOutput('metatest', 'MetaLink Test title', abstract='MetaLink test abstract', supported_formats=[FORMATS.METALINK, ], as_reference=True) out.workdir = self.tmp_dir ml = self.metalink() out.data = ml.xml self.assertTrue(validatexml(out, MODE.STRICT)) def test_metalink4(self): from pywps.validator.complexvalidator import validatexml out = inout.outputs.ComplexOutput('metatest', 'MetaLink4 Test title', abstract='MetaLink4 test abstract', supported_formats=[FORMATS.META4, ], as_reference=True) out.workdir = self.tmp_dir ml = self.metalink4() out.data = ml.xml self.assertTrue(validatexml(out, MODE.STRICT)) def test_hash(self): ml = self.metalink() assert 'hash' not in ml.xml ml.checksums = True assert 'hash' in ml.xml ml4 = self.metalink4() assert 'hash' not in ml4.xml ml4.checksums = True assert 'hash' in ml4.xml def test_size(self): ml4 = self.metalink4() ml4.append(self.metafile_with_url()) assert 'size' in ml4.xml def test_no_size(self): ml4 = self.metalink4() mf = self.metafile_with_url() mf.size = 0 ml4.files = [mf] assert 'size' not in ml4.xml def test_set_size(self): ml4 = self.metalink4() mf = self.metafile_with_url() mf.size = 100 ml4.files = [mf] assert '100' in ml4.xml def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(IOHandlerTest), loader.loadTestsFromTestCase(ComplexInputTest), loader.loadTestsFromTestCase(DodsComplexInputTest), loader.loadTestsFromTestCase(ComplexOutputTest), loader.loadTestsFromTestCase(SerializationBoundingBoxInputTest), loader.loadTestsFromTestCase(SerializationComplexInputTest), loader.loadTestsFromTestCase(SerializationLiteralInputTest), loader.loadTestsFromTestCase(SimpleHandlerTest), loader.loadTestsFromTestCase(LiteralInputTest), loader.loadTestsFromTestCase(LiteralOutputTest), loader.loadTestsFromTestCase(BBoxInputTest), loader.loadTestsFromTestCase(BBoxOutputTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_literaltypes.py000066400000000000000000000115251415166246000202100ustar00rootroot00000000000000"""Unit tests for IOs """ ################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest import datetime from pywps.inout.literaltypes import ( convert_integer, convert_float, convert_string, convert_boolean, convert_time, convert_date, convert_datetime, convert_anyURI, ValuesReference, InvalidParameterValue, ) class ValuesReferenceTest(unittest.TestCase): """ValuesReference test cases""" def setUp(self): self.reference = "https://en.wikipedia.org/w/api.php?action=opensearch&search=scotland&format=json" def test_json(self): val_ref = ValuesReference( reference=self.reference) new_val_ref = ValuesReference.from_json(val_ref.json) self.assertEqual(new_val_ref.reference, self.reference) class ConvertorTest(unittest.TestCase): """IOHandler test cases""" def test_integer(self): """Test integer convertor""" self.assertEqual(convert_integer('1.0'), 1) self.assertEqual(convert_integer(1), 1) self.assertEqual(convert_integer(str(1)), 1) with self.assertRaises(ValueError): convert_integer('a') def test_float(self): """Test float convertor""" self.assertEqual(convert_float('1.0'), 1.0) self.assertEqual(convert_float(1), 1.0) self.assertEqual(convert_float(str(1.0)), 1.0) with self.assertRaises(ValueError): convert_float('a') def test_string(self): """Test string convertor""" self.assertEqual(convert_string('1.0'), '1.0') self.assertEqual(convert_string(1), '1') self.assertEqual(convert_string('a'), 'a') self.assertEqual(convert_string(str('1.0')), '1.0') def test_boolean(self): """Test boolean convertor""" self.assertTrue(convert_boolean('1.0')) self.assertTrue(convert_boolean(1)) self.assertTrue(convert_boolean('a')) self.assertFalse(convert_boolean('f')) self.assertFalse(convert_boolean('falSe')) self.assertFalse(convert_boolean(False)) self.assertFalse(convert_boolean(0)) self.assertTrue(convert_boolean(-1)) self.assertFalse(convert_boolean(str(False))) self.assertTrue(convert_boolean(str(True))) def test_time(self): """Test time convertor""" self.assertEqual(convert_time("12:00:00"), datetime.time(12, 0, 0)) self.assertEqual(convert_time(str(datetime.time(12, 0, 0))), datetime.time(12, 0, 0)) self.assertTrue(isinstance( convert_time(datetime.time(14)), datetime.time)) def test_date(self): """Test date convertor""" self.assertEqual(convert_date("2011-07-21"), datetime.date(2011, 7, 21)) self.assertEqual(convert_date(str(datetime.date(2011, 7, 21))), datetime.date(2011, 7, 21)) self.assertTrue(isinstance( convert_date(datetime.date(2012, 12, 31)), datetime.date)) def test_datetime(self): """Test datetime convertor""" self.assertEqual(convert_datetime("2016-09-22T12:00:00"), datetime.datetime(2016, 9, 22, 12)) self.assertEqual(convert_datetime(str(datetime.datetime(2016, 9, 22, 12))), datetime.datetime(2016, 9, 22, 12)) self.assertTrue(isinstance( convert_datetime("2016-09-22T12:00:00Z"), datetime.datetime)) self.assertTrue(isinstance( convert_datetime("2016-09-22T12:00:00+01:00"), datetime.datetime)) self.assertTrue(isinstance( convert_datetime(datetime.datetime(2016, 9, 22, 6)), datetime.datetime)) def test_anyuri(self): """Test URI convertor""" self.assertEqual(convert_anyURI("http://username:password@hostname.dom:port/deep/path/;params?query#fragment"), ('http', 'username:password@hostname.dom:port', '/deep/path/', 'params', 'query', 'fragment') ) self.assertEqual(convert_anyURI("file:///very/very/very/deep/path"), ('file', '', '/very/very/very/deep/path', '', '', '') ) with self.assertRaises(InvalidParameterValue): convert_anyURI("ftp:///deep/path/;params?query#fragment") def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ConvertorTest), loader.loadTestsFromTestCase(ValuesReferenceTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_ows.py000066400000000000000000000132361415166246000163000ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## __author__ = "Luis de Sousa" __date__ = "10-03-2015" import os import tempfile import unittest from pywps import Service, Process, ComplexInput, ComplexOutput, Format, FORMATS, get_format from pywps.exceptions import NoApplicableCode from pywps import get_ElementMakerForVersion import pywps.configuration as config from pywps.tests import client_for, assert_response_success, service_ok wfsResource = 'https://demo.mapserver.org/cgi-bin/wfs?service=WFS&version=1.1.0&request=GetFeature&typename=continents&maxfeatures=10' # noqa wcsResource = 'https://demo.mapserver.org/cgi-bin/wcs?service=WCS&version=1.0.0&request=GetCoverage&coverage=ndvi&crs=EPSG:4326&bbox=-92,42,-85,45&format=image/tiff&width=400&height=300' # noqa WPS, OWS = get_ElementMakerForVersion("1.0.0") def create_feature(): def feature(request, response): input = request.inputs['input'][0].file response.outputs['output'].data_format = FORMATS.GML response.outputs['output'].file = input return response return Process(handler=feature, identifier='feature', title='Process Feature', inputs=[ComplexInput( 'input', title='Input', supported_formats=[get_format('GML')])], outputs=[ComplexOutput( 'output', title='Output', supported_formats=[get_format('GML')])]) def create_sum_one(): def sum_one(request, response): input = request.inputs['input'][0].file # What do we need to assert a Complex input? # assert type(input) is str import grass.script as grass # Import the raster and set the region if grass.run_command("r.in.gdal", flags="o", out="input", input=input, quiet=True) != 0: raise NoApplicableCode("Could not import cost map. " "Please check the WCS service.") if grass.run_command("g.region", flags="a", rast="input") != 0: raise NoApplicableCode("Could not set GRASS region.") # Add 1 if grass.mapcalc("$output = $input + $value", output="output", input="input", value=1.0, quiet=True): raise NoApplicableCode("Could not use GRASS map calculator.") # Export the result _, out = tempfile.mkstemp() os.environ['GRASS_VERBOSE'] = '-1' if grass.run_command("r.out.gdal", flags="f", input="output", type="UInt16", output=out, overwrite=True) != 0: raise NoApplicableCode("Could not export result from GRASS.") del os.environ['GRASS_VERBOSE'] response.outputs['output'].file = out return response return Process(handler=sum_one, identifier='sum_one', title='Process Sum One', inputs=[ComplexInput( 'input', title='Input', supported_formats=[Format('image/img')])], outputs=[ComplexOutput( 'output', title='Output', supported_formats=[get_format('GEOTIFF')])], grass_location='epsg:4326') class ExecuteTests(unittest.TestCase): def test_wfs(self): if not service_ok('https://demo.mapserver.org'): self.skipTest("mapserver is unreachable") client = client_for(Service(processes=[create_feature()])) request_doc = WPS.Execute( OWS.Identifier('feature'), WPS.DataInputs( WPS.Input( OWS.Identifier('input'), WPS.Reference( {'{http://www.w3.org/1999/xlink}href': wfsResource}, mimeType=FORMATS.GML.mime_type, encoding='', schema=''))), WPS.ProcessOutputs( WPS.Output( OWS.Identifier('output'))), version='1.0.0' ) resp = client.post_xml(doc=request_doc) assert_response_success(resp) # Other things to assert: # . the inclusion of output # . the type of output def test_wcs(self): if not config.CONFIG.get('grass', 'gisbase'): self.skipTest('GRASS lib not found') if not service_ok('https://demo.mapserver.org'): self.skipTest("mapserver is unreachable") client = client_for(Service(processes=[create_sum_one()])) request_doc = WPS.Execute( OWS.Identifier('sum_one'), WPS.DataInputs( WPS.Input( OWS.Identifier('input'), WPS.Reference( {'{http://www.w3.org/1999/xlink}href': wcsResource}))), WPS.ProcessOutputs( WPS.Output( OWS.Identifier('output'))), version='1.0.0') resp = client.post_xml(doc=request_doc) assert_response_success(resp) # Other things to assert: # . the inclusion of output # . the type of output def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExecuteTests), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_process.py000066400000000000000000000057331415166246000171510ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Test process """ import unittest from pywps import Process from pywps.app.Common import Metadata from pywps.inout import LiteralInput from pywps.inout import BoundingBoxInput from pywps.inout import ComplexInput from pywps.inout import FORMATS from pywps.translations import get_translation class DoNothing(Process): def __init__(self): super(DoNothing, self).__init__( self.donothing, "process", title="Process", abstract="Process description", inputs=[LiteralInput("length", title="Length"), BoundingBoxInput("bbox", title="BBox", crss=[]), ComplexInput("vector", title="Vector", supported_formats=[FORMATS.GML])], outputs=[], metadata=[Metadata('process metadata 1', 'http://example.org/1'), Metadata('process metadata 2', 'http://example.org/2')], translations={"fr-CA": {"title": "Processus", "abstract": "Une description"}} ) @staticmethod def donothing(request, response): pass class ProcessTestCase(unittest.TestCase): def setUp(self): self.process = DoNothing() def test_get_input_title(self): """Test returning the proper input title""" inputs = { input.identifier: input.title for input in self.process.inputs } self.assertEqual("Length", inputs['length']) self.assertEqual("BBox", inputs["bbox"]) self.assertEqual("Vector", inputs["vector"]) def test_json(self): new_process = Process.from_json(self.process.json) self.assertEqual(new_process.identifier, self.process.identifier) self.assertEqual(new_process.title, self.process.title) self.assertEqual(len(new_process.inputs), len(self.process.inputs)) new_inputs = { inpt.identifier: inpt.title for inpt in new_process.inputs } self.assertEqual("Length", new_inputs['length']) self.assertEqual("BBox", new_inputs["bbox"]) self.assertEqual("Vector", new_inputs["vector"]) def test_get_translations(self): title_fr = get_translation(self.process, "title", "fr-CA") assert title_fr == "Processus" abstract_fr = get_translation(self.process, "abstract", "fr-CA") assert abstract_fr == "Une description" identifier = get_translation(self.process, "identifier", "fr-CA") assert identifier == self.process.identifier def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ProcessTestCase) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_processing.py000066400000000000000000000076011415166246000176430ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for processing """ import unittest import json import uuid from pywps import configuration import pywps.processing from pywps.processing.job import Job from pywps.processing.basic import MultiProcessing from pywps.app import WPSRequest from pywps.response.execute import ExecuteResponse from .processes import Greeter, InOut class GreeterProcessingTest(unittest.TestCase): """Processing test case with Greeter process""" def setUp(self): self.uuid = uuid.uuid1() self.dummy_process = Greeter() self.dummy_process._set_uuid(self.uuid) self.dummy_process.set_workdir('/tmp') self.wps_request = WPSRequest() self.wps_response = ExecuteResponse(self.wps_request, self.uuid, process=self.dummy_process) self.job = Job( process=self.dummy_process, wps_request=self.wps_request, wps_response=self.wps_response) def test_default_mode(self): """Test pywps.formats.Format class """ self.assertEqual(configuration.get_config_value('processing', 'mode'), 'default') process = pywps.processing.Process( process=self.dummy_process, wps_request=self.wps_request, wps_response=self.wps_response) # process.start() self.assertTrue(isinstance(process, MultiProcessing)) def test_job_json(self): new_job = Job.from_json(json.loads(self.job.json)) self.assertEqual(new_job.name, 'greeter') self.assertEqual(new_job.uuid, str(self.uuid)) self.assertEqual(new_job.workdir, '/tmp') self.assertEqual(len(new_job.process.inputs), 1) def test_job_dump(self): new_job = Job.load(self.job.dump()) self.assertEqual(new_job.name, 'greeter') self.assertEqual(new_job.uuid, str(self.uuid)) self.assertEqual(new_job.workdir, '/tmp') self.assertEqual(len(new_job.process.inputs), 1) class InOutProcessingTest(unittest.TestCase): """Processing test case with InOut process""" def setUp(self): self.uuid = uuid.uuid1() self.dummy_process = InOut() self.dummy_process._set_uuid(self.uuid) self.dummy_process.set_workdir('/tmp') self.wps_request = WPSRequest() self.wps_response = ExecuteResponse(self.wps_request, self.uuid, process=self.dummy_process) self.job = Job( process=self.dummy_process, wps_request=self.wps_request, wps_response=self.wps_response) def test_job_json(self): new_job = Job.from_json(json.loads(self.job.json)) self.assertEqual(new_job.name, 'inout') self.assertEqual(new_job.uuid, str(self.uuid)) self.assertEqual(new_job.workdir, '/tmp') self.assertEqual(len(new_job.process.inputs), 3) self.assertEqual(new_job.json, self.job.json) # idempotent test def test_job_dump(self): new_job = Job.load(self.job.dump()) self.assertEqual(new_job.name, 'inout') self.assertEqual(new_job.uuid, str(self.uuid)) self.assertEqual(new_job.workdir, '/tmp') self.assertEqual(len(new_job.process.inputs), 3) self.assertEqual(new_job.json, self.job.json) # idempotent test def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(GreeterProcessingTest), loader.loadTestsFromTestCase(InOutProcessingTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_s3storage.py000066400000000000000000000044741415166246000174060ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps.inout.storage.s3 import S3StorageBuilder, S3Storage from pywps.inout.storage import STORE_TYPE from pywps.inout.basic import ComplexOutput from pywps import configuration, FORMATS from urllib.parse import urlparse import tempfile import os import unittest from unittest.mock import patch class S3StorageTests(unittest.TestCase): def setUp(self): self.tmp_dir = tempfile.mkdtemp() @patch('pywps.inout.storage.s3.S3Storage.uploadData') def test_store(self, uploadData): configuration.CONFIG.set('s3', 'bucket', 'notrealbucket') configuration.CONFIG.set('s3', 'prefix', 'wps') storage = S3StorageBuilder().build() output = ComplexOutput('testme', 'Test', supported_formats=[FORMATS.TEXT], workdir=self.tmp_dir) output.data = "Hello World!" store_type, filename, url = storage.store(output) called_args = uploadData.call_args[0] self.assertEqual(store_type, STORE_TYPE.S3) self.assertEqual(filename, 'wps/input.txt') self.assertEqual(uploadData.call_count, 1) self.assertEqual(called_args[1], 'wps/input.txt') self.assertEqual(called_args[2], {'ContentType': 'text/plain'}) @patch('pywps.inout.storage.s3.S3Storage.uploadData') def test_write(self, uploadData): configuration.CONFIG.set('s3', 'bucket', 'notrealbucket') configuration.CONFIG.set('s3', 'prefix', 'wps') storage = S3StorageBuilder().build() url = storage.write('Bar Baz', 'out.txt', data_format=FORMATS.TEXT) called_args = uploadData.call_args[0] self.assertEqual(uploadData.call_count, 1) self.assertEqual(called_args[0], 'Bar Baz') self.assertEqual(called_args[1], 'wps/out.txt') self.assertEqual(called_args[2], {'ContentType': 'text/plain'}) def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(S3StorageTests) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_service.py000066400000000000000000000012511415166246000171220ustar00rootroot00000000000000import unittest from pywps.app.Service import _validate_file_input from pywps.exceptions import FileURLNotSupported class ServiceTest(unittest.TestCase): def test_validate_file_input(self): try: _validate_file_input(href="file:///private/space/test.txt") except FileURLNotSupported: self.assertTrue(True) else: self.assertTrue(False, 'should raise exception FileURLNotSupported') def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ServiceTest), ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_storage.py000066400000000000000000000033031415166246000171260ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import pytest from pywps.inout.storage.builder import StorageBuilder from pywps.inout.storage.file import FileStorage from pywps.inout.storage.s3 import S3Storage from pywps import configuration from pathlib import Path import unittest import tempfile @pytest.fixture def fake_output(tmp_path): class FakeOutput(object): """Fake output object for testing.""" def __init__(self): self.identifier = "fake_output" self.file = self._get_file() self.uuid = None def _get_file(self): fn = tmp_path / 'file.tiff' fn.touch() return str(fn.absolute()) return FakeOutput() class TestStorageBuilder(): def test_default_storage(self): storage = StorageBuilder.buildStorage() assert isinstance(storage, FileStorage) def test_s3_storage(self): configuration.CONFIG.set('server', 'storagetype', 's3') storage = StorageBuilder.buildStorage() assert isinstance(storage, S3Storage) def test_recursive_directory_creation(self, fake_output): """Test that outputpath is created.""" configuration.CONFIG.set('server', 'storagetype', 'file') outputpath = Path(tempfile.gettempdir()) / "a" / "b" / "c" configuration.CONFIG.set('server', 'outputpath', str(outputpath)) storage = StorageBuilder.buildStorage() storage.store(fake_output) assert outputpath.exists() pywps-4.5.1/tests/test_wpsrequest.py000066400000000000000000000106341415166246000177110ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import unittest from pywps.app import WPSRequest import tempfile import datetime import json from pywps.inout.literaltypes import AnyValue class WPSRequestTest(unittest.TestCase): def setUp(self): self.request = WPSRequest() self.tempfile = tempfile.mktemp() x = open(self.tempfile, 'w') x.write("ahoj") x.close() def test_json_in(self): obj = { 'operation': 'getcapabilities', 'version': '1.0.0', 'language': 'eng', 'identifier': 'ahoj', 'identifiers': 'ahoj', # TODO: why identifierS? 'store_execute': True, 'status': True, 'lineage': True, 'inputs': { 'myin': [{ 'identifier': 'myin', 'type': 'complex', 'supported_formats': [{ 'mime_type': 'tralala' }], 'file': self.tempfile, 'data_format': {'mime_type': 'tralala'} }], 'myliteral': [{ 'identifier': 'myliteral', 'type': 'literal', 'data_type': 'integer', 'allowed_values': [{'type': 'anyvalue'}], 'data': 1 }] }, 'outputs': {}, 'raw': False } self.request = WPSRequest() self.request.json = obj self.assertEqual(self.request.inputs['myliteral'][0].data, 1, 'Data are in the file') self.assertEqual(self.request.inputs['myin'][0].data, 'ahoj', 'Data are in the file') self.assertListEqual(self.request.inputs['myliteral'][0].allowed_values, [AnyValue()], 'Any value not set') self.assertTrue(self.request.inputs['myliteral'][0].any_value, 'Any value set') def test_json_inout_datetime(self): obj = { 'operation': 'getcapabilities', 'version': '1.0.0', 'language': 'eng', 'identifier': 'moinmoin', 'identifiers': 'moinmoin', # TODO: why identifierS? 'store_execute': True, 'status': True, 'lineage': True, 'inputs': { 'datetime': [{ 'identifier': 'datetime', 'type': 'literal', 'data_type': 'dateTime', 'data': '2017-04-20T12:00:00', 'allowed_values': [{'type': 'anyvalue'}], }], 'date': [{ 'identifier': 'date', 'type': 'literal', 'data_type': 'date', 'data': '2017-04-20', 'allowed_values': [{'type': 'anyvalue'}], }], 'time': [{ 'identifier': 'time', 'type': 'literal', 'data_type': 'time', 'data': '09:00:00', 'allowed_values': [{'type': 'anyvalue'}], }], }, 'outputs': {}, 'raw': False } self.request = WPSRequest() self.request.json = obj self.assertEqual(self.request.inputs['datetime'][0].data, datetime.datetime(2017, 4, 20, 12), 'Datatime set') self.assertEqual(self.request.inputs['date'][0].data, datetime.date(2017, 4, 20), 'Data set') self.assertEqual(self.request.inputs['time'][0].data, datetime.time(9, 0, 0), 'Time set') # dump to json and reload dump = self.request.json self.request.json = json.loads(dump) self.assertEqual(self.request.inputs['datetime'][0].data, datetime.datetime(2017, 4, 20, 12), 'Datatime set') self.assertEqual(self.request.inputs['date'][0].data, datetime.date(2017, 4, 20), 'Data set') self.assertEqual(self.request.inputs['time'][0].data, datetime.time(9, 0, 0), 'Time set') def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(WPSRequestTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/test_xml_util.py000066400000000000000000000030131415166246000173150ustar00rootroot00000000000000from pywps import xml_util as etree from io import StringIO XML_EXECUTE = """ ]> test_process name &xxe; output """ def test_etree_fromstring(): xml = etree.tostring(etree.fromstring(XML_EXECUTE)) # don't replace entities # https://lxml.de/parsing.html assert b"&xxe;" in xml def test_etree_parse(): xml = etree.tostring(etree.parse(StringIO(XML_EXECUTE))) # don't replace entities # https://lxml.de/parsing.html assert b"&xxe;" in xml pywps-4.5.1/tests/validator/000077500000000000000000000000001415166246000160375ustar00rootroot00000000000000pywps-4.5.1/tests/validator/__init__.py000066400000000000000000000004141415166246000201470ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## pywps-4.5.1/tests/validator/test_complexvalidators.py000066400000000000000000000151621415166246000232150ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for complex validator """ import unittest import pytest from pywps.validator.mode import MODE from pywps.validator.complexvalidator import ( validategml, # validategpx, # validatexml, validatejson, validategeojson, validateshapefile, validategeotiff, validatenetcdf, validatedods, ) from pywps.inout.formats import FORMATS from pywps import ComplexInput from pywps.inout.basic import SOURCE_TYPE import tempfile import os try: import netCDF4 # noqa except ImportError: WITH_NC4 = False else: WITH_NC4 = True def get_input(name, schema, mime_type): class FakeFormat(object): mimetype = 'text/plain' schema = None units = None def validate(self, data): return True class FakeInput(object): tempdir = tempfile.mkdtemp() file = os.path.join( os.path.abspath(os.path.dirname(__file__)), '..', 'data', name) format = FakeFormat() class data_format(object): file = os.path.join( os.path.abspath(os.path.dirname(__file__)), '..', 'data', str(schema)) fake_input = FakeInput() fake_input.stream = open(fake_input.file) fake_input.data_format = data_format() if schema: fake_input.data_format.schema = 'file://' + fake_input.data_format.file fake_input.data_format.mime_type = mime_type return fake_input class ValidateTest(unittest.TestCase): """Complex validator test cases""" def setUp(self): pass def tearDown(self): pass def test_gml_validator(self): """Test GML validator """ gml_input = get_input('gml/point.gml', 'point.xsd', FORMATS.GML.mime_type) self.assertTrue(validategml(gml_input, MODE.NONE), 'NONE validation') self.assertTrue(validategml(gml_input, MODE.SIMPLE), 'SIMPLE validation') self.assertTrue(validategml(gml_input, MODE.STRICT), 'STRICT validation') self.assertTrue(validategml(gml_input, MODE.VERYSTRICT), 'VERYSTRICT validation') gml_input.stream.close() def test_json_validator(self): """Test GeoJSON validator """ json_input = get_input('json/point.geojson', None, FORMATS.JSON.mime_type) self.assertTrue(validatejson(json_input, MODE.NONE), 'NONE validation') self.assertTrue(validatejson(json_input, MODE.SIMPLE), 'SIMPLE validation') self.assertTrue(validatejson(json_input, MODE.STRICT), 'STRICT validation') json_input.stream.close() def test_geojson_validator(self): """Test GeoJSON validator """ geojson_input = get_input('json/point.geojson', 'json/schema/geojson.json', FORMATS.GEOJSON.mime_type) self.assertTrue(validategeojson(geojson_input, MODE.NONE), 'NONE validation') self.assertTrue(validategeojson(geojson_input, MODE.SIMPLE), 'SIMPLE validation') self.assertTrue(validategeojson(geojson_input, MODE.STRICT), 'STRICT validation') self.assertTrue(validategeojson(geojson_input, MODE.VERYSTRICT), 'VERYSTRICT validation') geojson_input.stream.close() def test_shapefile_validator(self): """Test ESRI Shapefile validator """ shapefile_input = get_input('shp/point.shp.zip', None, FORMATS.SHP.mime_type) self.assertTrue(validateshapefile(shapefile_input, MODE.NONE), 'NONE validation') self.assertTrue(validateshapefile(shapefile_input, MODE.SIMPLE), 'SIMPLE validation') self.assertTrue(validateshapefile(shapefile_input, MODE.STRICT), 'STRICT validation') shapefile_input.stream.close() def test_geotiff_validator(self): """Test GeoTIFF validator """ geotiff_input = get_input('geotiff/dem.tiff', None, FORMATS.GEOTIFF.mime_type) self.assertTrue(validategeotiff(geotiff_input, MODE.NONE), 'NONE validation') self.assertTrue(validategeotiff(geotiff_input, MODE.SIMPLE), 'SIMPLE validation') self.assertTrue(validategeotiff(geotiff_input, MODE.STRICT), 'STRICT validation') geotiff_input.stream.close() def test_netcdf_validator(self): """Test netCDF validator """ netcdf_input = get_input('netcdf/time.nc', None, FORMATS.NETCDF.mime_type) self.assertTrue(validatenetcdf(netcdf_input, MODE.NONE), 'NONE validation') self.assertTrue(validatenetcdf(netcdf_input, MODE.SIMPLE), 'SIMPLE validation') netcdf_input.stream.close() if WITH_NC4: self.assertTrue(validatenetcdf(netcdf_input, MODE.STRICT), 'STRICT validation') netcdf_input.file = 'grub.nc' self.assertFalse(validatenetcdf(netcdf_input, MODE.STRICT)) else: self.assertFalse(validatenetcdf(netcdf_input, MODE.STRICT), 'STRICT validation') @pytest.mark.xfail(reason="test.opendap.org is offline") def test_dods_validator(self): opendap_input = ComplexInput('dods', 'opendap test', [FORMATS.DODS,]) opendap_input.url = "http://test.opendap.org:80/opendap/netcdf/examples/sresa1b_ncar_ccsm3_0_run1_200001.nc" self.assertTrue(validatedods(opendap_input, MODE.NONE), 'NONE validation') self.assertTrue(validatedods(opendap_input, MODE.SIMPLE), 'SIMPLE validation') if WITH_NC4: self.assertTrue(validatedods(opendap_input, MODE.STRICT), 'STRICT validation') opendap_input.url = 'Faulty url' self.assertFalse(validatedods(opendap_input, MODE.STRICT)) else: self.assertFalse(validatedods(opendap_input, MODE.STRICT), 'STRICT validation') def test_dods_default(self): opendap_input = ComplexInput('dods', 'opendap test', [FORMATS.DODS,], default='http://test.opendap.org', default_type=SOURCE_TYPE.URL, mode=MODE.SIMPLE) def test_fail_validator(self): fake_input = get_input('point.xsd', 'point.xsd', FORMATS.SHP.mime_type) self.assertFalse(validategml(fake_input, MODE.SIMPLE), 'SIMPLE validation invalid') fake_input.stream.close() def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ValidateTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tests/validator/test_literalvalidators.py000066400000000000000000000107221415166246000231770ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for literal validator """ import unittest from pywps.validator.literalvalidator import * from pywps.inout.literaltypes import AllowedValue, AnyValue, ValuesReference def get_input(allowed_values, data=1): class FakeInput(object): data = 1 data_type = 'data' fake_input = FakeInput() fake_input.data = data fake_input.allowed_values = allowed_values return fake_input class ValidateTest(unittest.TestCase): """Literal validator test cases""" def setUp(self): pass def tearDown(self): pass def test_value_validator(self): """Test simple validator for string, integer, etc""" inpt = get_input(allowed_values=None, data='test') self.assertTrue(validate_value(inpt, MODE.SIMPLE)) def test_anyvalue_validator(self): """Test anyvalue validator""" inpt = get_input(allowed_values=AnyValue()) self.assertTrue(validate_anyvalue(inpt, MODE.SIMPLE)) def test_values_reference_validator(self): """Test ValuesReference validator""" inpt = get_input(allowed_values=ValuesReference(reference='http://some.org?search=test&format=json')) self.assertTrue(validate_values_reference(inpt, MODE.SIMPLE)) def test_allowedvalues_values_validator(self): """Test allowed values - values""" allowed_value = AllowedValue() allowed_value.allowed_type = ALLOWEDVALUETYPE.VALUE allowed_value.value = 1 inpt = get_input(allowed_values=[allowed_value]) self.assertTrue(validate_allowed_values(inpt, MODE.SIMPLE), 'Allowed value 1 allowed') inpt.data = 2 self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'Allowed value 2 NOT allowed') def test_allowedvalues_ranges_validator(self): """Test allowed values - ranges""" allowed_value = AllowedValue() allowed_value.allowed_type = ALLOWEDVALUETYPE.RANGE allowed_value.minval = 1 allowed_value.maxval = 11 allowed_value.spacing = 2 allowed_value.range_closure = RANGECLOSURETYPE.CLOSED inpt = get_input(allowed_values=[allowed_value]) inpt.data = 1 self.assertTrue(validate_allowed_values(inpt, MODE.SIMPLE), 'Range CLOSED closure') inpt.data = 12 self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'Value too big') inpt.data = 5 self.assertTrue(validate_allowed_values(inpt, MODE.SIMPLE), 'Spacing not fit') inpt.data = 4 self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'Spacing fits') inpt.data = 11 allowed_value.range_closure = RANGECLOSURETYPE.OPEN self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'Open Range') inpt.data = 1 allowed_value.range_closure = RANGECLOSURETYPE.OPENCLOSED self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'OPENCLOSED Range') inpt.data = 11 allowed_value.range_closure = RANGECLOSURETYPE.CLOSEDOPEN self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'CLOSEDOPEN Range') def test_combined_validator(self): """Test allowed values - ranges and values combination""" allowed_value1 = AllowedValue() allowed_value1.allowed_type = ALLOWEDVALUETYPE.RANGE allowed_value1.minval = 1 allowed_value1.maxval = 11 allowed_value1.spacing = 2 allowed_value1.range_closure = RANGECLOSURETYPE.CLOSED allowed_value2 = AllowedValue() allowed_value2.allowed_type = ALLOWEDVALUETYPE.VALUE allowed_value2.value = 15 inpt = get_input(allowed_values=[allowed_value1, allowed_value2]) inpt.data = 1 self.assertTrue(validate_allowed_values(inpt, MODE.SIMPLE), 'Range CLOSED closure') inpt.data = 15 self.assertTrue(validate_allowed_values(inpt, MODE.SIMPLE), 'AllowedValue') inpt.data = 13 self.assertFalse(validate_allowed_values(inpt, MODE.SIMPLE), 'Out of range') def load_tests(loader=None, tests=None, pattern=None): if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ValidateTest) ] return unittest.TestSuite(suite_list) pywps-4.5.1/tox.ini000066400000000000000000000005741415166246000142310ustar00rootroot00000000000000[tox] envlist=py36 [testenv] pip_pre=True deps= lxml flask owslib simplejson jsonschema geojson shapely unipath werkzeug SQLAlchemy jinja2 commands= # check first which version is installed "gdal-config --version" pip install GDAL==2.1.0 --global-option=build_ext --global-option="-I/usr/include/gdal" python -m unittest tests