pax_global_header00006660000000000000000000000064152612325710014516gustar00rootroot0000000000000052 comment=437c5d75477d2c63cc460a56e708d9278e8c567c pywps-4.8.1/000077500000000000000000000000001526123257100127125ustar00rootroot00000000000000pywps-4.8.1/.codacy.yml000066400000000000000000000000651526123257100147560ustar00rootroot00000000000000--- exclude_paths: - 'tests/**' - 'docs/conf.py' pywps-4.8.1/.github/000077500000000000000000000000001526123257100142525ustar00rootroot00000000000000pywps-4.8.1/.github/ISSUE_TEMPLATE.md000066400000000000000000000005071526123257100167610ustar00rootroot00000000000000# 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.8.1/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000007521526123257100200570ustar00rootroot00000000000000# 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.8.1/.github/workflows/000077500000000000000000000000001526123257100163075ustar00rootroot00000000000000pywps-4.8.1/.github/workflows/main.yml000066400000000000000000000075361526123257100177710ustar00rootroot00000000000000name: Build PyWPS βš™οΈ on: push: branches: - main pull_request: concurrency: # For a given workflow, if we push to the same branch, cancel all previous builds on that branch except on main. group: "${{ github.workflow }}-${{ github.ref }}" cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read jobs: lint: name: Linting Suite runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python3.10 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.10" cache: pip - name: Install CI libraries run: | python -m pip install --require-hashes -r CI/requirements_ci.txt - name: Run linting suite βš™οΈ run: | tox -e lint test: name: Testing with (Python${{ matrix.python-version }}, ${{ matrix.os }}) needs: lint runs-on: ${{ matrix.os }} strategy: matrix: python-version: [ "3.10", "3.11", "3.12", "3.13" ] os: ["ubuntu-latest"] include: # - python-version: "3.11" # os: "windows" - python-version: "3.11" os: "macos-latest" steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install packages πŸ“¦ (Linux) if: ${{ matrix.os == 'ubuntu-latest' }} run: | sudo apt-get update sudo apt-get -y install libnetcdf-dev libhdf5-dev - name: Install packages πŸ“¦ (macOS) if: ${{ matrix.os == 'macos-latest' }} uses: tecolicom/actions-use-homebrew-tools@b9c066b79607fa3d71e0be05d7003bb75fd9ff34 # v1.3 with: tools: hdf5 netcdf cache: "yes" - name: Set up Python${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: pip - name: Install CI libraries run: | python -m pip install --require-hashes -r CI/requirements_ci.txt - name: Run tests with tox βš™οΈ run: | tox env: TOX_GH_MAJOR_MINOR: ${{ matrix.python-version }} - name: Trust Coveralls Homebrew tap # Homebrew v6.0 requirement; Remove when coverallsapp/github-action >2.3.7 if: matrix.os == 'macos-latest' run: | brew trust coverallsapp/coveralls - name: Report Coverage uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 with: flag-name: run-${{ matrix.python-version }} parallel: true docs: name: Build docs πŸ—οΈ needs: lint runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python3.10 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.10" cache: pip - name: Install CI libraries run: | python -m pip install --require-hashes -r CI/requirements_ci.txt - name: Build documentation πŸ—οΈ run: | tox -e docs finish: name: Finish needs: - test - docs runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Coveralls Finished uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 with: parallel-finished: true pywps-4.8.1/.github/workflows/publish-pypi.yml000066400000000000000000000026151526123257100214630ustar00rootroot00000000000000name: Publish Python 🐍 distributions πŸ“¦ to PyPI on: release: types: - published permissions: contents: read jobs: build-n-publish-pypi: name: Build and publish Python 🐍 distributions πŸ“¦ to PyPI environment: production permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write runs-on: ubuntu-latest strategy: matrix: python-version: [ "3.13" ] steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: disable-sudo: true egress-policy: audit - name: Checkout Repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python${{ matrix.python-version }} uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} - name: Install CI libraries run: | python -m pip install --require-hashes -r CI/requirements_ci.txt - name: Build a binary wheel and a source tarball run: | python -m build --sdist --wheel - name: Publish distribution πŸ“¦ to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 pywps-4.8.1/.github/workflows/tag-testpypi.yml000066400000000000000000000027731526123257100214750ustar00rootroot00000000000000name: Publish Python 🐍 distributions πŸ“¦ to TestPyPI on: push: tags: - '*.*.*' permissions: contents: read jobs: build-n-publish-testpypi: name: Build and publish Python 🐍 distributions πŸ“¦ to TestPyPI environment: staging permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write runs-on: ubuntu-latest strategy: matrix: python-version: [ "3.13" ] steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: disable-sudo: true egress-policy: audit - name: Checkout Repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python${{ matrix.python-version }} uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} - name: Install CI libraries run: | python -m pip install --require-hashes -r CI/requirements_ci.txt - name: Build a binary wheel and a source tarball run: | python -m build --sdist --wheel - name: Publish distribution πŸ“¦ to Test PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: repository-url: https://test.pypi.org/legacy/ skip-existing: true pywps-4.8.1/.gitignore000066400000000000000000000002421526123257100147000ustar00rootroot00000000000000*.pyc *.pyo *.egg-info dist build tmp .tox docs/_build # vim, mac os *.sw* .DS_Store .*.un~ # project .idea # git *.orig .coverage .pytest_cache coverage.lcovpywps-4.8.1/.readthedocs.yml000066400000000000000000000010261526123257100157770ustar00rootroot00000000000000# .readthedocs.yaml # Read the Docs configuration file # See: https://docs.readthedocs.io/en/stable/config-file/v2.html version: 2 # Required # Documentation formats to build and make available formats: - pdf - epub build: os: "ubuntu-26.04" tools: python: "3.11" # Use standard CPython version; `mambaforge-22.9` is not valid here # Python-related configuration python: install: - method: pip path: . extra_requirements: - dev # Sphinx configuration sphinx: configuration: docs/conf.py pywps-4.8.1/CI/000077500000000000000000000000001526123257100132055ustar00rootroot00000000000000pywps-4.8.1/CI/requirements_ci.in000066400000000000000000000000471526123257100167340ustar00rootroot00000000000000build==1.5.0 tox==4.56.1 tox-gh==1.7.1 pywps-4.8.1/CI/requirements_ci.txt000066400000000000000000000171451526123257100171540ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --generate-hashes --output-file=CI/requirements_ci.txt CI/requirements_ci.in # build==1.5.0 \ --hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \ --hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647 # via -r CI/requirements_ci.in cachetools==7.1.4 \ --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \ --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6 # via tox colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 # via tox distlib==0.4.3 \ --hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \ --hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed # via virtualenv filelock==3.29.4 \ --hash=sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a \ --hash=sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767 # via # python-discovery # tox # virtualenv packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via # build # pyproject-api # tox platformdirs==4.10.0 \ --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a # via # python-discovery # tox # virtualenv pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 # via tox pyproject-api==1.10.1 \ --hash=sha256:c2b2726bd7aa9217b6c50b621fef5b2ae5def4d55b779c9e0694c15e0a8517ba \ --hash=sha256:fa9e6f66c35b5017e909825d8f2b5d5482ea699d7be809d21c03bd1f7317f36a # via tox pyproject-hooks==1.2.0 \ --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 # via build python-discovery==1.4.2 \ --hash=sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500 \ --hash=sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690 # via # tox # virtualenv tomli==2.4.1 \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 # via # build # pyproject-api # tox tomli-w==1.2.0 \ --hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \ --hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021 # via tox tox==4.56.1 \ --hash=sha256:4d06b925c4dd67872099b39c5a46fba79a2169c5f6e32060f95a8b1181f0ef55 \ --hash=sha256:db1c2610802553189cf40de251661d066a635ee0ed9bf2a60093b5f1a7f36ef8 # via # -r CI/requirements_ci.in # tox-gh tox-gh==1.7.1 \ --hash=sha256:4f4871ee4091b41464f2df9ab1fcedb14a48accdecbb60d53297301a9d8984c6 \ --hash=sha256:a82285b16a597516f9a24f20326b124ddc7924a8b70cb05a9192e1c53d7f701d # via -r CI/requirements_ci.in typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via # tox # virtualenv virtualenv==21.5.1 \ --hash=sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783 \ --hash=sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8 # via tox pywps-4.8.1/CODE_OF_CONDUCT.md000066400000000000000000000062311526123257100155130ustar00rootroot00000000000000# 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.8.1/CONTRIBUTING.rst000066400000000000000000000170511526123257100153570ustar00rootroot00000000000000Contributing 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/main/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/main/LICENSE.txt - make sure, the tests are passing on [GitHub CI](https://github.com/geopython/pywps/actions/workflows/main.yml) service, 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 reST 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 `GitHub 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 `PEP8`_ 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 ``main`` is the main development branch in PyWPS. for stable releases and managed exclusively by the PyWPS team. .. code-block:: console # 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 . # 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 main # create a local branch off main # 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 main: .. code-block:: console $ git checkout main $ git pull upstream main 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 .. _`PEP8`: 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.8.1/CONTRIBUTORS.md000066400000000000000000000016461526123257100152000ustar00rootroot00000000000000# 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 * @fmigneault Francis Charette-Migneault * @Zeitsperre Trevor James Smith # 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.8.1/LICENSE.txt000066400000000000000000000021161526123257100145350ustar00rootroot00000000000000Copyright (C) 2014-2021 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.8.1/MANIFEST.in000066400000000000000000000001711526123257100144470ustar00rootroot00000000000000prune docs/ prune tests/ include *.txt include *.rst include README.md recursive-include pywps * global-exclude *.py[co] pywps-4.8.1/Makefile000066400000000000000000000060151526123257100143540ustar00rootroot00000000000000# Configuration APP_ROOT := $(abspath $(lastword $(MAKEFILE_LIST))/..) # end of configuration define PRINT_HELP_PYSCRIPT import re, sys for line in sys.stdin: match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) if match: target, help = match.groups() print("%-20s %s" % (target, help)) else: match = re.match(r'^## (.*)$$', line) if match: help = match.groups()[0] print("\n%s" % (help)) endef export PRINT_HELP_PYSCRIPT .DEFAULT_GOAL := help help: ## print this help message. (Default) @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) ## Build targets: install: ## install pywps @echo "Installing pywps ..." @-bash -c 'pip install -e .' develop: ## install pywps with development libraries @echo "Installing development requirements for tests and docs ..." @-bash -c 'pip install -e ".[dev]"' clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts @echo "Removing build artifacts ..." @-rm -fr build/ @-rm -fr dist/ @-rm -fr .eggs/ @-find . -name '*.egg-info' -exec rm -fr {} + @-find . -name '*.egg' -exec rm -f {} + @-find . -name '*.log' -exec rm -fr {} + @-find . -name '*.sqlite' -exec rm -fr {} + clean-pyc: ## remove Python file artifacts @echo "Removing Python file artifacts ..." @-find . -name '*.pyc' -exec rm -f {} + @-find . -name '*.pyo' -exec rm -f {} + @-find . -name '*~' -exec rm -f {} + @-find . -name '__pycache__' -exec rm -fr {} + clean-test: ## remove test and coverage artifacts @echo "Removing test artifacts ..." @-rm -fr .tox/ @-rm -f .coverage @-rm -fr .pytest_cache clean-dist: clean ## remove git ignored files and directories @echo "Running 'git clean' ..." @git diff --quiet HEAD || echo "There are uncommitted changes! Aborting 'git clean' ..." ## do not use git clean -e/--exclude here, add them to .gitignore instead @-git clean -dfx clean-docs: ## remove documentation artifacts @echo "Removing documentation artifacts ..." $(MAKE) -C docs clean lint: ## check style with ruff @echo "Running code style checks ..." @bash -c 'ruff check pywps' ## Testing targets: test: ## run tests quickly with the default Python (skip slow and online tests) @echo "Running tests (skip slow and online tests) ..." @bash -c 'pytest -v -m "not slow and not online" tests/' test-all: ## run all tests quickly with the default Python @echo "Running all tests (including slow and online tests) ..." @bash -c 'pytest -v tests/' ## Sphinx targets: docs: clean-docs ## generate Sphinx HTML documentation, including API docs @echo "Generating docs with Sphinx ..." $(MAKE) -C docs html @echo "Open your browser to: file:/$(APP_ROOT)/docs/build/html/index.html" ## do not execute xdg-open automatically since it hangs ReadTheDocs and job does not complete @echo "xdg-open $(APP_ROOT)/docs/build/html/index.html" ## Deployment targets: # dist: clean ## builds source and wheel package # python -m flit build # ls -l dist # release: dist ## package and upload a release # python -m flit publish dist/* pywps-4.8.1/README.md000066400000000000000000000065241526123257100142000ustar00rootroot00000000000000# 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=main)](https://coveralls.io/github/geopython/pywps?branch=main) [![PyPI](https://img.shields.io/pypi/dm/pywps.svg)](https://pypi.org/project/pywps/) [![Conda](https://anaconda.org/conda-forge/pywps/badges/version.svg)](https://anaconda.org/channels/conda-forge/packages/pywps/overview) [![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 [pyproject.toml](pyproject.toml) file ## Install Install it from GitHub: ```bash $ git clone https://github.com/geopython/pywps.git $ cd pywps/ $ pip install . ``` ## Run tests ```bash pip install ".[dev]" # run unit tests python -m pytest tests # run code coverage python -m coverage run --source=pywps -m pytest python -m coverage report -m ``` ## Quick Guide with Conda Checkout source from GitHub: ```bash $ git clone https://github.com/geopython/pywps.git $ cd pywps/ ``` Build conda environment: ```bash conda env create -f environment.yml ``` Install pywps: ```bash make install ``` Or the development version: ```bash make develop ``` Run tests: ```bash make tests ``` ## 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 ``` Access example service: http://localhost:5000 ### 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.8.1/SECURITY.md000066400000000000000000000010761526123257100145070ustar00rootroot00000000000000# 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 | pywps-4.8.1/VERSION.txt000066400000000000000000000000061526123257100145740ustar00rootroot000000000000004.8.1 pywps-4.8.1/debian/000077500000000000000000000000001526123257100141345ustar00rootroot00000000000000pywps-4.8.1/debian/changelog000066400000000000000000000263311526123257100160130ustar00rootroot00000000000000pywps (4.8.1) trusty; urgency=medium * Async test that was failing for Debian builds has been marked xfail. (#711). -- Trevor James Smith Tue, 06 Oct 2026 21:00:00 +0000 pywps (4.8.0) trusty; urgency=medium * Tests that require specific optional libraries have been marked as such (#699). * Packaging now uses pyprojec.toml and adheres to PEPs 517 and 621 (#701). * Now supports explicit path validation for complex inputs (#704). * LAS/LAZ point cloud formats now better supported (#702). * CI testing now uses explicit commit hashes for Actions and Python CI dependencies (#703). * Project now uses Trusted Publisher for deploying packages to PyPI. (#709). -- Trevor James Smith Mon, 14 Sep 2026 21:00:00 +0000 pywps (4.7.0) trusty; urgency=medium * Skipped support for Python 3.8 and 3.9. * Added support for Python 3.12 and 3.13. * Security upgrade of numpy (#695) and urllib (#693). * Added conda support (#696). * Fixed linting (#694). * Reconfigure CI and documentation (#692). * Improve the content of default-config.cfg (#691). * Uniform tab/spaces in WPS templates (#690). -- Carsten Ehbrecht Mon, 15 Dec 2025 18:00:00 +0000 pywps (4.6.0) trusty; urgency=medium * Skip support for Python 3.7 * Update validate geojson (#680). * Sanitize test suite (#684, #683, #682, #681). * Support Python3.10/3.11, documentation fixes, and general codebase cleanup (#677). * Extended env variable interpolation (#675) * Don't use deprecated distutils module (#672). * Fixed tox.ini parameter passenv (#670). * Improve the implementation of pywps.inout.literaltypes (#668). * Fix tests/test_capabilities.py with correct check of request case (#665). * Activate async test (#664). * Move Sleep process definition (#663). * Remove use of mutable as default value in Process.__init__ (#660). * Better handle of crashed processes (#659). * Change coveralls branch from master to main (#655). -- Carsten Ehbrecht Wed, 01 Nov 2023 18:00:00 +0000 pywps (4.5.2) trusty; urgency=medium * Fix test section in README.md about pytest (#653) * Fix ComplexInput.from_json to handle data_format.encoding="base64" (#652) * Fix invalide use of encode on bytes object (#651) * Fix JSON encoding of CRS type in BoundingBox (#610) * Modernize CI using Tox, add a "[dev]" recipe, re-enable coveralls (#643) * Fix deprecated logging.warn() call (#642) -- Carsten Ehbrecht Wed, 13 Apr 2022 18:00:00 +0000 pywps (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.8.1/debian/compat000066400000000000000000000000021526123257100153320ustar00rootroot000000000000009 pywps-4.8.1/debian/control000066400000000000000000000012771526123257100155460ustar00rootroot00000000000000Source: 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.8.1/debian/copyright000066400000000000000000000023631526123257100160730ustar00rootroot00000000000000Format: 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.8.1/debian/rules000077500000000000000000000007301526123257100152140ustar00rootroot00000000000000#!/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.8.1/debian/source/000077500000000000000000000000001526123257100154345ustar00rootroot00000000000000pywps-4.8.1/debian/source/format000066400000000000000000000000141526123257100166420ustar00rootroot000000000000003.0 (quilt) pywps-4.8.1/default-sample.cfg000066400000000000000000000105531526123257100163020ustar00rootroot00000000000000# Configuration file sample # # Comment line with leading '#' to fallback to the hardcoded default value # # The configuration is load in this order: # - First the hardcoded value are loaded # - If no configuration file is provided in wsgi script, the first file that exist will be loaded: # * on Unix/Linux System: # - `/etc/pywps.cfg` # - `$HOME/.pywps.cfg` # * on Windows: # - `pywps\\etc\\default.cfg` # - Then if PYWPS_CFG environment variable is set, this file will be loaded # # Last loaded file override setting from the previous one. [metadata:main] # Setup the title in GetCapabilities identification_title=PyWPS Demo server # Setup the abstract in GetCapabilities identification_abstract=PyWPS testing and development server. Do NOT use this server in production environment. You shall setup PyWPS as WSGI application for production. Please refer documentation for further detials. # Setup the keywords in GetCapabilities identification_keywords=WPS,GRASS,PyWPS, Demo, Dev identification_keywords_type=theme # Setup the fees in GetCapabilities identification_fees=None # Setup the AccessConstraints in GetCapabilities identification_accessconstraints=None # Setup provider name in GetCapabilities provider_name=PyWPS Development team # Setup provider URL (informative) in GetCapabilities provider_url=https://pywps.org/ # Setup Contacts information for GetCapabilities (informative) 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=+xx-xxx-xxx-xxxx contact_fax=+xx-xxx-xxx-xxxx contact_email=contact@yourdomain.org contact_url=https://pywps.org contact_hours=8:00-20:00UTC contact_instructions=Knock on the door contact_role=pointOfContact [server] encoding=utf-8 language=en-US url=http://localhost:5000/wps maxsingleinputsize=1mb maxrequestsize=3mb outputurl=http://localhost:5000/outputs/ outputpath=outputs workdir=workdir maxprocesses=30 parallelprocesses=2 storagetype=file # hardcoded default : tempfile.gettempdir() ; temp_path=/tmp processes_path= # list of allowed input paths (file url input) seperated by ':' allowedinputpaths= # hardcoded default : tempfile.gettempdir() ; workdir= # If this flag is enabled it will set the HOME environment for each process to # its current workdir (a temp folder). sethomedir=false # If this flag is true PyWPS will remove the process temporary workdir after # process has finished. cleantempdir=true # File storage outputs can be copied, moved or linked from the workdir to the # output folder. # Allowed functions: "copy", "move", "link" (hardcoded default "copy") storage_copy_function=copy # Handles the default mimetype for requests. # valid options: "text/xml", "application/json" default_mimetype=text/xml # Default indentation used for json data responses. json_indent=2 # hardcoded default : tempfile.gettempdir() #temp_path=/tmp processes_path= # list of allowed input paths (file url input) seperated by ':' allowedinputpaths= # hardcoded default : tempfile.gettempdir() #workdir= # If this flag is enabled it will set the HOME environment for each process to # its current workdir (a temp folder). sethomedir=false # If this flag is true PyWPS will remove the process temporary workdir after # process has finished. cleantempdir=true storagetype=file # File storage outputs can be copied, moved or linked from the workdir to the # output folder. # Allowed functions: "copy", "move", "link" (hardcoded default "copy") storage_copy_function=copy # Handles the default mimetype for requests. # valid options: "text/xml", "application/json" default_mimetype=text/xml # Default indentation used for json data responses. json_indent=2 [processing] mode=default # hardcoded default: os.path.dirname(os.path.realpath(sys.argv[0])) ; path= # https://github.com/natefoo/slurm-drmaa drmaa_native_specification= # hardcoded default: os.path.dirname(os.path.realpath(sys.argv[0])) #path= # https://github.com/natefoo/slurm-drmaa drmaa_native_specification= [logging] level=INFO file=logs/pywps.log database=sqlite:///:memory: format=%(asctime)s] [%(levelname)s] file=%(pathname)s line=%(lineno)s module=%(module)s function=%(funcName)s %(message)s prefix=pywps_ [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.8.1/docs/000077500000000000000000000000001526123257100136425ustar00rootroot00000000000000pywps-4.8.1/docs/Makefile000066400000000000000000000025501526123257100153040ustar00rootroot00000000000000# 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.8.1/docs/_images/000077500000000000000000000000001526123257100152465ustar00rootroot00000000000000pywps-4.8.1/docs/_images/pywps-scheduler-extension_interactions.png000066400000000000000000001170411526123257100257120ustar00rootroot00000000000000‰PNG  IHDRΡ=ΦΪ IDATxœμέol[χa�ϋσ€:EJθ¬ΣΟΏγφξF‹bήΕ[8Eθ7&Ϊ:cawQ“Ν`7€ΠΊ%κdPνΆˆƒΤU»$eλ€œά…ip]έ›ΙP§Έ•ͺ αT <Έ‘ƒ?&λE΄Eht]ιλ˜υΙη>8€Dύ‘ΔcιηΎ_ΐm$ŠΜΞGη{ΎΗθŠΡο'@XP’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θRJt]ε˜RŽ%Γ0dΪ e'ζUmΈίm,Λ6 95–σ² Cv~Y›}ΈFEΕ‰Ό&Š• χΡΊ3[Zύ^£€1ӐaεTΎιέϊq`KΥ)% CFϋ0₯'ζUΫςk*¦ †£‰Šϋ•ϊlΪύωτ¬κ’€Š&C†‘Tρ_·{œ†ͺ³ye–LÐaXr�γ*V6nΥ؞@ΨtΏίπ“mϋλ;R™”c²ΖΛ[nίwλρψ]?τ DΧ5?f―μ¨₯�)9¦»γfηζUΧΊIuJ™dR™©|`σJ†ŒTk²ν[Ύ~:?.l©U’Ν€²ccΛ€δ† ΓTzzλ]-$d†RΣuI -η›Ϋ\{\Λ IυYwΫ”(¨ΊΝγ4–σξ›Ž�Ω1eSŽ»£e©΄nΓΖφΒΕΛ6~γΟR’7ή9Ώ«•�%Ί2ιξ°Y9Ν·�ΥX^=*R[$ΊύΣP₯˜S�6e–™Ι•x£<&Λ0δŒO*—΄d¦œLAˍΦw¬ωΐ¨ϋR­4©l’uδ<©±©rσΓQWi2ΣόC€);9¦©ε†VτlώΈ°₯VΉMWŽ<7JY™†!+wIΟ$ FBSUχ{΅bR†a*3_—*ξI�¬†j*& ™‰„L#©©Άm¬_VcΛΗ)λυ)·§W7ΨͺL¦dΫIε—ΧnΥ؞@ΈT·έΖ»GͺΣΝΩ£V"«BΩ½νΚο’ά€r©ζΎwvJξAμΞϋμRCΥιq%mC†™P6?&»Y’λσ™Ά?―ώ^Ή™NΘO)Ÿv”jύ’l=:Ώ«ΰίKt­˜’aJLVΧ|½Q―ͺZ­m8½ζ�—snθS㚜s�r–œRU«Γ°”Μd•n†8U¬ͺΆ\P�0d$ *W7ίι36­RuJ©ζ‘š±ρqeξ‡&;[W£4ζξl¦Η5‘S�lνilωΈ°₯υεΆQUiΒέΩq&*ͺN%›;5IuΝf fF³uI²rVσHsέέ1H¦”2LeKυfαΆ4Vjlϋ8­Bm8fUͺΤ:ώuν)„ΛΆΫψJΑύa§•ΟΊΫεζQκΦ>Ίa˜Jd²JΫξφ>=]ίrŸ}ε€š™�X.»rJ‘—έ]'0dX eΧΝήβwόΰ{‰^™f8Ϋy’Γζ%ϊšJY³νΘKCεœ%ΓHh²Ίϊ±Ηέs§WΞ₯_V£‹ιά†ΦΨΨXs€έ―5?H•IΗύΰ΄ώJW+ΊTQ―έY+3‘ιREΥς¬¦§KξωέLιp³6;'Ϊ0di«ξχ“†!33―z£¬1«ύœη†»½43š-OΚ1M,/k�1δL”5?ζ~oΎήΕγ¨�ςTN)Ηlϋž₯δXQλO™c{ a³υ6Ύ2αΘ0,εš‡gk³γΚdΖ4U]έ_·rξΧF9ηΞ έrŸ½‘εq{ν}N§=–θξ:™™έt ~WΑ=8άόHt­’J₯’Z£S‰~EΕδf;z¦²₯ΖκԍBσ~«ξ_Ή¬ρrW%Ίσ”ŽΆfλŒΦQgR•zY…¬Σφ|e&KξγπAp³ΦŸ«<–ΣψΔ”ζWώ²]U!α˜/»Ϋ±ττκ–Ζ]LΜVn"εζFC₯1SFrB㎻#PλκqΪΤ«*ΟNi<νNo[Ωή6±=€Ϋ°]ε1w›½ns/iγ9Κ«…w«}φΊζ3«§pΆ�\χ%Ϊc'ΨπΌω]…έΧ“s’Ýφ°znDΕέl~H7/ΡWV>t“₯e-//kΉ\VΉ\V₯.KτζJξτΗΧ—KšŸ/«Z]ΦμTΎ9•ΕvΟ#αƒΰfmr�ς†›2 K™1G†‘�šk­Ώš[†Œ„;Ε­5Ϋ\³­άκqZwά2ήΆΒΜκΡ‡΅‹ΐ°=€0Ω~­y$z¬δnΥλε)MNL©TΫͺDo΅ΟήPyάZs$Ίu kΌ¬z)#Σ0”lžΗܚnΎ£N°ώUσ» >θΡκάΦΚy ιLF©ζωΛV֝vΡιœθϊ|sΚv&―B!―ŒmΘ0³šν’Dg C†ΣτςΪHw΅Έ@ϋyω|Ϋy΅ζ4CN6―ΙΒ„Ζš ˜¬HΪβq`K]”hU «S±SλoWΥd’Ήmm–έΥσΐœΥEΑΆyœFyΌΉZ·₯D:«±lZ Σύοφ.‰ν)„ΝvΫψFλόe;­ρΦωΏλΞ‰ήX’·Ωgo­n₯46žm>^sŸ½Ή0¦a&”Λ¬|oG`ύkζw|Π³λD— Y%[Χ‰ΆΚδ§WΞ―λΌ:w]εBV «ωaKŽiͺΠ-K΄jšsΏ“ΥΉ3›­ΠΧ¨¨˜KΙn~Θ ;©±©Φ΅α:?.l©›­Š&›„LN­ΏΥκ₯­ZΣβTŸWfύ%<Ά}œ†ͺσΚ&νζ5DMY‰ŒςΣ―ΙφΒf»mΌ»’vΚvΟ™Άc*΄φ½·(Ρ[ν³»+k)i2LG™\fe:wCu•ς)wa0;₯άDΆyžυ:ΑϊWΜο*ψ G%°s­Z’Zwτ%B‘¦R1―΄e¬^6=G‰€0hΈS³M'­Ι2η]τ %€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ%€.E²Dφ³ŸνχS°{φμιχS6Ιύ;Ώσ;ύ~ jμΈϊΝ0"YU‘L&8ΨΆ£€~γw‚*’Ιδ;ΓvΠoΜ.EPEr/Ι0 ύς—ΏμχΣ€ΠbΗΠo†aθ'?ωIΏŸ°AdKτχΎχ½~? -Γ0τӟώ΄ίO0ΐ ΓΠ—Ύτ₯~? `ƒH–θ?ύΣ?՝wή©�ψ�θχS€P2 C“““ύ~€υςΛ/+›ΝͺX,κ#ωϋυ”Θ”θwήyGίωΞwtδΘ-,,¨Ρhθ½ο}―>σ™ΟθG?ϊQΏŸ„Ζ?�σ?+›ΝκΩgŸΥΨؘ~υ«_υϋ)"ζ·Ώύ­ώλΏώK?�ωΟU­V΅΄΄€ΩΩY}φ³ŸΥŸόɟθΓώπΚmŸ~ϊiύξοώ�Nœ8‘ΙΙI-..ͺR©θƍj4}|TKt₯RΡwΏϋ]}σ›ίΤγ?�/~ρ‹:sζL_ΗΧΎφ5>}Z?ό°>ρ‰OθŸψ„ξΎϋn}δ#‘išϊΰ?¨—_~yΝλψκWΏͺ{ο½WοyΟ{tδΘ}τ£ΥιΣ§511‘―}νk}M_όβυψγλ›ίό¦ΎϋέοͺR©ψώ¦lv\ώφo�VΠ±cΗVn{ξά9 +ŸΟλ±ΗΣΛ/Ώ¬Χ_}χ�K/鑇�±cΗ”H$΄�~½ϋέοΦ‘C‡d#ΐγΠ‘CzΧ»ή₯?όΓ?Ԟ={”J₯τgφgzπΑ599©ύΧέτ=ŸŸŸΧg?ϋYέ{ο½ϊƒ?ψήσC‡ιέο~·’Ι€‰„Ž;¦‡zH/½τR·»cM‰ώώχΏ―όγzο{ί«cǎιψ€Ύπ…/θΑΤη>χ9=όπΓ}…BAgϞչsητo|CίϊΦ·΄ΈΈ¨ϋ·Ϋφ…ώηώ§–––4==­©©)}ωΛ_V‘Pθϋkϊάη>§|PŸ�όηυ|@ǎΣ{ίϋ^}όγΧχΏ�}ίήψϊφ·Ώ­“'Oς‹‹ΑΩΨjΗεοώξοtυκΥM?σμ¬2α›νΈžΫο§�5C’Nž<©Οώσύ~.ΨDk&@<ύτΣΊ�ώϋυ›ίό¦ίOμΠo~σύε_ώ₯Ύρoτϋ©tεk_ϋ 699©―}νkύ~]1�^½ͺ}θCύ~ΨΒ‡>τ‘ŽS,{₯P(θ+_ωJ_ŸΨ}_ωΚWBqDϊCϊ—ή"μ§?ύ©>ψΑφϋitΕψϊΧΏ�OϊΣύ~ΨΒ§?ύi}ύλ_ολsψΐ> Ÿύμg}}`χύμg?Σ>π~?m:t¨ίO€ΟΒς97Μ9£‡~ΈίΟ[Β{–@οΒπ{ή0"seV„εsN‰ ΌGa 4π. ΏηΓπμLX>η”θΒ{–@οΒπ{> ΟΐΞ„εsN‰ ΌGa 4π. ΏηΓπμLX>η”θΒ{–@οΒπ{ώΜ™3ύ~ |–Οyπ·˜„°xΗοyθ%€.Q’θ%€.Q’θ%€.Q’θ%€.Q’θ�¦%ϊ}χ^FΐF?Tž5AΦοya„//ύ~͌`δ [ ω0#`# ϊύoΘg�:–hύϊ*# £Ÿ%Zo_ed}η†ΌkFςB‚5‚ΎέXψΘ‡υΞ«Wa);Ϋ!WΑaΙ%:ƒΝΓΞ y Φ /Œ0δ…k}»AΩ ΦKΩΩΉ ΦK�(Ρ!”hFvnΘK°ya„!/δ X#θΫ ΚN°FXΚΞvΘU°FXrE‰Α D3Β°sC^‚5Θ # y!ΑAίnPv‚5ΒRvΆC�‚5Β’+Jt%š†ς¬A^aΘ 9Φϊvƒ²¬–²³r¬–\Q’C0(ь0μܐ—` ςΒC^ΘA°FΠ·”`°”ν«`°δŠ‚A‰f„aη†ΌkFςB‚5‚Ύέ μk„₯μl‡\k„%W”θ J4# ;7δ%XƒΌ0r¬τνe'X#,eg;δ*X#,Ή’D‡`P’aΨΉ!/Αδ…†Όƒ` o7(;Αa);Ϋ!WΑaΙ%:ƒΝΓΞ y Φ /Œ0δ…k}»AΩ ΦKΩΩΉ ΦK�(Ρ!”hFvn•—7Ο)m:*\YϋυZ!.σΆ‡U{{AS·2ŒΝΖϋτ‘ίοτ=G…WŠ^χυaGΩG.¨ΡοΧM^ΊΘΖΝΌK*e†d:§zλkΧΞ*eJ<2·r»ΖβQΩ樦―m�WΞjόΰ^™†!ÈΙ9xTΣ―, \^ϊ’ƒ7Ο+kΆΏ?C²o=¨‰™…59Ι­!9οΣό΅ζm�œRΒ0dfΪrΡΚK6&Γp4ΩΎ zσ‚ς{ fZ₯77{^Ϋ|?Β9θ–ίeηΖΕ³zΰ@ΫηςΐQ½pqi럻˜S|ο}ΊRήαγ/>¬ύ#i-ns?7¦ο“³/§Χ(;»¦o%Ί\Τ_ΔΦmgbŽξyπ‚ntόΉ%-’yπœ�―dη¬ξ4 ΕœkΛΙQΩC£zaqϋΗΈ©ά“+Jt%š†›@εeΫ}Uz³€z₯€ϊ΅σΚν‰){aΑύοΚ�6ί+jlψr3­―ΝiΉpXΆ1’όb�Jyι67χώ՞•Ήη>-7ΛMύΒAw‡γ޳nžήΎͺκ—·žTu»Ηx³¨ρ=CJ>πΈ*Χ–TΏvA³Η™ο[½�AΙKίJτπˆΖ[οΟ• š$-ΫΡψLi%'Ή=1­|φKͺ½rN·2=ξ–ζf‰6†Σš―¬Ν˜[ΐΧ–θΖβQ9ΒΓ1e/”6<―νΎεtΛΧ²S.ꁑ!νΏ�q½ΆΈ€λ‹τβ1Gζv™z}-Ρ##:ώΜ‚�_.ιϊε9]9γώ�81έΉΔΎ59*sd5sןjώ>Ίύ¬ήjήζΣŽŒ}'υΖvq³Ή'W”θ0 J4# ;7ΚK7%zεΆ4Ύ'¦±—6)P›}―YΖΧ�MΌΟP²°°{―Όψ”­ήΏΝgbJœn]^�ς{eί[TγΚI%ΜΈ¦�Ή_/ί;’Δρ£J ΤlεͺτvIσ‡†dΏ Ζvyε„ζζΟ­fm"sT³Χ|zέΝK?KτΪ?š,iω™­?ŠΌYTnψrλΆ ΅B\ζ­'U}ϋͺtε”’Γqέ6²¦τ6^:"ηΦ΄RΓν%zIεγ#²ŸWω½²69z½υχ£ƒnωZv.žP|θ€^Όά^r.θ‘CGυββU½σκ’ή˜<’ύ#ξΡΊψα‡υZωj³DΡcχ=dȈκΔ3₯•ϋΈώLNwο’a²ά§ΉΕΦύ—tωΑ΄β±ζ‘ΑUΌY’o<DNό€ήhήGϋ―/Ρο�bNρψ ½p�¨lŸ QXΚΞvϊ]’XS˜gτΠ^CϋΟΜhξPLρO΅Ž./ιΚύ{eίSԍK'ŠλΙf./ί3’ψ±£ŠΗ6σ[�άΑ!ΩΗ.θΖ–±ΠEξ)ѝP’C0(ь0μά*/=.Ρυ—rJ± ;άδ₯³ •θφχ―ώάAY·rK�Ϋ3šΈuDc3KΝ, )s‘δN»}ί^ε_Ί Ι5ߏ);³΄}Fή<―άCN&§ι—ζq%Ί9ψ°;•z³ύ挊‡br(ΊοΩ•SJTρΛ£²3η›ογ’ΚχξUꑇ•έΣV’ί,*·g―ς‹Kj,ή'g³£Χ[}?β9θ–ΏG’Ολψˆ!ηPN/βΘΞ_9R\{zTζm§T.ŒΚ:θžۘ9,ϋΦ*?wPVkšοΆ±€FϋWf4:-ΗάΫ·σκ)Ρ›‰Ž)»ςΞ©ς�Yεή7€Τ—›Σύ›%ΊTYPρwFBcρ¨œΫN©Zi/Ρ­…Ζ ™ζLӝz»rnυΆί~ΊεoΩY�φ)Ο—g4χ©΄œ‘½:1ύ²ΕΦ,ά΄2֟½R’—τΪΗφΚ0†dΕbΝ1$c(�Η.•τβνCξtΪΦύ\:΅rNτ¦%zίϊ½ΥύoςΌΈμl§ίG’�≽qiNo\ZΠυ5οΧ‚ž½=¦»žXΠ:²Ÿ_ω£Λ[“£2γ§tωΜθΚRn₯GγΝ©Ϊ­ο]:©xlTΗoιΞΦc.ŸΣέ1GǍΈG€»xŒ­sΏΙz0Β’«”θ%U¦Ž*ύϋ#2 C¦WφΛηUo~Ώρύϋδό~N•__Uγ;i™ζAΝ�|χσsJ[£šύIΏ_χΥΑ-Ρ\†&t;7”θ` ς�)]Ό―œPΒ’Ρ:*Ή&ΝU—_i}}AΣwΈΣγV¦ίnχΧΞ*3<€ΤgUΎ² Ϊ΅• G”ΰHt2ΠνκάKtυGζ««s·J΄�=¬Τ°£δžΈ»έi+Ρυ iY{Ž�]y½ΉB{ϊΉ�Άί„tΛΧ²³xVwΗ†tηύguω�‚ήZœΡε3Go‘»1}ŸœΦ9Ρ—ΞλΡΫcŠjf‹#ΡWυΞ₯SΊ3Χ‰§ζtύςœ?5*kί ½φjsϊφPσώ‹zτφ‘•s’ίΉ˜“34ͺΗ¦KΊqΉ¨‡βCξJΛ―�;'z‹ϋ§Dw/Θ%Ϊ]ψkHFμπΪ•ΫΛτΐˆϋϋθΡ‹mεχvχχΡέO•Ί{ŒmrO‰ξ,%zI˟‰Λ΄hβ[Tύρœ–/žPΪRͺ0·‘DλΗEM?w^΅<&%ZRJ4—‘ ΥΞ %:XƒΌtΚF7’Ά™9ΏξvξκŒu…§ϊˆ#ΓUρZχQι”rwŒ4�@7$λΦƒšx�‹Œ \‰6ΧώuΣλDoR’λϐ9œv·—θ·TΌΝp·4Γ-Ρ%Νgb²7d©|oLζΑΟλΆόώٞNιϊvΓο²sύωS:~{ΫηrίA=τDk±₯%½vζ°φΗZ«sŸj[»C‰~υͺήzβ>έ5βNΡ·βGτΒ₯V1i[{ΘΡ=žΤέ{[—ΈZΠ‹Ηβ² CζήzθLNϋ;¬Ξέρώ)Ρ] t‰n�€m:ΏnΡ/χzΡΖΘΡ5ορΊΏž]μώ1ΆΞ}οGXrόύ㇕2G4ώύ₯5_―=wPφŸTu}‰ΎšSβχξΣrσHtύ;9e~―Ήτ�ϋοΣό›χq5§Δν9MŸ9(Η4dX£Κ§$ύzF…?vooόΟƒ(�ƒ\’Ή ΝκϊΞM J4ƒΌμhΜ©p눻�vߟK΄σμ ήϊv£oe‡κ²³`ηjNνqWΪξϋs!Wν_’λΚό½nAξ0:–θŸUΪΪ«ά·fT�Ɍfοί+σO©ΪΌcΔ”:STύη *}&.σχέRΞ‘hWΠJτ _†&θ;7μ k—›o–T½pTΞuSΉ#>(ь~ζ [Α.;ƒ7ΒRvΆΨ\•Kzγ©£rFΦMεŽψK�_’kSq™|vεόg/%Ί6—υώΗWφ'+mͺψcχvŽ•Φ|«(·—oJ΄€”h.Cͺv†ƒ5ΘΛ͍ϊ…ƒ²Μ½Κϊ³ΐΧ ε%¨9ΤτνF`ΛΞ€Ž°”ν5Wן:(kh―ξ9ӟΎΘΥΦ_’λί:°z„Έ}ό|FσϝSεηJτ’*Ÿi.ύoŚcH†Wα‡W7Lϋ¦DoΤχΝehB΅sΓΞp°ya„!/δ X#θۍ –Aa);Ϋ!WΑaΙUΰK΄~xRIc―&֝]Ώ˜–i₯UκX’―ͺZˆΛϊ?Ο©±ςsKͺύxΑύoJτΆ‚P’Ή ΝκϊΞ ;ΓΑδ…†Όƒ` o7(;Αa);Ϋ!WΑaΙUπKτ―K*έΏW†uP“ίΊ κTωΞIe,C‰Ο\PγΧ[œύΓSJYqε/Ξ©ώ“9•ΞŒΚϊύ›.@Ά±DΗέiί}ύ”θŽ·°ΛΠ}熝α` ςΒC^ΘA°FΠ·”`°”ν«`°δ*%ϊͺτλ’Κ_>¬ΤοΕd†LΛQζΜΉ•ΛXm΅:wν[χ)ύ?›K�ί~DΣ?\ΪXš7όχœ¦ή“aΔΡhJt§Ϋ Φeh‚Ύs”α ]Ϋ»ŸcΰςrSΧ–οcNg�2ΝƒšΘβe”θΦXR₯pTι[ένΎ9Wφ‘σ+—™j,ή'η֜*»υΎyNιαΡ΅Wΐtˏ²sγβY=p νwƁ£zα’»ςϊΛIυm”Οι�Ψ¨^Όά�‚Ζ²³]ΛUωœξrτΨvUXž IDAT₯­ow}rTΦΑs›_Bjρ¬φΕυδβ.<ŸŒ\"-μΉ I‰μ1%Ί«1X—‘ ϊΞM ς°k{“—Ξ|)Ρ7qmωΎkEM?}~έι"ƒ——@l7VΖ’–?—9|@Ο]Pυڜ–/œPzxH©ζ)<ν%zWήCJ΄'»^’ΛE=02€ύχ?�Χ—t}ρ‚^<ζΘl–Jt4ΚΞv(Ρ› JτΆ(Ρ!”θMΖ^†&θ;7ΘKΧφ�Οδ”y_σΪροΣ|kfΒ+9%n;‘ιFeΏοΓzδ�r―ήΌŸκiGΦ!w‘Ίξξ£ΏΕ}ΰς²νuίΧΏ7Kͺ>}T©=C2Œ!9‡r*­\�}IΥ§(ΉΗ=2•Θ>¬Κ›ΫδηΝ9Mί—e2Œ₯?ΩδΞΆ²φΦ³­YΫζ3§>uPΐ!#6ͺΟ”VξsΣ<]Μ)Ύχˆ»�€μ ?Σ)«›|n"›aΙ%:ƒ½q βeh‚ΎsˆΌlwmοkg•ή«άs3ͺWf4{ο^™w4―7ώŠ{νψDφ”ζά…λξ8Ϋ<Κ4§Β­1ež+yΊ~N#ΈΌlwέχυοΝ•“JšŽςζT―Μh:;"λPσύ~ε„Γ£š|iAυWΞ)[LΙ/Οmωήן; σ}G5₯€ΪK'•vΧdθτυυ%Ϊ1bJ.ͺώζ‚JŸŒΛΌ΅­΄ Pα₯Υ―œSώVcΓ©*aΞK Ά­Ό\8(σ}'ά‚άat,Ρ]l6{9νΝξ‰>―γ#†œC9½πόά†#ƒ^J΄cΔ?|JsΣ Ίqρ„#¦{ΞΜθΖ咍2bi½piIo=ŸS|(�gΧ—€Ε³Ί+ΆWǟ˜ΡυΛ3zρž½2o?εήϋ‘θ‹'κΡηtύβ9ˆΗ΄�t.–²³_Jτ₯“Ϊ?δθΔSsΊ~yF/‘uπ¬ήj–hÈιž3tύςœζξ_ύ°e‰ΎtRϋcq=ϊ̜�/ΞθΕ{Fd>―]ζσΞOu½Ό ΕΕeξk}―Cž.ζδ†φ7ζςΗβ2χ5σΈUVΧ"œ+Jt%š†›ΐδe‹k{Χ ρ•Λžιν«Rεq₯‡›ηΙΏ’“3|Pσ­Ω+§”njώ�ωŠΗϋ /υδœθφλΎ―{o*§Y™s«ογ΅ζ5δ+KZ~`―μ{‹+iΌrVΕ [ΎχυηΘάsDΣ‹%χg*%5ήξόυ %z8½š›•ο-iωψˆμγ«Ο₯~α LJ΄/£VˆΛΌγμκϋ»ΙθT’·ί.lφώ^₯D{δΛP‹E=ϋ±ΓΊk_LζЈβκΙgΌ—θΨAΝ΅Žh_<‘ψΘ]._Υ;―.ιΚύ{ε»ΰŠrQΗχ:ztέ”ί·ΞΔex\Χ[_»όΈξŠΊe{₯D»χeίS\)'7.žΥ³O-P’wΐύΪ§Y‡Ξ­ΎŸ‹§΄hT/\vK΄ΉχΔκiεσϊ‹Ψˆ˜^ΪΊD—KzγRσhpyA—?ζΘ9Σφση•vTΈ²€R&¦Δ#λgΉlσήΏ½ ωG+ٜv=vϊœͺonρυυΣΉΫsΣV’η5‚7ŸGcρ>9”h_FύΉ«GˆΧdkFσOŸSεΝN%Ϊγv}ΣvΏD/ιFϋtΣΛ3šϋTZΞΠ^˜^ΪΎDοk›.Ϋ>uυβ‰Ά�v‹Jόc3ΝςsAl(ΡKzνcΝ ΕbΝ1$c(ξΥl+Ρ‹‡bŠ?؟#Οa-;ΫΩI�n\.­m])ΡKΊ|lDNλ=oε{bnΑΎ>9*³½„Ύ:£GχΕtΟ3Ϋ”θW4χ©΄ξά·Wρψ¨ξΊ=Άu‰ξ˜ΟΥέ1O›ύΜΎœ^Ϋ.«»p.uXrE‰Α D3Β°s„ΌlwmoχΪβηΪ¦Y/©vmaγ‘ΑΦΏωiGvφ¬¦ξQΆyi4―χA^6Χ›s’ΫΖΊχfΛ#ΡΗG6‰žzzN―oρή7�\Πς΅%χk‹g5~됒_žλψυnKtωήΩΗ/΄‰NΛ’Dϋ3�œT�Ψ«‰uͺ_HΛN«Τ±D{ά.P’oΪn—θλ“£2γ§ΦŸΌ βCŠŸžΫX’χX))ן8Π6%v§%ϊͺή8_·ΨΤ’ήZlN‡m?}ldΓ‘Γ''7NE§Dwοζs5§'χ ιΞΙζΡαΕ³Ϊ3G’/ŸΣ=±½zθβΥ-Kτ[gFeΕOθςβ�J~­φέu>ێDwΚSΗ½MV)Ρ”θ J4# ;7ΘΛvΧφΎrJ©αxσ<Ψ9•NΚΊυΔ¦‹©υ5sDVϋv^οƒΌlͺί%Ϊ]„nέ9Ρέs’έσ›ηD_9―Ι;bJœžΩς½―žvdήvB₯k%Υ―œΧΔmnYξτυξJτU5^:ΊzNτ5χη9'Ϊ―QRιή½2†jςΉ ͺ^[Peζ€2Γ†Ÿt�Ρρœh/Ϋ… %:ΎzιEΆ[Ϊυ#Ρ‹guwlHwήV—/-θ­Ε]>sDρMŽDΏs1'ghTM—tγrQΕ‡dμb‰~η�)έ‹7Ο‘Σβ§Feνk­ς9έsΟ£Ύ1}ŸœΦ9¬—ΞλΡΫcŠjfέk[Πε'ΧεΕ­ώ?%Ίεζs΅€ΕΓ1™NιΚε’�<8*3vX‹εf֟}`ν9ΡwŸΎ ·.Οiξ˜#³υ^·ŠψΕ’�_^7šεՌŸΤ•Ε’ήxώaίkȈ?μώΘS>W�»cžΆ(Ρ[f•M‰� D3Β°s”ΌlwmοΪsχ)½§yνψێhϊJ³tmZ€έ՝­μΪk‘{»ς²™Ύ—θ·[«s++§Ξ·­Ξ])4§`ξbd­ΥΉ;Ύχ•’&Ϋ�Ožl­θέιλ]–h½½€εG�ξͺΞ{F•/δ”Όuχ3F‰n’ʏVκ}1†!sΨQζτΉ•Ω-[­ΞέυvaΝΟiκ`LΖϊ« °έΨ”ηD_ώ”Žίήφ;cίA=τ„{dwν%�τβ1w₯}sο=t&§ύρ],Ρ―^Υ[Oά§»FšŠΡ —š+6Ώ:§'Δd Π‹——τΪ™ΓΪk­¦|jγκάεσΊghHw?΅Τω�S’Wμ(W‹λΫGάνΕΘ=΄fΥλ£Ίs€ω;ζ@neλλ“δ:©‡šΏΦΌΧ‹g΅ίXΏΎ‡‘ύgτΞεσ:q{¬Ή2wZ=qJwΔtΧδ‚Η|ζΦδsΣbΑxA^ΰyDΰ @Αδͺ·"½Υ§Dw‘VT*‘Χr£ίOΔ?μάΐ ς/Θ $ro(;πΉκ­HoυΓ5»�r!«„mΙ²₯rEUR΅˜–“™VMR£<�Dr’Yx7Ώ½TΣ|>-ΗΆdZ޲S5$5JY9Ω’ά�άP)λ(3�Ί¦2Ά ΓT";­ZΗϋ 7¦ΩΑ ς/Θ $ro˜v ?«ήŠt‰“Fy\‰tAΛuIͺ¦Η% UIUM₯e§η•O&5ΡƒcqqΡσΟ°s/Θ Ό /ΘΌ‘μΐδͺ·"½ΥΟtntγ­·ή�{ήσ½ϋέοΦδδdΧ?Η4;xA^ΰyDΰ ΣnαrΥ[‘.ш–·ήzK–eΙ0 ™¦ιΉLΐNQ’ν%Ί5ΪΛτoϋΫ~?Eιέ^ΆΡ·άrΛ¦_Χ»ή₯γǏ{Ί/`3LΛ„δ9€7L»…ΘUoEΊI„ka1lg³#ΡCCC2MS_ϊ�—τί�ύί]—cJ4:a xA^ ‘xΓPπΉκ­Hoυ)ΡΡ�^’ΫΛs;J4vŠaxA^ ‘xCفΘUoEz«ΟκάΡ�Z{³ςάB‰ΖN1-^Hδή0ν~ W½E“@¨|ο{ίΫςϋ”h~’I R(Ρόι&ΑtξΑC‰ΖN1-^Hδή0ν~ W½ι&ΑΒbƒ‡b xA^ ‘xΓPπΉκ­Hoυ)Ρƒ‡bg^HδήPvΰrΥ[‘ήκ3{πP’±SLΛ„δ9€7L»…ΘUoΡ$)”h~’I R(Ρόι&ΑtξΑC‰ΖN1-^Hδή0ν~ W½ι&ΑΒbƒ‡b xA^ ‘xΓPπΉκ­Hoυ)Ρƒ‡bg^HδήPvΰrΥ[‘ήκ3{πP’±SLΛ„δ9€7L»…ΘUoΡ$)”h~’I R(Ρόι&ΑtξΑC‰ΖN1-^Hδή0ν~ W½ι&ΑΒbƒ‡b xA^ ‘xΓPπΉκ­Hoυ)Ρƒ‡bg^HδήPvΰrΥ[‘ήκ3{πP’±SLΛ„δ9€7L»…ΘUoΡ$)”h~’I R(Ρόι&ΑtξΑC‰ΖN1-^Hδή0ν~ W½ι&ΑΒbƒ‡b xA^ ‘xΓPπΉκ­Hoυ)Ρƒ‡bg^HδήPvΰrΥ[‘ήκ3{πP’±SLΛ„δ9€7L»…ΘUoΡ$)”h~’I R(Ρόι&ΑtξΑC‰ΖN1-^Hδή0ν~ W½ι&ΑΒbƒ‡b xA^ ‘xΓPπΉκ­Hoυ)Ρƒ‡bg^HδήPvΰrΥ[‘ήκ3{πP’±SLΛ„δ9€7L»…ΘUoΡ$)”h~’I R(Ρόι&ΑtξΑC‰ΖN1-^Hδή0ν~ W½ι&ΑΒbƒ‡b xA^ ‘xΓPπΉκ­Hoυ)Ρƒ‡bg^HδήPvΰrΥ[‘ήκy:w£œS"3©‰΄-'Ώ¬†κ*²JΨ–,ΫQ*WT₯!©^�DΪ‘e™2ν”ςσuIR­˜R27‘±€#Λ²”kή^5ΝO€εΨΆlΫQzb^΅φΗΛ¦”°LY©ΌJυΞχ―NΟ'ΰ(ΡΨ)¦eΒ ς‰ΐ¦έΒδͺ·h}�(ηd™IM”ͺjHj”Η•H΄\—Τ¨jzΜQ²PUu*){¬€Ί€z9―LnVuI΅bR†™ΡtΥ½}1c+Y¨ͺ>Ÿ•˜ΠrCRcY“I[ΩωzσρR*T’ͺ*¦,₯gλοΏΣσ :J4?Ρ$ϊ€QΞΙIUs�KΛyG†P2™tGΒV"WV½œWΒN(3^ΠtΩ-ά’{$Ϊ+­όw£”•ϊ¦sΆ’SΥ•Η©M%εδΚͺ―{ΌrΞQzΊ�Ζ¦χίωωύ`4%€Ÿ"έ$‚>ΫI»G}[₯51Qi»A]υ•†\U©8©\Ϊ‘*¨’V‰.o(Ρ—6)Ρv«D·=^«Do~�Ϋ<Ÿ£Dc§˜– /Θ $ro˜v ?«ήŠt“θfa±W_}UΏψΕ/zπlΦZ[’₯F)'ΫΙiΎ.©QΡTΪQvΎ¦ε|B‰|³,׊JΫΝΧ›ΣΉ­¬¦kr§s§-% ΥgΧNηžHΪΚΞΦ7”v·DwΎ�͟O}γ J4vŠ‚ΰyDΰ @Αδͺ·"½ΥίͺDΏϊκ«ϊσ?�sύΡύ‘ήb]_’₯ΊJ“9–%Λ΄•Κ» ‚©:­\�’iš2­„Ζ¦*j¨y$:S&aΛ2-%Ζ¦VΛ§έϋ±₯σ« ‹mz$ΊΓύw|>G‰ΖN±3 /Θ $ro(;πΉκ­Hoυ7›Ξέ^ž�ρ�±ΟjwԊ)%ςˁ?GΉΧ(ΡΨ)¦eΒ ς‰ΐ¦έΒδͺ·¦ID₯<·P’7G‰ΰ§Θ7‰ύθG‘*ΟΨ%€Ÿ"έ$ώύΗ� �ρ?ώ‡ξΊλ.†!Γ0tζΜκ”«3gΞπυˆ}bZ&Ό /ΘΌaΪ-ό@�z+�Mβ}χ^ύπ‡?Τ=χά£;ξΈCsssύ~Jπ%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκ·―Ξύƒό€2=ڏHo7€Ν°3 /Θ $ro(;πΉκ­Hoυ7[›2=x˜f/Θ Ό /ΘΌaΪ-ό@�z+�%z+νeΊΧ‰„Οΐ–θ–όΰϊΕ/~Ρ療H—θΝ¦scπ0Ν^xA^ ‘xΓ΄[ψ\υV€KtϋΒb\,ψ/Θ Ό /ΘΌa(ψ\υV€·ϊ”hHμάΐς/Θ $ro(;πΉκ­Hoυ™Ξ ‰ivπ†Όΐ ς‰ΐ¦έΒδͺ·"]’ΨM”hΊιΝtnHL³ƒ7δ^Hδή0ν~ W½ιΝΒbXπސxA^ ‘xΓPπΉκ­Hoυ)ѐع7δ^HδήPvΰrΥ[‘ήκ3Σμΰ yδ9€7L»…ΘUoEΊD°›(Ρt)�%šιܐ˜foΘ Ό /ΘΌaΪ-ό@�z+�%š…Ε ±ΰ Ό!/π‚Ό@"π† ΰrΥ[‘ήκS’!±soΘ Ό /ΘΌ‘μΐδͺ·"½Υg:7€pN³«L&•,T;ί ±¬βTI΅ž=£ΑΖΌ Θ $ro˜v ?«ήŠt‰$ιΜ™32 ΓΧqθΠ‘]}ΞΫ–θZQ©D^ˍ]}XΫ D»ΐ0Φ~”₯¬œlInΗm¨”u”™o¨VL)™›ΠX�‘eYJŒUiHjTUΜ%e[–μDFΉŒΣ,ΡUMη’²LK¦ιήΎͺͺ¦2Ά ΓT";­šκ*²JΨ–,ΫQ*ΧΌO».�%šιܐz3Ν�ϋ”af4]•[œ3Ά’…ͺͺS)Y©)·όΦf5fJͺReRιLσλeM$š¨hΝ‘θFy\‰tAΛuχ>§Ηœ­bcKLΛ„δ9€7L»…ΘUoEΊD³°€ή,ψβεH΄=ΦϊΊ{;;υι�NYJΟΧ[_ΥςψjnΤ+*ΟUΘg•0ε—m%Ί‘εΌ#ΓN(™LΊ#a+‘+‹ƒΡ7‡‚ΰyDΰ @Αδͺ·"½Υ§DC B‰�k>m·•θς†ύ\zσ]/+™ΘhΌ0­�rY…Tbӝ˜¨΄=x]uτMcg^HδήPvΰrΥ[‘ήκ3RŸ¦s—Ηd' ΝιΩΣʘζκtn+«ιšάιάiKΙB₯9»°a:we"!'Ώ¬†€ϊrAiΛΦψJ‰w§s—r²œζλ’M₯eW 9ΌbZ&Ό /ΘΌaΪ-ό@�z+�%θ•υ%ZeM¦m™–£dz\“c e[G’Σ9eΆ,ΣRbljea±ι\RΆι.,–Ο'•*T₯jQYΗ”aZr2yΖ“²““ͺ4ΚOXr2EUUWi2#Η²d™ΆRωy.}ψ„ μ‚ %ΊƒZ1₯DσΘ2€π‰t‰f:7€ώLηξ„|LΛ„δ9€7L»…ΘUoEΊD³°€ώ,,†πb xA^ ‘xΓPπΉκ­Hoυ)ѐ(Ρπ†axA^ ‘xCفΘUoEz«ΟtnHΑšΞΰcZ&Ό /ΘΌaΪ-ό@�z‹=`t]’Λ*N•Ά^=»›Ϋά„ΚdRΙBu—ο,”h`t]’kE₯y-o΅²X7·Ή ”h`η"]’™Ξ ©OΣΉλ%M€Y–)ΣN)?_—TΥTΖ–a˜Jd§U“{mhΛ΄dš–cEUΧέ¦ZΚΚΙ–š«y7TΚ:ΚΜ7:ά�:ͺŠΉ€lΛ½φt.γ4KtMσi9Ά-Ϋv”žh]WΊ¦ω|ZŽmΙ΄e§*ΉŠ8Σ2αyDΰ ΣnαrΥ[‘.Ρ,,©? ‹U§’²ΗJͺKͺ—σΚδfU—Φe�L*™R₯!©±¬‰DB•΅·it(ΡοΝsHΙJ5οΏ6«1ΛP’PU}>+;1α>‡Ζ²&“Ά²σuΥg³²[·―N+c§Tάν9ε!ΐAπ‚Ό@"π† ΰrΥ[‘ήκS’!έάΞΝ?ύΣ?yΊύϊέ(η•°ʌ4]��Ρ]7U»Q―¨<_T!ŸUΒt”_ntU’;ή�ŠΊfS–�+G¨Zw”,Ό�rΞVrͺΊrΛΪTRNξ_τ½¬­Ττjo4κy$šaxA^ ‘xCفΘUoEz«ΟtnHަٝ={V¦iκ–[nΡOϊΣ�nΣs’U•Š“Κ₯Ω©‚*�š‚\/+™ΘhΌ0­�rY…Tb›]Χ|Ϊv§swΊ�uΝ¦»/Ρφf%ΊVΫpt{0-^Hδή0ν~ W½ι t«UžMΣ”aήA‰nh9ŸP"_vΛo­¨΄Ρ|]Ν‚<�ε†T™HΘΙ/«!©Ύ\PΪ²5ΎR’έΫ4Κc²“…ζ”μieLS™ωzηϋoγNη.lœΞ=»v:χD�VvΆ�Ϊtzu:wmVcNJm]€(Ρ`Ώύνo7”ηΦΨY‰–TV.iΉχm%4ΦZ€«QΦxΒ’“)ͺZ-*λ˜2LKN&―ΒxRvrR•φΫ4–5™ΆeZŽ’ιqMŽ%”otΎ�v wα2ΫtΛη“J΅Λ§εX–,ΛQ:ΏΊ°ΨμxJΆeΙ΄»°°•H—h¦sžφ"ΌέΈοΎϋτ�w½kΣο½ϋέοφt_‡κχKΗ.aZ&Ό /ΘΌaΪ-ό@�z+�%š…ΕO·Χk6 CΏύνoυΕ/~QCCCΪΡ‘hD Α ς‰ΐ€‚ΘUoEz«O‰<^Jt»υeš=ΈΨ†δ9€7”ψ\υV€·ϊLη<7[’[ZeΪλκ܈¦eΒ ς‰ΐ¦έΒδͺ·"]’1xvZ’[wγιˆJ4"e·J4l&�M‚a%ι' IDATιάƒ‡bZ&Ό /ΘΌaΪ-ό@�z+�M‚…Ε%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκS’%;ΕΞ0Ό /ΘΌ‘μΐδͺ·"½Υg:χΰ‘Dc§˜– /Θ $ro˜v ?«ή’I R(ΡόD“@€P’ψ)�M‚ιάƒ‡bZ&Ό /ΘΌaΪ-ό@�z+�M‚…Ε%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκS’%;ΕΞ0Ό /ΘΌ‘μΐδͺ·"½Υg:χΰ‘Dc§˜– /Θ $ro˜v ?«ή’I R(ΡόD“@€P’ψ)�M‚ιάƒ‡bZ&Ό /ΘΌaΪ-ό@�z+�M‚…Ε%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκS’%;ΕΞ0Ό /ΘΌ‘μΐδͺ·"½Υg:χΰ‘Dc§˜– /Θ $ro˜v ?«ή’I R(ΡόD“@€P’ψ)�M‚ιάƒ‡bZ&Ό /ΘΌaΪ-ό@�z+�M‚…Ε%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκS’%;ΕΞ0Ό /ΘΌ‘μΐδͺ·"½Υg:χΰ‘Dc§˜– /Θ $ro˜v ?«ή’I R(ΡόD“@€P’ψ)�M‚ιάƒ‡bZ&Ό /ΘΌaΪ-ό@�z+�M‚…Ε%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκS’%;ΕΞ0Ό /ΘΌ‘μΐδͺ·"½Υg:χΰ‘Dc§˜– /Θ $ro˜v ?«ή’I R(ΡόD“@€P’ψ)�M‚ιάƒ‡bZ&Ό /ΘΌaΪ-ό@�z+�M‚…Ε%;ΕAπ‚Ό@"π† ΰrΥ[‘ήκS’%;ΕΞ0Ό /ΘΌ‘μΐδͺ·"½Υg:χΰ‘Dc§˜– /Θ $ro˜v ?«ή’I R Γθz€W4 ΊιΝtnHL³ƒ7δ^Hδή0ν~ W½ιΝΒbXπސxA^ ‘xΓPπΉκ­Hoυ)ѐع7δ^HδήPvΰrΥ[‘ήκ3Σμΰ yδ9€7L»…ΘUoEΊD°›(Ρt)�%šιܐ˜foΘ Ό /ΘΌaΪ-ό@�z+�%š…Ε ±ΰ Ό!/π‚Ό@"π† ΰrΥ[‘ήκS’!±soΘ Ό /ΘΌ‘μΐδͺ·"½Υg:7$¦ΩΑς/Θ $ro˜v ?«ήŠt‰`7Q’θR€K4ΣΉ!1ΝސxA^ ‘xΓ΄[ψ\υV€K4 ‹AbΑxC^ΰyDΰ @Αδͺ·"½Υ§DπŠaxA^ ‘xCفΘUoEz«Οtn^1-^Hδή0ν~ W½ι ΐn’D£+ς˜œτΌκΝ�mxΎƒe§Jͺντ67‘2™T²Pέ…{ͺjj,―R}ξκfέΔΏQλυΧηΗ•›ήν]`°DΊD3{χμΈD׊J%ςZήκ»ΉΝMΨ­]ŸSzWΚψάΔΏΡΚλoT4™Ξυχ!ΐ΄LxA^ ‘xΓ΄[ψ\υV€K4 ‹uV+¦”ΜMh,ιΘ²,%Ɗͺ4$©¦ω‰΄Ϋ–m;JOΜ«&%Ί^�DΪ‘e™2ν”ςσuIUMel†©DvZ5U5KΚ2-™¦ϋΨΥu·©–²r²₯ζγ4TΚ:ΚΜ7:ά�:ͺŠΉ€lΛ’Θ(—qš%zσΧ&Υ4ŸOΛ±-™–£μTe“ΧWΣT:­©jλΏ7{ [έW§―ΧU.d•°-YΆ£TΞ}6Φ�;nώ³_ΏT™L)3M‹ή Α ς‰ΐ€‚ΘUoEz«O‰ξ¬VLΚ03š�Κ-]»9ε7+;1αιl,k2i+;_οΊDW§’²ΗJͺKͺ—σΚδfUwpυjeRιΜT³π-k"‘ΠDeνmJtΗϋ_σR²RΝϋ―ΝjΜ2”Ψβ΅Υg³²[·―N+c§T\?λΉ>«L²νp‡ΧΠιΎ:}½QW"]Πrέ}¦ΗάΒΫιύYσoΤαg;½~Ij”sJdob&Αag^HδήPvΰrΥ[‘ήκ3»³Z1%{¬΄R¦₯¬μΤ7΅˜³•\=άͺΪTRN�ΌRž·+эr^ ;‘ΜxAΣεκκνΦMCnΤ+*ΟUΘg•0ε—]•θŽχΏ’�Ω”₯τΚκ†–Η% ―«Όιkϋ}/k+ΥvtΆΡ¨oΌίΚ„’©βšs‘7Ύ†ΊJ›ήW£γΧ—σŽ ;‘d2ιŽ„­D�¬κ¦οΟ΄κ+�F~vQ�ο¦―ΏωΊ«%S­ώ+`=¦eΒ ς‰ΐ¦έΒδͺ·"]’Ρ™[’ΛJτ₯MЦν‘D»wVU©8©\Ϊ‘*¨β>ΰJA�—Ζ•Ld4^˜ViΉ¬B*±M‰�k>m»ΣΉ;έ�ŠΊfΣέ—h{³]«m8Ί½ΎDoώ6)Ρ΅šκ›•θζΧ—σŽm― QW½Ριύ™Vm]‰ήψ³^σuS’€‘D¨Z1)ΓΚjΊ&wΊpΪR²Pq§·MyžHΪΚΞv;»‘ε|B‰|³όՊJΫΝΧΥ,ΘγZnH•‰„œό²’κΛ₯-[γ+%zΌ9UyLv²Πœ’<­Œi*3_ο|�mάιΜ…ΣΉ;ΌΆΪtzuͺumVcNͺνάη¦uΣΉ;½†NχΥιλRNΆ“s_C£’©΄£μ|½γϋ³ζί¨ΓΟvzύΣΉ€Šd‰~ςΙ'•H$τΏ�o{”H$τΤSOυϋ)N­˜’Ξ)“°e™–cS« ‹εΣr,K–ε(χΈ°XuZΉ€%Σ4eZ ΅Πj”5ž°δdŠͺV‹Κ:¦ Σ’“Ι«0ž”œT₯ύ6eM¦m™–£dz\“c eηοΏ]Γ]τΛ6έ…΅ςω€R­…Ε6ymRM³γ)Ω–%ΣJt·°X§ΧΠρΎ:}½��dΖ}N¦­Tσ9u|Ϊ�:όlηΧΟΒb?�ωΟuνΪ΅-oΓ΄LxA^ ‘xΓ΄[ψ\υVδJτιΣ§υžχΌG†a¬Œ[nΉE_ψΒϊύΤ₯VL)Ρ<’ŠξτςWώΌ?\βκ―ώκ―dš¦ή�ώχw,Σ,/Θ $roX ~ W½©­ώ“O>Ή‘@·Ζ{ήσ}γίθχS JτΝ¨κ�gοώb\Ήλ»[ͺΞ™“MΘpΡ>£rΡ©„Π$½`T.j©‘Σ+«­ΐR/žm€žU*­ΫH},‘RVI¬�‚ €1΄°ΒY€€•“ Wό©ϋ0SώhN…ΐ…�NA’Cސ>Ο…ΧήΏ³ΗΏυΞΜϊ·ο—΄"ρz½γΙΗ?ΟΟοτv[…”Π<ώϋ$£¦ƒ£ΫŽ_.―{έλVkΒ}χέwb™ζb&Θ $r3”δ\ΛͺU�ε/ω‰zωυΐ”}ˆ(cvX:X’–ι^xAyς‰ΐ c·ΘΉ*–U%ΪχύSKτK_ϊ�SΏΟ_|Ωύuί}χe~ogg§μ% [ΐͺ}·O’|πΑ²@‰²>‰~葇τόσΟ—}xΨV•θ'žxB;;;'θ{ξΉGο|η;Λ>D”€1;,ύ3Ρ=τ¦Σι‘ϋ˜ /ΘΜ0v‹<«bYU’%ιMozΣ±"}Ο=χθζMήΰ.+6|Α�7δ8Ξ‰εy‰ΌΐyD`†  rU,+Wύ'Ÿ|R<π€^φΏvτΰƒς τ%ΗΕ –Ύύνog–η%ςδ9€Κς@�Šeυͺ�_ύ@Ω‡€ €1;˜ /0A^ ‘˜aμy WΕ²ΊDpž(Ρ¬ΙκΝ87$Ζμ`†ΌΐyD`†±[δ\ΛκύΒΗώwΩ‡€ € _`‚ΌΐyD`†  rU,«W}J4$.n`†ΌΐyD`†²ƒ<«bY½κ3Ξ ‰1;˜!/0A^ ‘˜aμy WΕ²ΊDpž(Ρ¬ΙκΝ87$Ζμ`†ΌΐyD`†±[δ\ΛκΝΖbΨπfΘ LHδfΨ y WΕ²zΥ§DCββfΘ LHδf(;ΘΉ*–Υ«>γܐ³ƒςδ9€Ζn‘rU,«K4η‰ ΐš¬.ьsCbΜfΘ LHδf»EΘU±¬.Ρl,‰ _`†ΌΐyD`†  rU,«W}J4$.n`†ΌΐyD`†²ƒ<«bY½κ3Ξ ‰1;˜!/0A^ ‘˜aμy WΕ²ΊDpž(Ρ¬ΙκΝ87$Ζμδ‡υ9€Ζn‘rU,«K4‹AbΓωa}D`†  rU,«W}J4$.nδ‡υ9€Κς@�ŠeυͺΟ87$Ζμp‚tͺ~o¬yή?λ±Ύ@"0ΓΨ-ς@�Šeu‰€Νϋͺ-MӜΦ‘D°W2V;ς庎―ͺΦ(‘«WσT©8 κΝkΠε:�ΗU°ΫW,)4Τ:jGžόΦGυφC?€ΛΚκΝ87$Ζμ.³ΈΚΫ+‘”LZͺ5†J€ΓŸ*Ο:Šj=ΝRIιTν P{Ά(Ρ�ͺ=Ž•ύ`λ $r3Œέ"δͺXV—h6ƒΔ†/—Y:i)π՚] &{eX:VˆΣd¦Ι¨―n«�ΐρ՚¦J' ωΥώώ§Ξ”hœ€υ9€6€BΘU±¬^υ)ѐΈΈΉτ�Xγ~GΘ—Wνj&*ΔΙΈ©0¨©Ωh<¨[ φKt΄χΙυ‘Ÿ–X_ ‘˜‘μ δͺXV―ϊŒsCbΜξςJ5m Z“Υ8vδΥ4J΄Wˆ›š¦�¬ΘoM•JJ¦]E�§ff‰nR’qλ $r3Œέ"δͺXV—h—\°(Ρ€KοφνΫΊχή{uνΪ5u:²\`V—hΖΉ!1fΰξnίΎ-ΧuU©Tδ8ΞΪešυ9€Ζn‘rU,«K4‹AbΓ˜!/—ΣΑ½ό:X¦οάΉsβΟ‘HδfΨ y WΕ²zΥ§DC�‘‹bΎψβ‹―¬―oΏrεŠnάΈaτXΈ\(Ρ0AΩAΘU±¬^υη†Δ˜̐—Λι€O’―^½*ΗqτψγλΕ_\»S’/Φ ˜`μy WΕβpι,ΡΛσA”h Q’XνΞ}Ry^’DΙςΝ87$Ζμ`†Ό\^Ο>ϋμ©ί§D# λL0v‹<«bYύNΟΖbΨπfΘ ²P’‘…u&Ψ y WΕ²zΥ§DCββfΘ ²P’‘…u&(;ΘΉ*–Υ«>γܐ³ƒς‚,”hdaέ€ Ζn‘rU,ήιX%H”hΦB‰’ε%šqnHŒΩΑ yAJ4²°nΐc·ΘΉ*–Υοτl,‰ _`†Ό %YX7`‚  rU,«W}J4$.n`†Ό %YX7`‚²ƒ<«bY½κ3Ξ ‰1;˜!/ΘB‰FΦ ˜`μy WΕ❀5P’€D‰`-”h Y^’η†Δ˜̐d‘D# λL0v‹<«bYύNΟΖbΨπfΘ ²P’‘…u&Ψ y WΕ²zΥ§DCββfΘ ²P’‘…u&(;ΘΉ*–Υ«>γܐ³ƒς‚,”hdaέ€ Ζn‘rU,ήιXƒI‰Ξϋλϊυλ9?[… ΐ.�'Ρ|Ϊ @y¬~fœcv0C^…,¬0ΑΨ-ς@�Šeυ»0‹AbΓ˜!/ΘB‰FΦ ˜`(δ\ΛκUŸ ‰‹˜!/ΘB‰FΦ ˜ μ δͺXV―ϊŒsCbΜfΘ ²P’‘…u&»EΘU±x` ΫT’gPa7Ξύ8’aUŽίΡ,γϋσ^UA{¦4χ# 8”hΦ@‰>A:W'™ί¦Dldu‰fœcv0C^εB—θ4VΏΚs]yAMš―°+ΧεΧΗ{%6ΥΈξ«6J•Nš ’–š΅Pžγ*lτΤkF PδΈͺζ’ζDž’a’y?TΕ©ikQ�kή±O·—%ϊφ¨./h/ r:U'τT%šχBU–ΏG±ϊ5WA'kψ{{±nΐ@!δͺXV―ϊ”hH\άΐ yA–‹[’ «�’Ρr¬:Υ΄y·O’ς£IJ'jψU �’IΣW4H4οWενŽW£ΨιΈ.―Ίχ3ΛΫ& ωΡΣΊΥπφβΥνσ^(Ώ1QΌχΏ‡£―ωωŸ’R±nΐey WΕ²zΥgœcv0C^εB—θhhyJτp}΄ΰΣJ΄·W’½c%ϊp·λL0v‹<«bY]’8/·D/ΗΉ»ΗΗΉ'»ςΒενΥΗ D/F±s-ΖΉ#Waχπ(φjœ{xxœ»zͺχΗΉ1S/ςT=PΆΨF”hΦp‘K΄�XƒΖb“0/¨©Υ UνΖ‹?Ÿyr\_aΤTg7Pέδ“訑ZΰΙu\»½Σ7kEς]W�λ+jνo,4Z«ΗΕn,Έ\¬.ьsCbΜfΘ ²\蝃yΏͺ 5=υ―§JΗ»ς–eόcέ€ Ζn‘rU,«K4‹AbΓ˜!/ΘB‰>bΦUδͺ3žΝΊ#l…<«bY½κS’!qq3δYς,ΡΟ=χ\.Η‚b°nΐey WΕ²zΥgœcv0C^%ύΨcΙqνμμθίψΖΉ ŠΑΊŒέ"δͺXΌ °†σ,ΡΛςμ8Ž*•ŠξΏ�~J4[‚waΦ°i‰ΎsηΞ±ςΌό’D°=¬~fœcv0C^ε€βz°ίνλα‡Φ•+WNόή΅k׌λϊυλ%œdaέ€ Ζn‘rU,«K4‹AbΓ˜!/Θ’U’ΧύΩ;wξθΡGΥΥ«WuυκՍ>‰ΖΕΒΊl…<«bY½κS’!qq3δY6-Ρ-Σ”θνΖΊ”δ\ΛκUŸqnHŒΩΑ yA–σ,ΡKΛ2mΊ;7.Φ ˜`μy WΕ²ΊDp^ς(ΡK·nέ:Σ1€βQ’XCž%l«ίΥη†Δ˜̐d‘D# λL0v‹<«bYύ�ΞΖbΨπfΘ ²P’‘…u&Ψ y WΕ²zΥ§DCββfΘ ²P’‘…u&(;ΘΉ*–Υ«>γܐ³ƒς‚,”hdaέ€ Ζn‘rU,ήΥX%H”hΦB‰’ε%šqnHŒΩΑ yAJ4²°nΐc·ΘΉ*–Υοκl,‰ _`†Ό %YX7`‚  rU,«W}J4$.n`†Ό %YX7`‚²ƒ<«bY½κ3Ξ ‰1;˜!/ΘB‰FΦ ˜`μy WΕβ]€5P’€D‰`-”h Y^’η†Δ˜̐d‘D# λL0v‹<«bYύ�ΞΖbΨπfΘ ²P’‘…u&Ψ y WΕ²zΥ§DCββfΘ ²P’‘…u&(;ΘΉ*–Υ«>γܐ³ƒς‚,”hdaέ€ Ζn‘rU,ήΥX%H”hΦB‰’ε%šqnHŒΩΑ yAJ4²°nΐc·ΘΉ*–Υοκl,‰ _`†Ό %YX7`‚  rU,«W}J4$.n`†Ό %YX7`‚²ƒ<«bY½κ3Ξ ‰1;˜!/ΘB‰FΦ ˜`μy WΕβ]€5P’€D‰`-”h Y^’η†Δ˜̐d‘D# λL0v‹<«bYύ�ΞΖbΨπfΘ ²P’‘…u&Ψ y WΕ²zΥ§DCββfΘ ²P’‘…u&(;ΘΉ*–Υ«>γܐ³ƒς‚,”hdaέ€ Ζn‘rU,ήΥX%H”hΦB‰’ε%šqnHŒΩΑ yAJ4²°nΐc·ΘΉ*–Υοκl,‰ _`†Ό %YX7`‚  rU,«W}J4$.n`†Ό %YX7`‚²ƒ<«bY½κ3Ξ ‰1;˜!/ΘB‰Ύ»dX•γw4ΛψώΌWUО)-τ¨ςΗΊŒέ"δͺX—γ]€ e•θuΏ.…t�8N2Ώmk‰\.—δ]€ΝΨϊ©s:i(¨uԎ<ω­©’q]~}ΌWtSλΎj£t�~υͺΧ‘[miœ,?*Ρ\£v$ίσδyΎ’φHs-J΄_kͺϊς\WA½§)°eΆλήγܐ³ƒς‚,6—hΧ ΥΗJ%₯§”hΧ©ͺ;K%ΕκW]EΓδΨcωΡP·GuyA{QΣ©:‘§ϊ(ΡΌͺβΦ5œkρ5WA'kψ{{°nΐc·ΘΉ*Φv½Σbc1Hlψ3δYl.Ρ~΅―ωςίO)ΡϋχK5iψŠ'•θ§u«α)μΕ«Ϋη½P~c’xοΣΏΛ;π»·λL°ς@�ŠeυͺO‰†ΔŠ̐d±ΊDGC-λπαhyϋ%zu?σνν•hοX‰(ϋOQoΦ ˜ μ δͺXV―ϊŒsCbΜfΘ ²\š=Ω•v5K%Νͺ9Ža‰κφππ8w;τTξsζ’�™z‘§κ²½­X7`‚±[δ\k»ήι(Ιe)ΡJ§κDžΧW5ΥΩ T7,Ρ‰ζ΅"ω�+Χυ΅φ7 -ΥO�γ*l _°ΕnέΊUφ!.Ένz§ $Ά–θσ”Žwε,δ[ζφνΫΊχή{uνΪ5u:²pAYύNΟ87$Ζμ`†Ό %ϊ.f]E~ ϊgίΎ}[�λͺR©Θqœ΅Λ4λL0v‹<«bYύNΟΖbΨπfΘ ²P’νw°D/Ώ–ι;wξœψs¬0ΑPΘΉ*–Υ«>%70C^Ε€Dσ΅½_;;;'ή~εΚέΈqΓθ±€“PvrU,«WxΖΉ!1f3δYΦ-E”§νu�'ΡW―^•γ8zόρΗυβ‹/’lŒ±[δ\‹€5Pžμw°D,Ο‘+<k <ΩoΉ;χIεy‰¬^αη†Δx.̐d‘<]Ο>ϋμ©ί'Ψc·ΘΉ*–Υ+<‹Ab£(˜!/ΘBy‚D°96€BΘU±¬^α)ѐ(E0C^…ς‰`s”δ\ΛκžqnHŒηΒ yAΚ$r€Ν1v‹<«b±Β°Κ$r D°Κ€Λƒy―ͺ =Sz†ŸM†U9~G³tͺ~o¬yΦχsψέpήμ[α`œγΉ0C^ε2—'μ»Μ9Ψ¨Θ¦sΕq"Νϋͺ-M>Θςϋyόξ †±[δ\ΛΎώ6ƒΔFQ0C^ε2—'μ³*ΙXνΘ—λ:rΌͺZ£E‰MΗuωυρ^aM5�ϋͺRΝ{Uω΅¦κ‘/ΟuΤ{š¦R:i*ˆZjΦByŽ«°ΡS―)π9AC£DJ' ωΡ»τ–š§JΕQPϊ4zρύ‘Ν5jGς=Ožη+j4—2χ6b(δ\k Vψ³£DC’Α yA«ΚΞΜ¦Δ½PήξX‰€d�R­1T’ΣJt¨Š[Χp.I±ϊ5WAg¦t�·Ό}>PδΈͺζ’ζDž’a²_’3>‰^~�φ¨./h/ΎŸNΥ =ΥGIζοήF”δ\λβ―π`œγΉ0C^Ε¦ς„³³)ι€₯ΐ Tkv5˜Δ«QιΣJ΄ί˜ΊŸWν+ž4δG%‹UΓ―j°ψMšΎ’ΑΊ%ϊiέjx {ρκφεοŒ3~χΡ?[½ »EΘU±.ώ ΐ`SyΒΩY—ƒ4ΦΈίQ#ςεU»šιh‰N4ŠΌU‰φŽفζ«QlεR’½½}�οΞώSԐŸ-Yα(—uε gbORM[‚Φ^1χy΅½?ΏΌ+/μj–JšTsœCγάƒΉ€t¦^δ©Ϊ‹όyf­Y’›ΩγάΓΓγάνΠS}˜dώn(ΓE_α7Β87$Ζsa†Ό ‹=ε ›°*ρ@Π•γ8rά@»½½έ―Σ©:‘'ΗυFMuvΥχ6 -ΥO�γ*l §2+ΡιDΝΐ•_λ+>p(‡6kEς]W�λ+jνo,v�ο.Ϋ­[·Œ†±[δ\k Vψ³cc1Hl3δY¬*O83rtΌ+oYΔ·ΔνΫ·uο½χκΪ΅kκt:k�@!δͺXV―π”hH”"˜!/ΘBy‚Dr1λ*ςΥ·l<ϋφνΫr]W•JEŽγ¬]¦);ΘΉ*–Υ+<γܐΟ…ς‚,”'Hδϋ–θεΧΑΎ-+ͺ IDAT2}ηΝޱ[δ\‹€5Pž ™ε€/ϋΏvvvNΌύΚ•+Ίqγ†ΡcΨΌbXΓΊΉ\ ۍ`ι€O’―^½*ΗqτψγλΕ_$/€₯¬~Ε2Ξ ‰ρ\˜!/ΘΒΕ0$r€}KτΑς|yAQη.–Υ―X6ƒΔFQ0C^…‹aHδϋ–»sŸTž—Θ ŠΒΖbΕ²ϊK‰†D)‚ς‚,\ C"8μΩgŸ=υϋδE‘DΛκW,γܐΟ…ς‚,\ C"0C^PΖΉ‹Ε+€5p1 ‰ΐ yμΔ+€5p1 ‰ΐ yμdυ+–qnHŒηΒ yA.†!‘˜!/( γάΕ²ϊΛΖbΨ( fΘ ²p1 ‰ΐ yAQΨX¬XVΏb)ѐ(E0C^…‹aHδfΘ ŠB‰.–Υ―XΖΉ!1ž 3δYΈ†D`†Ό (Œs‹W,kΰb9€ςΨ‰W,kΰb9€ςΨΙκW,γܐΟ…ς‚,\ C"0C^PΖΉ‹eυ+–Ε ±Q̐dαb9€ς‚’°±X±¬~ΕR’!QŠ`†Ό ÐΘ̐…],«_±�ρ΅•}ΈΎ;}΄μCΐ!/ΘΒΕ0$r3δEωϊί_φ!\*ΌbXÐΘ̐ΐNΌbXÐΘ̐ΐNVΏbٝ»-Γ yA.†!‘˜!/( »sΛκW,‹Ab£(˜!/ΘΒΕ0$r3δEac±bYύŠ₯DC’Α yA.†!‘˜!/( %ΊXVΏbη†Δx.̐dαb9€ς‚’0Ξ],^±¬‹aHδfΘ `'^±¬‹aHδfΘ `'«_±ŒsCb<fΘ ²p1 ‰ΐ yAQη.–Υ―X6ƒΔFQ0C^…‹aHδfΘ ŠΒΖbΕ²ϊK‰†D)‚ς‚,\ C"0C^PJt±¬~Ε2Ξ ‰ρ\˜!/ΘΒΕ0$r3δEaœ»XΌbXÐΘ̐ΐNΌbXÐΘ̐ΐNVΏbη†Δx.̐dαb9€ς‚’0Ξ],«_±l,‰’`†Ό ÐΘ̐…ΕŠeυ+– ‰R3δYΈ†D`†Ό (”θbYύŠeœγΉ0C^…‹aH۝ƒy―ͺ =Sz†ŸM†U9~G³tͺ~o¬Ή$όη5Ν:‘Βn|ςcopάYq�ο+�6ηΫ…qξbρŠ` \ CΪξlR’•ΞΗ‰4ο«΄4MuψŸΧtb©]>φ&ǝρ”hyΰ ΐΈ†tΑrŒΥŽ|Ή�#Η«ͺ5Z”Θt\—_ο•ΞTγΊ―Ϊ(ΥΌW•_kͺϊς\WA½§i*₯“¦‚¨₯f-”ηΈ =υš‘Ο‘44J€t�½Ko©yͺTυ·λOW�<Π\‰&έΊΟ•λωͺ6ϊš₯’�XύF(Οuε55jώ±R»xμ‘Ν5jGς=Ožη+j4—2ϋΔΗXγχιBεΐΉ±ϊΛ87$Ζsa†Ό Ð.Vβ^(ow¬DR2i©Φ*Ρi%:TΕ­k8—€Xύš« 3S:iΘ[ή>(r\ΥsIs "OΡ0Ω/©ŸD/ŠxWΣDRk°»(―q―*·Ϊ[κωP»nEAF‰Ύ=ͺΛ Ϊ‹ΗN§κ„žκ£$σΈOzŒ/¬ρϋŠt‘ς»1Ξ],«_±l,‰’`†Ό Ð.V�IK¨Φμj0‰WγΞ§•hΏ19t?―ΪWTφ!ΰψξτΡ²[„Ό ÐΖƒO~ς“ϊΥ_ύU½α o8Η#I5m Z{εrήWδΥφώός�Ό°»7�πg’΅f‰n(Ρ‹NΗ yώβΟO/ΫW}”μswΧηηn‡žκΓ$σΈOzŒ/¬ρϋŠΔΊ’|ύƒο/ϋ.^±¬‹aHf9ψθG?ͺWΏϊΥzυ«_­|δ#η0ρ@Π•γ8rά@»½½¬Σ©:‘'ΗυFMuvΥχ6 -ΥO�γ*l §2+ΡιDΝΐ•_λ+>ψΟJ4ξΤδ»�\ΗS΅5Ϊϋk°b ‹ ΛΌ ¦V+Tυ΄ΕZΡβ1\_Qkc±“ŽϋΔΗXγχ‰u°―XΦΐΕ0€υώϋΎο}οS₯RΡ―όΚ―θ“ŸόdG΅έ�ρ�Όe‰· λ`'«_±μΞ ‰έ–a†Ό в�ϋώθG?�;ίωNωΎ―‡~˜¬kΦUδͺΟΆλŠΒξάΕ²ϊΛΖbΨ( fΘ ²\–‹αdX•γw4ΛψώΌWUО)Νψώi1λ„₯nςt²ώϋΎώυ―Χ+^ρ ύήούήκ~O>ω€^ρŠWθg~ζgτΔO¬ξϋΤSOι—ω—υΪΧΎVƒΑ`uϋG>ς½α oΠοώξοj4­n�τ§?­·Όε-zϋΫί�ώη^έώόσΟλCϊžyζ}ε+_Yέώ­o}K_ψΒτΒ /θ?�σ?W·�Ο�όΎ�ύολΕ_άόD`-—eέ@ωΨX¬XVΏb)ѐ(E0C^ε�\ §sΕqφ`ν:%:λ1l.Ρ/Ύψ’ώδOώD?φc?¦7ΏωΝͺT*ϊήχΎ§―|ε+zώωη5Ÿο�…Lqλcϋ˜>όαλωηŸ_έώωΟ^O>ω€ήφΆ·ιSŸϊΤκφόγzδ‘Gτ;Ώσ;zζ™gV·‡CύΪ―ύš^σšΧθ=οyΟκφwΏϋέzε+_©|Pέnwuϋ[ίϊV½δ%/ΡΥ«Wυ‡ψ‡«Ϋ�μΟώL?ρ?‘—½μeϊσ?�σΥνύΧ­ŸϋΉŸΣ/όΒ/¨ίο―n�ΰ?¨‡~XΏω›Ώ©αpΈΊύοώξοτΖ7ΎQόΗ¬O|β«Ϋ?σ™Οθέο~·ήϋήχκ‹_όβκφ―}νkϊψΗ?�OϊΣϊζ7ΏΉΊύΏώλΏτo�φoϊΦ·Ύ₯όΰ'žοmqiΦ ”Ž],«_±ŒsCb<fΘ ²\–‹αC›<΅#ωž'Οσ΅χ7yςkMΥC_žλ*¨χ»)Ÿτi¬~#”η.6yjΤΚύλ†ΞΓέώϋώθG?�όΑ¨R©θ]οzWAG΅™ώπ‡ϊΞwΎ£o~σ›ϊο�ώοΥνίώφ·υO�τOϊΔ'>‘―ύλ«Ϋg³™ήχΎχι=οy>σ™Ο¬n�ΗόG=φΨcϊ£?ϊ#έΊukuϋίώνίκ·~λ·tγΖ }πƒ\έώ7σ7ϊ₯_ϊ%ύόΟ�Όώκ―ώjuϋΫήφ6ύΤOύ”~ς'Ro~σ›W·ίΌySW�\ΡΞΎ:ΞκφΏψ‹ΏX}βίλυV·Ώχ½ο]}β�τΣO―nζ™gVŸψ?ϋ쳫Ϋ?υ©O­>ρ�—ω—Υν_ϊ�—VŸψυ«_]έΎΞ'ώ—eέ@ωη.―XΦpY.†WέΠθπ_7Τ =ΥGϋέΠp.I±ϊ5WAgvβc,ώΊ‘ή…ωλ†ΞƒI^�ϊΧλ§ϊ§lc3wξάΡ~πύπ‡?\έ–$Ικ��χ_έώυ―}υ‰� /Ό°Ίύ³Ÿύμκ�ψ‡XέώάsΟ­>ρ?Έ›ϊ>πΥ'ώO=υΤκφw½λ]«Oόίώφ·―n�Σ?ύΣΥ'ώ—eέ.^±¬α²\ / πΣΊΥπΨμiή ε7&Šχώwωαs:�Λ«φ5?φOι©ͺ«h΄λN5mΪ�IτΡϋΕq¬_�υ_Χ+_ωJύλΏώkž‡† 責ΐecυ+–qnHŒηΒ yA–Λr1|Z‰φφJ΄w¬DύυD«Q’—Ύπ…/θ½ο}o‡„ ¬R©¬ύl‚qξbYύŠec1Hl3δYΞ»D?χάs›NnVγάΓΓγάνΠS}Έ?Ξ=˜KJgκEžͺGώz’ΓγάέK;Ξ œ„  rU,«WxJ4$J̐d9―ςτΦ·ΎUχί�‘Ž.’C‹΅"ω�+Χυ΅φ7 -ΥO�γ*l Ÿ²±Ψ Κs‹΅Z‘ͺ”h\r”δ\ΛκžqnHŒηΒ yA–MΛΣ²<�ώο�ΎΎχ½οη‘«tΌ+/*ϋ/ΉΊά(ΡΨc·ΘΉ*+<k8kyΪ–ς,IšuωκGΖ³± `…` &ειΝ;ΫUž±6J4ΐκžqnHŒηΒ yA“ςτΫΏύΫzΥ«^₯Gyd΅σξΝ›ϋΩΊyσ&·oιν”hlб[δ\ΛκžΕ ±Q€σaϊIτ[ής½δ%/Ρ#<’ο�ϋ9ŠB‰Ζ¦Ψ y WΕ²z…§DC’D8g-O”i»P’±)Κς@�Šeυ Ο87$ΖsœMΛeΪ”hlб[δ\‹ΐε“NΥο5/ϋ8°UΞ«<-Λτϋί�ώσ8,μΰŸΎΫΐN¬π.Ÿy_Υ ₯iZφ`›œχ'·nέΪδp@I¬.ьsCbœϋ2I' ΅ŽΪ‘'Ώ5UͺD“n]ηΚυ|U}Ν�X½š§JΕQP(ΧεΧΗZτιTγΊ―Ϊ(=φXΙςίλU�#·Ϊ�8)ω £PŒρ" ο30ΑΨ-ς@�Šeυ;=‹Abc±Λ$4δ:‘ΪγX©€t�Tu5M$₯±»ΎΒn|θ“θτ”}ψ±rͺΊ³TR¬~ΥU4€E_&”hdα}&Ψ y WΕ²zΥ§DCββζ2I' ωΥώޟuN5mωͺxΒ0\|ž‚ΖDιš%z�±Ž?φ€α+P’/J4²π>”δ\ΛκUŸqnHŒΩ]&ι€!?jQm%:hΟά!Q’κ”O’"oΏD―λψcS’/J4²π>Œέ"δͺXΌΣ’>χΉΟι»ίύnΩ‡ +Ύγ†<Ώ‘Q")©ωͺ’½έ\”θΙ�Ό°«Y*i>PΝq(Ρ8%H”h\rŸϋάητΪΧΎV?ϋ³?«4e«ζmw΄ψJ‰Ζš|Χ•λxͺΆF‹qμt’fΰΚ―υ§Su"OŽλ+ŒšκμͺS’qJ4,/ьsC:yΜξ`yώπ‡?\ΒQΨ6”hdaœ&»EΘU±¬~§gc1H‡7|‘<8+J4²°±L°ς@�ŠeυͺO‰†΄ΈΈω�—ΎDyΖZΈFJ4²°nΐey WΕ²zΥgœ�bΜξΗόΗυ‹Ώψ‹ͺT*ͺT*ΊysτξζΝ›φφG}τBΟeΈύ�4―_¨γαφ‹s;%Yη† Ζn‘rU,ήιq)|ρ‹_Τk^σ½κU―�3ΟΠ|“cHΖjGΎ\Χ‘γUΥ-ού˜A­£vδΙoM•μυ/χ« mν†Ύ\wqœ³τθq–ƒ],«W}ΖΉ!1f3δY(ΡΘΒΊ…ŽsΟ:Šj=ΝRIιTν P{vz‰vͺΊ³TR¬~ΥU4L” λςͺ{TσͺκΟuδώsυ«žj£μcη½P·�α\‹Η―Ή :3)γ8~Β»Ι1Δ½PήξX‰€d�R­±ψδϊτΗ ΥΗϋE=κΛύP§¦A,)Υ―y »ρ…ψ$šqξbρNΐ(ΡΆΕΑqξ4™i2κ«Ϋͺ+p|΅¦ι©%Ϊ―φχ>MM5iψŠsλžͺƒύwΣ4Ω/—Gο?Μώ(zή ε7&«qξt\—·χσ'η~9M7:†t�Rΰͺ5»LβΟ{Ηn* jjvO'κVƒJt’Qδν—θ՟/>₯DΟηJtτΟ#―W’½c%z ΫΗyj‰6=†4ΦΈίQ#ςεU»šT’O|Μ£%ϊψρΟ)Ρ—ŽΥοτŒsCbΜfΘ ²T*•΅ΏpΉ°nΐD‘γܟoς[S₯’’iW‘λ©9M•Nvε…έΕσ| šγœR’ΝΡώΨσ|¨]Ώͺ^|Ά]qλΜ%₯3υ"OΥ^¬YΖq.JtSΣTCͺi+PΠΪ+ΏσΎ"―¦Q²ξcη^¬~δ*μΞgYη.–Υοτl,‰ _`†ΌΐyD`¦ΠΕβΎκΎ£ŠγΚ―΅Τm†ςŽfιTΘ“γϊ £¦:»κ§”hi�a³*ΟuεΈΑαM½ŒJtUA£₯ZΰΙu\…βTRζqNΤ \ω΅ΎβMŽ!¨ΊrGŽh··ά!|Η<ςItΤX°»όs܏ss·nέ2ώ6+–Υ«>%70C^`‚Ό@"0CΩΩ^σ~UΑή'ζyΉ}ϋΆξ½χ^]»vMNgνŸ#WΕ²zΥgœcv0C^`‚Ό@"0ΓΨνφ*ͺD»�«J₯"ΗqΦ.ΣδͺXV—hΨKτςλ`™ΎsηNΩ‡Q’€Β˜lRΘΧεόΪΩΩ9ρφ+W�θƍF…|X}fη†Δ˜̐˜ /ΘΜ0v‹ΣœτIτΥ«Wε8Žόq½ψβ‹k—cJt~¬>³l,‰ _`†ΌΐyD`†  pšƒ%ϊ`y>ˆ]>«Ο,%70C^`‚Ό@"0C‰Ζi–»sŸTž—(Ρε³ϊΜ2Ξ ‰1;˜!/0A^ ‘˜aœwσμ³Οžϊ}Jtω8³°%(ΡεγΜΐ– D—Οκ3Λ87$Ζμ`†ΌΐyD`†qnlŠ]>«Ο,‹AbΓ˜!/0A^ ‘˜ac1lŠ]>«Ο,%70C^`‚Ό@"0C‰Ζ¦(Ρε³ϊΜ2Ξ ‰1;˜!/0A^ ‘˜aœ›’D—3 [‚]>Ξ,l Jtω¬>³ŒsCbΜfΘ LHδfηΖ¦(Ρε³ϊΜ²±$6|ςδ9€6Γ¦(Ρε³ϊΜR’!qq3δ&Θ $r3”hlŠ]>«Ο,γܐ³ƒςδ9€ΖΉ±)Jtω8³°%(ΡεγΜΐ– D—Οκ3Λ87$Ζμ`†ΌΐyD`†qnlŠ]>«Ο,‹AbΓ˜!/0A^ ‘˜ac1lj“}σζMU*•\Ώ�_Ώ~ήOωΒ±zΥ§DCββfΘ LHδf(ΡΨΤE�$ϊ2|nυ3dœcv0C^`‚Ό@"0Γ876E‰.ŸύΟ,A‰.ŸύΟ,A‰.ŸΥϐqnHŒΩΑ y ς‰ΐ γάΨ%Ί|V?C6ƒΔ†/0C^`‚Ό@"0ΓΖbΨ%zMιTύήXσϊ‚<Γ|P’!qq3δ&Θ $r3”hlн¦y_Υ ₯izώ}Aža>η†Δ˜̐˜ /ΘΜ0΍Mm_‰N4ιΦx�\ΟW΅ΡΧ,•β~$Ώ6Π\R:i*Ϋ{…χδϋKsZ‘|Ο•γϊͺχfJ%₯γΊόϊX‹�œj\χU}Y½š§JΕQPhžω˜g|Žg�Q@‘Ά­D§“¦‚¨«i")5ΨυvcI±z‘―ϊ`€Vͺ½χ‘qΦύ“a]^΅·(Ώρ@5―ͺώ<«D§‡>‰Ξ>†3>Η3�$ PΫU’SM[Ύ*^ 0 _§ 1Y”ήΈ§ͺSQО�JπΙχ�{=[χT$ϋœ&§|}°DίεΞςΟψs[qnHŒΩΑ y ς‰ΐ γάΨΤ6–θ =;pS’d―½¦ΣΆ§"ow€δΤϋ§-ΡσΉ-Ρ‰F‘wb‰Ξ:†3=Η³�θΕΗΖbΨπfΘ LHδfΨX ›Ϊ�-₯γ†<Ώ‘Q")-FΈG‰”NΥ΅Ζ΅C_qrκύηƒhœ{>Τ�_U/–�Ι�Ό°»wϋ@5Η9P’›‹qξ¬c8λs<σOnJ4$.n`†ΌΐyD`†Mm[‰–;5ω�+ΧρTm4Wͺi;TΠ\ŒT§Σ–Ώ©IšuIškΨ¬Κs]9n°ΪXLιTΘ“γϊ £¦:»κ£TJ'j�όZ_qζcžρ9nπ³γܐ³ƒςδ9€ΖΉ±©ν+Ρφ±�€%(Ρε³�€%Š.ΡΟ=χœΡύ)Ρ[ŽqnHŒΩΑ y ς‰ΐ γάΨTQ%ϊ±Η“γ8ΪΩΩΡ7Ύρ΅Ž½εΨX Ύΐ y ς‰ΐ ‹aSy—θeyvG•JEχί?%ϊ«Ÿ!%70C^`‚Ό@"0C‰Ζ¦ς(Ρwξά9Vž—_”θγ¬~†ŒsCbΜfΘ LHδfηΖ¦LJτΊ_?ό°�\Ήrβχ�]»fτXΧ―_Οω ”Οκ 69οO’+•ŠξάΉ£G}TW―^ΥΥ«W7ϊ$ϊ2 Dΐ–Θ£Dt΄LS’³ΊD3Ξ ΐc™0A^ ‘˜aœ›Κ»D/-Λ΄ιξά—Υ%šΕŠ‘NvεG#%{�›�υCSυ{cΝ7½ΟΜ:‘Βn|«·Ϋ�89ε.gygψ™εsJFM5η}Ζ.6‚ ς‰ΐ ‹aSE•θ₯[·n­υ8—‰Υ«>%Ίg*ΡσΎͺAKΣΣξΌΞ}ΞΰΌJt2άUt·Η9Λs8ΓΟ¬žS:S'jœ^μq*.†a‚Ό@"0C‰Ζ¦Š.Ρ8Ξκ3Ζ8χΩΜϋU…ΆvC_�λ*Ψνk–J�\£v$ίσδyΎ’φHsέ₯D'c΅#_�λΘρͺjI±z5O•Š£ >Π\±P�γΚqΏ/>rŸx\—_ο=vͺqέWm”f<ώi¬~#”ηΊς‚š5―DŸό|€ΉF­HΎηΚq}Υ{³ώΉzQ€^Όισ”�ICA­£vδΙo}To?τ3‰&έΊΟ•λωͺ6φώ[d>'iΦ©ͺ6 EŸc™0A^ ‘˜aœ›’D—3†cζύP§¦A¬EY«y{£ΒuyA{ρ i:U'τT%§–θΈΚΫ+‘”LZͺ5†JΏd�ΣΦYGQ­·W§jΪ³ΓχI3Jtζγ:†ͺάκήγΟ‡Ϊu+ Ny>Ι°.oy�x šWU�θ„t2T-ά�΄x“η™NrPνqΌx~Ÿχ€© κjš,ώ[ ve9λ9I{₯ΌΎζDΆ %Ί|œ13οWενŽW%,ΧεUί£[ Oακ£Wiή ε7&«ς|R‰N'-^ Z³«Α$ή�ή‘‘ε4™i2κ«Ϋͺ+p|΅¦ιZ%:σρW «�’Υ'Τ©¦M_aχ˚œψ|ώ^ΟΦ=U|’›¦Ιρǝ΅Vϋ«?·ΌΙσL' ωk�gRM[Ύ*^ 0 _§ qKOŸψœφžKάUXνj�™ΐ”θςY}Ζη>›E‰ž+Ρ9‘tzw)Ρ‹ˆ5ξwԈ|yΥ�f‹_²*—ΙΈ©0¨©Ωh<¨[ ξR’"o1Νυψ+‰†Ρϊ%Ϊ;©DΟηΗ>έ>Z’7yžι€!?:π ϊ‘΄<£4Q’f=§½ηB‰ήc™0A^ ‘˜aœ›’D—Οκ3ΖΖbg3ο‡ͺΈu ζZŒsG�Βξl1ζ|`όΉzͺOηN5m Z“Υ˜rδΥ4J΄W›š¦�¬ΘoM•JJ¦]E�§ζͺD7χƚwε…έ½ρεjŽ£Ϊ(Ι~ό£ΟέγγάΟg>ˆφΗΉηCνϊUθΪ ‡ΖΉ7{ž'—θ½η=nΘσ‹ΗJgκEΎκ£$σ9IŒsoŠ ‚`‚Ό@"0ΓΖbΨ%Ί|Vž±wΌγ ‚@/ϋ_; ‚@O<ρDΩ‡΄UζύͺΌ¨‘ZΰΙu\»½ύΕZ‘|Χ•λϊŠZkl,Τ]9Ž#Η ΄»ά€+¨Έςk}Εq_uίQΕqεΧZκ6CyaG³ƒχI§κDžΧW5ΥΩ T₯ُPΊΨΠΛs›p΅Z‘ͺˍΕNx>�\ΓfUžλΚqƒυ6Ϋΰy~ώh‰>ψ3J4ξΤΗθxͺ.1σ9±±ΨiΎσοθσŸ�ό©χαb&Θ $r3”hlŠ]>λΞΨίψFέ{ο½ͺT*«―½ιMo*ϋΠΆΖΌ_U°χ‰)²­υW\ŽΏβκ4ΏρΏ!ΗqτκWΏ:³L3– δ9€ΖΉ±)Jtω¬:cοxΗ;ŽθεΧ½χή«ΏόΛΏ,ϋ·%z]ς*9ΈIDAT±z»­ UX“QSΑΡ­Δ±τΊΧ½n΅&άwί}§–i€‹ˆ]>«ΞΨΛ_ώς τςλ(ϋ”θ`‰>Z¦_xα…²ΰ�(Ρε³κŒωΎj‰~ιK_zκχωβ‹/»Ώξ»οΎΜονμμHb,fΘ $r3ŒscS• %ΊlV±»}ύΰƒ–}ˆ(Ύ`)λ“θ‡zHΟ?�Ό$ς3δ9€6Γ¦(Ρε³κŒ=ρΔΪΩΩ9±@ίsΟ=zη;ίYφ!’\ά`ιθŸ‰~葇4Nέ‡ΌΐyD`†MQ’Λgέ{Σ›ήt¬HίsΟ=Ίy“Q«ΛŠ1;,έΈqCŽγœXž—Θ LHδfηΖ¦(Ρε³ςŒ=ωδ“zΰδϋΎ|πA> Iϊφ·ΏYžΆ%Ί|œ1Ψ”θςY}Ζώγ«(ϋp0fδ&Θ $r3ŒscS”θςY}Ζ^ψΨ�.ϋp°α L˜ /ΘΜ°±6E‰.ŸΥgŒ ‰‹˜!/0A^ ‘˜‘DcS”θςY}Ζη†Δ˜̐˜ /ΘΜ0΍MQ’ΛΗ€-A‰.g Ά%Ί|VŸ1ΖΉ!1f3δ&Θ $r3ŒscS”θςY}ΖΨX Ύΐ y ς‰ΐ ‹aS”θςY}Ζ(ѐΈΈςδ9€J46E‰.ŸΥgŒqnHŒΩΑ y ς‰ΐ γάΨ%Ί|œ1Ψ”θςqΖ`KP’Λgυcœcv0C^`‚Ό@"0Γ876E‰.ŸΥgŒΕ ±α ̐˜ /ΘΜ°±6E‰.ŸΥgŒ ‰‹˜!/0A^ ‘˜‘DcS”θςY}Ζη†Δ˜̐˜ /ΘΜ0΍MQ’ΛΗ€-A‰.g Ά%Ί|VŸ1ΖΉ!1f3δ&Θ $r3ŒscS”θςY}ΖΨX Ύΐ y ς‰ΐ ‹aS&εxέ/˜±ϊŒQ’!qq3δ&Θ $r3”hlŠO˜Λgυ™eœcv0C^`‚Ό@"0Γ876E‰.gΆ%Ί|œYΨ”θςY}fη†Δ˜̐˜ /ΘΜ0΍MQ’Λgυ™ec1Hlψ3δ&Θ $r3l,†MQ’Λgυ™₯DCββfΘ LHδf(ΡΨ%Ί|VŸYΖΉ!1f3δ&Θ $r3ŒscS”θςqf`KP’ΛΗ™€-A‰.ŸΥg–qnHŒΩΑ y ς‰ΐ γάΨ%Ί|VŸY6ƒΔ†/0C^`‚Ό@"0ΓΖbΨ%Ί|VŸYJ4$.n`†ΌΐyD`†MQ’Λgυ™eœcv0C^`‚Ό@"0Γ876uΩJτ¬*μΖΩwH§κχƚvD–—h°Ι&%ϊζΝ›ͺT*Ή~]Ώ~ύ\Ÿο]KτΌ―jΠ�4=Χ_{*J4l‰‹ώIτΡί›Žλςλc-:nͺqέWm”jή―*l΄΅ϊr]WΑn_³TR«ίεΉ�Ό ¦FΝί+Ρ±P�γΚqχ«WσT©8 κΝ•h�­+π\Ήž―jcο1Οϋ9ž�C^ŒsCbΜfΘ LHδfηΖ¦μ)Ρ‘*NMƒX‹β\σvcΕ½ͺάjoQ~ηCνΊέXšuΥφnO§jΪ3ϊ$:4D]M“ΕcvύΣ?Ε>λs<χGΌ@ΨX Ύΐ y ς‰ΐ ‹aSφ”θͺΌέεν‹ϋyΥ�«§ͺ�’Q²ΌUΣζ~N“™&£ΎΊ­ΊΗWkš(Ρ©¦-_/P†‹―ΐSИθΌ?ŒΆzΥ§DCββfΘ LHδf(ΡΨΤv—θD£Θ;P’'ΗJτ�‹N.ΡΙΈ©0¨©Ωh<¨[ N,ΡA{vΰ—'Jη6Γ87$Ζμ`†ΌΐyD`†qnljλJτdW^ΨέΟ¨ζ8ϋγάn]ƒΉγά‘«°;Ϋηξ瞡ω­©RIΙ΄«ΘυΤ\•θζbœ{ܐη74J$₯3υ"_υU!?ΗηxξΘΕΆ•h₯Su"OŽλ+Œšκμͺ/?‰Žͺž\ΗU°Ϋ[m,6h„ςœΕΖb­V¨j7–βΎκΎ£ŠγΚ―΅Τm†ςŽfιDΝΐ•_λ+V’q§&ίuε:žͺ­Q.υ%ΆΔΦ•θ σ~UΑή'ΛΫΖκΝ87$Ζμ`†ΌΐyD`†qnlŠ]>«K4‹AbΓ˜!/0A^ ‘˜ac1lΚ–½Ν¬~†”hH\άΐ y ς‰ΐ %›’D—ΟκgΘ87$Ζμ`†ΌΐyD`†qnlŠ]>ϋŸ!XšNΥοOί={ϋœΑ¬*μΖgώyJ4l kJτΌ―jΠ�τ΄ΕΦΉΟP’OΑ87$Ζμ`†ΌΐyD`†qnljλJt2V;ς庎―ͺΦ(‘«WσT©8 κΝ΅ψ»‘]Η•γΈ vϋŠά'ΧεΧΗ{»y§Χ}ΥFiΖγ‘Ζκ7ByξβοžnΤό½=Χ¨ΙχJ” λς–χͺyUυΜ”[½κS’!qq3δ&Θ $r3”hlͺθύάsΟέ�θοM'-^ Z³«Α$ή�DχΘ¨všΜ4υΥmΥ8ΎZΣt­ωψ+‰†UWΡκκTΣ¦―°ϋeMžΒ^ΌΊηΌΚoό½ž­{ͺφ«xš&—η“hΖΉ!1f3δ&Θ $r3ŒscSE•θΗ{LŽγhggGίψΖ7ΦώΉokάο¨ωςͺ]Ν€C9755»§u«Α]Jt’Qδ-ΖΉ³%Ρ0ZΏD{'•θωόΠ§ΫV—h°Iή%zYžΗQ₯RΡύχίΏA‰N5m Z“Eωχy5ν䦦©4kς[S₯’’iW‘λ©Ή*Ρ‹ϋ€“]yawo${ šγ¨6J²�€Ε8wχψ8χππ8w;τT&š’ύqξωP»~UΊ6%ΆE%ϊΝ;ΗΚσςk³-)¨Ί‹Ηvν.7ιJ'j�όZ_qάWέwTq\ω΅–ΊΝP^ΨΡμΰ}�©:‘'ΗυFMuvΥGiφγ”.6.σœΕΖb­V¨κrc±V$ίuεΊΎ’ΦώΖbΓfUžλΚqƒΛ΅±γܐ³ƒςδ9€ΖΉ±)“½ξΧΓ?¬+W�œψ½kΧ�=ΦυλΧs>ε³ΊD³±$6|ςδ9€6Γ¦Ξϋ“θJ₯’;wξθΡGΥΥ«WuυκՍ>‰Ύ ¬^υ)ѐΈΈςδ9€J46•G‰>θh™¦DgυͺΟ87$Ζμ`†ΌΐyD`†qnl*エ,Σ¦»s_V—h°IQ%zιΦ­[k=ΞeB‰€-Qt‰ΖqVŸ1ΖΉ!1f3δ&Θ $r3ŒscS”θςY}ΖΨX Ύΐ y ς‰ΐ ‹aS”θςY}Ζ(ѐΈΈςδ9€J46E‰.ŸΥgŒqnHŒΩΑ y ς‰ΐ γάΨ%Ί|œ1Ψ”θςqΖ`KP’Λgυcœcv0C^`‚Ό@"0Γ876E‰.ŸΥgŒΕ ±α ̐˜ /ΘΜ°±6E‰.ŸΥgŒ ‰‹˜!/0A^ ‘˜‘DcS”θςY}Ζη†Δ˜̐˜ /ΘΜ0΍MQ’ΛΗ€-A‰.g Ά%Ί|VŸ1ΖΉ!1f3δ&Θ $r3ŒscS”θςY}ΖΨX Ύΐ y ς‰ΐ ‹aS”θςY}Ζ(ѐΈΈςδ9€J46E‰.ŸΥgŒqnHŒΩΑ y ς‰ΐ γάΨ%Ί|œ1Ψ”θςqΖ`KP’Λgυcœcv0C^`‚Ό@"0Γ876E‰.ŸΥgŒΕ ±α ̐˜ /ΘΜ°±6E‰.ŸΥgŒ ‰‹˜!/0A^ ‘˜‘DcS”θςY}Ζη†Δ˜̐˜ /ΘΜ0΍MQ’ΛΗ€-A‰.g Ά%Ί|VŸ1ΖΉ!1f3δ&Θ $r3ŒscS”θςY}ΖΨX Ύΐ y ς‰ΐŒ ‹%ΓͺΏ£YΖχη½ͺ‚φLιY<ͺίkΎι},F‰.ŸΥgŒ ‰‹˜!/0A^ ‘˜±‘D++Ž“ΜooT’η}Uƒ–¦§ύπ:χ±%Ί|VŸ1ΖΉ!1f3δ&Θ $r36Œs§“†όh¨DsΪ‘|Ο“ηωŠΪ#Ν΅(Ρ~­©zθΛs]υήρΒ›ŒΥŽ|Ή�#Η«ͺ5J$ΕκΥαΟ―ϋi€Φ$Χj˜κ(lιt%IKš‘’ώεαοε0UԞ*—”ΟzjvN•K—ί2/ϊʚC-6ϋ?I,.oS””θ�ύ{€]?�€½­KτϊΊ).ΟΏ_ Sŝ™–›ž½|.¦-EΡ₯?Γ\ΜzJ’DΝξ@γΩςbτϋΉQν"_h6iΠk) bυζΕ^%Ίt� DΧΟλ+Ζ87$Ζμ`C^`A^ ‘Ψψ3Ξ½»DG›])Ργ«o‚‹₯¦£Ύ:Y¬¨1X/TΆUσiWi�Tw0Φt>Σ ‘Ό DηšdΡzœ»l� DΧΟλ+ΖΒbXπ6δδ9€Ν> ‹=}ϊTϞ=«ΰhnζ|œϋτς8χI©uz1Ξ=^I*f‘[e[*4ο%Jz›’½)‹ššδΪδ�ζ…΄8Iχζ*$εσ²0RχΌD―·)fmEι`=ς½«jNςςύ{€]?――%7°!/° /Θl�+ΡOŸ>ΥϋοΏ―·ί~[Eρς _ZX¬—)C…a¬¬w±°X�ι©™D ƒPig¬ε󧳫“† ‚@A˜¨=ά¬ζ]ΜΤMBΕΝ‘–Λ‘Zq £ TάμiΠM₯}-Ά·)ζκg‘‚0VšuΥo'jMŠςύ{€]?――γܐ³ƒ yyD`³kœ{»<ωε—5•M1m+ΚόY¨λUC‰�W ¨Α«Vž%I‹²8Qλ�x6ͺd)Ηϋ~`Γ*τύχίΏzε/ ή0ΧΟλ+Λ87$Ζμ`C^`A^ ‘Ψόχ/Φ›oΎ©O>ωD’τπαΓσ·^d‰οωΎμ{JtύΌΎ²,,‰_`C^`A^ ‘ΨόγΏΧwί}§χή{OοΌσŽΎϊκ«Ί ―JtύΌΎ²”hH<άΐ†Όΐ‚Ό@"°Ω^ϋΙ“'”i˜Q’ληυ•eœcv°!/° /Θlv­ΞM™†%Ί~\Yΰ%°]¦_ζΏ'υ’D׏+ ΌDž%7°!/° /Θl(;p\UΛλ»>γܐ³ƒ yyD`ΓΨ-\ WΥςΊDpH”hφδu‰fœcv°!/° /Θl»… δͺZ^—hƒΔ‚/°!/° /ΘlX .«jy}Χ§DCβα6δδ9€ e.«jy}Χgœcv°!/° /Θl»… δͺZ^—h‰ ΐžΌ.ьsCbΜ6δδ9€ c·p\UΛλΝΒbXπ6δδ9€ @ΑrU-―οϊ”hH<άΐ†Όΐ‚Ό@"°‘μΐrU-―οϊŒsCbΜ6δδ9€ c·p\UΛλ ΐ!Q’Ψ“Χ%šqnHŒΩΑ†Όΐ‚Ό@"°aμ.«jy]’YX  Ύΐ†Όΐ‚Ό@"°a(Έ@�ͺευ]Ÿ ΐЇaXHδ6”Έ@�ͺευ]ŸqnVŒeΒ‚Ό@"°aμ.«jy]’8$J4�(fmΕΩDωζŸΕ^��\£αT«_ΊΝ ,ϊ©�Αςΐ{]jΨξišx·XΟ�lϋ|�Ug|θί ΐo^—hΖΉoζF%z5R#ιi~έΖϋls.Jt~ΪVvπbξΖMK΄Š…ϊYη•ψUb,δ9€ c·p\UΛλΝΒbk«QCiηDν4V†JΪ#- IZir’)Ž"EQ¬μd’•^P’σ©N²Xa(ˆκMrIK ›‘ŽŽ%­±VZjάI‘‚`ύσ–Οm³œΆ·¦›}šΆb5'EΙώŸS,5ꀊΒPQ�T§oJδξσ‘Všτ2ΕQ¨ ŒΥ.^π?Vf™†ΛλΞY*ΚΞ‘τη•}Ÿk6h)‰B…Q¬Fημχ§δϋ�σ·n/-ϊ 5Η΄θm, ς‰ΐ† ΰΉͺ–Χw}JτΪj”κ(hjΌΤΊP5£Ν8oKQr²~3\ΜΥO#΅&ω΅%z9L΅§Κ%峞šSελrρ–yΡWΦn ά\'I’“ΕεmΚ hιώ/CCac³�Υ©Ϊα‘’kΞ'?m):Ϋ~9V3jhtέs~ͺfzρΖΌμ˜ΚΞ‘μη•}_ΜΊJ²ζωϊχgά^—ά²οΛΞίΊ½$³Ž’ΦžΣ― †aA^ ‘ΨPvΰΉͺ–Χw}ΖΉΧV£†’φτΌ(Σ–’Ɵτu'RzώΊUZ SŝΩyyήU’‹YOI”¨Ωh<[^όΪs£ΪEΎΠl2� ΧRΔκΝ‹½JtιώΟε:m„ΚΞίPšwc₯ƒkΆσ|ΎΡ?[‘[o[‹"ΏΎ4.N”6Fηv»μ˜vŸC�ιΟW”~?οΕ:Š₯iΊώ$‘’Ξ7ϊΧΞοΏΦ%ηΏ{?eΫo�Σr ΄1ΠΕUc™° /Θl»… δͺZ^—h¬­KτμJ‰ώێ�½ D―w°ΤtΤW'‹5Z¬ΘyAΞ§]₯ISέΑXΣωLƒFς‚k’E›Qθ’ύŸΛušν_’£]%z΅Ίςvϋ’ηJtΩ1ν>‡%z΅RΎ«DoΎŸχb%'[gYδΚ‹²οΛΟίΆύζ:Q’Jτk`5JuΆ4^i=Ν…J‹υxρΦψσI©uzέ8w‘y/Q�ΫςΥHYΤΤ$Χ¦ w5/€ΕI’Έ7W!)Ÿ”…‘Ίη%z½M1k+J›㱚A ζ$/ί�–υxςΰκ8wΙω¬ΖΩΕυκTνΈ‘­�}Υ₯qξςsή}EιΟ+ϋΎ˜vŝυy ³x=V_ς}Ωω[·—η¬Ό,я=R’$ϊέo₯$IτψργΊ©V«QCQΦQ3‰‘’φπba±^¦8 †±²ή ‹-Ηꀑ‚ P&jŸ-ŽUΜΤMBΕΝ‘–Λ‘Zq £ TάμiΠM₯}-Ά·)ζκg‘‚0VšuΥo'jMŠςύo+Φ —EΑz‘¬^/Uγla±η#­tΪm( Cab_X¬τœKΞ‘τη•}ŸkΪo�;ˆΤ8?ξ’οKΟίΊύλ·°ΨΟ?�¬oΏύφΪmΛ„yD`ΓΨ-\ WΥς�DόρΗΊ}ϋΆŽŽŽΞ?ΗΗΗzπΰAέ‡V›Υ¨‘dσfϋy•ώŠ«›{ύώŠ«>ψ@Aθέwί--Σ, ς‰ΐ† ΰΉͺ–WwύG])ΠgŸΫ·oλΣO?­ϋkA‰Ύ‰₯†νžΧ3ŸtΥ_·LΉ>ϊθ£σ{;wv–i†aA^ ‘ΨPvΰΉͺ–Wwύ·ήzkg>ϋά½{·ξCD ³Γ™νύ|™ώρΗ%‘ؐHδ6ŒέΒrU-―JtΗΧ–θ7ήxγΪ_ηΓ‡ίŸ;wξ”ώΪρρqέ·0ΌΌ*Ρ/z}οή½Ί@ΚήDίΏ_?όπCέ‡€W€W%ϊργΗ:>>ήY oέΊ₯Ο?�ΌξCD ³Γ™η�Lτύϋχ5ŸΟ/mC^`A^ ‘Ψ0v ΘU΅Ό*Ρ’τΰΑƒ+EϊΦ­[zψ�ΐ½�Xπg>όπCA°³<Ÿ!/° /ΘlX .«jyyΧ�μ³Οtχξ]ύζΧΗΊwοo _s<άΰΜO?ύTZžΟXHδ6”Έ@�ͺευ]��ωkέ‡€—cv° /° /Θl»… δͺZ^—h‰ ΐžΌ.ьsCbΜ6δδ9€ c·p\UΛλύγί�Xχ!ΰ%ΐ‚/° /° /ΘlX .«jy}Χ§DCβα6δδ9€ e.«jy}Χgœcv°!/° /Θl»… δͺZ^—h‰ ΐžΌ.ьsCbΜ6δδ9€ c·p\UΛλΝΒbXπ6δδ9€ @ΑrU-―οϊ”hH<άΐ†Όΐ‚Ό@"°‘μΐrU-―οϊŒsCbΜ6δδ9€ c·p\UΛλ ΐ!Q’Ψ“Χ%šqnHŒΩΑ†Όΐ‚Ό@"°aμ.«jy]’YX  Ύΐ†Όΐ‚Ό@"°a(Έ@�ͺευ]Ÿ ‰‡ؐXHδ6”Έ@�ͺευ]ŸqnHŒΩΑ†Όΐ‚Ό@"°aμ.«jy]’8$J4{ςΊD3Ξ ‰1;ؐXHδ6ŒέΒrU-―K4 ‹AbΑؐXHδ6,ΘU΅ΌΎλS’!ρpς ς‰ΐ†²ΘU΅ΌΎλ3Ξ ‰1;ؐXHδ6ŒέΒrU-―K4‡D‰`O^—hΖΉ!1fς ς‰ΐ†±[Έ@�ͺεu‰fa1H,ψς ς‰ΐ† ΰΉͺ–Χw}J4$n`C^`A^ ‘ΨPvΰΉͺ–Χw}ΖΉ!1fς ς‰ΐ†±[Έ@�ͺεu‰ΰ(ΡμΙλΝ87$Ζμ`C^`A^ ‘Ψ0v ΘU΅Ό.Ρ,,‰_`C^`A^ ‘Ψ°\ WΥςϊ�Ο›hHΌ!€ yyD`ΓCΈ@�ͺυπως†JqN‘IEND�B`‚pywps-4.8.1/docs/_images/pywps-slurm-demo-architecture.png000066400000000000000000001366051526123257100237130ustar00rootroot00000000000000‰PNG  IHDR)Λ.g IDATxœμyXηΪ�o\j·©έœΣeήΦ¦š j †H ’J-G‘�jQ_)ΦaΡƒZίƒUp©Zmέj]κZ«΅’u« ξXw­V­Κ©έN«Ψεgλ†"ςύύ1dL„ (ξΟu}/MΘ<3“ΜάΟύg#b†a7dU·n¦5/wΆζεξΓΎŒŠΎϊεξY«^κ–Νς-ϋη?ΓˆΒ›EίEDˆ¨yΉψ�c†a†a\Ś—»ΫΥ? Η>Ος@퐆/’"ή%27%²άO}O™hHl†a†aκ%^k^ξ>μψΔ (\žγς„•U³*\žƒγ'`iΔ+|}ž€ ή"QάƒD/άWfΈE€a†a¦βυeTΔπγ'ΰψΔ ΰΟΡ�i°ύ�K"^ώ°₯ΑβέΆs’J§3?.θ{HξΔ&€a†a¦žΡ`υΛέ³lΙβρ‰pωΐ>–Θώ7]ΨνΥΩΊΤ€VΙmΫvNTΙ-–ϋνZ†a†a˜zDƒU/uΛfΰy²�Mηw‹^¨ Ly^’Π�`ρφυMxR#ή„Ψ0 Γ0 ΓΤ;Ψx¨μΣy�|υ³6!Φ�mƒR;>¨ΡιRŸ’±ΕέMl†a†aκ Ωx¦μΣΉέ^[Ϊή”φ’&(-΄…ΏUλη—ΠœΣ.3 ]}2 Γ0 Γ0w6*ϋίtNΔk9z£υemgkηΊ€vMς3Τ>ι6 Γ0 Γ0υ6*ϋίtv·Λl& $EΧΆs’ŠMΓ0 Γ0Lύ…M€‡ŠMΓ0 Γ0 γ 6*6 Γ0 Γ0Œ3Ψx¨Ψ0 Γ0 Γ0Ξ`ΰ‘bΐ0 Γ0 Γ8ƒM€‡ŠMΓ0 Γ0 γ 6*6 Γ0 Γ0Œ3Ψx¨Ψ0 Γ0 cΓ‹U-ΥΨx¨κ‰ puŒ¨ b†a˜rxQ’+ωFvjΜͺTφίUΓ²οΠS+[6*5Σκgœb†aͺ ˜Ϋ‚θ…ϋˆ’ο! oBr…ې<³‚eΰ‘ς4ΰκ˜P©GqŠa†©"^��œά κ-ΚI’Ή)QΔ½DΡw‘gV°lςσσYα1―\Ή_|ρ S»kSžo–μίΪdiAA½Ey5"ΟκoΛ&ΐCεA&ΐ‹ˆ­]»}ϊτQβGίΎ}±jΥ*§±ΚΩύο,†EGGCΔΗΗW)~={ααα$ *• yyyΰPNUφε켜•_OγΓ0 S Š"~ύυW#22'ND~~>AΐΆmΫpυκUμΨ±F£ηΝΓ₯K—…ιΣ§ΓΩφGE—.]P\\Œ«W―B­VγΨ±cU.מ·ί~γƍCQQT*|||qqqX°`V­ZƒΑ€‹/ββΕ‹0 X½zuΉ}ΩLΐ{gππp\Ήrπα‡’K—.Έ|ω2~ϋν7¨Tͺr&ΐΎ°X,ΈtιόƒΣ^mmJQΫ))Pm2·’›Ϊ-χ“ηU� WGvΑ&ΐσT5}y€ ¨(V9»�Ε0ϋ§σU‰_“'OVVδζζΒb±(ζΐVNUφε켜•_OγΓ0 SΌˆ¨ρΜ™3‘ TλΦ­CTTςσσͺΌŸ•••J“Ι“Ι­V‹‘C‡ΒΩφpζΜ¬^½οΌσAΐΧ_]εrνΩΊu+ΊvνŠmΫΆ‘_Ώ~Πh4(,,„J₯Β™3g‘‘αΠovκΤ©8p`Ή}-X°*• ‚ `βΔ‰Κϋ111ΘΙΙQ^gdd”3φε؞€Ώ)₯G›kW�ΰdcKƒΕ›tζΗΛ*ΧΖδ~•kc»�Ϋ/2Τ€ˆ± πLUΓ4"’»¨ξ.@εED+3Ε*gχΏ³fŸ˜W%~εηηC₯RaΠ AΨΎ};JKK”οκs³}9;/gε{pœb†anΕΨ?9Z·n’ψFFF*ο6 ΩΩΩΚλ .ΰόωσpΆύΝ;αγγƒΙ“'γΰΑƒ UL@UΚ΅§ΈΈ’$!++ σηΟ‡ΥjΕ|  ΪΝΘΘΐ€I“”ΟO:εφ΅`Α˜L&=z’(βΜ™3€ΨΨΨ›šϋrΦ�]‹%K–Θ•k¨υυ6¦€n:Sj‡ΑV{W�qDt€ˆό¨ά"KΡw± πLέΤ˜ΜQ $’>TwžͺΠΔΔΔ8tΊ1V9»�Ε0ϋΔΌ*ρ Ν;‡Ήsη"22R‰%φεTe_ΞΞΛYω§†a˜Ϋ€•™I’pϊτi\Ήr˜0aBΉΔwΛ–-P«ΥψσΟ?qι�%F¬\ΉΞΆ9r$¬V+JKKqθΠ!ˆ’ˆέ»wWΉά‰ŒŒ„$Iψα‡°tιRH’„!C†V­Z½^ .ΰΒ… ΠλυJw M€mL@ff&βββΣ§OGXXŠŠŠpζΜ™ »Ω—c΅Z•Šή?Τϊz{»ΚU«M~‚(^ χ›~Ο‹ˆšQ'’νDΤ^žJP֊—ΊͺŽ 8˜σ9Ί’  kp0/ΟΑεϋp(g)Τ*ΥMv ·n† ώM‘ƒAΐΚ)“”mv/ZZ}G™ˆpaΟ�;jόƒ’_ΡvΆvn–μOOtμND»Λ�‰ΛDΤ’lžωžͺ f 5ήΌy3΄Z-�^½ŠS§NAEΕT«œέ�ΞbΨΩ³g‘R©ͺΏ233‘––†��RόςΛ/Χ�]s(§*ϋrv^ΞΚχΠ8Ε0 So±οžq»jDήdζΜ™ˆŠŠ‚Α`€$Iˆ‹‹SΫ'Ύ0fΜ¨T*%/--…³νOž< FI’…!C†ΐ`0TΉά™6m$IBii)~ϋν7ςσσ@¬R© R©;3/^„J₯Ž;P\\¬ Τj΅θίΏΏSpυκU˜Νf₯L�PλλϊNΦM§τ`czk_ί„'ν¦ίkTCΏΥνΙ]O„ΟΡN"M0¦?όEχξc«j.νΫ•$a`|<ώ»1?ζ�C�>} V©P΄o4λ§„Β­›τύϊ΅˜;rD½3b›�‰DΝΡ…λΧΓύΛ(0ύa LXžkΎη²!P�υ†5|MήRL+**BΧ�]!I"##Ρ―_?ΕT«œέ�ΞbXII τz=bcc«Ώ~ϊι'eŸ*• sζΜ‡rͺ²/gηUQω.ŽSu±«Γ0Œ[ςu%’·ˆh0 ―!eш™3g*OΤo…ΫέήέΉ�Ι€₯’δ�i³f~³©™z*Qσ DΝή%’eίqMύ^΅©¬2eΡ$’[쟊_ Ί��ˆW&TΥόηΛΥ "όΏ―Ά;ƒˆP ·nVLΐ;–Dˆ’•$aέGΣ”ΟNz{0$Q„ 0h48ςΕ2\> · k΅xΗ’Ÿ²„#’?ˆh Ν$’q$WΆΩ$οΫΡH"Ε&ΰφΈ�Ι€₯͚ω/’f>sˆZN#R} 'G4ͺμ;ΎέίιNidΩ1HεMΐE’ϋ}Φν•)U5ηvο„J’έ% [ηΞΑΕ½»Λ%ΧD„¬d Ξξ؎ώ}ϊΐ¨Υβς}ψyC.A@ξΜιψeγτŽθŠΎ―D)Ϋ ‚€ψ¨(μϊτόΊiDAΐ�χΗγτ–MHˆ‰F¨^Λφ!wζtˆ‚€ΌY3πέΪ5ΦkkΜTΆίOǎZ₯ΒρU+°λΣO Ύ]ρΏ7Αz­RΆF­Fή¬N?oίPΩώŽ�X"Βά‘#pjσFh}| ’$¬YΝs>αΤζU7B³�Pyp…¨ε\"υT"υ"i’œH>:Žˆή­#ΧϋH"εμ^­Ο±ͺγΤ{Dτ1M#Ή�ϊœˆβε†θ»¨nŽa†©ΣδΡ|"j-�Έ»ε>Έ/ά'ζŠδ¦ψΫQάƒΤ>ιWWN)­g{SΪKš ΄ΠΊ€v­M–:ωρ²ΉΥΌύίιNΙά”Σ&’•δ˜ό|όΉξ=υ,ϊEέ_ž^1ΏηoEV²’("ΊKΚYκΜΫΊΊμ_²>eΙφΩΫq|Υ \>°οځρρˆξ�Εa»s»wβς}˜9|’BC•}ώ΅σ+‚€Kϋφ 96ρqΚί6͞U© nΠά‘#œš€Κφ;τH¨$ {/ΒεϋπΛΖ 8·{'~Οί Apfλ[ωDQΔΕ½»~ήήTΆΏ£+–CEεoq}ΧGy-‰"N¬^YeΠ�szL3ΝK©Dβaω(»ξ&Χ?8½}ϋ°d�η5­:$©υϊ7ώ‡‚z‹ς΅cnκΪk˜cš3ξ\œjюˆ>%’ rw·`#ΐ0 s6‘žΚϊ·^Oώm‰ΏΉ©΄ΛΤ!φ‘[R`ϊΓόF3WWNξŽRΉ–-ΔΣͺC’Z£yS’ΰ7š)}§έAιω‘ά pθώύO΄zρu}Η” M§τ`)΅Γ’ξΡ³ͺΣπΧΞ―”ΧgΆnΑ{ φ,ώ΄ά˜€C9KpiίΕ<΅˜τz ’$e»w,‰*IR$~Μ]‡θ.]0-3SωμλΧUj–Ύ?'V―TτΗφmNM@eϋ½°gΖHWή7=Mω.Β |:v ΖτO‡9:—μ«τσTf*ΫίΡΛΎΛΑ}γρŽ%Qy­’€j™ύσ©―ι;Y#΄­ŸhρR_"ρ’Νΐ©Υk/λ;¦„€θZ[}όόš“Ξό8™ΜΊόzη˜ζ”ZSφu™›=Σ‘ˆΆΚu—2ˆάΦ*ΐF€a¦rI6ε q―όδΏ‚δ�v W˜5†g™€fΙDt˜¨Sx‹`«O@HŠξVMΐ’qcΊΎΨd�λ1yΘΫ•š€Oή ­~ސ‹Λφαγμα&ΐ~»i™™θΡUy}iίœX½EϋχΒ‹ρρΚߢΝSc݁*ΫορU+P°f5ŠφοUΆω`Π@\>°f wDŒZ­2’²ΟS™ ¨l΅i4�ƒυS‚žlωΟ8"α?͚uΘfΰ~ά‘8ucύDΝ;ΡFΉSΫf“b#ΐ0 cGo’»5ͺΨΨδ>Mη£GVέ©ˆ’’"‡EΎn•ͺ”SSϋΊκDέ�AA½E½ώ�iΥ!Iύ\@’¦}X²Ώpz{�ΰτφΥιτσ†\ˆ‚€Μ3Ž�X�Μ²#φ}ΆΈR0>#�ݘ§ p 5pqοξrΫ}Ώ~-DA@ξŒι(άΊ™ fθ5>JχQ°eξlόΌ!aC™€Κφ;*-΅'Χ}‰kVAλ㣴H_΅’ @E₯+TeŸ'"œή²©�ύΥ΄ hΧ9=¦)©›―Ρ’ν”Ψ>,Ωίv<­}+Π“»-Z΄Z­ΆΦ⅍Ϋ)�VΆ½σqΚV_Ρ""©ί F€»1 ΓάΐgDΤΊΌ°)^ί{α>ϋΉΫo]/άGΤσ[ͺ…ͺΘΝLΐ+fή*U)§¦φu#~!©±Αι‘~’M-ƒ�ό4šδg(ψfςoVSΏΥPY—³±Qΐ›�ΠιRŸςξ˜�²E°ΥΗǘ޺E°Υ§:ƒ/Ψ‡Ÿ,@¨A―τ±7h4X>ρƒ ŸθΫ›€Σ[6Α€Χ+Ϋ¬˜<*IΒμμ¬ §]1y"Τe+A›τzeP­ΝPΨfšώN¦CίωΫ1•νχ·m[Π58X™ν'>*Rιγωΐ>ψ¨TˆŠR^Wφω˜.] ώό*ίιώjΪθΒR£Ϋ†€όSΣ)=8 $Eη”�¦E°ΥΗώZπξ˜�R§K}Šήό‡ό8^pύυ~ϋ1νοΏ�Ζ?όPkρΒΖν”+ΫήΩ8e?vνYεΘϋ©Π0 ΓΤ{!’?δTΡχ8[εjde›uΑ^ΤΈϊŠΎ‹(βήΚ*gΛΤΫVή4$ ρρρ(**R–©—$ z½=zτΐϋᅬkΧ�!11’$)Ÿ/))AtΩ΄ˆρρρ€I“&A­VC­Vcΐ€ΈvνZΉΚ/<<\™;//Κ©ΚΎœ—³ςoVΉ:¦vo”Ϊ±U`r[.υ)ω©hΔ½·ώΫάiΩ�£Έ»εk-ξA κ-ϊϊ&<©Σ₯>eSu¦e9—A£Ακ©S\~ΞL€pΪ«­C’»h;%z₯΄ij~Φώ:ΠιRŸςυMx’‚z‹ςSδξ»>Τ•Χ|ω˜¦Υj±wο^€ΡhDίΎ}sζΜA―^½ŸŸ^½z!<<ƒVΦ©nl²­UbϋŒm­g±ΖΎ|gρ΄*ΫV5fΥnœΊ±> oβ8™ύAδύτu#}OΩ>Ή[Γ0 ΙλLΉž„ΩšSmOifXhH5³(O#’π&•%Έ•™Qρλ―Ώ’ΈΈ‘‘‘˜8q">όπCtι�—/_ΖoΏύ¦¬ΌyτθQtι�ΕΕΕΈzυ*Τj5Ž;ζπDkǎ08wξ.]Ί„¨¨(LŸ>έαx&OžŒ^½z‘΄΄ΉΉΉ°X,ŸŒUe_ΞΞΛYω•αy‹…ΩZ’z> τ³.Su c•ΧŸ_ε#wΖtH’ˆσ»kw°Ϋ2AΙ―΄ ΅Ύΰœή^cL|ΞΧ7αI₯οΈ,fΎύφΫ7nŠŠŠ R©ΰγγˆ‹‹Γ‚ ŸŸA°mΫ6\½zU1ՍM«V­‚Α`ΐΕ‹qρβE ¬^½Ί�ΆςΕΣͺl[՘uγ”­~jtύΑM#’^)_§qkΓ0 ‘ΌΨLω IΌp½?eΌpΓS“šμGΩ€ˆW–ΰVf”χΧ­[‡¨¨(ΔΔΔ ''Gy?##CιtζΜ¬^½οΌσAΐΧ_νP™eeeA₯RΑd2Αd2A«ΥbθΠ‘Η“ŸŸ•J…AƒaϋφνΚʜ76ίl_ΞΞΛYω•αj}½½)©›Ξ”Ϊ‘E°ΥG«M~Bώέ¨1ΉWg[‰Ί!5Ύώ$οz3�Š—Ίbpλϊμ½qEŸ½7ΞεΗr3 νlνά>,ΩΏ΅Ι�B~βoKψmRf|± τ¬ }ΌΛΕ΄­[·’kΧ�ΨΆmϊυλFƒΒΒB¨T*œ9sωωω uˆ17š€ͺΔ¦ŒŒ ‡~ϊS§NΕΐ«l*ЧUΩΆͺ1λΗ)»X}Ν"jfuR―ΉSŒd†©qΌH^ xάυVΫ4k–ϋkρ‰‰UΣΔΔΔ8t²―΄"##[‘ ΨΉs'|||0yςd€V«p=ι·Q‘ ¨JlΚΘΘΐ€I“”ΧS§NEFF†ΣXs£ ¨(žVeΫͺΖ,Ε©²ΦGτhζυξl­αMˆ»1 SΟρ"yωυl9(ί±@yS°yσfh΅Z\½z§N‚(ŠŠ $ §OŸΖ•+W &`ϊτι CQQΜ9£t9r$¬V+JKKqθΠ!ˆ’ˆέ»wγμΩ³P©T€-[Ά@­VγΟ?�Δ₯K—`4±rεJ‡γΙΜΜDZZJKKρΛ/Ώ@\»vΝ‘œͺμΛΩy9+Ώ2<ΠΨπ²S"jΔ&ΐ3uSΠ>ιωΙ­Γ¬.6Υ%*Œi‘‘‘$ ?όπ–.] I’”•ƒ+3Υ‰M«V­‚^―Η… pαΒθυz¬^½Ϊi¬±/ίY<­ΚΆUY.ŠS^$Χ]YD4²’\uν:b†Ήc4 ΕΔ ΧηXΆ5Ώ+]jš›š€’’"tνΪ’$!22ύϊυSL@TT $IB\\ŠŠŠP\\¬ Φj΅θίΏ?ή�}œελ›π€άχ ό[+­l˜κΒυΓ0L%pdά/"j('…ΡχΘέDΜM©CμC˜ώ0΅Oz„εΖ LXŽ=ζ¦e]€ξ)3 ‰ skpύΖ0 S $w’¬E€Ι bxyζ–η(ΌIYς߈Έ€Ή=Έ~c†©’Œ»αEΧΝ€M Y!ϋίΤφ;3Μ­PΦrHYD4‚Θά”Σ–ם07•λ7nib¦~Γ&€a†±ΗŒv#’λ―l"Eb"“ωQ2™•λΈxδυ'—}ΦΥΗΛ†™a˜;›†aΖλΝI_JoήπΪ[cσώ•8vΓπ„qΉY γr³ή›—νŽ2ΙρLΫΰ­*M‡ό7G―ΫoΤϊqύF­χζθυcϋΙ}·ί˜άQζ1y#\}œΥQΒ»Ήϋ^ίσ•Α Uv]ηlF† Γ0Υ‚MΓ0L=Ζ™Λ>kmJ±ΙjΠŸ“ [ξwυΕΗ0LέƒMΓ0Lύ AΒΈά,6ž«y'Ρ睜Οۘ’ΊιL©|Œι­ε.A=pυΕΗ0LέƒMΓ0Lύ€M€‡Kn ΘYœιΧ!Ωτ\@’F§K}J^7€aΖ6 Γ0υƒoΝΛfΰΉZw�›Ή,§]§”(MPZhΛ 4??Ώ„ζr½Ξ0 㛆a˜ϊACw5k·†)4‚ @E„vιŠυ_}‹‚Β¬Ψ°jΛ±.ΘfτFλ˚ΐ”η[θ’Ϊωω%4§ΐτ‡]}ρ1 Sχ`ΐ0 S?pKpόT1$• )ƒ±λΘi|υΝ/θkΙ€J­Ζ‰ΣWΩ81ΪΞΦΞ-tIν4šδgΨ0 Sl†aκniΆξ�D„Cߟs0aα‘Ψwβ°dΝvh΄εsφ―ΧζήhBΪ αPϋh·σ8΄z#βϋCEhυF|Ά:­‚ >!Νεη~k& gY9Π>ιW_| ΓΤ=Ψ0 ΓΤά�ωω$• αΡX²f;ŽύzΕαοΥ1‚ ¦W_|‘·y;ƒˆ0ώΓΨsμ7ψh΄$Άμϋ‹WmaΟ±ί\~ώ·cBRtm;'ͺΨ0 Sl†aκni Kpΰ»³θ?d΄ZDQDxd ΦζΉ%pδηK((,AήΞγ%Iω¬9y ΜΙ•Χ’$aΣήο\~ξl†©-Ψ0 ΓΤά�ωω�xAy½ΏΰO ɞA°bγώJM€ά½ηΊ P©ΤΚίςv‡J}ύubΪ€ �Ό–$›†a<6 Γ0υ·4“g-Αh*χΎΑhΒπ1SΛ™VωΜΔ‹M€šMΓ0Œ 6 Γ0υ·4;œ‚(аf EήΞγΘ?ψ3ΖΈ‚ `Υζ―LΐΪό# "¬άt{�½ΡΔ&€MΓ0N`ΐ0 S?pKPPX‚eλwΓ*― Πj ˜ρΙJ:Ž 8qϊ*ϊτK PϋhπΑŒElΨ0 γ6 Γ0υ·5,6 ΓΤψՌOjzL€€Rα£ω_  °*΅Vl؍σs6Θ—TψxΡ—ΨΈ»‚ `ξ’υΨsμ7DΕτAxD΄’ΐ ‚€˜^}ρEή^Εl;π#$ιzω»Ώ=A1mήrμ=ώ;zΕ%ΒZa™€―OώI’”ςμM@eΗχΙ² —oΒΆ?Β`4)& ²cr&Q’Ψ0 γrΨ0 ΓxΆ�?ۈ¨ˆˆ`§KDšΎ#ΧN¨i„”ΑΘ�ϊ'ˆ’ˆγ§Š‘`„λ μ8ό+ˆΎ;‹!YγΣGΩnΧ‘Σ "ϋυŠ’ΐωω�υδά ΅ΪΗaΜΑ˜‰³Ρ₯k”ςϊπ ŽŸ*.WFE&  Pξ²$IΎ>ω—ƒ ¨μψϊτKA‚uς·E+·*εUvLl†©Λ° `†ρ Όˆ¨ιˆθ$₯λ&ΰ€Ϊdn7rΝ”š6γ¦Μƒήh˜Isƒ‚ΒΜΟٍΞ]V‚Β˜“š1Τa[AπΥ7Ώ`mώ‡±Σ¬!>!‚ �λŸPPX‚΄AΓ!$I₯ΘYΞL@Aa ΒΒ#ŸΨίΑTv|α‘11~ΊςώφC�UΚ«μ˜Ψ0 S—aΐ0 γώxQW"Ϊ@D‡‰šΝ#Ή[ˆ¨Έ….©]ϋ°d�ψμU3jΪlέ�A@Χ¨Θ~ο#–ΰΘO!’cγΡΧ’Qα“φέߞαΫ_.Λ ΌΪΡMa((,ATLe»γ§;”qόT16νύ'N_-WFe&`ΛΎο!�g9m °?Ύ8sš2�‘ °KΦlWΚ«μ˜Ψ0 S—aΐ0 γΎxΡ[DtœˆΦΡ?‰ΜM‰Δ$€ Oψό3Rί1%HgJνπFΦͺkcI%?ύήΈ»@yΟA0γ“•((,Α†]' ζ-ΝΓήγΏ#*¦�¦"`œ�υOΛsχ �ΰΟE –mΔΎΐš1­‘Β2*3…%Κΰ^› ¨μψ­ά QρΩκ|μ‡Ωœ_Aa 2GΌ―Μ4κύ™%Iω›³cbΐ0L]†MΓ0ŒϋΠ–ˆζάί�"zPNώ#ξ%²άOdnJ&σ£DΟό›}f¬1½u@HŠN’ΠΞd5ΤFw Vέ›†aͺŠ5$ωi�"sS LX~Šdn*W*Ρw•}ΖΛ₯GΚ0 Sρ"’Q}CD}‰Β›8&�= 27₯±‘Ιό(ι-ιt©OywLiιcLoύ\@’Fέώ-ί7²WΘ&ΐsΕ&€aξ 7�ΔΨ€δdٝԈδ'ύΩD4J©Ξκ(3§Οΐ�[Φ§gϊμWTeζ]tέΘpβαyx½9ιKι­Ρ^{klήΏΗnž0.7+a\nΦ[cσ²YuC οζμ7z}ΟW/δϋςζΨχχŸIDm‰βόΗ ε’Š{P~έσr­A½E xσ€·<φζθuο± π\± `˜Z`θŠ� ³6}ν¨ΥΦs³wŒ/\ΈsςεΟχΟ«ξiαΞΙ—η|5ΎpΤjλΉwΏμŸ;h^|ΈόΔ,β^9ω Ζ$?‘δ„ΓΝ1Ω2pꎡύ§l;7yω7…3֜Έρ·OώmίgΔ½²aΈq;sΣ~crίeΰΉbΐ05ΜΨuΦy“6ώϋμWίoΑΡΒ#,7�φ“›01ο�ώ΅ΖΊX,gn*?I oBן@2nH�©ΫηeΝέwvΗ±Ώ\^ρ²ͺ§νίώ…α³χόΥ�VΎ/ϋϋ&’7φχ/—όχ|@NπmΙάέv-,eŠΎK–܍(αέ #Ωx�Ψ0L 2>χ_“sφΟ>ηκd–u{ϊl�¬ ο�8ΫΧ7αI9ιθω€\aΦ»„Γ#<}Ηδ…Ώ?ηκ —u{šŸ{ςΒΐ)ωυυΎ¬~‡§ώρ‚όΉθ{μ’ΫΨ­¨!Qτ]ζ1y#j�ˆ’h[‘ΨAy;ί±λhΦ§kΚM�YU<Z½A^ρW₯ΒΫΓί«t‘―[ΡΎάςρUWl¦†»Ξ:oιΎYΉ:eՌ–μ™ynhNς?Ώ„ζ€·<&W¨ΡχPύH8<†ώSση-Μ;ωםLVY΅§yλώs.yόWή—^wX‘<ΓyRϊϋΫwέqφΤ߾ˏ-ωWΊQΩ'�ΞΊSyQ£·ΖζeΧ΄ ˜Ÿ³ϋNόα cΏ^ΉcΧPώΑŸ1ώΓΥήnπ°q$ Σ¬@ώΧ?aΦ§k ŠFŒŸ^£ΗΗ&€a܌‘+ώ7dβ†�ακΔ•U³š7ψ/σ”7ϊ΄ 5?+'ζ¦vOλs_d·ΐ±?DQ„VoΔg«σ‘Ρκ!β�PPX‚%kΆ;¬°{γk›‚C» {ά4ευΐΜwSn‘°aοN(IZ­Ή;Ž•+Λ–˜/[ΏΫαύχ?Zcp¨ςzς¬%v‹‡Ebη‘S((”&Σκ4t DQ‚$©0oiž²έμ ΚΒbΆ•‰«s|·*6 sϋx½·ΎξW'y €§iϋΙM΅2}wΛ 4Ώ6‘ζg)ΰΝΘlxβA‰u―·?Ϊ™»γΨί.OZY5«νίώ…ώ“·Φϊ} 7ΐΧ7αIΞό8υ――Ωb{ς―τχΏΩSgάq@D�αμ9φ|4ZH’ [φ}Ε«Άˆ°ηΨoU6ΓΗLEh—λ«kυFL“γ`v9AπΙ²Ψu΄Q1}Π£Ή\Ys>[•Ϊ§�sΫΈ»‚ `ξ’υΨsμ7DΕτAxD4 e ϊCߟC?K΄zω˜?YΆ‚ bαςMΨvΰGŒ&ΕTυψnUlζ6±ζτ”F―M>οκ„•U;Ή&©¨{jbχη5~~ ΝεΎΘ/άGrεΚ& Žςζ€/₯ώSΆwuΒΚͺυŸ΄΅¨cρ΅y_zΉ:Α― ­M–~~ Ν΅Ϊδ'�“P ΐ­&�6jΕ‚ΰ Q”PP(›Q’”Οš“œ<πϊΆ’„M{Ώ«² Ψ~θΏ‡Ό€―ΎωEωΏ½ ψζ‡σΨΈη?((,Α‘Ÿ."!e0Β#cΚ•5rΒ Ma•žΫ¬ρˆŠι£Όήuδ4ˆΗ~½’˜€oΉŒ‚Β¬ήrP9†>ύR`€l·hεVΕTυψnUlζ6ώΕ―ΝΫ5αwW'«¬ΪΡμνcΟ%O~3«Ιjhi°xϋϊ&<)?m oB<6 ΞςΦΨ ―Mωβθο�NVY΅£‰KŸ‹Έ¨6οΛ�Nπ«BΛ 4?΅ΙάΚΟ/‘ωυ‘,]+&`ΪΌεΨ΄χ;E›χžDA‘lμ»ι$¦ AΪ αΚkIRUhδ.CεM@Aa ΄Zf}ΊΩγ¦) Ί½ 8~ͺύ‡Œ€Vk€Vo„ΑZa’=γ“•PϋhΚ½�ΝηρξďqψΗ 0'„5c¨ΓίAΐWίό"οSuύάμ!<2Ζa\ΝΌTηψnUlζφπφΕ�ZΈsςeW'«¬ΪΡ‚]SŠSg&Ξς5ZBZψ[΅Mς3ς·ˆ{‰ΗΤUΌή›χ―kN\vu²ΚͺΝXq¬ΈΧΏsjσΎlDQ•'Ψ*• ‹/�ΡD~Ρ’EˆŒŒ¬τ3Σ¦MΓ;οΌSξ}�ΰdc ]R;1ρ9ΩΕ•­P#έ’ξxw ͺšVyβŒΕNM@FζhτŠ·ΐŽYŸ�AA‘cώΑŒEπΡh•Ύϋc'Ο­0ΙΆ΅$¬ήrΠαύζQqβτΥr-»Ώ="Β·Ώ\.7Αώuœ9 )ƒ•Ώ-Y³]1U=Ύ[›ζNr§gU¨ŠnϋœήYΩwψη>vy²Κͺ-έ�1|όΦg­Mι/Ά3Y cβsς`DΛύΔ]‚κ*^‰c7 ηωΝ=W ςN’Oζ²ΪΌ/ΩLΐωση•€{νΪ΅EEEuΒ΄ ΅Ύ 3₯vPΊE΅OzΔΞέ.΅b*šθΘΟ—ͺlΦζaε¦Ψ{όwθ&§&`ύWίΚnE G�[„‚BΗ[FSX…³%€½ IR)³Ν[šI₯Bϊΰ,–`Γ�σ–ζaορίΣ]ΊF•Ϋ獯­ά QρΩκ|μοδΤΦ}ιENLΐ΅kΧ N:…΅kΧ’OŸ>Κίϊφν‹U«V!??±±±θΥ«T*L&ώώϋο*™€��RdffB­VC­V#33₯₯₯˜6mbcca4‘R©Π»wo\ΌxΑι‘ΪΞΦΞνΓ’ύ½;¦΄€ ήbΩ؈i © @¬0nΚΌ*›€§―’OΏ‚΅ΜXδΤ–@­φqLkŸ€ο=ώ; ΖPe杙 WC’T7e^ΉrNœΎŠ‘£&ΒG£UΦ θ�vΆ�ΟΏ Pn•Έ>;Pvώ΅ά>+z9β}ev QοΟTΖFTηψnυ>²7-tIν4šδgΨ0·Euž‚ΈŠ ζNΎ•ζS6�₯ϋ?Fϊœ„₯Αι‘~’MΟ$jtΊΤ§δfwPGaΰα’[rjλΎ¬Π”––"//Z­*5‚ ΰ»οΎCII L&–.]κ΄.²7«V­‚Α`ΐΕ‹qρβE ¬^½Σ¦Mƒ$I8sζ Š‹‹‰±cΗB–έΪ”ώ’ΆSR ΪdneΧR#& αέ܁ܭΞs5cΕ±βΧίώμ“r& 0ύαΫΌv˜zŒ[Μͺ@A½EΩνš›ΚΝ§Κl Υ œ ήYώF6›Ο•άπVN»N)Qš ΄Π–Ai~~~ Ν©CμCe}oΩΤ=ΤτLVέ’όsYmέ—^DΤ(?&€ˆŸŸ r¦ΌŸ––† 8­‹μM@FF&Ož¬όmκΤ©8p ¦M›†””εύυλΧ#22ώ¦”mB¬]ύƒ“- o�™―Α.Q ϋ^ίsΚ²oy­ Υ€ΟŸ΅�ό‰z£υeM`Κσ-tIνόόš³ `n·˜U‘΅Ι�ΒΫ;ιi�[“+Žˆ{IœΥ©<Ί£ ΨϊΝe rxκ‚©Πυ7έ~ϊ§A—ŸΗ4IeS·¨ρn ¬Ί%› ¨₯ϋ�ΑΨZ�]»†%K–ΐΗΗ@y£˜ϋ>ώ™€ΔΔD|ύυΧ€Y³f9˜€I“&)Ÿ›:u*2220mΪ4X­Vεύ/ΏόΡΡΡπ΅ΎnλΥ"Ψ꣫YΠΰ•Α U<Υ�η*mβζΛ^Ν²Tl¦ζΦhxυκU‡'(6effΦd_!EEEORœΡ>,ΩΏU`r[ΗU'•Εfͺ|�ξlAΐΖͺm6ά„±ŽqωyάiPΎΉ”M@…M€‡ΛήΤΒ}Y‘ €’’�^½ŠΝ›7C«ΥβκΥ«8uκDQ¬² ˆŒŒΔΫoΏ��RτκΥ  w�λυΈpα.\Έ½^οΠθΤ©SΈ|ω2ΒΓΓρΡGΥΊ o2θΓν›Ά{ΦεΏ9«f•τ,ϊ°ι°_HjlΕέκζΦhd3W―^­‘ΔΎ:œ={j΅ϊ¦ŸΣ¦<―ν”Ψ*0Ή­·w�ΣrΧ θ{¨z³*Έ­ ρ–x„…‡:5#>Θ†$I$ #'Ž„$I8Zx«ςWB₯V)�Χκ΅Θ:’(B’$Μ^*#'Ž„Φ Ε‘Σ‡ρΝ―‡ΰ£ρΑ”ωS\~ώΥ6Ώεdΰ›€: ›WεkΟ€$IΨ΄iŠŠŠΠ΅kWH’„ΘΘHτλΧ―Κ&ΰπαΓΠj΅$ ‘‘‘8sζ €λƒU*T*•ΓΐΰΔΔDθυzH’„ΔΔDί}Wόπ•αΓgοώ�ώΝY5«Οάu‘kΒτώ¦”°g˜κγE71³gΟFtt4`ηΝΠλυΚη&Mš€ΜŠ0`ΐ\»v ₯₯₯2dΤj5$IΒ¬Y³8ο-'ΈρρNΛCΗΤξšΐ”ηΫ‡%ϋ·6YZάβ¬ nmφžάQ•Δάή,Ϋ”Q±xνblϊz#΄z­S �†€aίχ{ΡΧ­^‹£…Gpδτah ZŒ™ϊ.†Œ‚°�a.?χΫ5ŽS¨± ¨£° πpU>΅aΝ™€ΊN- π’Λ±άoΏω“yλ ΞΉϊwgՌζ|Yp)aΤͺΝΎ‘)½ύƒΣ^u2Υ.ΓTpcw Ε‹£΄΄]ΊtΑ�₯K‘ΥjqψπaΐŽ;`4qξά9\Ίt QQQ˜>}:–-[†°°0γΏ�•W�ϋλ―Ώœšϋ–ge€ύ…³*Έ΅ 8ZxSζM†(‰Ψ{rƒ 0§υCΌ%^ΩfςάI•š€CΏΔΡΒ#ψbΛrεoΆΧ’$·lϊz£ΛϝM@½€M€‡‹M€L Υc•}ˆ"ξ%“ωΡASςgΟ_ς’«{ΦνiΞΊ‚’τρλΧϋuLκγj}= 8=�Ι’{ SmΌˆ¨ρΝΊύψγYYYΚ{YYYʜΚ&“ Z­C‡…ΕbΑόωσ•ϝ={W―^­’ pV&PcOPάή-<‚ΠπPΔ%Ζ9˜€Θ˜H =DωLΞ†₯NM€Ju=ι·�›M£αα.?o6υ6�Z6 ˆ¨1Q³$"* Ί[³–!)mB­/΄λ”₯>υ5ΏΤXΏΤX�Pλλ.‘)₯‡pΪ«5Τ’}“ο"Ό ‘Ή©―oΒ“i7}1lΦξsΒ74₯·¨υu½ΡϊrkSϊ‹+N›Μ–™ΘΖ5tύ0υŒ›š4β‡_ IDAT€ββbθυzμή½z½›7olΩ²j΅ώω'.]Ί£Ρˆ•+WβΣO?E—.]pεΚόφΫoE§OŸv:3ΓΩ³g‘R©*-“Mΐ–rS|–AπΩΊΕEKΦ†­ίlή¨―Ά ψϊ§P©U˜�Ε<Œš4 Z½‡O}γςσgΰρΈ­ X»ν0L‘α‚("΄KW¬�κ[–`Ε†}Pϋh\~ŒuA΅`ZΡΗDτ7eQ3’ΈIoy¬M¨ωΩη5ΊΤM§τ`m°΅“6ΨΪ©mPjG)΅ƒ+δœllg²jh–»Κπ’Λ oBΤσΫχΡ2(Ν―cρέc.ΚκυοœY}2—}Φ睜Οϋdζ,ύίΜe9Χ•³Œε:½ώφgŸτθ�ΙΔ―fYόBRcm-HΑι‘mB­/θL©Z₯ω΅ 5?KoώC\£ΧSΟp07Ž ˆŠŠBvv6ϊχοΨΏ?|||pι�%ΐ˜1c R© I† ‚��R\»v  PfK° v63CII τz=bcc–Ι& Ό 8τΛA¨}Τ³eŽΞ„(ŠP©TχΡ8¨Υκj™€δΙθϊJW-<‚Γ§ΎF«ΑΘ‰#]~ώl<·4ΗOCR©2»ŽœΖWίό‚Ύ– ¨Τjœ8}•M@Ν›/"z‘ˆΦΡ"κ''@ΡχYξ§±‘ής˜·w�Σ­:$©½ƒRΪ<¨Q·ΛχΉ€DwPJczλΑVŸ;­–‹·ΪdnUCλέTε{j$―ŽΖ¨Ιjπ5ZBZ›�_lcJκœΩ�SJ”ήh}Ωε”όJerυρέͺΪuJ‰ NlcJκΦΪ”ώ’―Ρ�Ξd5<¨q4qwSΝu%cκ!ξ3 ͺ›€ͺ(oo.–o^¦Ό^Όv1 FƒΛ‹MSά�lέ�ˆ‡Ύ?η` ΒΒ#±οΔ&`ɚνΠh Κημ_―Ν?½Ρ„΄AΓ‘φΡ oηqhυFΔ'φ‡(ŠΠκψlu>4Z½<“ZBšΛΟύ›/"κGDGI6αΧ“�ξ#ŠˆΜM)0ύa κ-j΅ΙOθυoό·w�Σ~~ Ν½½“žΦλίψζMI«M~B«M~B§3?~'E:σγ€·,Ω_ίΙ’Χw²θΫ‡%ϋΫδκ㫎μƒ–Ai~Ο$j|Œι­5ΖΔη4šδg|}ž”Ητ|ΐ�€ s[° π %LVΊru}₯+φžάγςcͺG&ΐ‹δ€άδΰlSc7•ύ94,;·ΪJVά�–ΰΐwgΡΘh΅ˆ’ˆπΘ¬Ν?rK&ΰΘΟ—PPX‚ΌΗ!J’ςYsς@˜“*―EIΒ¦½ίΉόάkΞDίCŽΙpk’“ώΏIτ[ΆͺpΔ½rΜ·KώΛΙάτz²�Β}ς6qw—΅άuοWίS6ʌ@xωϋ07%“ωQ�[συMxR§K}ΚΟ/‘ΉŸ_Bs&ω&ω™ΆUξ"j¦ϊ€θ™‰ήSZΆκ€nΥ!Iνέ1₯ek“₯E›Pσ³�>ΎκΘφύΫ~.υ)_ί„'IoyLNώΝMεί0Ό ±`jO4o8?›Q#O6,טWί΅E ?΅¬τΎtGpδηK8όγευώ‚?1${AΐŠϋ+5rχžλ&@₯R+ΛΫy*υυΧ‰iC6hΈςZ’Tžh‘c³|έΩ'�=(λ}CβoKϊ-χ;&ύJβmKΈΠν·ŒΉKλš=eƒ…©±ό½ΨŸv­(Ά–w‘Ιό(Ρ£cˆKzΛcJΧ+[·«ΰ7š)­Aξ"ΫοΠ!φ‘λΧΆνΊV’�Ϊl=ͺ2wϊβΆ―0ͺͺ+šκώ­>ΰ‰& Žˆ‘•{*}ΧΠeoŒ`ΰΉr πrυ½Q[Τp�ε8ͺδΎ4Ιαn&`ς¬%0Mεή7M>fj9ΰ£Ρ*Ÿ™8c±£ PΧS`2?JD‰δ΄ΏΏ³δ�Ζ'ώΡχ”uΊ‹ξμ“vwΑΑtQcω»²-wSτ=rL²u +33Jςlίd»6άM­WΆ'�7ΝOύˆθ •Onk"±΅•έ€ˆΌ‰θ8]ζ7:νκβED%t}”Ό½r‰θ²VΛ_Lτ]D@rΕr–ˆζ‘mΎΤFDΤώ�ֈˆ,DBžoPL@―^½™™©Ό=z4DQΔ΅kΧηΝαΤ©S³ ‚€ππpΎόςKh΅ZH’•J…aΓ†)³�ά �PλλννL€V›ό„|£;Μ¬ΰEDΝ‰¨ˆˆΞΡv"j/YCsϊŽΊU Š"ˆH‘$I:&³�m^‰BdL„ςzWΑNgVή[Όv1H μύ~ΟMχ±rΫJ‡—MI(ΐΤΕ„5_­Ύ₯σ™ώιG³ύr°ΒĜI‡γ΅iνΞ/]jόƒ’_©δ‰cMέΗ^DΤΰVlw ΅Ι�ΒΟ/‘9ι̏˕λ χ•ΕέκΖω›ή—ύΖδŽr7°σˆ<Υ±5c(ςvGώΑŸ1ώΓ«6ν`ΦζΛχΧΚM°χψοΠMυ�ΨξΛζΪΧ;=3‘δώώσˆH{=Ή³οοο,ω―°='�U£’  έPδxdίZdίδ`έM·œ{Ϊυϋ₯ΪΥyšn―vee7"’ΆDtό&Ž₯:jH² Έϋz‚―$ϊ?QiΚΞO]Ά8‚@DΏ‘™ˆž!’D4²lΚ­ϋoνoJ3β"ωX*όoυϋ«¨ΕαvΉ•{5" or³d`φμΩ U^‡††BEμΫ·——§,ψED(**\Ήr½zυBrr2ώψγˆ’ˆνΫ·£΄΄gϞEpp0-ZT₯„Δ?ΤϊΊΎ“5BΣ)=Ψǘήϊ†•ςlלνΊΟ‘λ ιy"ΪI€ ¦ΐτ‡3?ο;ζvLΐμœΩΨybςlΓ{Σǁr˜•ηF½;e΄Γ ½SζM†  Vή2ςmhυΪ›ξγπ©o ©$τKι‡ό#Ϋ°υ›-ˆ·ΔC₯VαΘιΓΥ>ŸΝ7aμ‡cnΫ؎Χ^ίόzΘε& Νσ©aνΓώ?{gΦΔ΅ώρWνv[F»ΘΤΦiΥ‘ I ’C-΅Κ΅�Eψ(iIHτβv«­K΅κuΧͺhέW¬»ˆK+ZΧ nάΪΥ«t»m—*ϊώώ˜Μ° Іε|žηϋhf’™3 'yΏsήs^³ΚOoθΊžNEUκͺ·‡‰�­Α7o~πΑ5ϊΫ/Ϛ5kP‘PΰΝ›7qφμΩχ}nTTT΅Ο©ͺ-όšζΎ¬Ι‹3έ‰Ο;ŒTmίΓϋφΛδwNjh& ¨Έ³sŽ V§ηL9E‘B‘ΑŒ•›±¨ΨuNΐ…+w0!يE‘X*Γkš€ π–E§%έΧΐΣ{ΐ[ޜΑδΣyͺΚχwώ+Mω©ΛίUBΓΐρ݁迕⿣š ΰuGξτ€"ΰ&Ψ|m‚υwΰ? TΪώΛ=.wΧζ0H―QΐWŽ9w\z,ό ί0o–uNψ\pœw#Όκ8φΣ0Έ;πΏΐŽcηχΓπx@ Όάκ·Έ†|Bμ/ΌΪΞs_ ύ[0‘π5χ…ρ ϋψ/ά€€c帞p›kGτS°@ΗsWΐώŽηώάy‘'pΑ�r§σΖgJ$·Έη'QP τχeΠ6ΐ[ .ˆήίxd„ΚΉB ­‡xl,[Œυ€;}h€Φƒ<6 Λ„Agοϋο‹φδRΐ«�€ιπoGΫ6ΐ5€Χ»°ΟgR:ΐ�q―…ΑŽχn*°ΐ„rΧ Ύ• U=ˆSύΚ‚ώvόθ#˜P“ΐC$α±cΗpΫΆm‹GEN‡ˆˆ=zτΐΜΜLDt57oήʘ΄Z­ˆˆh2™a4›Ν˜Χ�]«Ι©‘3žžͺ5ΰ)]ΰ=€J`‚γ:„λ€O b°qΐγ«akΝ©+°vΗ€( Wo[…3>αRΈK,㲍KρLq!2"η,ŸƒgŠ Q$aΦξ (•IqqΦbn?Γΰ‚5 ξ{ŽS?œDFΔ`d―H\½mU΅wΫš’cΡ3)ŠΒ•[Vΰ©O"EQΈχδž wόΑi$Π6�†ΗΏ=†I¦$Thd'2 ϋ₯2)2 ƒ»εβΚ-+πΠΉ/κάˆCύΟyzͺΧ-_ž~ ˜ΩτT‡iœπ€}₯ΚώSΑέ±c&$$Ϋˆ[ΆlΑyσζall,j΅Z‰DΧ―_Ηόό|μΥ«FFF"Γ08xπ`4›Ν(‰P‘PΰoΏύ†yyy…ΡΡΡάΊρIIUφgpοή=5jŠΕb‹Ε8jΤ(Όwο^•mρτT­ΦŠεά{(žΓοΦ“ΚυΉΏOΥυΛďΆΝ!& ρjEξEΤDš*#�’}YϋκΞΦPI¨ΥO"ImΑƒ^† Τ—ΚŒΙχ'ΤήŒ©Δ8ηΠ7š—²σhz€UΒlc!Ψ yΐs<Μη:›‘@ϋ.―FΏ ΠIΖm ₯ j³ΈΧ+Ί@8+Bΰ*@†&Φ†ΐy§Ι”„gŠ ρΛ― EQxΈ(wف4M Aϊ;±}0ٚŒ{OξAš¦±ΰςihˆ-qΑηάΊί_Ήο9ψσΨFΪP¦9–$ŒΔ-y›+΄uΑš¨Π(°πJ2 ƒ±I±˜>z�ΫΉΆΚκΑΰd(ŠΒ―.Β3Ε…ψΩη…κΓ•™ηφR…4M &€fθ2cbˆΝΛήO†Ζ]GsκάΨ?}/[‘νίΕ©ξl •jνΎIj;§ΉΎΘ+ξο ±jƒ[ `ϊι'Ό}ϋ6FEEαδΙ“1??)ŠΒK—.αΟ?�Œ€ ,ΐ{χξa=pύϊυ‚ ¨νHΐ–-[P£ΡΰυλΧρϊυλ¨ΡhpλΦ­UΆ%°›=ƏM}K‘³tJΥ+ ―p}¬6οαύϋe�Ψ­ ˆ hΌβӁ»Ωcό»Ψ"U:³Φ'Δμ/—ΫƒΪΤΖυ{œδϋj (G3PΐWŽUS¦΄žΑƒ^. ”α:€Φΐs)@λΡ ΰ),£€ΰ ν ΰ9Ž— tΐγ3.XΦt€B.θο’€‹JeΪλIj;€αpA&ΔxΞh=EX’I7ΐ³μΌ=^π|Έ΄ŸοZπχφά1‘ΰνv2Ω †[ΏΦPf‚½Μ=ηM1άθΰ3€ΪΜ? ‡/wŒθΧΪ 6+•i―s†‘»ίύχυ{ ΰ€η:ξ½bΫΐ5€.!ά{κ@mθ0 ΐσS�=^)Τ—ͺ€_Hϊτ~ υ¨²k}π6ˆ„Ψ_t€7ρσj; δσsfΒQ1Ρy†|MbtΎΡf@Λ0‹π˜a˜JMΐͺ­+Ϊv³Ηψ‡[ίδƒ ™ΜάAXo™�{yX•λ?•άκL?B†ˆ˜““ƒQQQ˜ŸŸ‘‘‘Βv†aπΟ?�DDΔ!C†ΰŠ+Ψ€§§»Μ!˜;w.:΄ΚΆΘΓΣbyγnUϊ„₯ŠŒm]ϊ\Mί§ϋτΛΔ1›ΠxεbύRjυ“ˍνΉ~‰Ο“|B-)·τiπ 7X4N& KηZ�Bά Ζμήαaz½ ΐΨ– f5έΌˆ“ΰ@_n»·€Ϊ,—Ϋt‰€3\Π―ΧΐΧ| €ˆ�pΫ'ϊΐs]Ω±{ψ–7< ‹š»kΪ :ΌΟJ:ΚεΖφjυ€ΧΈΐ>ψMIP›ΪpNJξ·zΛδύΰœR™φΊR™φ:@Πίΰ[ξ�ςX8Η]μίU·3^I^©|YqΘηF<—τVΐΟΤV`δ�n)"? H$©νΈkοΏθΊpζAΔ]kHώZŒmΛξHΊ¬ΆQ» Έrε Rεπ―XΑ­„a±X„mP… 8yς$2 ƒΗŽΓ{χξaqq1ΪνvLLL¬Ιιka<ΝPΠ%�Kg‘‡[•uiͺJ}9SΜMπŠ‰B…Fα’CΏϋx.·RRŸHόπίβ™βB<ωύ €( ί‰νγr§ΏΊsL_4έ%εˆ—Z«�t•"…Fqγ06)žΙγFzEβœe³οoΔ57Υ₯ΥΤHeRaϋ΄Œ© ΚTgpΛ›€˜˜Α8χ›νΫ·ctt4ζηη»δπ3 #τ§šš€””ώuτ*€ž’2CO€λ2›£_ΐU½£³h-³-KοΏΛΉάc8Η™φΈk}3ΨωZ%’Τv …ωUξ‹/‰rΈΦΪώ±>φt DD©TŠ6›MxόΣO?!`vvΆ° ͺ0ˆˆλΦ­C™L&€ %$$ΰoΏύV£sΧ8zΏ ‘ρ΄Z=ΰ5Ÿ°Tq§ΰYP„Y₯�ΩƒT:{ΠΓ¦UgrΎά)€Γπ©4Bΐ+b(ΐGvΫtzψΙΚΉ5:ǁΒύHΡš�MΈ#»°ΊEQ•�PdJ7!EQψρœ‰x¦˜›ΐ§,Ue�’Φ& ²ΥNύp²Ζ&€Ÿƒ½7 �ΥZuIβϊOuwίΎ}¨P(πΝ;xω2·€€s:ΠεΛ—ρΦ­[‰σηΟ―΅ ‰DϊJTTŽ1οέ»‡qqq8dΘDδ�Τj5–””`II ͺΥj—t ςm©YŸ«a:Π}ϊe}IΪq Y}€c ^υ=zbΞg«MΉΥ}TΗ.όŠEέχyΞ«5ρ&€ŸΘZ4bΦΰΓύΞZ• άHπO¨ d$ �&€5΄vάΩ��xδτT–έ΅φϋ'όWΉ@ύοΉ»φΰ&€Ηqy"\ΪοŸπjηήgύτ†Ž κΦ #\ΰ―Χΐ…²»ς~)• Z ΰ‘ ΧΰΟ΅ΰ/ρeM^ΤξωΡRnΏψ�€G@±γX;z«ΚΞ«ι gΰά5Δy•΅Aέ«6ϋΈΐέ#›O/⾜ϋtοΜ™€ΆλΘ0ŒΛΆαγ†#AyMΞ‘™³5:/SΘ\L„³–m\κ²ft\΄°ie& 2Š ~ΦηfΦΚ@%u>ž3±Ζ& πJΖ'Η9–OγԌ?΄ Pιl}}ΓΝ=ψ`# ΐΨΆ, ―6}€fύ§*ƒ{σζMμΩ³'2 ƒQQQ˜œœ,˜€””T«ΥΘ0 ¦€€ΰνΫ·keJKKQ­Vcll¬K_)((–+ΥλυψΣO?!bΩΔ`‘H„"‘Θebpem©YŸ«ιϋT}Ώ¬ƒΟ_ΎŒH„Fλp<\xΏ8} š�Q$γ…+wˆ ¨ �Ωϊϊι-έU:{€³Υ›K2y<`ύ BΣ†Μ (G3x‚{’(J} Τ¦6ΖΆjυ€ΧψΨYIj;>7^&Δπ°Z=ΰ5‰$΅Lfξΰ˚Ό$­ή’ΞVo?½‘£·‘\nlΟƒOέαg…ΒόͺL6ˆ©κœόyωT"�n)"ώΈ2™ΉwήAŒRixBγiaŸΠxΊͺλqn?�΅zΐkΌj²Ο9ψJO;JQΛdƒΉάΨ^�Ωκ-f >ΌΈ‘€Ά[}Y“—ŸήΠ‘Ώώ=βΕ_'Ώ= ΐΨ–3iύ[BYΣΪώ±ςF€_ƒϋΙΪ+ϊ)€^Ο>�@ώa‡§Εj:§υφMλμbφW*Σ^η‚:~‚5/~d$ρy§Ήω%e+³DhM€P+0cmΖ#;>QΝM€K°‘6΅)λoBΚΑCͺατŸAΕZϋρ΅9|BΜώjυ€ΧΈ‘aQƒΎGχο—υa‰ΠύΗΏεRβΎΉκb ""£πΨ…_±¨Έ§/X"‘)ŠΒ>1 xζΗ›œ ‰Ρ6lŒcI].ې+cΥΖ½(–ΚυAzΰΑ―~φ7 i†Ašapψ‡SΉν PG όγς& ͺγοΘ+D΅–EΫ°1n7 ΞΕΒ\κw°†ΦŽ•번‘i@V*‡ch$ϊ)αΛ–5΄ζσι σ«|°ξ,~Ÿ³ψyJeΪλrΉ±= σΑΏL6ˆq –ωΥ|x9&#—?oωγσΑ|y3Ξ Δώ"₯Ύδl*k3?9WhW9Υd8_o>t +όα2"FφŠΖΜmρά�rΩΏσΰ€i3·Δέ‡/ T¦ΐ?ž#T{ι—βΦΟO‘X,Ε’βRœ4s1φθΩGxMΑw%HQžΏ|’­h΄ φ­ΩΌΏΦ& Ίγσν*όα†ΫίWή¨΅–·+φKa4œ@¨ dN@%T2a”/ΓνX=Ζ₯ ·³œφρ·sΝζ!φ+τΰΧυεε\ώ»ͺσ:‡W₯ΕBL•?Nω hΞΫψΧΤd_e₯Ιyρ“nΛ΅Ήά…0ΚΐϊΒ{ετΊ°ΨΚrj]ξb»ƒfΠ€L€Ÿ“ ΰVo2y@εIωT)‡’Ÿ=`ό£L"ͺ&ΰ1OD� ‚R_’HRΫω„˜ύ]RCd!Φ�Ύ¬ύ ΏpKO?6υ­₯pKO_Φώ†,ΔΪUΡ%5Δ'Δμ/‘€Ά+g€„*ϋ₯aRξxw›€Βn`Αw%ΒγγEΏαΘqӐ’(ά΄η8φK0ΰπ&WxŸδό˜7Άac’(d‘ Š’π‹Σ—02*ΗO] ΌξΰW?ViΦmΝ«ΤTwόςν"&€ΠΘ «UBΉ;.Ό¨lΒ(�†•ίW>˜ηpώ�ό„0ώΝζWδΣeωŸΌ+«ΙDUησ9Ϋω­ξœχσΟqή^~Uϋœ_η|-Ξ9­όσœίΗΠ6o„‘„ςf’όυΥ‹Ό5Α€S‘/Š’022‹‹‹έϋ Τ�8_ŸsΚΤl0Ž˜€Ζ+7˜€\_6΄΅©ŸήΠΡ'ΔμaV²JgΦ*Ω΄°†$•Ξ¬ d-š ³Κ'Δμο§7tδζTιwϋVθ—οMΞηn0{Q&j΄l…ν-‹c&ΝEσQ˜d΄ Ϋ7ν9Žs—dU˜μlΖO]€}b„}η/ίΖ½GΏΖ Wξ`’Α†Fλpa_ζΆƒ.&@*Sϋff¬­ΤTwόΊš°LL‘žBFͺΐ9GάωŽKωI[OTάW>π­L•ΎΎE%ͺκΌUL«t’Y%m¬r�Υlwή_ΥvηΧςΧPΝϋθΌ*σˆ _κΌ² ˆΒγϊRύΠΕπ«όυΧ_‡f³Ω­Ώ3hΚΣ‚˜€Ζ-7˜G f―g!,φP›ΪH$©ν|Y“—˜5ψxkL~5±†"oI"f >Ύ¬ΙK"ImjS›² Α.ιwuE‹ϊ`ς Ή%\-ι£17�<ζϊ§~ΒΥBΩ²ο$nΪ} išΖ ;αώγί’B£Εa£'UkςNύ€4MγŠμ=xμΒ―hI-σk6οηVۚ‡ω…—QΛF&€Ÿg°yο E_x !Ιe„ Τ—ΚΝ_zwΧκ… (*.Εμœ#¨Υι…%x f¬ά,μŸ8c‘rΣ'& ΏΏ^­ (*.Ε…«Ά’H,EŠ’P£eqχα ΒΎQγ§ «M˜Ύi†Α’βRΌpε&$[ΛζΚpFƚ*WͺκψΔ9du ‚ΫqZ―ΏΒΘHCψ¬ΤάΌycbbΠj΅b^^R…ΐ;wξΰ–-[P£ΡΰυλΧρϊυλ¨ΡhpλΦ­˜xϋφmόρG.·υ?ώΐC‡‘V«Ε«W―β7°OŸ>Έ`Αœ={6ΖΕΕα½{χpΧ�]h2™«άή”LΐςΟ–aώ…C˜{tŽœ0)ŠΒ̜u.&`ώͺy˜α:�n=ΈΥZ5FΕDΉ˜€ς+\>4Γ₯{9›€υ»2Ή₯ E .X³ B›ξ·Ώ>Ι &€οGN7ψΡA>…²‘ͺϋsN©‹rx½ή˜’GkΘκ@„:„Œκ•4”?ΌJηΠ4½zυΒ_~ωσςςP―Χ wυΣΣΣqφμΩΒγΉsηβΠ‘CΡd2αςεΛ…νΏ�ώ;ήΉsǎ‹"‘Y–E–eQ‘PΰθΡ£1//E"6 <ˆχξέCD¬r{S2Y»7ΈlKΆ&c^=\LΐŠMΛ]žσα”P­Q &€’(d#t.AϋŠΝ+ΈBc΄« H2%b’)6ƒ`$œuΏύυIn2|_ͺd�k]Τ#p‹ηM bΉVδ^Du·Aω€N‘!s„‡€Κt Ύ:©³ ˜5k–‹ HOOG£Ρˆ«V­ΆγΝ›7ρΓ?ΔqγΖ ΫKJJπΪ΅kˆˆxυκU\Ίt)FEEΉœ£²νMΩ¬ά²Β₯Ϊpy°οΤ^ΤGκΡ6Βκb>š9ί‰-K J0&ΰθI£\L?:½7 ?ϋ|#R…'Ώ?!Όζ~ϋλ›άhΚsΏTΙ†’Η1\+r/’‡§δ2υx*ΦΌ"y»€³Υ‚½μT1˜ΪHΰF¨ du α!©΅ Ψ²e ͺΥj,))Α’’T«ΥΈuλV\½z5φθΡ�ϊλ/όωηŸ‘¦iΌrε ~ώωη(‹ρ·ί~Γ7n V«ΕΝ›7γ¨Q£Πf³α½{χπ�₯KHQή½{·ΚνMΩμΘߎEΉ˜>g™ŸC SΘπτΏr1‡Ξ}!€^)@FΔΰώ‚Ο]LΐςΟ–‘X*Ž-‹pΞ²ΩΒγϋν―oͺG&€Psˆ hδβӁΌ‚’zΆ ^ ΰρ#u@lζ–Ρζœ4€tZ‚{!#ΒCRkΐO ‰D(‰„‰ΑwοήΕ!C†۝'Oš4 E"2 ƒ#GŽΔ{χξαχߏF˜ΌdΙDΔ*·7ePΩHΐ΄Œ©Έλhξ:šƒλv�EFΔΰG3?r1gŠ Q«ΧβΒu˜™³NHr6ύc’(—εa{φι)œλ~ϋλ›ˆ hΠΘΕ›€ΐnφΎ 5΄e<²ΰd@HΉUυœΦ Α‘<ΝA˜PεH€»j0 ΑΤwT¬΅Ÿ_Έ₯§JgΦzkLhB& &sšb|r\0vΪXŒŽ‹Ζdk2Žόh„‹ 8υγI€i—f/Αύ§?Ηύ§?Η%ΩK’(<φΝΡϋξwχ{EL@£˜€F.n}S₯3k}BΜώrΉ±=@χΧZ€S₯@Λ²z=dt Ž©j₯Γ†¦'`,Œ«X‹©Β* M•­VI Τ9Νΰ wψ5A₯³υυeνo²˜5ψpΕ‹LP» e Βπ«ν>–‹#'r«­ί•Y­ 6vFτŒ¨`žΙCš¦Q$αή“{\Lΐ‚Υσ‘a,ΌR §πJ2 ƒΣN»ο~wΏWΔ4ˆ hδ*?ΐ›™ΜάΤ¦6Žώω<@p£·`5t/7:ΐ/ύH Aνh6hΦv潉»�ρήδά¦Lή=Ζ8eΧXγ”]cί›œ;�!Κ0)w|�°Ο;ψ‡Hž΄k‚αγά‰†s'&OΪ5Αψρξ “rΗ&εŽww;k#γΗ»†&OΜι�ΞπU"'μόwO ‘NiOtBγi_ΦδaVΙB¬]5Σz«tΆΎ*ΦΪO₯·Όλ6±Φ~*­―¦sZoYˆ΅kP„Yε˚Ό 4žζ\γ3Pƒ:εMΐΌ•Ÿ MΣxδ?‡]Lΐ™βBΤθ4¨P+ΚΞα0Q1Q˜`L¨Π†ψδ8Œθqίύξ~―jbΌ”©2™Ή1υbΉx ŒH‹φeνo¨;[C₯Z»―D’Ϊ‚½ !φΉ‚t‰Ο—‰I€<Έ£ΰ΅jFH`T†I»Γ‡Ξ=΄cπœWgo<]œ±νΒ­ε»."QύSΖΆ ·fežsΰκΠ9y»’ΖlŽδnvφz–Μu <*žˆώ₯Ύ$‘€Άσ 1ϋ+Ί€†ΘB¬]}Yϋ~α–ž~lκ[nSΈ₯§/kCbνͺθ’βbφ—HRΫ9­/ύD-�΅Ε¨¬„‘«ςgίrw°Jτh΄βπœΫ–ωΖ•L@ˆύEbκ--Œοš±νΒ-w«DF›Ξέ~wΔΊ•*­―Ÿή�]ž,f >2Ω †[&4ρy�(‘•«H|@SΰΨ }+ ιB•0xξΑec—ϋύΠΉ?άώ7@T;<ϋŽYόεƒgν_ΛυC+ξΖ§K14α‘iΑύQZΪΤΖOoθθbφŠ0«Y‹F₯3k•lZ˜»€�™΅¬EaVω„˜ύύτ†Ž\*‘•£3Τ&¨kρ―υ ύ—εOϋΥέΑ*Ρ£Ρ§§^3N5ΞTk-oΛB¬]½”©rΉ±=1υšΙsϊΟΙ>ϋ«»x‰f­/ΈΦwπς™Ό9FtΥ¦6\ Ο§3yτoYΉH|ΰΥ~° ~€) %£•3|Α‘Ω«φ|sΥݟ=ΡΓiω�‹%Cηδ-0ΆεΜ@�–Žί2buBsx Χ³ϋ¨Mm$’ΤvΎ¬ΙKΜ|Ό5&‰—Ξ"u—Ό5&‰˜5ψψ²&/‰$΅¨MmΈaγ^ΟBYUΐ_«}ρ;’‰;ΜΧά¬=}΄ΝtλδχL]¬}d‘6½w¨M.—Ϋ;R ž©εί αρΠόα«DƒηΈζξ\’G#ΫΜ}·ΒϊŽϊ₯0BΗZs?Ώ΄cβ3e“<“¨ͺ _GϊCΰR…@Έ.Ω€GΟΝ[Ά*χβξώά‰κFΛvώηͺyκξLΉάΨΎΜ8 ™δ7πP8Φڍ~Šϋς5΄‚ Τ— 4ž΅© ( ―(έ(P^΅© „ΖΣ\ ‘•Σš�΅Νkωτ€φ½/ξu{ΐJT·:xqŽΟ\ O‹ ΦΩ£δafΆSpŠL©L{ϋ�Œ|Θf}€9@δΣΓ>9ΈχΰΩίέώƒKT·Κ;σ;ž±Χ₯_Šƒή P*Σ^w2η|ΠώχέξlLe† ²ΡΆ=`-p“‰€šπR£†I»ΓΗ,=BFΥ™F/:ςGΜΥ �ΩBίiτΧ„GKΉ’Ρ+š­/κώΧ�‡šΣ ϊ©aΛ’"gμώ?w­Du«©9ΓJFΗ«Xk?Ύ¦„Tkχ 0Άεˆθ§€˜€ϊHs€θ§’Ζl޳ψΘ�άύcKT·zαα’žΖ5ι—ΞΛV:~’Ÿa ψϋ₯ …�­CΩR£6huŸω­ w³σσw:χ§Ϋ?w’ΊΥΑ³ΰΰΩϋx‡Ϊδ~zCG�Ϊv�–M T �Μ―ΏΛλΙz ηφ<Μo3ξx&q›,+Χ}Ήπͺ»W’ΊΡšό7ώ΅ΖΌ/@ow^NV¦Mι䴜lmjJBΏ΄Lέ·rΩΞ"’ΗάH΄d{Ρ γ„-�/Λ�=yίс°Ψ Δώ"7�GΡΐsόk§UVψ΄!Α4΄ί±*4k;C�κ―ΟΪ³sΏ©½;§Θδrc{.�ϋsŽΏ/‘NqΧ]’GyΕ±$j―g5΄ώxΛΠΕ™Η^wwKτpZϋεό›¬–# �[ή ΦΩ£΄¦p/•EQ–wάλY Γ¦υ—~9lNήβε9―»ϋ—θα΄dgΡMϋԜ‡ν—Ξ…­Κ₯ ΉŽΈ»–MM¨£ν*yoςξΜωμΜ/ξώ쉍fn(Έ3tΝΨ@Φ’ρ֘$άhš°H @¨ΝωՐŒmΗo²|6cχπ«dŽ@ΓΣΑ‹{pFΈλ£ΧΨΘ;§&ΘΓΣbΥZΛΫ~zKχ*Ύ$I*PύΕ₯_ΪfξύμΓEG�’9 Oyg~Η2_7OΪYΧύ²’Ρ>]¨ϋsξπkBΝm«ςύyorξ?ΙR»W›Ξݎ{?kQfš@ ΤGκAχη€5΄–ˍν s$ŒΫœvhΒVӍEϋ§όΎβπœΫN,F’ϊ§‡ηά^x`ςm5έ“•φUΒhγψ½5^₯·Ό«ΦZήφeνo(Ω΄°*†KΙ(@ύ₯BΏŒ²:aȌ=‡μ3>Ώ1=σΤο›Ξέ^Ήϋ$ͺΚΨtξφτΜSΨfξΏ•6mχW= eΏ,?:π$@β3ξπkB­rWεϋ’2yχRt―ρjEξEL•½�Š΄:P“ #Ÿθί’―ΰj“χNKιmš;hlΪΒ”EC>}o}qςzϋγϋβχ²}ϊ^vC’w¨τ¬8Τ�œ»ΫQW²Μ7�4Ξ0Ξ|#ω=“<<-–―(¬³Gωι-έ•lZX5§ˆ ¨ΏTΩ/;χ›Ϊ;f蚱qοg-J•½.წυ £²6όί¨μ¬2eeΉOοŽX·²ίΰ•3ΓϊŽ}άύ�a’ŸBD€iišFŠ’P$αΪ΅kλ4_³f FEEUϋœyσζα|Pa{Υ»©ŠζΖ)»ΖΠx΅"χ"&|΅Ύς φ‘¦8rŸH|ž8:§ΘY‹&@k χeνoψ±©oλμQ]¬}ΤZΛΫ IͺPσ;ͺPσ;žm΅λ<=Υλ”iсέμ1έμ1ʈ΄h•ΞΦW₯³υεŸηξφΦV]¬}‚uφ(?6υ-_ΦώF€ΦΘZ4‚Sdd ΅K£ο—]nκ—Νΰ ή\»vMΊwμ؁EαΝ›7λ… …ΤIεϋͺ & ‘‹ ΘΪPωΨ‘64n’Ωίψ€C.7Άχ֘$^*‹"΅h”lZ˜<ΜΜΚBmzYˆ΅«’›₯[C’_Χ΄ˆΦmˁ–―ποb‹τ·Ύιn}ΣΏ‹-�7άάΓOoιξΧ5-Βέν¬­d!Φ�²P›^ff•lZX kΡx©, oI"—Ϋsw ­H1•I£ο—Unμ—Uš€»wο"EQxωςeά±c&$$ϋˆ[ΆlΑΌΌ<ŒΕΈΈ8‰DΘ²,ώωηŸ52χξέΓQ£F‘X,F±XŒ£FΒ{χξαΌyσ066΅Z-ŠD"ŒΗλΧ―£¦sZoYˆ΅«P194žv€EΥ…!jώήδάqΔ4^­Θ½ˆ�7*;«ςb˜‘Ά8ŽΔgϊ·ΦΠ: ΐΨV&3wiS:I΅vίNΑ)2οP›άK™Θ+8άͺl Š0«hΪ?<ύͺ;[CU:³V₯3k՝­‘¬E£�Ωƒ‚"Μ*w·³¦rώ ΌCmςNΑ)2©Φξ+Σ¦t’ΙΜHYυFC£ξ—Mυ _Vjξέ»‡ΉΉΉ¨P(«5EαΧ_₯₯₯Θ²,nΨ°‘F&`Λ–-¨Ρhπϊυλ\―ΡΰΦ­[qήΌyΘ0 ώτΣOxϋφmŒŠŠΒΙ“'£σ2©bΦΰγ”Ο]& EC5; «DŠ’’iΤχθ‰9_œΕ’βRά΄ϋŠ₯2··±>ˆ7j­εmYˆ΅«—25P.7Άη–Ε%‚#ΰˆ|š»#ch¬‘5¨MmŒm•Κ΄Χεrc{ΉάΨ^&3wΙΜό»₯ˆ‚ότ†Žΰι=€™-ΥΪ}}BΜώ>!f©Φξ+f >’ΞVo?½‘£»ΫYSρο?�y(•i―Ϋ‚ΪΤ† 2 ­ΈΟ0ςi  ‘ΣhϋecS=θ—ΝΰIή8Ο ΜΛΛ»― ˆˆˆΆΫl6\±bEL@zz:Ξ=[Ψ7wξ\:t(Ξ›7­V«°=''£’’PΕZϋω…[zͺtf­·Ζ$₯α•:¬a� MΐωΛ·‘‰Πhއ ―ΰ§/α@S:ŠΔbΌpε1U˜E7K7/ej Lfξ@Lπp”«˜Μ£q’q.B”ϊRƒ‘n€'@‡)τT‰$΅�c-‘€Ά~€u<έήΞڈ�Βb_ΰς ϋ·,+$dpγ qφΛΖ(χφKΐά½{333Q*•Vjbbbΰœγ_™ HIIΑ“'O""β’E‹\Lΐ¬Y³\L@zz:Ξ›7-‹°}ϋφν*½ε]~R§—Ξ"U€ϋ‹€_}sΥΕDDFα± ΏΊ˜€ΜmQ¦ΠΟs~Ό#―ΥZmΓΖ X*Γάόσ¨Pk1)e0�4 ΅ΧmΝC™BEa’Ρζφk0•]Α₯Ύτ°ˆ@hκ8/;η΄5_₯²‘‰/€γ9ΐσc§AmjjS§Ή;ύ[–―qw{D.U?ω;ŒuUXŽP?hdύ²)θ±χΛJM"bii)ήΉsχνΫ‡ …οάΉƒ—/_Fš¦kl’’’pĈxοή=Œ‹‹Γ!C† "—€V«±€€KJJP­V»€]Ύ|oέΊ…‘‘‘8ώ|b*Qα7‰0²W4fn;ˆηώϋ—ΛώژŠ’0&n ~–{sσΟ#ΰΤOVΰ—η~F©L #ΒϏ}ƒk·@ΐ/ΟύμφλnUϊwK@ Τ-΁― LŽ€ Ζΐ‹}XCk` ­Ή»uIχƒ-όP»»½΅•σgC�¦Ac藍]ξθ—UšDD†apοή½xσζMμΩ³'2 ƒQQQ˜œœ\cPPP€ …†A½^?ύτ"–M ‰D(‰\&§€€ Z­F†a0%%oίΎML@:ρυο8xδxT(4HΣ4FFΕΰŽΌΒ2…?άΐ’βRΜΝ?4ΓΟ5˜‡’ΑcόxχGΔ4^@ j1‘©P�Β4_0Ρδα~%Qœϊ·δdhU¦Δη+W۞°nΐ|Π8‚@8QvI”αγά‰Δ4^@ j1‘)Α“kewΝωΒ‰υ@ΞΥ�£�Ζ}w�Μ Tf Bi€ΦCΰœhm€yΐ™€ΘέOΠ π41g21WΔ„Ϊ@L@hΚ4«gr�tν0'Ό)θυlΩ¨A�–•tθ ‹ 8€pe•ho IDATΐc£\nlŸ4aϋτΊ4Ρ±I/<>^τŽ7MΨΆaG>R…§Ύω³ΖΗ]°bFDφͺ³v~qϊΧ�±S+œG]I ‚ϋιΨ…_‘’¨ZΏξ“₯Ω(‹‘’(ΤhYάq €˜ΰ6ˆ „ϊ‡³)p~”ΐ9©άθ@ˆύEΟχΰO(3WΔs?Ϊ6§.Mΐ”9Λ\ͺπΞ[Ά)ŠBV)lυΡ T¨΅΅:ξ£2EγίΉΕ|qϊR…+7γ‰�όMφ‘(K‰ nƒ˜@¨ί”Oa&ϊΊ¦ ZΐΈ�ΰwΰ�‚�u‰Ά±.Mΐώγί"ΰα3ΕXT\ŠρΝh0EŠ’°πϋλXT\Š=ϋτC£u8sΥE"ξNxDdζ^Ζ’bn ΅–EΫ°1(–Κ\Lΐφύ§‘f\»ε—βͺ{Q,•!EQ¨Σχΐƒ_ύXι1* ΐš�1"2ͺJPUϋŠŠKqδΈiH3 � ƒΓ?œβbͺj“³ζ.ΙB…¦Μ ώpž�…˜ΰˆ „†A F ς<Χ€§χ,πTŒ£;tIgΔ= ŒΟ?ή0vΛ§u='€‰pώςΟ°¨ΈEb)nΪ} ₯2.ΟΪΝνgDψιšνΈηHR…K3sπΛs?cŸ˜Œμ-πEaLά@ό,χ¨`œψ¦μψGΞώ„MγΌeρθω_0.15:}₯Η¨ΜœΌψ2 #ΟΩTΧΎ•Ω{’h\΅q/8ρj΄¬`ͺk“³N|ύ»‹©X΅q/2Œ/\ΉCL@p ΔBΓΓyώ€Σ܁ξΟAXμ …ωU_Φδ%z/@ž¬dΣΒd]μΊGa’c“ΠhŽy'ΏGš¦ρόεΫh΄ C£e*ψ/žψϊw9v*φ‰I^wΈπ žϋο_B_ψͲΰ\£C±Xκ2η`�ΜΕΨ£gαqΑw%HQžΏ|»Β1*3EΕ\ΚΓ0xςβ.& Ίφ%$[Ρh&μ[³yΏpΌκΪTΩϋuαΚόdi6�Nf„˜ΰˆ „† ožΰR„ϊ·„ΠxZ.7Άχ֘$^*‹BέΕ€V†§'Ϋ’QΧ&`ʜe¨Φ²8iΦŒŒŠΑ’βR\ž΅₯2~²4e 5—’Α<-ι£]^KQ~qϊξΘ+t™[°`Ε&L2Ϊ‘’(Μ;ω=—’mΨ€( F$¨ͺcTeŠŠK1"2 “R»˜€κΪƒγ§.ΆόκGαxΥ΅©|;Žœύ #"£P*S`ΦΞΓuϊ9@ j 1Ππα@YUd₯αΉάΨή',Uμ₯³H%‘VΏ€ρ[ηΦ΅ Ψό[€( {φι‡γώ=‹ŠK±πϋλHQFΗ&α@Sz₯wڏœύ Ο^ΊΕπbW e#°¨ΈϋΔ$―?uΛ1Ξ_Ύ{~�ά©pŒκLΐηΗΎAŠ’Π>|l•#ΞνK4Ψ„y EΕ₯˜Ήν pΌκΪδά†Βο―£T¦@£mžϋο_unˆ ΅…˜@h8Š‘E> `ς€°Ψ 4žV(Μ―ͺΥ^“HRΫ ˜Έ}Ζ£¨ΐˆΈ»ί{Ž ΫX}€°NQq)ξ>|)ŠΒerρθω_°OL‚FS™ ΰ'ηό)ŠΒ»ΎΔΌS? MΣΈ"{»π+Z�G£L‘©τΥ™€’βRar/oͺkߚΝϋ‘¦i\·5σ /£–ŽW]›œ5}ΑjKeΈχθΧ.ͺKC@L@¨ ΔBγΑ‘ω4χžD$>a±/@PκKͺXXtl2ŒΘeΫΘqΣπxΡoΒΆ™kVίι…‡ ώ{_PT\Š–τΡ¨Φ²xαΚ\Έj+ŠΔRa­ύέ‡/< 8{ιŠ₯2—Ձͺj_Qq)Ž?]XhΒτ…H3Œ°―ͺ69+Ω”ξΌb“ ~e%bΒγ†˜@h<8Ν(_ΈΧ³Ι“vM ƒ―ˆ ΅˜@h|8―δXR4ϊ)Γ€άρΔ4^@ j1Πψi-ή›œ;Ž˜€Ζ+bBm &€@ šΔ4r@ j1Π4 & ‘‹˜Pˆ „¦1\Δ„Ϊ@L@ 4 κάΠ4]ι²—Ήωη[ΰ»hυΆ ΛΦT“g/E…ZΓUό‰pĘW(ςυ°:vαΧn1αQBL@ 4 ‰ Xž΅]ψΥEͺ"neΚ;υNύdE­_7όΓ)Θ0 .X± σN~‹VoCšfpόΤΔ„&1Π4x$& kηαJχεζŸG…Z‹I)ƒ‘¦iT¨΅ΈnkΚj€( “Œ6,*.ΕΜm]*μ–ΜK§ογ¦Μυ1FFΕT(φαΗsf€(  ξ:t�Κΐ<;ηˆΛφισW‘V§Ο^”ιT<, σ /cQ1W˜L‘Φβ°Ρ“¦d.ې+ΌnδΈiBa1Ύ2qmΪGL@x@ MƒΗn§~²Ώ<χ3Je d~~μ\»ε~yξ盀1“ζ’ΎGYa…Z‹s—dΉ˜€όΒΛHQ�Μήƒ‡ΟcŸ˜μ—`¨p¬%λv’H,­φΪφ)BŠ’pif~yξgμ“€‘½’@Q9Ώϊζ*&›�Q‘ζΪΌ2{R«6ξΕ'ΎC–L@MΫGL@x@ MƒGb(ŠrM3‚  Fx�Α< ζ‘e―eά{τ뛀ƒ_ύˆEaΑw%ψΕιKΒ�Mΐιo―αž/�ƒEΕ₯Xψύu4Z‡cdTL…c}4-΅lD΅Χ6rμTμ“ <>\xΟύχ/Αœ½t ‹ŠKqλη§„6$$[Ρh&ΌnΝζύ‚ ¨iϋˆ b‘iπHLΐΌeqοΡ―ν;zQ0Ξi:)Ά‘h6FxΜ0’JM—2TΡ—’B‘ΑE«·αΈ)σ„έΩœΏ| …j-j΄ϊJƒμŒ•›Q,•UΨ~ϊΫkψρΜO±ΰ»4˜‡’%}΄Λ~Š’π‹Σ—ΈsŠΚ�ΝΉ ‘Q1.σ xσR›φ@ Δξ§™“šΧRΞ―%ͺγ±§ΥΤHe aϋ̌΅Uš€τQ1.Ι„¬>­ήV!Ÿ‘±₯2…»?yφ�Jƒl~$aλη§\ΆΟ_ώ�4�ά©0pδμOxφ�­ σœ'lh΄φen;(˜€šΆ˜π8 &€@x|”τ[LLωD4+9£ΛŒ΄₯½gš—ΗΞ²,Ožc[n›•ΆόύΩΦe£f§-·Ο±�0L·-ν�oΫ’ΏO5-Ÿ·Θ ϊ©2Α“π„C- ’A γκ@…?ά¨± Ψ‘Wˆ€›χžΐ£ηA΅–­�δ|q–›pK3xζΗ›πQγ§£B­ΑΓ…WpέΦ<”Κ¨e#*]­Θh #VZΆ!‘νΓΗbQq)ξ>|)ŠΒerρθω_°OLφθΩ§Β9Λ?^³y?�4λΆζa~αeΤ²‚ ¨Mϋˆ b„GK3h>Ω°΄γ,ΛςΔCW/\τώϊ£‹FdώΨηWš0}‘07’6ν« ΰ₯L ”ΙΜˆ •ALPχ4›?ž5xeς§οoΘΝ±φΟ…οg^ί8'χΖώυGρθΞBΩ}sΡϋ™fό+σψ$γ§ύΉ>έύΉr†€˜¦E‹δ‰9ύηdŸύΥέΑ*Ρ£Ρ¬υΧϊ^>S­΅Ό- ±vυR¦ΚεΖφΔ„Κ &€@xxš-™9~^ϊκ›™ΣwώutgΑc ϊο§/w`ζτΝK_sc^ϊͺi\0Dqύ;ςi f )Ρόα«DƒηΈζξ`•θΡΘ6sί­°ΎcM]¬}d‘6½w¨M.—ΫsΏλΰ 1ΒƒΣlαπ ιs‡¬Ί»ΓρΏΎ>ρƒΫƒώͺτυ‰p�†γΝ²κξά�ωXCλ²~NFšΝ"ŸφΙΑ½Οώξφ€•¨n•wζwΏ1=σΤο›Ξέ^Ήϋ$ͺΚΨtξφτΜSΨfξΏ•6mχW= Ζθ­ρ*½ε]΅ΦςΆ/kCΙ¦…u N‘ΙεΖφά(@χηΈΟœ@ \!&€@Έ?͏ήpετώ"·λJ_νΏ€ ίΟό₯SpŠΜΏ[ŠˆH’wΙdαΖC3hΑ}�ύ[‚ΪΤΖOoθθj“wξ7΅wΜΠ5cγήΟZ”0*{]ΒYλFemψΏQΩYeΚΚnH�ΏϋώΩπwGsw;κJοŽX·²ίΰ•3ΓϊŽ5ΙΓΣbU¬΅ŸJg묳Gωι-έ•lZ˜w¨Mξ§7t,3σB&ˆ ξΓ|ϋ:γ¦ΉΉ·ά¨?jmœ“ϋΧxγΒ‰UάE$ߍΗά€ΔgŸη@§ΰY kΡhMαΎ¬ύ ?6υ­`=*°‹΅Zky»!Ij~Gj~Η³­v§§z2"-:°›=&°›=F‘­�ΩϊͺtΆΎόσάέήΪ*°‹΅O°ΞεΗ¦ΎεΛΪίКΒY‹¦SpŠΜOoθΘM6΄"£y‘:ˆ ͺ§Ω§οg}œUέ₯σGΎΕŒQλΎUtI ρ�Y€2Ω †ϋ>H|Θh@c£9<Α- ΛΉάΨή[c’x©,Š@Φ’Q²iaς03+ ΅ιe!Φ�Šn–n I~]Σ"Z·U,ZΎΒΏ‹-�?άϊ¦ΈυM�.ΆHίps?½₯»_Χ΄w·³Ά’…X»ΚBmzy˜™U²ia¬Eγ₯²(Ό5&‰\nlύZ9–ύ%σzB•@ TΓΠή“¨ιkn»;@\š—Ύ¦T£aVI:[½Ή€Βδδ» 1β0‰Οτo ¬‘u@€±­Lfξ Σ¦t’jνΎ‚Sdή‘6Ή—25WpΈUΩaVΡ΄xϊ-TwΆ†ͺtf­JgΦͺ;[CY‹F₯³E˜UξngMεόx‡Ϊ䝂SdR­έW¦Mι$“™;Ϋ–Υύ ϋ Βύ!&€@¨†™¦εa+&lΎιξΰόqiω„M·-ύ'‘u±λ|BΜώjυ€ΧΈj£Βh‘qα0‘Os©_†VΐZƒΪΤ& ΐΨV©L{].7Ά—ˍνe2s™ΜάΑΏ[Ѝ!ΘOoθžή3˜ΩR­έΧ'Δμοbφ—jνΎbΦΰ#ιlυφΣ:Ί»5�ώσŸ‡R™φz@€±-¨MmΈΰίЊϋ I­P3ˆ ͺ¦Ωμ΄e¦M{Kέœ?.mZ°ηξIsη)ΊYΊE˜U~zCGΠ πt|άβΖ‰c²0<ɐ½žεFϊ·H|Βb_€ϋ‹b‚R_j0� πθ0€ž*‘€Άγƒh‰$΅<λxΊ½΅�9pizΟsŸ‘ΙƒϋΜ„ΰŸ€ξ„ALP5ΝζΨV~Άς£ΝnΞ—V~΄�m^rΔΏ‹-RΡ%5DΜ|œ*Ž> $Έh¬4sΘ12OD?Ε–‰Ο4ξΣQπ?Ηp{€ώ¨Utμ{\<: Ηζg±ΆΏΛΓΜ¬8θ½₯2νu€Δηιδϋ€ΠΰSœΖΐxC+!• ­Έί·θ§€€Ξ„& 1BΥ4Ÿ“Ά|άgswγϊι;έ€?jeNΫ‰›>Ωƒc σ³»XϋΘBmz/•E!—ΫsΑSβ3@Ύ ςϋF Υ@Ύ$ „ͺi1η�Ϋ;σψ¦Κμ�X]ΰβQ ΖhΪ¦ IΊ$ ½--~;΅’Άƒ…!ΆYšVFφAΡ ²Š²S}ί„"K‘2ŠύRρ;ŽίQ ,�σϋγ&‘…�XZJΟϋυ:/^ΉyξMr{9η|žη<Ογ\8vΫ'hλΒΟiΣΌ½už¨ί+Ϋ8w/mϋδm�τ ±ΜΜ1˜/k#^:[W΅Ϊώ| ΐ+1υŽo Γ0ΐN’aΚΗ# ς iϊ\Z3cG'μ5mk¦ο =+SA~!m�τ 8s•[†₯ιό"R°=εΪ|ˆES_ΰψΖ0 Sμ$¦|J‰€‚όBΪ·κKš3lεν=[ηΙϋ΅»Ο�œa+hίκ/=Ηά"@o΄ΏΒ"€©ηp|c†©v’ S>wˆ€‚|iνš;hΙΔ τΕ†Ό:Oζ«k_l8AK&�§5Ω;θλ#₯ήcΐ<@p|c†©v’ S>eŠ·ΫyšVMέJσFδΠή•GθΤώsužΰ—g§φŸ£½9GhވZ5uΫy¦Μv—ρœ¦^Αρa¦ΨI2LωT(άvζΠyΪ±ψ Z8v Ν‘Cλ?ΪM‡7Ÿ¬ΣeEΟ}Y@‡7Ÿ€uν’y#rhαΨ5΄cρtζΠω Οcΐ<@p|c†©v’ S>U· ‚½9Ghυ΄ν4{Ψrš;|%­ΘάL[>ήOΧ§Ό=_ίQ‚σGμλ#”·ηk:Έώmωx?­ΘάLs‡―€ΩΓVΠκiΫi_Ξ—•&ώ,˜Žo Γ0ΐN’aΚ§Ϊ"ΰv;{ψ;Κέ|Šv,ώ‚VOΫFŸŽ_Ks†­ ƒΣΌ9΄xΒzΪ8w}6};­Ή“6ΜήM›ζο£­ ?—–%Ώ6ΜήMkgξ€Ο¦o§ sχΠβ λhވš1h1ΝΆ‚>Ώ–VOΫN;AΉ›OΡΩάοξϊϋ²` 8Ύ1 ΓT;I†)Ÿ?,Κ³οN^ 3‡ΞΣρ]gθπζ“t`νqΪΏϊ(νYy„v-=DΫ€ν‹��₯‡hΟΚ#΄υQ:°φ8εn>EΗw₯3‡ΞΣw§.Τψχbΐ<@p|c†©v’ S>χLά―Ζ"€y€ΰψΖ0 Sμ$¦|X°`κ/ί†a*€$Γ”‹Lύ…γΓ0L°“d˜ςaΐ"€©Ώp|c†©v’ S>,X0υŽo Γ0ΐN’aΚ‡E‹€š’Ρnχ#ί†a* .d]­†™κΑ"€Eΐέ�’οl i kφ€YΙίΦΔυ›ο�Η"€a¦jΣIrPdκ,XTj`=ZρΡAςMpψ< Γ0P+N²�ƒTmsE¦z°`P]Υ΅�©m`L’IΟ‡ε1)nΔ7ΗύασX0 ΓT@m8IŠχOPdͺ‹Υ₯q]ϋŸΪΖG΄zy{Ϋ:ΐ`}FŠχM‚έΐ{ήaΐ0 s'΅!8(rΐ©―°`P]šόQ�ρξ»οRfffΉοQVVV•―Χ§O>|x©λΛd2Ίyσ&ύϊλ―€.\Έ@H&“‘L&#A(::š~όρG""ΪΈq#i΅Z’Λε€P(hΤ¨QT\\L‘v}—`»ŸoΈε¬ΟHΡ‘ξŸ•Ζf°³`†Ή“Ζ†˜t$Εϋ'(2ΥƒE‹€κ�τ.έ\)T‘Ώϋε—_H©TVωzσζΝ£ππpΟλππp’ΙdtδΘ""ΪΆm›ηz¨¨¨ˆˆˆ�]»F}ϊτ!»έN�ώχΏI&“Ρώύϋ©ΈΈ˜~ωε2›Ν΄dΙR§uΧ†Ϊ‚»ΫύΌ½m€g%ώHσ’κ’Ζ¦θι‰oH~œEΓ0ŒDc©ζ–-β›γ;IŠχOPdͺG₯"@&“Ιεr7bβJΔΠ7'ώΕ" ώΡ@ΣM›6QίΎ}=ώευΧ_§uλΦΡμΩ³Ιj΅’Ιd"Ή\Nύϋχ§’’"Ίvν₯€€\.'ƒΑ@½zυ’ΜΜLΊyσ&₯¦¦’\.χ΄�ύχί)>>žA ώύϋΡΤ©SI©T’R©€Aƒy:3ܜ?žA 7nΠ₯K—H.—Σ˜1chάΈqDD4jΤ(JMM½Γί­\Ή’ΒΓΓιάΉs$“ΙJ½———G‡¦ nΞΥΑiέ"νzΡκc’Lš…¦¨ΫΘΖώ @.Ε΄δΗo‰€ψGΐ"€a˜Nc1fzœd͊ŠχWPdͺG•Dΐ’y+)οΰ:ΊοM›4“΄eυN JύL&£~ψ�_ΏNqqq4eΚš1cEEEΡΥ«W駟~"…BA™™™”ŸŸOQQQtύϊuΊqγ)•J:}ϊt©NΙd’_ύ•�\ΉB={φ€Y³fέΡρ‘P(θΘ‘#΄aΓJLL€άά\2›ΝDDEΛ—/ΏΓίQBB₯₯₯‘Υj%Ή\Nv»V­ZEΏύφιΝι―ϊˆκ*:‚”’₯‹4ϊim‰Ίχw*+€XVRτ\ΟrS°`¦Σ€ ΐΟwφ”ΤŽΰ ΘΤ%“Ω²φxh„*Š€uΛ6—:¦Rͺ(σέ,*Θ/€™™sH‘PH₯d‘Ρτεή“T_Hίζ}OI½ϊ’ $d4Θ>„ ς Ιl=# §sΏ‘χ�‘ι½Œ›BrΉœ ς iΗΊ½d’ΑŽ·H©TQA~!-�x5©”*D“HΉ»WψYΥρ τχfeά·†L#Ν*ςw)))žγ›7o¦ž={RBBεδδxŽ<Ψ3ςyρβEZΏ~=9’A cǎ•ςwcƌ!…BA’(’(Š€ΥjiĈwψ»Χ_¦L™Bιιι4ώ|Ίyσ&ΙεrϊΟώC‚ ΠO?ύδρwξςG™LF±±±τΏ�ϋΏžλόψ㏴tιRJJJ"…BA;vμ ½˜ΦΛ7Μ£7ΫM‚¬ήΠYΪΊόϋω¨«ΏΕ'ϊI0ξψfyLϊnΐΟ-Γ0 –Ɛα @‘/Gό‘ž?ŠχOPdJ3ΐΟ‘τήξ½hZ]°fιFV/^Oϋ6A‚ Π’9Λθā―(>.b’c© Ώ²3g“B‘ }[Ρ†•[IΪ»ι`©‘€Ν«vL&£΅K7Ρ‘GΙ 5”‚ Pb|Ϊ°r+�<ŸAFs¦- Ό§©ob2™‚Μ•~Φ]ˆ€¦Ί8 oχ­!?ίeϊ»„„„R#Ÿ%ύ]\\%&&–ιοέlΪ΄‰fϞ]κΨ�₯K)**ŠταŽΧ|E۟u’3ΔΛμPιξ§°MJφ­-₯δίΫz΄(1G«!?― Γ4pA ΰΙ–ήr”–ΗjΘQrPΌ‚"S—\° €JJr“–žΘΊ†IDATϋψζ@|󬴅�¨L‚ΰ1”�ίJω…4β­Ρ—ΰi{tί)@ίζ}OYοg“B� Ν«vPA~!ΫŠΞ/(%μ–4Ο΅ ς ivΦό;D€ϋœΖM‘θΘOΫ³G₯’·σ'¨π³ͺ,DKkΑ>π€«Ό\·ο‰ΡPΕ@cΝvνΪEZ­–nάΈA.\„’ΛίΙεr*,,€kΧ�Qll,}ψα‡4kΦ,ŠŒŒ€’’"Ίxρ’gδsάΈqδp8¨ΈΈ˜Nœ8A2™Œ:DΏός ) ""Ϊ½{7)•JϊωηŸιΚ•+d2™hνΪ΅wψ«ΒΒB‘”oϋδ“OHr8•ϊ»cǎ‘\.§#GŽPqq1ύψ㏔‘‘AΙΙΙ€wΌPΒίi΅φvRΙMΤά7‚$vΊώ?"}Λc’υ€c52ߍa¦^Σ@Wί<Χ½œ!Σ»u–ΐA±�ƒbUΈ½ζ~΅Ζ5hMlΔ­I½�° θ,υZ[=ZdΩŽ―LΜ™Ά€φo=Lϋ·¦3GΎρΌ—ϊΊ�­ƒJ΅rχœ oNό‹†ν)υ6x$=zΎ”ˆK 1ΓΖ{Ξݘ³­”PΘžχ;ή"A<Χ“ΛεUϊ¬ςD€ήhΕ-Π.4ΐ!HΙΏϋ~ε 8γIgΓ0Œ›Ζ·zL°�Ο­OIΫaV·φ·1ίΌ‘Eθ Ή{·*1²R±IZsh €'<iώΙ3ΪC*Γιΰtΰΐ€RΙ‰@#3€Pα"τ ibϋ‹zx@€ΧτT��Rι�JX6iή„΄‘Πe”Xέΐ�-χΒά― ‚3žœμψxBuηΈνφ‘€γŸη{ό}[сνΉτέ© ΄1g©”*OΒοn“ϊΊR^Ώ50oϊΒ�"@‘τΌ7qτ€RŸuώδ΄λaϊξΤ… ?«2΄< i΄€δ=Ί ΘfΟtόx~Πϊ= Ν»ώ`,€1.έ€l €”ι,ˆhφμΩ4lΨ°ςή�·΄lΈR&Χ/nΣF3m”Σ₯g’ΝΧ³p/Ÿχ ν0�*@+€�οΙKρ¬G )ŽυnuKΨ{FΈˆaž,w― ΦKΦ!μV―‰G”Lτͺ’x6’./x<¨A±u§ˆmα“Ϋ΄1N€L3 PΌ ΄)ρ|ΐί ΄GΓ;F@ jcŒ0ΐϋ>0@€fB zs,€Τλ΅ΐRΛ¬°R­ϋzH=έ[l°ΐϋ€Τ³› ΰ(€γNΘpΐ9�pΐ�π=€B!Ϋ�@κ9Ώΰ €kn(vΩο�cE�6�π‹λܟ\Χϊήuνο|λϊΜ3Ύr}—�ο–λϊ�μwύ†]vΨ `3€ Φψ @Žλ>,°ΐ§>0ΐ³\χq€y�οX2Α½΄<�Ρ€tτχOi?Εώρϋw+φn:H‚ Πβy+(οΐiŠK θΘh*Θ/€aƒ†“J©’C;�ηΫrI­RΣΔΡ“<" οΐiZ·l3Ιd2ZΏ|3εξ9A&ƒ©\pxΧ1’Ιd΄t~εΠ)PL–Dα=ή`‡Τ —:­z΄’ύ’ΙΏ[”©βQ†axJ‚ά£Φ–@»^’§bΧΏλ\�Ξ‡”P} ` €Dk%€Δλ3W» ³­΅=ε6(ΆτΫΫ²₯r;Ε ύJ@ώ)Πf.€lΣL `€„Ώdκ(ΓόΐېΔΒ ιz°ν¬ή€ΤΓέRw€D½ υ†Ώ)8ΎΰΟώ@€HH½ι‘z׍zΫ€Ια^]₯?ΠΕπRκΞ€w'ΐOh:Ίη΅Π΅Œ2@l „<D<ˆ-₯η§"λ/ΤΌ• φU1ΛcR K›εΈ•Ψώ΄<Υ^υW/­«Κ”αγ#Z½2Sηgή­(Θ/€~δY(*<šŽμΙ£‚όB:uθkŠ£«2 7g@ρ A)1 ί’7gDΪυSΥ]BlJƒ‘ί³0&Ιœρδ­ς‹†dɏ#ΐφT]ϋŸΪF#¦χΣ_RΣΓ½tΆ�>’ΥK§³΄u-+ϋxν�άυ�ξg°Lΐ£ Γ0.<υ¬’³΄Άt•―΄:„νώ΄ RYΕλ&�@oHΙη_Ό ΰeqbφ1@ϋ˜ΊR΅;(ϊ›¬a₯mΝ%G%ƒ\M˜{U¨znξΝξ*³ΰŒ'π VόhωU;―ύ΅‘Ά`θ ι*:‚Ότm§ «χ‡φy“λjΗΰΟ·ε–Ϊo`ν�Md 2έΣΟ,%Β=taΞ@· B»n/-B*‘ΊΦΦ;Ύ§‘[š10,Mηev¨4š”ŽΠYΪB΄΄φΜhHœρ$ΜύΪΤ΅�©m<"ΐ΅7J—›R­ w—ΤΥΚ½ΏΓ”)άΧY0 Γ”ΐ=ι² !π“KΡ�Ί�ƒTmS²gΜύύSΪΧjP¬„§&¬�Ύ;d©ΞϊΧΌΜU`XšΞΠ-Ν¨!ΪP[°—ΞΦ΅�EΐςW“B‘ Γ»ŽΡΉγKΓ ―3ΰ#Z½ΌΜΪE%ΒΧmΪ„ŒepΫsΕ" φE@™ΙA* ς¬\ε.geΐ0 Sn!Π¬l!p—=�λ¦g¬�`YŸ­&F>^|Ζ$™ΑΠοΩ.!6eηΐTu@€]―7gxιl]½iΎU)ΊΧ6Θ>Δ3ρ<.ζϊ*χŸ΅'\ε@^f‡Κ/"U‘Ρ€tτ­^ήΖ4_/­kνΑ\Tυr %K–V«-ΧodggΣΘ‘#ο…KΊ§ά?ε@ύχͺ^·–ϋ½ca †a¦nΈ}……2JGώ€hHAQ«΅·»KΦ«Φ…έ‹ΊόΚζΤ„υhqχϋθ-s=Σ!‰Oΐ`}ΖΫΫΦ‘KˆMιev¨T¦ ΅)΅³_DͺB­ Ÿl[0©.E@mΫν"ΐj5¨M© †~Οj΅φvjυΉ_DͺBmJνμev¨ΌΜ•w·΄N:σ9xZ›ξ^Ψ’χΌ!˜΄ey~αΏ�ύ/?Ύ\ΏQoύ]˜31М§ ±‹Œι΅Ϊώ<ΜύΪHΎ¦Άžw�_rŸήĎa¦Ί”ξ΅–γΉ52PΦΚ ε™ε1gkΠ AtσζΝj·―ΜίusΎθgtvλlχΣιœΟI£Až%¦kλoPΦ�ߜό3 ΓT·p-σιpξδΙ- *³ΨG+κk˜A±*Iν= ŽueuΉgAyKΩ–˜��»BŸ@€ν)ιο”όΈ{t£ΌΝΒά» AH«Φ��ω9UNΆαΪ  ’6“'dQ\Μ+u*Γ�tΎα–€$ίέ³kmιψΌYX)k D?T™Ώ[·nΡεΛ—ιςεΛDλΧ―§μμl’ΛεtρβEΊ~ύ:ΕΕΕΡ{ο½WκsηΞ₯¨¨(Ίzυ*ύτΣOžΛσ_ “ΙDΏώϊ+]Ήr…zφμI³fΝͺvϋŠΈ6 s'ύœψ3 ΓΤ%wiu ·ΥXΟΨƒ½iΎ·‚b©*οG«ι~ο•ύQJˆάΫG»JΦΗ7ΟJ[ψςDΐŠ…«)οΰϊ|[.6ŽA υΛΛ_.΄>‰ƒΙρ²[ψE€* ZZ»j­KŒ¦”Όg₯V_q?K ΖšUζοLYYYžγΣ§O§!C†Pvv6₯₯₯yŽoΩ²…βββJ]#11‘rrr<―\‘Ώ3f ) E‘DQ$­VK#FŒ¨vϋΚό]=Ϋ!a†©&ξ¬:Ie“ͺτŒ5œ ίο'nρΊ½œ ι4η±剀9ΫJ³L£θ¨Ολ Ÿ@2™ά3R°{γηT_Hf“HH.—ΣιάoΚm7yBEGF{φ Πͺ΅₯– -οΌoσΎ§€^}I’ 2dβ9gωΗ«I₯T‘ $šDΚέ}Όj" ΐφ”kWρ’"Ά¬{Φ“7PE0uκΤRώnπΰΑ”M‡Γs|γƍ_κ Uςw ΄nέ:5j;Φsό�₯KτΫoΏU»}eώΞ·„ΏΣι,m₯Ρ"4CΓ~†a49(ήο”7�Π@“κˆ€UŸ�υμθϋεή“$-[°ŠŽν?Eρq Τ'!ιŽ‘€ŠΪMžEθΓρS)�π9r€€“R‘€oσΎ―πΌμΜΩ€P(hί–C΄aεVφn:HΗ?Ο'AΡœi (οΐiκ›˜L¦ suE@“ΫξQMŽΠΤwͺδοΦ­[Gƒ.]ΊD—.]"ƒΑPjδσΒ… tυκUŠŽŽ¦™3g–ΊΖΤ©S)22’ŠŠŠθβΕ‹ž‘Ο]»v‘V«₯7nΠ… €MμΦ­£έ»w“R©€Ÿώ™�\ΉB&“‰Φ�][νφμο†a˜κΒA‘ƒb}¦Z"`Ο¦$δ�Ω/Ώ₯}[QA~!}}μ;²L£ΨθΈ;D@Eν&OΘ"΅Jν9η›�"™LF;Φν­πΌ¬χ³I!WΠζU;¨ ΏŽν?EηŽΠγ¦Ptδ­‘Š³GΟ“ tώδUΙCLΩTΙίΉη@) R(₯ζ@₯¦¦’Α` Ή\N©©©tύϊυRΧΈzυ*%''“\.'ƒΑ@‰‰‰”™™IEEECrΉœβββhΰΐž9M'N$…BArΉœ† FΕΕΕΥnΟώŽa†©.9(Φgξz$ΰόΙhHϊP�ͺ΅dΠΘd*STΤN*Š)υ*•š–-XUαyߜψ 2šδrΉτΜIgž§ΑŽ·HΟqΉ\*%Κέs‚E@ΝP‘ΏΫ΄iΣεŒ”wί}—233kτšΥE/¦υς sΔθΝvS§ «7Ψί1 Γ0ΰ ΘA±~S-PrNΐτf‘Z₯¦/χž€‚όBΚ|7«LPQ»Ι²HUb$ΰάρνΫόE…ηνΫrˆlΟ₯οN] 9ΫH₯TјaγiβθI—ΰΉήω“?Πώ­‡ι»SXΤ 4-Λ'|υΥW€R©hεΚ•5κkξ gNΥGΜψSWΡ€-]`°>γςwMΑώŽa¦ΑΒA‘ƒb}¦Bΰ^θΐφ\3l< ‚@Vl‘‚όB5t,i΅:Ίο­Z΄ŽΤ*5™M"}›χ½Gδ8]a;χœ€‰c> Ό§Ι�?•΄j-}wκB…η 4œTJΪy”>ί–Kj•š&ŽžD‡w#™LFKηηPήΑ3”nDZ΅ΆsXTB#M-`L’ωˆV―€H»^œΦ=¨›σE½9ύU½˜ΦKξxν01­—ήœώjP7η‹ξέΡ}D«—΄œl`Η0 Σ α ΘA±>S‘@ϋδ8M¦ “η½3‘\.§Ι²¨ ΏΠ³βΟ;Ž–Ϋnς„,JŒοCQαΡ$‚hίζ/*½ώ©C_S€E‚  Τ+Ύ;^@ω…΄`ζ"R*”$™ &Ϊ»ι`…"ΐKgλͺV۟gPešρ ΐφ”··­C—`»Ÿ6Τ¬Nλξ#fόΙ7Μγ+Ϊώό@X˜#ΖGΜψ“:8­»6Τά%ΨξηνmλpΫJR Γ0L†ƒ"ΕϊJ“)i mk>Ϊωϋέ¬Ή_mΝ¬7�ήoΪά;D€΄‰‹€Κi"ν™`y λ3Ύα–ΊΫύ"νϊ�’#HoΆ›t’3δA0½Ωnκ*:‚"νϊ.Αv?ίpΛ �¨§g£0~V†a89(ΦWΏηψ(τ“ρk‹κ:9―-[8~Νuλ«οŽ4˜/«ƒΣΊ{ιl]5š”Ž%DοsQ14bEHβ0XŸρφΆuπ­^JΡ�₯SΥΫΛμP=Φ)Θκ­-]|D«—··­ Φg€έ£coΖ0 Γ€ƒ"ΕϊKγŒΰŒ'g Yz£�“σΪ²μΑK~7†ώ­_ΧΠ΄žjczx'cΊΖΫΫΦAzŽYTFπμDϋ(`y Ά§`L’Α`}:K[έbΠYΪΒ`}Ζ$™4ΪiyΜ΅ƒts”ήO‚a†i pPδ X_i D?4wDΞWgWη ϊ½Ά3‡ΞΣ¬w–�KζL 4gΔiBμbηΐT΅Nη|H~ά5šΕ" r\>Ν€{�ˆ4'ΘΪςΑ΄-€ίύτ›ΩΧ1 Γ0·ΰ ΘA±>�ˆož•ώ©γ³iΫ�Φu’~―mΥ΄­Χ�>pϊ}Έγ5C¨#Všaφ6¦ωϊϋ§΄z·r‰YU£€{Υ�\ ·5{ΐ¬δokβϊΝμλ†a˜RpPdκ4¬-ηΌ“σΓ‰έgλМη#fόIkv„B­•)ΓG£Iι(x/ Κ0 Γ0 Γ0jγ]B ΏΡ�Ϊ`θχl—›²“1]£ ΅ϋ›¬aέ»Ώσκ;ύ¦Ož9dΙ—½΅μ�μ‘Λ―­œΊεΪ�e‡ιπ¦“tj�?ιΜ%ψ6ο:΅�ŸtxΣIΪ΅μ0­œΊεΪμ‘Λ―Νz{ωειCŸx»ίΤΩΡo₯ϊ‡§%iœ‰†ξΞΏθφW‚Ί9_τ3ώδo²†iCmΑŒιš.!6₯ΑΠοYi@‘„ΰy Γ0 Γ0 γΒ5"ύΠ£‚3ž„Ξ�V£IιΨ)Θκν₯wh»ŠŽ ­Ωκξθαζˆι3ϊυ‘}§fNrΞΫ:γν%_ΟzkΩ�Ν΄θζGo/»ΊΪ0{7­ΙήI9™[n,yoύΥεnωeφΫΛ�Ν΄θζ¬·–ύߌ‘KώωžcξΞ!}§dΏ=*έ_tφшι½υαŽΧΊFd$θφWΝqΎaŽίξΞH­ΩΪUtyιΪNAVo&₯#t–ΆΞxRšΜs†a†a¦<\sΠ ˆθέ ’₯΅Vko§V۟WŠ–.Κ€7ό ‘Vƒήl7iBμ’owg€_hz΄!Τ ¦Ώd09^ώKμˆ7μyχοo&}0ώ­Ύ™™CϋM›1²ί΄Ή£f/50{ρπώΣζMž:sprζδ7{Ošh}υύ‘q~Η¦ s&jœ‰ϊpΗkz1­W׈Œ]€3ή`rΌ ¦ΏduΔϊ…¦GϋvwFjBμ’ήl7B­eΐώJΡ�E­Ά?―ΥΪΫI½�½[IΏΝΐ€a†a†a*€<εAρ́ΨGέb:K[Ξωœhυς2;TΚ€7ό"νzm¨-Xšaφ7YΓ΄ŽŸ0{”_hz΄o˜#&@΄ύ9¨›σΕ@sF\€˜ώR€˜ώRΧΠ΄ž%Ν}<МΤΝωb€hϋ³o˜#Ζ/4=Ϊ'Μ₯pDψ›¬aκΠ ³6ΤiΧ+ήπχ2;T>’ΥK§s>₯-lOIΙμ£�wχ”�°`†a†a˜*P†θ/ $ρ “dZ­½··­ƒoΈε₯hιβmLσνdLΧ†₯ιτζŒ€�’#ΘΠ-Ν¨!~Fg7­Ωͺ ±‹ώ&k˜Ϊ˜^�όMΦ0Mˆ]Ԛ‘~Fg7θ 1tK3vAzsF@`Xš�“1]γmLσUŠ–.Ύα–Ό½m΄Z{;“d�ζ_ύNώ†a†a¦fΈM $?,ΥΩχn…Δ' ZZΓ`}Ζί?₯½··­ƒ_DͺΒG΄z©M©;Y½U¦ ocšoηΐT΅2ΰ �NΖtM'cΊΖKοΠzιZχkeΐώSΥήΖ4_•)Γ§SΥ[mJνμ#Z½ό"RήήΆώώ)νa°>Ρ�ZJό{·’ΎKςÜό3 Γ0 Γ0LΝγMPJΔ> X[zDA€ν)˜ϋ΅Aΰ€§‘³΄•ζ  ύžΥιœΟy{Ϋ:x{Ϋ:h4)5š”ŽξΧ:σ9ƒ‘ί³jυΉVko₯-< sΏ6°=u+ι·Ά”>³Tβίœό3 Γ0 Γ0Μ=Ε-\#hvKΔ?"%ι=ZxΔz·,ɏ#$ρ „$>ΰŒ'œρ€η5’—ΪΈΫ[[J׈}TΊ¦'ιo†[=ώœψ3Lζ�θ]VχΰPΗ2IEND�B`‚pywps-4.8.1/docs/_static/000077500000000000000000000000001526123257100152705ustar00rootroot00000000000000pywps-4.8.1/docs/_static/pywps.png000066400000000000000000004112551526123257100171700ustar00rootroot00000000000000‰PNG  IHDR{ΪΉPbKGDωC» pHYs  šœtIMEΰ °b E IDATxΪμ½ϋsdΝyίχν>ΧάXμ{Η«Δ2Y)‘J€’hΩΊΔ©bΚJ‰ΊΈJŠ])ΕΚoΙ‘_ςoH‘ͺ"Ω�Ў)©Κ%§,ъe‰CQ–DJΉοΎά]μ.v1Έ Ξ΅;?tχ9=gpΩ°ΐξχSuή,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!δόΠZλYBMΩ#„B!„λ%xβΧ�ώνEKeB!„BΞGςά!½ϋΎθiΚ /Rψ({„B!„ςζ’η žx‡ϋžP[Ι«μmνπ’„/䟈BΘ5XOΕeν‰ „ςΑŽEΒΌΠ1€Θή—ξ)Vξ {”φίkσrZ\ĘEΩ#„rΔNΜΈ䩝ηkŠ !„ =Α‹$RCϋo}¬8p`oύ1‹‘=B!œΰωϋό=@�xέs«z„O۟£)€„Bή`Œς£yNςμ±hoΟ·J+y;φyn|ͺνk)­υΉC”=B!WmνΫΰ.½cΦΖχ>αkφCΨAΥ�ή‘λΐ“  BΘ+z‘Ί!€€e7ννL„/@9Φ(w2$k;�Ď3%Μή½Σ[ΞΚ!„«2€ϊ‡K‹‘hχ@ήmΠ?‡ςD�F»ΎφΣΎCw§ΎSE/Š&!„ΌSΡ˜σ$oΐš½?Βψ›1Ά~?@Ή[CΔΛ[ψ�EΓ€sΊTΞΐJί™){„B�ΛΰιKžΏΑέ?{Dήcό΄N`:-Ζ­˜Vφ¨{ξםCBψϊREuΟΏ_θ―ξ’D“I!§½y7άpΐ]w?Ώ'�rΩχ#h ˆ°Δ.б@ώ|‚dm&ν3θΗ({„Bή;Ρ 0γ6ΈΟΑ€ΗΈϋ©'}.ΚΧ•=_τ\Ε3wί�Ϊ�Ύ»u�ΧMρΔ1bW{_Ÿ6*ψV’wA’ι $χ2BΘρ’·`ΐmχ›#ί\Γ£_‘Ϊ@Wno^ €ΐα�ΰ˜1Œ²G!δ½=?’η€nΡKh7ΊΟw„/Βt#'[Κ“Ήά“ΌάϋΪέΟμαΎ.;�‡™ςEЏͺc$ρΌDο"Dσ€’6,fC‘θ½žθmx€|σ6ύΪ Κέ9¨"΄„‚Δ! &F!‚τΒ£y”=B!W_τOςVμ ΊjoέFχyCL>ˆqπΘ[Υnά44·Q(‘¬�’#~ΎδvχΓΌκ‘-Χ·F›*κ"ŠΊGΟSφΞS4uΟαΏ–Βμb6?BΘ‡.z›cηΟξΰΩοά@΅;•'Π΅έO.Kθ ‡Πs@�~qΚ!„χjυχη%Vδό ξξΈ‰r<Βώ_/bχΫs˜|/…�ch*€Φςˆμ iEHΦ’‚’υΡr‰t½DΊ^ ]ˍ “ŽμΉ(ŸΫΫΧ'{έ=~jhέ#‰η)zη-š³²οP3Δ²?J!位FτžόŸ«Pωκ<†.Ch© $ „†5V>ί7ͺ‹–>Κ!„w!znu½e+wχš£άYΓΦWo`ό�.AWθ*…�#hBΧ�ˆžζπεEh‘© €€Bύ¨>�°+kΐ dΉ Μ}”cψπΓ‡~š§/n}‘/'W~Τ°τώύ’dοπ;vOΎlE/K ŠZ  @*ΘPAD%ζ�Vtύf‘qbΗ œΉ({„B.7€Ζ0ϋοF0₯ͺοΑ­’nύαlύώM¨b ͺB—1TAΧt-% •Œ―4[χ4 `"|ΒDϊΜ}eWZMδoοΫ5φ�Ίd !KΜm”Hn—RΝ Rd½gΏž¨!P!Zͺl„°Δt”νΌWj/B4g €^/cχ(zD³²%σG¦τB�·θω{Ι]Doΐ+z6’χεUΤ‡ PyU„fŒ‚‰κΙ°†ˆ „ ¬}aΐΨϋφ[xceBΘ{1ˆϊι›�š™@7πμ·οcό5¨b•  Κͺ ‘+ ][ΙS€Φβ¨OyΒ'lQ@›[ao₯€Ά�§l°†5v�JUC+k’SE€jRE…¬a£…’2B©λζyΙ€³)ÍΩί R YΗŠfŸμωBηοit+ΡξώΪQnΒ"ΰU3ΥZ !δOΐtuh_τVν8励ψ©›‹Py<-z‚ˆjΘ$G`ύl#YΫπΐk{φϊ9•ΖyΧLΚ!„ΛΔ₯pΖ0-F0ύ‰ξΈgΏsγo¬Ce#ΤωTfE―4’§k@+ΨτΝ~±C-΄tB#Œψ9ω6ς8Τ­ Ոš&L%ό9€>‘μλXaΚ ’=μc'Ž�mμ}ϋΤs‘sΝh€ΜgˆF5βQ™˜=G Ψ8ΉΫ·“”1€»φΨ·Ώ ΒΎzwΠdžBΞAτ"Oτ–0½ iŽν?ΉƒgΏmD―ΞΊ’§!γA’A&ϋΈωχ_aι3Ο<πΐ+{uYLγ$„ς^ ¦ΗEυξ`ηΟocό[Pωu6•'¨³Ί’P%Lκ¦·ΝLλ™Zd­vwμΆ>aށΒ+ζ"¦PΐΘ’ϋ…΄„› šΗΊ’0nΏ`GΟΛ‘ΟY4€0m +ΔΛ’εsεHΧ3 "Hμefeϊ€-{DhK‰7Cξα#„\“±Ι½f1r m1–{0ϋτξαΩWncϋλ=zΆς¦d\!HsΘdΛ?²…ΥŸ|ΰϋžΨ° ³@6΅gο’�•”=B!—E7ͺ·“³†:[Ησ}ΊAεΓFτT)‘+Ρ3©›˜^%|’+JζΪ ž/€M$°σά©ΧλKυΫύσ }!’Ω#FkdyόE…ύΏ)!dˆ—1�ιŒ>³‹dm&ifίeθ‰ήTQ¦sB�¨δωγ’½mΡ°UOτξΡϋWλΨώϊ κlήξΡ3 ’ B'z…½έΒν/>π €ΗVφΆ`2#όύzl½@!δΪͺ³Z-ά°†ν?YE΅?‚*†¨σͺ°’Wϊ©›gBέΉ�6O³­Λx―sΎ|N’9K Ν>Fέ²A TQ"ίΚπϊ&Hϊ“KXψΑ‘ ΄Νμ+osα=€!δ EOΪ#D[qΣot·½:»ƒΗ_Ί…Ι'ΛPٜ٣WvE―Ά’·‹ΕΪΒν/>³’χ&²χ&+Βυt½π¨eBΘeαGυόΚf7Qg«xύΗ+Πε qχ2-Ρoq+ΦΪh!„\Ισ£y�βζνΦ‚5wΰφ’ηΟοΰΙΏ\Εαγ%¨|uA—A»G―½2Ωυ"z[Ρ{ ΰ™½{τΕ.Κ!„‹\EgPC»ιύvΎ΅‚jͺH‘ŠΘŠ.FτN@œώgiΡ/cΈ„·zn’ι δΤ^Faχ2ΚVόDB…!dA'! %Ύ«πψ�Θq�ο"H_Γ€'νZρs:χξBή±ΰΉ+?ΉLή-΄ύ^οbςρ:>ωΝUT»‹¨σͺˆ‘KiEOŸ zί³·ΟΠe9DΫ�Ζ\΅/ψΪHΩ#„rΡψ=‹\υ‘•½μ|s ΊΪ .’'z#pWkqΒΧω£ΟY4»Ω΄q·R˜Ÿνqh§‘!{Ά„Ώ·„Ϋ?»΅Π–.―ψΏ?!δ ž‹ζ9Ιs}^ηaš₯»B,·a"zw±υΥ[xρί€* ς*‘*i―"Τ'ˆήγcDO]†θQφ!„\–μɎμ-!>Βα“9¨21i1•κ)/ͺG.V4έήE—ͺη³Ri’|Ϊu₯€D!(AΘ“G±΅ΊOμόVη½κζ§Yž$o]sŸ)πž#gH^„vΛΐfΫ€KΫy²·ŒΙΗΛxωϋ#ό§y¨r`$ΟFσtm …† mD/)$„‹»ΨψΥ-$kO`¦lo―œθQφ!„\ψάjζCB([νQ7cΆ“ sžk\ΕΉšΐ΄`uΎ'¦ΎΦ' ΰύόNƒv?š'•MΩ¬θ…Dtν"½ϋw~φ’΅§h›οYΩ+Α¨Ήš’7«€Η «.WςβαKŽKYt{Τ‚žΟΧw-Vžθ91Qο§œžFήόΟΒΟiο=―εφε₯φπEo¦0Ψ&ρςqπŸζ ͺνοAU©mχ ΫgTΖdR@& ξqχη_Ϊλž½'6qбPφ!„ΌKιs“‡@`PBΧDXA„5D¨Li hW‘Sΰέm;M4ν zτΉωŒπWzβ5΅ή‘±Ž$κ3ώbŽ€“maϋιImΫ*Έ=yDPB„D0AΌ²‡›?½₯ΟΌ€)Bπ‰½u+Ϊ™7q€π‘«$y’GΊ’«^hΘΏ8ψΓD³\ϊ’ϋ^ίηѝk²kπ]zχ+Mο|ΛU­FήBOΞŽτu�6~΄.ξyNw^»/άFψΚρ<φΎ3‡W�~Ελ!tiχδUTx-lΪf m!– 2Ι${˜�ΟΗΈϋK/€.’η�}/¬θν_5Ρ£μBΉ,Ρk%˜˜`ι³ϋxύΗϋΡ!t•*΄€²„Y]υ™΅~KΑ:‹ΐ‘•©“γήΓTλ}Όθ5ς&­hvY="ζdΜ,9›Η˜nεoςω΅ΥS€T�ފ„¬!dΘΡJ†ω –~xΓ‡;Vμ6a"zΟμdg&³π&ŠLα$οZτΊ•}1{ΔA^aΩλφŒ‹=ΙZΙΨ― ΙcdOybW’έsλˊ;ή†>y‹½χv~�ξπ₯0ρžη�Ν‚#―[g ςη)Κρ‡›)&ίΰπi]%Πuβ ^`*@Χ�²°-DhΘ¨nφηΙx7ώξk¬}aΣ[ΰϊΎ½�m {vΥD²G!δ2p©@₯ χŒ±ϊχ^cςxΩ“yhν&% „ ‘dΫ\έnΊ³·O»έ§K§ΩwŠΚφ犩”M4R#zVξdd+[†6Š&••:Υ ž/cB™―έLΌ‰ν:ΡS&]SΤ€¬!Dˆ ινΡr‰t­Dz»@Ί–!M`’v»Vκ^ΩcΛ~=ΆW²CΙ#WAτ|Α‹=iHνύΔˆG#|W 'BN’\•Ι9{ ΌΟζGΜ€ιΠ]Ωs’—{ηpuΞ²η$-φ_Je†ΝcΚqŒr'„Fˆ|3@¨ςMσΨμY€:A«¨Ν­VΆXš½x•έ“W»2v‘M24E¨‚€€ŒΝώΌυŸΩΒ�g6­ΰ}&mΣνΟΫL·WωJ‰eBΘ…!„ΠΪ‘ΫRΒ€ΈμΨ‚L—πΰΟαΙ—c|WΪb saQ„vΥΥφή«΅if;ε.ΘuςώΎnA’>λΩ3w\ ₯ \A+ϋήj΄ίΣ²3Gς²―œθΙX™½p‘M• *M5 j#x¨¬Œ™ˆ[Ί^!Hk4ΏŒ&b2�΅ΞϋΠ r ‘�Χ6β:άp+ϋξΘνq³j½o�v;φvlo]κ’=FυΘU=_ς\jί‚#χ΅Kύσ#LWUφOφάώ΄!Μή΄!φ�zˆl3ΑδQ ­”c‰rl>Λπa›z-ΡH!ՈG’₯ ΡȏμωβriœGΣN'‰ΣPŽ”Ϋζχ^ŽΚ±y|9–(ΆC@‡€ •ω™k­΄i—ΠZ۟‘$΄ ΜQ»jΟ²‰βi@yr©!;i›2>@rkŒϋΏΌeχηω’ηφ(»Φ Ύθι«$zŸBΉœI—ΰη`ͺ‘έπΐCρκοβΥXE΅Ώ ]ΞA•)tC—tmSm”TιΣjZϊNœ‹œFΰ„nZ˜rΫ8šBι½ I5uQHӏΙL(]yΈI iΘXA&fγ�πA†pnΘ s™rPZ+ D#ζGΟή€ˆ‚[­χSΈœάήmfοOΌcί»Ÿu$―¦θ‘+rΝqםē‘%ΈJŒζp�η’aΡ—=?%Υ—ΨΆΎ:ΐ«? ž$&ͺ₯#sm�nZ³�γ)€²Χ΅ΊYdJoW†Κ?Ÿ‘�›…£.“Gύο΄Ξ€|sϊ}g6:„Φ! ‚ŽΌyŸS›ŸΦ�Ȝ²·Ϊ~Ο-Έ5γ€MΗTν­ΉFONςLΚ¦©4,£2vi›ϋXό‘mάώY·?ο Ϊ΄M—²ξΉ tφ(_΅keBΘEOΌœ=Ή‰—Ύ5wμquΆŽν―­bόΝŠWsΠΥΠ_ΫΪΐ τ΅]Εmt;ž)7„ΟιŽ8·OΞ… lΚ€nS'₯_₯ΞM6B¨*΄ύC¨2€*„•?΄½E›Ύ)C ™(i ™fΨψŸ°ψ_μΐDΜv:“?΅ΚΊυUΞ;«μωΧͺσ󊞯³ŽΊ―:οƒ’Gήυ5ΗE‘b{½YpΓ.2­Ωc¦�<&›}n!&+Wyߞ%ΣΖ߈QΌŽ‘UlS^δ«{QμΉΞ[ͺxΟ{Φξ"lεMun›Η΄·ΪΚ\³°¦^σMςH›Qα |Ayi�ΊSήΩfWˆHC†5dl[Κ$Θhk?σ +Ÿ{a%Ο�•Ϋ›|m2˜ΖI!δβf%&•SxbQ έ΄©�¬ώΔk¬ώΔ φώz„Ιχζ±ϋν9”―h•uhRxthStd»rν―χΪή)&6.-R˜h™[ρžN‘¬!ˆs#h@&€λ@'ψδ·€ƒοf’‘„­–ΩΎ·ω_„Κ¬"ΗΜύΐ6Μjρs˜=pn�[ή#|ηQ<ΑP:i¬Ό‰‹_™―φώ­κ|ΏΒto�+ΉͺM>8ΡλFυάβ�m ²»ΨώΣuL­βΰΡ’YTR)tν�-έΣ”={Ν3/“�eχ£)Χ#N΄’£{\ο” `oTκΘ’_ή\›χ^[';Fΰϊ²9Ί{Έ{Ζ!l1¬@C†ΚdWΔ%Dœ!ˆ0Έ?ƝŸ{…dm³#yΟ­θν έŸηgW\ι.Κ!„Λ> MΜν·\Ιοf₯tleg ?ΈŒ…\ΒΪ;r<‡ΙΗ)²ΝΩ³ΩfŒz΅)@:8*|]Ήι€,υ1|h £ΘTΩΚ Ρ¨D4ςeHΓ€{ ΰχn€rGB΅΄’g=Χν RΫ”!ζ˜�Α}ƒWvRαšςΊή¦χϜgYτγzluE�OηκF)yδ ΚP“ζ˜Ψstΐ]ρψ7 {Ά].CUσ¦BcA��ξεG+Ώϋ ©'3~̈�mJy7eQŸπ«:EjϋΫ�EΔ΄„Fήμc§�=ςͺΉΖΉB[.’š΄M©6šBΔ{ΈργΫXϋΒK+wOΡφΞsi›�^+Zˆ…²G!δ]γd˜ξοδdΟ•τ_‚i‚»`ΡhK£9,}Φ5Κ5τ&Μ&�vˆ uχ 7Π)Hβ�oœ/<~•ΊΒ{¬΄οc&5¬ qπ�EΠeΘB »0έV†i&S†+Θ(Ηβ§χ¬ΰ>πmͺΠ¦WΟ»α±ξHb7B§zdRυ<‡’G�"�€ΙΠ^GV¬γΥήAφτTqͺX€*Rθ2τφ‹ΩWδ jξψQ―6­±IοΚͺž!zξFΈλ•nΎ!Ξ;‹΅―z²—^©g½O}ΒΧ=ŸΙΙͺ€°‘Ό6£’6EXbS„%^έΑŸ{…αΓžθΉ½y.ΣΒ₯m^ωύy”=B!ο„žtNνέο Ÿ+!>τn]…Vφ†¦dϊp£Ϋ£i–Ψ¨λ;«ΌΗ»½i�Ќ½ΐ»9ˆΑ‘•π&Κ6,7*ˆπÍ]+wΟaV‘7Ρξ ©ΌŸι�ή€6uσm&ΊηVΝψήΤ} Ήrt΄ΥBΤY”Ή‰ρ7oB+PωκlUDΠ₯_†Ώ#R]‰x'(fJO·ύ‹ξ¬αœ&2©ύk”—―yaY¬ΗΘΫ›FR§ΔTštM!2„‘Ό¨†ˆ*ΘΘDσdΌ›�υ6–?·… έ„Yps)›�IΊ«Ά™yΧγk·/™²G!δ�„ΟNΚ€ιθ”ΫΛηΚόο‘νΥν…εnέ}Wε3π&z³dOw�»�9sΒΧM›L체οc ΗΑχjτNόꁕ½ @΄rˆt}ΧN*ΆμνmΊPΥγΎhάΟΎ(wδϊ]jΠ„ΐUα,ΗΛ(Ά— «y¨"…Κc¨"€*ΰϊ­yEžΜ�”ξ(Χ»ΩΓ§gɟž%§=΅ν?‹>±}Ϋ ώqf~Ž―ί#y&]ΣFς"e3) Θ8ƒˆ0Έ·‹;_|dΝν•~Š6eΣυΞ;ΐΡ΄ΝkY€Š²G!δ�₯ΟλΏΧΈ…0«¨aΟΑo°ΫJήIuηgωHΊ2§q4M�ο5εφκ)+’€ΕΨ4φΥΝϋ˜žX5ϋF Ԑa‰ω\£rW…sΟN0\χ{fDΨ.BΖ Ήζ’ηd/BΫvaεΞ—Žι§fΊ―οίέ}“ϋbW€o\ߞ57ΤVψ4\ΊΨδ‘-£₯νϋ$¦ηZBΫUge§η˜�ΤΪ&εϋή£Bg_eŒ3 Ÿ_ Ε€€g›θ:±mRdΫ³‚Χ{m:b$„-Σί½΄\ΖΉ¨ΕTφ¨IΡΤmͺi~΄U3€�ΐχΨ·Ό#ΡΆ^SτΙ[γμzϊu{^S8Ρ \e2'’ 2,!γ"œ ˜ίÍΟο`ωs―€[Vς^’­€όΪ^‡έήΌn“τkέN†²G!δ*IŸ-ΆŠ±“ϋ�ŸœBφϊ ’tε=χύQeοΗ0Ρ½φΎΫ&Ɓ©ζ§¦g0Νͺ³-Ξ‚ ΗπαL4oGϋ6)°_!o"z~dΟ₯{§¨c@EMΫΧl»·DΏ„Ωcλ$ΒkMΠTxΌhΧkώγφγ™B,B³›*Δ2Λ?g€qΞJ£<«¬‘OΦΌ ŸΗΚΪ¬?έ)*ƒNEτ·Ί†kˆ°„ δΰΖηΗXώάΨJήKοΨ‚ΩνRηδωiύοE*Κ!„+#}3ΔϟmψMΪŌο;}Βшέiφΐ ;yΪfΝ˜±!Ύ›B7}�:%ΟέQ*3! JΜ}4Aϊ²ηο y“V „PτΪkƒτdŒμMΕFτjiΫι™J%t{kCW½ΡT2QyӏσΌχΜσYš†θύν΄�&xŒθυUΏ8&ΈΧU;­π½A‡#?ΧΆ¨ιλω’Bφ¨ IDATΧΆΡΡMΆšλjδΑ!βύπ�•ΌWVμœΰ½F[|ΕeTψ mο]ίPΚ!„++~Ζύtwj"0έHό¬λμg©ε=5‹Αt•Ώ!€yL>’>L λpz��ξ€Ά‘‚BXψΤ‘htΣ75Ξή${πάyΒt*·MTΟo4Žξž1'z‘ι…i*8–V$*Σ›΅•– –½·hœ>»―nϊΪ‰ΞΕ“DMˆι‚5G�ΎoΪ ½#qB(#pώ-|Ρφϋ¦*@˜΄`aρΣ‡Xϊα= ξΒDν^ΩΓ/€΅‹Ά–k§ΰ²)ή›heBΘu•ΏFΞΌ ž>§Χ=nιf3n=`{ίubzu)iͺϊΉ‰TΣ[ΟE jˆ ΗάΓΙ Ω{/φˆςqQ=W ΕTπ-ΆC@ήΎΪ£�a�­θ%%nόxdυ‡²w/Χ~Ώ8Υα³{C«Ψ.,€ μυ¦mo.\¦ {ΫJB4bΨΫKΠ�#Q5+p½’Φ•5‘L΄M8λΚڌΕ6'oB’dmΔZTζ@h€τ”ψ V%ζ6 $kgEoΫ žŸͺι_oϋ$ο½μJΩ#„ςΎHΰEO ύ¨ή€σ8ψήΊŽ›Θ”]Eo&hϋλ%’• ΙΊKαtΥ7‹K˜@ςή_:ηͺ‘½l3†Φa³WOχDυόjŽ2�$9ξόό>‚«–»ΣY˜QtΎϊŸΑ₯Œ›TΤ­?bϋ›)ΚΧ  ΜgB³ΐ4•/iί–_­₯}œ½…˜jhξR(τŒ¨š>&šιGΪμ^莬IτΆ_ >τ~ΆVHnki a[ρ$λ‚Τ Yι]œ)ο{™ύΫΈ’W�πΥ�w=όΠ$²G!„œmςεRΒΪ¨^9žCφ4΅’Χ³_zͺε‚ Μ}δR8έJ³-` '!o~žΊ#τΞΧεΨPςΞQ?ύ»ι…i+:†%Β…Cƒ1LΕΖη0ι€NψJLWς½(ΩsŸΑ,0­ώ”9²Νςη)Šq„r;DΉ#MΔ@φ PΉ‰ jχ::t­M4Ϊ₯‡Ά?R‰λ‹ͺΝmL‹Z²iϋάhI!Z6’'P!•ˆF�‡iŸ¬ug Σ-xJ΄ΕRŠΞυΡ—4�±™·xvΠΌRς({„BΘ›Λž‰κM>šrξώ~=7—6bΰ΅\@cαS~Ί‘_…“)œ„ΌύΉκ$ΙU㌐mF6εQN§Ώ½:Ίwqς(„nΎ–¨Κ1ήn{i‰t½ςδθ$Qλ“΅’sτ΅Ίι>ίUH.{„Ο�/žξu ΄>_ξ|αόΰ$²G!„ƒέ―η·xαfζ±ϋνΩC£ 6₯ά§JΙψ-l½Άε‚[yφWœ)y„ΌΉθΣiœ.²ZΡ“/θΞS›Β"RAΘρ²“½žΨc m΄θ’��ύετη0Χw J<‘“=²ΧFnψΟ 0έxο΄Q΅Y²η"mξΘ=ΩκΚΪΤ₯Φ{ΝΊσ>jουόΗu…Ou~vε=W¨’GΩ#„BN?ρr¦A#zΐ<&ί@Χteг4…€ώ– ?pΘ– „\¨θωQx[‰sl#{J )ΊΤ­Δ)쒌ΒGΛ‡φ<} “Βω�ήίΗt΄λ2Δ5μHŸϋlξ{έ>£²#|QGμωψbu\T 3dM{�εGθ|ιŒλœ�|ώH`W4UηyώsUηϋ€δQφ!„Σα&LNφLq–Ι£!ͺ‰MαT&…Σί―Η– „\8=mdGŽbdΟ"h„MQ’ι>˜πzΌiY²Δά†ΫζϋΨ΅ 4-{ΐΡ_ΰ‰šΔtt�ΫwΤΏοΛ]„ι΄O?²η§EϊQ5?]υ˜"-g–΅YΒLGγ0γΉ}}SUΟλΰCO‹§μB!ύH?Σ­Œ{-Ύ3(Ϋr‘fΛB�ΞŒk‘’’ΞbΤ™+Ξβφμ• W5Χ()2+v�ΰ‡λΛζ"^—‘vν�?ξ³u£Q½ξs»iAΟs|Ω:«¨VΦpŠΧθήκžŸυΌ©ϋΌŽRφ!„³Lg΄\¨ΨrwΏ0γ·+Ho&¦Ήm¨�ϋΌΕS’&�ΙήΔ<—n]]ς9«z>#p4‚Χχ;AηρΑ1ΟyQ{Y;NήNύ<ΚeBy“ $[.r}ΞW›‘½r'†VaΫvAwDGΈ={�xτΆ+Nβ’{eδ]V^ΫLƒ�|Ν’Ό>α3δPœ³¨½±¬Qή({„BΘU½‹jΉ ΐNBήφ|υ‹)™ώtΕvd{ΜΩN?έΪ{šŸΖ L—πχ#π—Ύ0ΣΉ.hoŸ’>Γ類Δs5ΚeBΉ²\LΛ… ΗΒίvΕϊZ.BήNςϊ‘'˜<²η©’€²Zϊ^BšΆ �υG›qO*y—2σ?[{ΧΆ%Ž’FΩ#„B>” δω΄\Xψ΄_…“-9{Q#|εv­ƒfΏž>²Ύ’mλΩ“�5?ͺΗJœ„²G!„Ό‡c#[.r½ΞY‰ι{1ŠνЁIγμFφμIΫτΨ“5„¬¬h£z%:)œόUΚ!„rΝ`ΛB�-έ¨ž©Δ9yCλZΙώΆ M?φΆΗ^–RΏg Ά\ ”=B!δ½Ωrλ#zέΆ &²Wg1 #@­šΫ<Ϋe€ !j€·]υMΥσ‹³0O({„BΘ5Ÿ8²ε!ΧοΌ ΰ·\"d�8K“ΒΩΣv�z�D…h䊳8α«:’ΗH<‘μB!ο‘μ±ε!WϋœuσYΏ8K‚μy8»ν‚p{lν!„PˆF•=G»ηͺ{"!”=B!δ:Α– „\{ΡλΆ]ˆ‘i»`Ώ^ΫΘ s%Lϊ¦Kαd%NBΩ#„Bή“‰γy·\pU8χΡVχγ~=Bή’m¦eoς(‚F8»ν‚‹ΘΫβ,BVˆF�8KξΙΟWr­ ω+ „Bš‰γ[΄\~Λ…ΜJήL gΦ=a'­ό­rvff¨³Ί)ΞrLΫ+{BΦvϞKατΣ8Ή@C({„BΘuδόZ.4ΕYŒμ™– +{.J <©Τή€•rvΩsΡΪΧX‚‰4˜θήΎ7‰Tœ@ςVψΕYζ`VV­π-#ίB—t-M4^‹#§½^%NYaΈα’zέ†κŒμΚ!„rΝ'ŽG[.L₯pΪ– �Πƒk™ l�.σ}‰Ή\κfe_wu`Ÿ=BΞ?Σ/¨΄` ΩΣT9€*ν>Ϋڞ―ή>[!μΙ+„¬-»*œ™=Ίϋυ‘μB!Χ…žΞ£-&zZ.tƒr¦D θ*ΐ«?аϋ‘€@.@ΘΠ 8y$δ\…O€ˆ‘U¨Z Qξ¦PY ]Π•0{φ”>!<α“m%ΞxͺΏ^‰ι…ž³„²G!„\γI£KKΡkR8»-΄%ζV+ „ΐδ»D”@FD˜@Θy@TB™j!BήκΌΥ€‹ΚkΨσ42½"€*%T ‘οi»ΰWβnψΕY\_LVβ$”=B!δš‹žKαtQ½‹(Ηs(^§fΏ^=£ΘƒΆΞ§(] „ΠUDΆχž«Τ©ΫΘ!δ­0©Τ’Ω/λR©u%‘*s>ϊQ½)Χ“˜ͺΔ œμΉtkVβ$”=B!δzΞ΅ΏηΗfI­θΩΫϋΜݯgRΑš"=σ?­œϋ ­!j Ϊψ$L ™ζΔ‘s:“…;ρL*΅ΆrW£]œQHΌ[߁n*qBVHΧό¨+qΚ!„rEή¬ΟνΥs雦1σΑ£9h»_―iΉ §#ΝDΣ Ÿ°“M-νφ<ΫΉΩί'D9Ο3ڝΨφσδNΟ̚Ά 0ΒTβ²B²^ κ•θ€pςχL({„BΘυ=Ώl»_ΙoΕ‹ΨϋΛ‘έ―LE zη~ώ„SXι­έiŠ!.|ξ~³ ΣΝΐτΞA!ΪΘ^–RΏ§ί…’G({„BΘ5=—ΎιDo¦GΧM«Ψϋφ"t=€�B¨Jšβ,~ ηqNέiιEΙ#δ�…o&ΒνΧƒi» j€·]υMΥσ‹³°'‘μB!ΧLτ"΄ΝΣW¬Α4c^pί[‚�L3uΤΗ€pΎνΔ“ςNh"{’B4rΕYœπUΡc%Nr­‘όBωEo¦Ωω€»ξΈ:»έΏX€*b¨"0UύŽKα$„\»σn]kM‘ „B4ͺ¬δ9αγ~=ς^ΑΘ!„UτnxhΘ6ΧρδΛ+Θ_ μ•�«ΔΙy!ΧΪχ„ΝήhΪ.@V˜Ϋ(a�7] '+qΚ!„r DΟo±ΰDo&uΣ‰ή€ δΟοβΡ―­’ΪY€Κ¨"€�Ϊώzš²GΘυ§©ΒiгY!Ήβ,Ή'{<α eBΉ’ηϊθω½uΡΫϋΞ=<ύςMT;K¨S¨<²Q=˜ύzŠσ>B�5ξrm"|�΄]0{φ\ §ŸΖYSψeBΉz’ηfv=|Ρ[΅’wߊήCμ|λž|ω&TΆ„ϊp•ΗPΉ„.T-ŽίΊΓͺ›„ΌΓ3~ΖύγœΟ¦pB(©�»’,Nτ\qŠ‘μB!WXτB˜fι΄=τ\DΟμΡΫϊwwρβίά„Κ§EO•ΒξΥλIίτ~Œ=’Gι#δς$Ο~=užΞr4{Ύ ›ΖY#Zv•8μΉ†κ”=BΩ#„B�˜θɎθ ŒΠcΉgEο>ž}ε.ΆΏΎjD/˜ΤMOτ΄ς�7ug²θ$Oz‘Ψϋ>B.\φΪΖιΪκŽ6TGη|lΞ[³_’BΊή•=ΏΗžωl»@({„BΘ•½m³τL£τ;Vτ ΞξαΩWncχ?ή€ΚΡ«s ]ΨΤΝΪ‹tEΟ–m7{~˜Ι£€nΊ hFΉ€3ޞο Π°Ρw»―V+αi:ΒΧTβtZ„‚•m¨ή•½ξΙOeBΉ"’7°’wΐ-+zχάGέΓγίXΓα'+¨³¨α½“Q heZ£θΪέΒ€_£_ψΜbΜt%Ξ ρ+qΊΦ l»@({„BΘ½˜ύykpΡ<ΰςηwρΙ?½…όΕ2T1•%¨³Πˆ^Ω=Υωi6uΣȞ†Œ4D¬ £",!‚"¨!„­β' δό6²@λΊΝQFPeUšΠΊ�„φΞ@Ώ§τ*qΚ Γ Υλ6Tη9L({„BΘ;½“zθέƒ©ΈiDοΡ―ίD΅;B m½*fž:^τ§”"�‰‚LJΘ(‡ˆ!‚C™’€¦_ƒζd‘s=ˆZ‡€N U ]  Β c”’6•S˜ΣΟ‹°OWβ¬-»*œ™=Ίϋυ‘μB!οPτΊ=τœθΉŠ›±σ­»Ψόέ›(w— ²AΣ0]ͺΔ΄θΝp3Υ“LT/*±π© ?π?οΨ0°`–o'δόE―©ˆ„ΘžσC˜*»#‹8|²€οόoΊ΅€–Ζλt_*Ά­ΔOυΧ+ΡFφ4…Pφ!„Λ—<7k;m½;M=•QηT؊›VτTΟ½Ύy¦0{}DXCΖ%φΌπά―¬πevβ¨<α#„ΌμuΣ΅GφœΟΕ8„Cˆ@š£ΆŸΪ«žΫ©Δ9άπ‹³δ`%NBΩ#„B�„θάCοωοέΕ«?μ΄V(‚©Φ 'Ί˜—Β)@„2¬!£ƒ;{^ψ>€OΌ€‰πM:²Gy{άO‚ΆΣ‘=ISδ›Cˆ …"›ͺιΐ3*q'{X‰“Pφ!„w*zgι‘wΗτΠs­ ΣCO»…ϋŽΜ jי㉩β, "¬ Β ρς€-O­μ=ƒIιt©œœ4ςφtΣΆS˜H~aΏ^pεvήTΔέ}z@S4ΙUℬ�ωQ=Vβ$”=B!δ‹ήI=τξβΙ?ΏύΏΉ:_°{τB¨B@Χξυ„Λ Σzͺ)σασR8Ρ”k/!δ!†»0iœ/a�8]dΟz` '!o'yώ}ΩΨs+°•+wΊ]Υ{^�‰μΥ’B²^ κ•θ€pςO@({„BΘεŠή =τΎt ‡ί_AΝCε©=S΄ΑU摝ȹž]΅>t„ΟŸ' [�=ΠA…τNΰΐΚέ6LDoΗ›|Φ”=BΞEφœθI+x!ΪπΌ+ΠγΰQh#δΡ³ΞEπνΎ[ Α Dϊ•8K°¨‘μB!οTτfυΠ»ƒOώιςKPωκ,.ΒΆ­H )΄y9h,}V ήΨύ 4Ύ#σ@‡Π…„ͺΝ€QmZ%D "*!£Α0ΗΓ_)0χŸ@ŒΓ')D L_�JΨ`@wͺΫ’ν²d‰dένσρKΆsΏ!gη8Ιsη�’½[hχꚣؾν―ΝC•1T)½Θž>SSCέ΄]€¬1·Q’m€^•8 eBΉΡ;Ή‡ή³ίΎ‹ν―ΩΦ yjzθΩ‰ΠFσd\CΖdœapo‚»Ώ8Az;·?cˆμ©€BΪ^ΟT΄i¨� d -€.uΣvΰD‘³ŸοΗIήΌ½e˜‚L«hΣ7o‘ίΔΛ»‚Χl{Tšk@ΣCSυΙ¦ν‚(!ΣG#{\¬!”=B!δ&~gν‘wΓ¦nڊ›•'z† dRA&9d<Αΰή.ώ“1‚tίNμ†4Κq š\.ηKˆξ$ΡNEmeџ(ήD‘…9Ι[…IίΌ‰rΌ‚—°Œν―/BεsPE •EP…έ§[‹6Σ“½¦’�4 ΥΣυΎ{ŒΞΚ!„rA’w\=ΫZα7nγπ“Τω"T–΄«ωΚ_€Τ±‚Œ+I™μaωGΖΈύs―`zβνΫ‰ζ2€ί[ΐ‘ˆ\OD ν±WaψQ‰ΆκfŽι½zχλrή’wΓJήγ?]€*δι2†*B¨\šΚ»΅€VG[νΉθ<„‰Ξ§w\ϊfζΙ«pΚ!„r’η&|+θν‘χ·pψxun{θeT%-Μͺ}¨!Γ2­ “ d²‡υπ +?φ¦ιωk˜ΤΛy;φ-#ΫTm₯Ύξ^½&o―O…tΝMύύzŒrQ’·„ρŸ.@WsPEbχη…ΠeUXΙ«¬θω)œ^½§&:/kD£ndήOγ€πΚ!„ς–“ΐΎΦ �βζ:€»π{θ=ϊίo‘ΪΩz1tъ !" ՐI‰  \άŝŸ‰…Ώ½ ΰ€M˜~x°Ί%Τ‡5T.mpυkš-κ`'‰BTˆF.’Ηύz„œ,z³ͺkΞΑ^™%yΛ^$o]₯PE]ΩύΉ₯)ΖdφθΩΤM? ³λšΝ~½ ιz‰62_€ΡyBΩ#„BΞ]τϊZ+ΈŠ›fήδγ;ψδ7o’Ϊ‘nZ+Π•ν£%4d¨!β AZ@&ˆwππ|dν)€'Vτ^ΑτΔΪȞνDT‹ώ,�&Ί§šJœf―Οag’Θ½>„ηχ΄δΉ¨½Ÿ’ν"y]Ι»ΩȞ/yΊB•NςBΣ?³–&Šg%OΫuέ\oϊdzΡy!*ΔΛ₯·`Γθ<‘μB!η8 μk­p݊›S=τς©ΈY΄…X ]!–2Ι$ϋ܍ΏςAϊΐχ­μ½°g'qA#šΩfθZIΫ|έoθŒ&υΛD4DPcξ#?…ΣMΉΧ‡πόnΟoΏͺ�_Yw¦Ί�“<'x·Πέ“g$/*βVς*›�YΑΣΪ‹ζαOs}2½}·Ρ’=Ώ8 £σ„²G!„œƒθωΐEOτξ7’·ύ'wρμwn@勨mYu=ΥZ‘«Έ™!Hφ°ό£―qλ ›�οψΔΚή c+e;™ aš©'Π:0iœΪξχŽμχqόΠ€pvΫ.p―αΉ=²£MΩtιš+hχγΊcυΙ“ΠΆ��+Ύb*mŠiΉλ½fνF7ϋυLtΎΒpΓ―Διͺι2:O({„BΘNϋZ+,Α¬μ»Φ ΠΆVX…ΚL½"‚.d³‚/݈ž©ΈΉ‹[�Ν+¬ώδS+x{’·mΕLΨΙ§K)KPŽ#@y‘==-zSι_6"ήφ#{�ηT'χϊXςŽδ-ΐTΦu’·Φˆ^9^ΑΦWW°ύ§σΠεάQΙsιš €&MΣ-Θψ΅”όύy=§^·g΄μгΈσΈ»_Κ!„ς’ΧΧZ‘½:»‡ΏwΫ_[A/˜z…™ψΉy˜5d€ “29„Lvqχ^bι³›{’· S}sb'r©}‘ˆ&8ψ^ ­@‰&²7έvΑ؟lqY"]χ£z�ŠŸ£„’η_qϋςVμy~«‘½r|[_]Ζφ7‘‹9³'―Œ‘K_ςΌ’+Ί5;˜Ÿͺ΅vk+ζ”wφgρ+qΖM%N—ΒΙ>™„²G!„œƒθΉ};nΏΞ]˜ΤΝ{¨³»xό₯5ΫCoήτΠ+L³t(;a 5d\CΖ‚ταΒΏΊ…dν™½Η0ι››0=Χ@]ZΩsϋS”γΔξΧ ld½i`mdΟg>τ#,ΞB(yύ’7ςΞsΝ«³›Ψώ“eΌό·ΛPΕT1€�¨"π ―ψ’g’ΤSη’y&―m@N Qχœ=•8“©Jœμ“I({„BΘ9‰ž«ΈΉ†6mσΚρ|ςΟnβπ±i­ ςΨ4K―lj₯d  βΊ©Έ9Έ?Ζύ_~hτΤ ήc΄•7θεVΐt£zΩf­"@m g·8‹€—ΒY#Ήfκώ~=v ”ΌiΙ[A[xeΥHήΧnΰΥΏ‘Ϊ_€.‡ζ/#[xε¨δ a£x."h»ηΞ,ͺ˜={6Ν³–P₯ω7αEψX‰“Κ!„ ™φυΠs)].msΐ}ΣCοΧn’Ϊ[:ΪZΒμΟ lΕΝ4Gοcρ‡Άqϋ‹/ΌB,Ÿx ³Gο΅'z΅˜Β{/&²—mΆϋυL ηΡ={έτ―τvή™$2ύ‹PςŽJήM7Qg«ΨώΪ ^ύΡκ½(Χ½ˆL3τJυμoΠ IDATB—b†δis(ˆPC„5DPCˆZki€ΥŸP„xώ―‘₯}γsΈS‰²BΊζGυΈ`C({„BΘˆ^_½Ϋπ[+μ}Η΄V(wΌΦ �β¦φ ±Δ%‚4‡Lφ°ό#―pϋ‹Οaφ幊›OaΪ+ŒazιeήδΝMR;AMa"{1΄%ힽΞv½f²hΛ΅‹ ιΊ_؁ι_„’wTς\$o„W4B½o’— TC!t%‘l»λ”δ… "TQ –ζ9‚A…?άψ{‚AŠΝίM #Z@)@K‘ΫP@›·/4„¨!D…d½ΐτžΫ©s˜�Κ!„2{‚8«‡ήm˜ύybη[wπδΛ¦β¦Κ¨]ke[+LbΙ γ]άύ‡―°τ™gVπΫΫg0 ΣΗ0ΕXœ„)ϋ>ཟ¨‘½|3”IαΤέ-w^Λ—Ζ Y!]/1Υcϊ‘δυKήΌ•ΌΤ¦k†6]Σ¦lΪy€ω1}’'£"Κ!Γ Αp‚•Οbε󂁄«τ™=D(!j ΘB΄5[\VΆ6²(iŽΪ}·~%žΓ„²G!„œ z~½%;!τ{θ=ΔΦΏ»ƒ�Ζ΅V˜ΥώRšb �DτdTC&%‚d‚pqχ�Ρ†ύB,nήk;0ϋθœ€Υή[tΉ\.4’ΨΆ•8]½#s=ν Ÿ«ΔΩMγdϊ‘δ'ym―<Χ'O΄§ΛI’MΜΰΖηw±όc»�+gξΪθ2„#  !„†+ί�ˆžtMAˆÍ�[¬ρS8m?B({„BHw’ΨΧCΟUγs{τΈ‡g_Ήν―ί4’g+nš¦Ιζed¨LΕΝ€€L&άγξΟΏD²ζ ±ΈΤΝηVτφ<Ρ«=э |œ*²•8Ε±•8]q–`P"Wœ…ΒG(y³%Ού,4‹(ΗKήnό],nAΊ6r+z5€u>„Γ¦bηT�½ndOTΆΘRιθΌ/zLΕ&”=B!δ”=τξXΡ»:»‡g_YΗ@ε ¨³*š‰ ›ψΡΛ$܍ΏςAϊΔ½'0ϋσ\!–ΎŠzώDΦ―Βi’zε85•8•ωω€Νύκˆ„€†j―™ϊ!Lt―ΣΏ%oͺΙkΌY@ t“¦-Β>ΙΫ±’χ &Eϋ5€]{ώ νω—XFΎYž‡ώGs_J{!’Q坿ܯG({„BΘ)EΟο‘wˊžλ‘wΏt “O–‘²9¨άτΦ�΅4ε�₯+ΔR!H2Θt�˜B,[0­&VΌά„­+]’σώ\jι‡Οbθ©Jœφ™={φDΣ›Λ/ΞβGφ({δΓ”<] MuΝ2†�+y&J?•�ٜK"r’WAF%D”AF‡ΙΫ² :ξ\Ϋsφ½EνΉ/ΌΕ#§‘†Ίi»Ycn£DΫH½S± eB9•θυχΠ˟ίΑ“�λ&?^2=τ\k…Ϊb‘Κb©šB,λ�έ+¬|nV!–˜Š›Gͺa !΄χώάΔΦ₯p¦˜*Ξ’CΫLύhΛ1ΥL]AΘ ƒΫΪΞξΟΦ`ϊω%―ν“ηIž_xEhˆΠΆPˆόtM+yσ{Έρω�δ½DΡΫ± ;Ϊ~™…blΓvζτλ-²Τμ»• %dκmŠcŠ‘μBω`'ŽoΠCowΡb‰‘ ΫκhD―D"\άΑϊΟΈŠ›'bρ'jθ-W™ΑMl]d/EφΜgQ’‰μ-Ξ’!Σ_OΘɚ‹θυ₯qBΙk$Ϟv"А‘ΆιΩDTBFYgOήλΙsΥuχ¬θ•φEηνyg’yεΨXrη:¦3Ή›…»h#*―Θ’ίcιΨ„²G!„“Gœ₯‡ήΟέΑζο�’ά]‚ΚSΣC―r’g₯Η5dl ±„‹clό“-$kO0Ίy\!–Y’Χ-Ξ’4G9NQΪύzJΞ.Ξ�‘¨Y ]οŠ‹³J^·!zSa3�‘‚ˆjΘΈ„ŒrΘθΑό>nό±'y[^t$o&z?ρdΜ-Φ(΄YFτ΄KΕξξ»u}2…)²”ήqι›™'{¬ΒI({„B8yΔι{θΡ{ςεU¨|*OΫzZ‰ dRCΖ9dz€αύν7)ΔrBΪ€θLrS”γΪ«ΔΩ·ΥOHL7Soгtχ 2²G(y朢’Β«�YBΖDxΡ;©‡ή<�½;xυ‡έz’=·o'Ι€ϋXώΡΧΈυ…Mι™ ±œbœθ™μ¦8xSΕYΌzν}>M3υ©ύzN:9I$ο‡δ7±σ­^ώώΕΦ[H^`ΞmΥ&’gρ>F�ε·ΎπAϊ&Šχm–>Ι+ΌΕχ™άŠhΞmuθΞρNΟαWβtϋυ*€λ%ΪN?:ΟΆ „²G!䃝Dž΅‡ήͺΧCΟN΅-Τ0Uqs7ώξ+¬}Αbωg(Δr‚δ‰Ξϋv‘½₯k¦�lJ©φ*qΪIb·8KχDο΄…Xf½oΏˆŒ›ό&Ȟ»JœΊož'Μ?ψΕY†ΎθΉχΔNBΙ›–ΌάJήVκ5’Ρk+vΟνα§lΊβ+�‰;§Ίmϊ /™ΟœoΪθΌξ<ͺ§§’%'z~q–χ¦νB§qϋΫΰB‘μB�σ@v Nzή™Βχyπ|‹z#¨l*wFΫZΑ•\ [qsΏΊ…dν©ΌΗφφΞ^ˆε8\ΐo¨#{fφλA›Θžž΅gOhY²D²ζχΧσ£ŒJžMCτяμαζO­δΉΒ+/0έJΑ_ιΆ?˜Υ+³{>»h½έ?μ*qκΎ ΅KΗ�YaΈαWβτSE―Μyό†γœ8εkžι3R){„rYƒœ8£ΌΝš(τ‘Ξ(}ϊ€Ασ:gμ‘gZ+|ς[7‘ΏAεΓ£=τ:7wp�—^"ω’ηZ+lΫ‰`֝ˆρwκ§qϊ)œ)²ΝPQΣ_―WτΌΞΡ¨Dϊ‘½χ."@(yη y;Vς^‘-ΌβͺlΊs{mEΫnCσ©ΞxΰGκμ…(·]EΞ={nΏž­Δ-»β,nΡ¦»_ο:Žsέ�7Δ1γΦTžλ‡<ΞQφ!δς»γΎ=™8Ε`(Žy~ίx� ¨{nUΟs\So}έΖ7μ‘·ΪφΠΛ#θʊžπD/) “},ό­1ξώ� [ˆΕcyj'„―½‰ ›ΎΝ^8Ώ Œ‹μ%ȟ'Π:΄mzϊλ‰v’θ*ψΕΛ9¦χω°8 ‘δΝ–<ΧNΑIήNGςʎh΅w uξšΤΕ8€φ#{Ί32ݞΗM%Nχ.τ<Ύ„qNt~?ώ"—O7r©ŽλΞ:ΞMύ;²G‘Ψ‰2ζ,qŒ€ΉΙΞiW2ύ*m²ηηu3�ΗH‘ϋΪ_Vοω·�^΅AρΝ{θν,˜zE]ΫVΒ5Q�$9dΊεyΫ_ά„‰βω7_Βμί9@5;m!–“dΟ�,&²WlG¦§ΆΡ=]‰S4�λ΄m†~DΰH±°8 ‘δυIήŽVΨτ‹―œεχ―εq{θΠτΩS’Ω»Χώ괍ξ™JœΙT%Ξs;OΊ‹ηΊcœμyΎξνqcέiΖΉξγ¦ώνΊ.tRφ!δ|Δ�o°λ–Νf `ώ^ ηy˜ρ³όθN€ώΘ^£)yέάΎ�ΐΥή$‘ξL^Ί‡ξyώ•‘Ώ7μ‘w»ι‘g*n†Π΅4m­lrΣC/ƒLφpχ_ΩB,O`�6ύŠ›cLχΤz[Ρλ«Διͺ§˜<Šmd―Ώ'`#Βk»°^b:ͺΗ ~„’Χ/y�πŠΫwΫ•Όή”ΝSœΣ~ZΉ½Ι£Z{-T:/Υμ»=ΏJœΗˆέqRwήγ\wŒ qdγΩσΗΉjΖXw–qn–Dφ-tRό({„χ@μΊƒ]ίΡ]yμlώύ 3Ρ ½>τΎ7kΥ³;pžvœZqξ ύΗ»T$­:―Χ=fE§δο²Δ7κ‘χμ+·±ύ΅U¨|užBε=Έ‰’«Έyˆhqχ�Ρ+ ΊŠ›ώώΌΧxʛ§ψ<ώ�g1Ϊ4Ξεvr4…³7SΒTβLΧσžI"χλJήΕJf,ά…νym[¨ΜNΗΆ=φd…t͏κjίνŒLΡ‘Πγ€ξ’ΖΉξηžυΜιύρͺœ1֝vœ«1]Pη€qN½λqŽPφ!o&w' zAGπ‚ž#ςΈ¨ηΆ;€ΉIϋ¬°Oφ|ΡσŸoRΝΎ�μ©ΞθΕέχΏφΏ_Μ0λΞ$¨/c@Cύ•R_…? Άœ7i­0o'”έzwρψK71y<‚ΚηΜ„²°…X ½Š›9‚dΛ?Ί=£ΛLΡ†‰ύ=ψi›η]ΰ€ΫLέ΅]H‘•Iύ2­!Όή\&Μ’)κΠύ»–`TPς.KςΊrγ?nίνŒ&Βξ½E…ΉΊξύύ阳Ζ9ΏΊoδύέβcΖΌψ­ΖΉ:“ΘŸ΅ŸΡŒsν§u4‡+>ε-` s-ƒ¨MjΊ¨›±nψ°]Τ”tέό η6ŽηΊΧDΌ›tξηή�'—6ΞΚ!dΆάuVyΒ 7k€K<‘τά&�?{oώ$Ir~ξqdδU™uu=ΣέpΡWœ@»‚ˆI@Fb@�¨•d$΅δΚ΄ΆΏμšν/ϋΘvΧdkZ‚\ν ’Aˆβšˆc 03S=3έ]Υ]GVeVfdξϋƒ»GΌˆΚ¬Κκ�ͺ�Γέ,,ϋ¨#3Β#Ύχ½χ½ο‘W†iXΑpΝGϊW•DgxG½†w\ bΚ20dΓ€ΪˆώA‰μ3HΙTPž5ρ3.\BŠm�Ρ}%;MΨΦ_ΛΈξEΰJδTK1*K1όvŒ`1‚ΧΒkGτϊ„πυΗζ�)‘0€8²ŸΠβaΐpŸΡ Ζqsͺš—ΟΠ[ω“y$;SQ bθ©€R00F‰^^ια�―l`ξƒγŒXΆ‘R> #–ƒˆžC‚²*€*Β;>€ †ω7 €|˜Ίγ όιΕ*-•&Q΅Λ’ΌΡ$―³ΫŸ»H$o.ε²όp•`Ԟ~=©₯œ&QG¦Ίd�… qπA8G–eL+'/ƒ�Ώ©kέΏι#ξψˆ:�ͺΔ Τk:8 Ξ•1Ž\I ½ιfόŠψ¬λΎB, ؁sŒ'¨]‹Α«1‚Eux­Hχ2Jnφ‘»°ξ–ŽcΗ9»,Ω³Λ.xŒ�ލκΉ+χ»½2ΰ•@1Υπυoˆ;>«>†«>Β;>�§€.UΏ/ο«β*s)4  Mή(Κς‡3=κ3IQϊΪ, €ΘΊ°=8�:fͺ\  ΔΫ)Ί―&ΊΏK#x„ϊcκΧBK!ͺWB8Α(‚g¬ΙιkŸΕ°”.K`δ€`8f΄B ƒΛi응·„•OΜ!ή™‚V•l3QΑDfΔβ'ڈ₯‹Ε�£uc'`ΔrΐηωŒ^!؊;Κͺέ TiΞ ζ¨Ή\ʜ…φλ™χ?nD‡]vΙ»χυ6v_»H$―LψΚf$DθfΙΌΜQ·πm2‘β΅i™,%ƒ<μ•DŽΒΉͺΎ^ε#(aœI^ͺcpΗCΌε#\=:œq,“΄’š™€UŽΗΊ}p\ ϋŠΒ·¦ΜnO£ώX„`iˆΪ΅!‚…^{P"yΧvȟ ημ²dΟ.»lΠ1Ύz·_³wεΠ«—€/'{i`Έ¦ΘέξλΔ[>’-ž–ΣΉdΐ΅£*.μ$Σ¨›ο}՟Ad9ΙŸšε�©ΏΎ˜™jΰ Fδ• `ζNRΛύ΄”‰άΤƒ§θΎ’ χ“ŒG,,†¨?’v%„Χή-ί6Εύηr6΄,Ι‚­q`8Αh3CΟ8n>Šν—.γ֟ΟB ›ak’gF+Έ)œŠ2bq§vpνΏΏ‡Κ‚q܊dŽxσεΡ SPŽ›‹’·υμξόε,D¨F+ΘΨ…HtŸ#—JΆιΑ+}Τ�#γΈωNΈeΏ‹Tž―WAά ©ϋυΖX΅3bκΐxŠ`ΙΜΧηΔi—]–δ ~•η쩞6)K2NIŸγΉρˆS‘ϊ:#ͺ‰}©ά{Ξ5ΞΥ�Ψ]©’�sι ’πM“»"©;Fœ…oε rά2'f_Ό›ηr(ή₯`NŠt˜"ގ°σsΛ!κo ~­‡Ζ;z �¬Ϋ!―}‚uβœ%}–μΩe—%x{«w”ΰΡΖpL6³Qz-‚^ο'5μ�οT±ϋz>€π΅γ‘ΚhΚΤ!vzΨe�ˆΉΤ�ŸA«<ƒ™™,“·}ΐπΐ‡Θ£/ ’ηΰΙH‘ΤΘB™c€Q‘#3I sR0'A%ΑΰΝwΏ2„?;@σ]}4―χP»JIί–~5ΗŠ2κ~Ζ°wΈν(ςSӁΞφŒVψόΆžŸ-:nj#<&ΰAΗοaκ—6±τ±5νΈω°ŒXΖ],`«}�ϊΔ‰sό|=CΪSxνς|½E q»μ²$οψοgzOΜ’nNžΖ’"υ½Α’G’\sϊgΕ„–˜ η�°†Α5μ�T5Ξ@ͺpN 25δΞ-ΰΫRw\8''Δ<�› χ#zαU?0hŒ“Ω+s„>(Ξ ±ρΝ!œΪSΏΨGσ=4ήAqŽbMpšͺΰ8œRJf Ÿ%{vΩuQƒ ZΑ£}wΤήΉBΘ]½DπP?σZΟ2šύ›UμΎ>τ„€NVΉ+»”HS;ΝK€ h#J'Κd†ΝνOζ&ω1ŒιΈGƒ§Μ$2€d,ϊr¦ Wΰ˜‘&~Ή› Ζލ°ρΝN}€φ»{˜yo^›’ΎM}t(@,;E0€DŽV˜ƒrά|ΚqσQάύ�ΆžŸFj’’pW€y œJτ0ϋ7°πk«šΰ=,#–IɞͺR‡«Ύ’ s–²μ+ϋV}}X‚ ΰΔΉησΐš³Ψη�%y'q?SωΉj+ˆ;F"™Λ8₯,φ Ρ[τΟθŸ5­�ΏB0°N’˜ ηΒ;η΄J%u²g‰LM³„oς„qξ°3κοηθ³’k7f¦qΞ@G»: λι«‚» Δ0Ζζw#l=―pnκ]=Μ<ΩEeα~q.­ςY²g—],Π(7±»%‚G{ξκ₯£Y"xS�ΦΠύI »+5μώ|θ W0Θ”f/ Ψ•GڍΉI³—χ}6ρ₯l4pf©YƒΤ²@E‹ ˜eB αevβ¦`N ΕX¦Ώ ώΆ]΄ίέEλρνnθcS�ΫφJ&iJE_g:ZAΉn¦α2ήψ.©Ρ Qb¨‚L¦ή«©θUBπJ—{­· Ρ{ͺ?Ο½c5b9 Š)ϞΚΗ}„«Ύ–Xi»vΊ�2‚.σΩ\ςΡ2L�9΍αj ½Wμޜη2¬c™‰ŠΒΆbπ©ΔΉCόΜq8—ύ]AšμgZέβ€9&ΑιŒΕΉΝgΫΨza€`y³� ‡Ζ;:p‚Iqn¨χEΦnIŸ%{vΩuƒ Œ xކ’4“Vν¦τQ&yuτ~�@χ•v^E΄@ΰKύ1 —g5³Œζ8b'φ;yΜ w`)Η$+@Š™Ɛ?�ΑΠs<07χͺ`NέWšΨύY«5‹ΩχχΠz|^{KΰΊΰξ’ΰΝτ<˜*Χ{ΑTτ`₯"ξ,γΝ?›Ηΰ)ˆa "ς m`Β€~/ xΒ›ΪΑ΅?ά F,7‘Wτ†Λ¨ϋ‚φ£�{ ‚αͺ―εV%c–R\™›Δ#dœΆ_Ο>-Ι;™σϊ@Β|©ώ[)sC)₯›Κ ΥŊΎ& L£θΎβ’ϋrEυά₯F–YΖ9ύΜ(α\vmˆΛ³”{IέΎ„ξœΰ\φgΣsΘΥHΏqΞ­‚ΉuτWšί αΤgΠ~O3οηhr3F±ŸΟJ;-Ω³Λ�sd”ϋπh^ΉzG«u-BτTυ.ξ4Π£Ž—«θΎ\…L+@ͺΑOKV€ ο ΠAμΞ$©{€”ϋ#!Œ’3]ι˜ΓΑΈιI ˆ·5°φ₯)άύjΣο™Αάgα΅η  Vf4�ιμ§‘Qšώ†@σΘϋσΕpmQVΨnBDUˆΘƒLt–œKp7χcπΚΥ+;ΈόqγΈiͺyf΄‚ΙΈ†„θ)Φ�p·<,^U΄£-ŸΨœθρΙϊυ”Ω€Sα΅χ3g±Α„%y–δίy.χ—Ηϋ¨\κΖ™“.%”*Kx-Ρ&°σ#»―ΧΠύq’ΎΪ :ΥΖa©“+T(Ξ₯¦RXΒΉύˆέEΖ91Ξ W‚»~η֟™ΒΖ·Fαά΄ήΣk%�GΗβ0Bψl…Ο’=»μ:sΖ<κ I]3›$θh’§^γNΫ?h`ηΗͺι\ȝ«MπέlžξCξ&½σ|‡FΪ*&‘†dL’Κ„ΚD’«² ΒυΑ½Μ­aσ» l}o ΣοicξƒΣπΪ3Ϋ$ΐ3δ Θε›fΌΒ#θί\Β�qΙN“±’η%ΰ•άίEσ\ώgχ΄Λ[šθέΦΣ8nPœa%RoOΩΘAυγτoV΄iP©²GΎΝτœ(ymŠ`Ι S§±Ή`Iή8’ΧΤχ‘%yGƒs.IؘσΫ�―5ˆAeΟΜLΪ'ƒL$8nˆw*`Ž«’ ‘€ύΐΘ2Ε8wΖYœ+Ώq8Η@:ΜuΐΉΆΖΉ½ξ’}ΎMπΞ$ε€ΤΚ:-Ω³Λ�³`L"Σ4UΌ!yτ˜Bάi’ϋ“:/Φή�B&Mς\Θ„d6ΣάT%λ»K€ά6^ίοο‡Σ Ί²H4dJ.΅Π™P#q˜>8€η‚Ή€Wσl>[ΗΏ›˜}�¦ίΫ‚˜@syuOκ@iV“½elΏ΄ˆ[Ÿ™*θ4Ž›L;nf£z˜ώoΆ°τ±»„δέ�Dοž"w±Χζa‚+CqX²ͺκŝ@Οut2wΡqNΖΐΤ�QsZΩ³dΟ’Kφμ²λL4»IΑoΑ+Wρ¦³?ǝΊ?ij‚WƒLΘDUπDΰι¬fΚφlYζaAn`;�g0~ςA-«OiΕO%žUΓR%‘ ΐ\ΥƒΒbW]ΗΈ‚»SΕΖwΈτα)LΑ+ IDATΏΧE†ˆ₯$XšΗϊ3σΈϋεΆ­ΰCFz ΖSEτ*!x₯‡ΛΏ½‰ΦUBτ¨γfΕήΐΣDτ(ΩS’―Α?·Fׁ‘,xφI2L]€ρώt„\"Tώœ«ziΧι y΄’7›‘;EτfΡΏ9cIήγ\$0η z—υ9n©„eκιͺ\ώ3Νσ^fƒΘ™šž@9H2žΏ£ŒNΠ~0)NL0³Υβά!qΞ―`λΩ*v~άΐμϋ›˜ϋGF₯d_]ΗE¦₯ΐ`Σϋ–πY²g—]§)ΈWΕσQ‘Π$»Ιt΅‘†mμό ­›Šΰ₯5UΕK Αsu°Α'«ήΙ - Ω+Μ8WrΊƒΎ� ΐΣ} Δ mΰ™ΟΗΖ��‰bYκ)ΤvŒ©„tΚ `(}"ρ!’wώ’†­§°όΤ ‚E3„]ΐT nΆ­ηQ "�Ž›�±p?χπZ],�Φ:šΧιhӟ·‰‡λΈ9ξ’›„φψs–’δkoeόΕPζ,•SΡ%γ΄Λ’ΌύH^"�AƁ%yχs i3ϊόαmˆ€ͺ)<;Ÿ{€„BA‰ωXœ=ω’©ŠΌΟ1=ηŽη�χ!’ ξ~Ή†νο7°τ±)Τ�R£ΉΊή;ΎΖ¨!|)¬¬Σ’=»μ:EΑΕ~UΒΥ7�€†™χ70χp‚I„WΙΎκκύ6Τ?όΒ»uZ²g—]?Έ ΩM#a©#wΞlT$/ξLaσΩ&:/6ξV!SΥ‹'hΘā0ε2Αl�ž„q€Wϊlˆ•Σ�> Ψΐd$3λ{=σL£γF‡1κ€Œ™Ο’~Έdˆ»DΑ­-�ΜΚ’γίJ–ƒγ~Ξ’Η‰ MΟƒΤΞf‚A¦qQ Ÿ%{vΩυπΐΟ'Α)uv£Mcε­dš½ŸLaγΫM„·λΑSΩ/O? u`›0�‡‡‰+xϋ/‡€jΙμ5“Η•Ν| $Ι†J0&tΐM^M€ΝΖ!Ž8% ’’λa·δ,B5iXζCΰU‡„”*ØΟP�ΐ8Jβzœ•?š©6§„+ž¦ͺίAΖ Μs΄γœΑ@ΰη�N7IH–Ή©ͺ?O½Ό�Ητ»…可jrgF+άсεζ�s{ΦXιžRΥ‚Έ£ηgi'Ξύζλ™aκ^‘_Ž” 7]—δMγήΣνœδ%ˆΘ·$―΄oœ›!―m€α΄ΒΉοL!ΌU‡Œλq %θĐ«sΉDώΩ;�ςv?8g0ξ,βPš{JqŽ –ƒ�Ζrᚏ›RΑΜϋ«XψU3š£R"|ζ>Ι/»„Ο’=»μ:9π+»QωΚ¨ ž"{Γ΅6Ώ;…7ξΦ ’ (ΣLœΌ/ΙηeƒNΕθ‡τaˆse Α₯? nœ:&΅λΩ>ΐΖΈ1 ‘μΉx ΖRΥQΟ”tŽi ”ϊC8 š·«@Nšs+]=?Ι„HG‘Ԉ-Λ`ιA3λλbΪΈ†iPΤ³›44δ™Ζ�R€4O –e/Ϊ@HΉirgšΈδo mun9ΐ=ξ‡Έτ«]Μψž&z+ΘG+ΗΝΎΙΣbΔ2)ΩSnœ»+ΎΎlΜYd1ΒeΞ,Ξωμ ¬9‹%y†δΙ€—Hžξ5ΊΈ$oœ«¦?!ΞM£s Ϋ/M‘σb2©+œ‹΅Z%v³ώG—™#ž·%l++S²ΏλsŌΊ2wXœγ ێηŠεθqΞ@™2xŠqNHuκTŸ¦6η€π°ρMύ›�ό^Nχkο­πα">Kφμ²λδΐ―,ai ―άωΚlFςΆΠΒΖ·ͺŠ—θ,r⏔i*r2^¦Ή5‘£“±ΔΜc@OθΰXsτ+Σ`ΖΤpj xΖRπ AuI[eQΐ R€Ixm―‚!"‹ dh5IŒψ EΛ}uŽΓU"τ!α’Ώb’£SγΰŽ κ‘Ί"…ΤCδ…“K�RNf κa»©άk; Ž&zœ€h~#ΏΓΌ�δ>(Kn‘―§Θ ΣHpω·hΏg ͺ'ΟΜΡ»5ΗhΉ«ΩC7b9€δ±χ˜ϋΨ SΧΧ-sέΓήscΜYς~=SΥ‹a%œ–δY’7ΙΉ-γœ!yΖA‘J4Ξ)΅J χΎΦ@΄9 ηθyfΩΉΞMΕΖΰ[Π!λ“ΛqVλr¬3Υ9Uι—cp.ΥDΟΰ\’8o:ίή‹sζΟ )€•ΕNp�8—†>†«κ\KΈˆ;qηpNπά•ϋ4βΜοΧο)5„_y nzxνίzxδΏ« v΅\Ωg~‘Ÿ%{vΩu2ΰgf™ΰ’Mȝ 2¦w4ΙϋV©Šg53g7-Jσ9Cl|–³Π IΏRμΖ; v<c‰"q<X Ζπ Fu9R‹1ύΚƒΑb<‚ΐ™`Εζ� Α ΖbάΫτ,V4(z¨_s±·oDύiXAΈκ#ήroW0ΈνA <•=”:Ή0Υΰ˜RGSέH.˜Κ<�¬h9z€Oξ1`ӁΰΤήώ?%–PVΥ4Θά�ε©'zδž+BΉd,\3Nœl€C!“ΟS€Η¨,Πωz4°Άζ,–δ‚δ9PN·‚δλΗ3RMŠsj^^άiaσΩ:ί‡sΖΕTW Ήxc€›¬HςrL#8§― „Ξό™;ž*Γ&FpΞQU,†ΧJΰM'πΪ)όv ―ΑkΗΘgr/Ξ9‡Ϊ΅ Α³ρ8�ϊa»+9ΞνΎξBγœΠdπ4γK;%ƒ ς|�I«ƒ›κaωγ>ZΣσΒ1ήκτΒ>Kφμ²λδΐΟ4’ϐcΐ ϊ7§±ύR [/ΤΥΘ„8(Vρβ�ȝٔ…ζμΐ7Žά•@OU7ςŠr&L5Π%`<x §£Ί‘²ΑŸ"X’²Α L@bΐ+"`6 ΨΚ@B €£ϊ₯Κ³šŒ<Φ#I·τΥ�|4ίYA.ύ¨ \ 0\«`p§‚ώJE;:V€Τƒ”ΎκHudZE}} ’4πχΨIŸΉόYΫ†± φο`–…>ΏΖˆdWrξ)Ι9ν–ΥfЁκ>Β;*xQΑ@©ΪΝH�NfxνN@+{1ŠNœvηρ(’GŸΓ'DςΌΜ Ο1Ι3 Ψg\£‹ΙΜώΝil~·…M€s)zΎΗ\cϊo ΙsΝ51‡ΠΧIK'ΥIž½ L§–hœŸŽΞ…%,‹fEδHO Ξ‹κy¨Θa]―ΈSA�fPΐ92υOΞi2™‘KΣS/�—�$«·�άA� KΏασΆοΏ„Ο’=»μ:žμqeπ›Λ^·ΠΖζwZάj@Ζ*Έ©―­₯χ©β•ΧQnacΙ]z©"xτ‘S€Ηx o&B°!X’~-‚Χα΅ι ²ς<2^1Δx°•³˜ε¬gZΚvξƒξٟ]’έ€™N‡όΏω»ΉF΄βšΏ‹5‹Z7jYΣΏ \­’ϋr€έ•*dZΡ‡―Ρ%½“&(”δš±!}ζtG5¦3‘ζ?vώŽγ­O9xδw=’ΨWξrŠAΚ8©„3@ΈZΡ™k^¬μ •Œ3X2{8,m֜εμ<Λ3܌΅cΙ›ΟΘήρ’Όζ?Τ9‡$oΏΎσ™,©© WΪΈχ΅’ΝΓΰΫk2�5ΑάΛ.±s%Έ§0ŽΉγœDͺ ξTtŠ’ϊ”e_™ »,F„μ Gb֜εΤ?M0LŸΕ-Bς Α»„|„Βƒ“Kφμ²λxΐofΟQΏ†"yq"φ΅„Ε!#ˆt₯ό δy°šUο¨\Εs”εšCμνK(g3Ε°KΗ€°W—SF„‰Ζα–�Sωο£²£ i–Ττ~™£:»M]ο¦>Z§,Ά°πλSθΎΪ@χ•&:/jg9Ώ ϋ±ιi@LD’ίΒIΙ;ƒ]β¨}ηT±ϊWm‹»¨,t‘§ΣΎ=Ϊ³—žΒκή('Ξ\¦Τ_ρuegƒ—%FU€6dˆυΨ*γ΄ύzgοylTmBς2’wf°ώΜ Ά^hθωmGMςΆQ»ΊEή(’ΧCn‚tΦHΕ9Ϊοhp�₯Ξ-νΗ‹έά\,Ξ-�Ηαœκ5—9―!ΓΒνΫCξ²Ι ξJp?―DXών]4ήΎƒ`iKŸw“ΠΪ!ΟΊ]ŒV§DδhqnΞ…« l~·‰ξλHvΗγœLΞQΒ'sƒ:™}Ά†ήO;,ύΘυ₯?›•žr%‹%{vΩuΒΰW%Γι‘ΰW0]IκΉ₯τ$ΰ"5ۏΰ™~7s"Eξœ!˜3Dύ±"wΧϊ¨]5δŽ%z»#Θέ(bg¬θΛF+eɊ“Alς€ΏOJ&ω;šγ$24κ—² &ΓmŒwZh^o£y½…_o£χκ:ίobχ΅ΌŠΛ\X²Έ”dtΖύ~ό±:Ώd‚3=|=@�mβΝ?›Γc�f'θ‘,wTΚ^ }_œ γΔι‚–x«²WΒY(92s§—dΛesKψΞN�mF?ƒ/ed/ξΜcύ™il}o 2�CΊcύ,>v’Χ!$㼐<#‡Υ¦+mμόˆτγ%žšg€š ƒHσ‘ήϋβ„< gZPp},TφΈŸ ωΞζ>° 5FζΤx™{šτΡλP±R–cŽͺΪYœSΔ―ε§ΪH5Ξm|§‰πΦ^œ±ζ#ι3}|\M}`ΰf„O}ΡΦσκuι7D)~‘ΟψS‡u–μΩeΧΙΰ$2–Ωΰ7ƒ{O·°ϋZ όbWΙ¨₯tͺŒ‚—±Ξί—ΰ%ΰn8yO‚S ω�>κΧϊh\ί…μr7Žδ•ηŠ•›ΟG»r&σ lζΔΐφ€XYΊ†γ~–“-ε#‚6+ λ$ RύAN0Φ΄n(ΊΞ‹Sθ|― žΤ "†ΒuΑ"RΦβHYž©<*ΒGϋ^΄…Ήˆ9˜γΌŠθ^ w�fˆ₯ί4R&ZΩ₯=k§΅ΒEέoUJˆ:Δ‰SŽpβΤ# {Yƒ₯E†vδΒY#yΖk¦š—†σΈϋ7Σθ|o"ͺCDZRiΩό±Ό δ=y§ŽδM€s£LmfFγάΧΫ η²~<_+Uˆ„e8γΪxΞ±|€Ή"uϊ‹…κ 3νp™df@&³λγΟνκσΐM¨’w‘χHφIB'ƒoe`qξΎpΞ3 EϊD‚γιιϋΎ σΌΊžKΏAϋ*£RRS꿟»ώ=Kφμ² ‡rtkaœm7?λα°eπKιtVxgYΝsb07sBx3L½k€ζ;{¨]ν!οΩ)½9*Σ€bΙ„ΔnTs,θœτs‚ίGΑRξŽ FΛa Ή(GTϊ’χ³Τ�Ξ’vuσng}B,�ƒEdμƒqxŠ*JŽžπe½{)ƒˆΖ0§‚tXΗΦσΣ¨] Ρzœ&FΞ™;EΟ²Œ3L†kΖ‰sscdΔΐT ζ,΄²gΙήΩ y†Œ¨žΌ4œΗΦs³ΨψVI―©’nCŸτY’WΔΉς ΒΦώΙLŠs±6£RXcΦ‘‡qOŒsϊK΅f€tΰN9hΌΝΑϊ70 •Š3κν‘02σ†–»ϊΊάπ&€7τυΩ&Χ#Αήωv’„kγ2-ΞM†s π€ž%7G)Z€(Ξλ;>ΒW˜DηΪ•­Η鸌ΈχœΛ>mKφ첁Εήΐβ G·KόΙ�Ν`υ €_)°0™6f@oΡΚx‚,Π~wΝλ»πΪ”άў„.Ιb�tJξΚφΟιύ»³šύρΎG£A. PΠ„€GφKMt¦β<Όφ<–ŸšΓό‡fΡω~wΏ21¬,˜@>I*|Gh[*cFϋΔ0§‰ΥΏŽP»2€Χξ—εYP§)γY¦nΜYHαιΑΐ#Ζ.@7�d3φΤL­’„™‚ΚςΝ:qžf’§žΗi8‡­ηf°ρν�nB›"ς cG9@jιόΕ%yγpŽVςζΖΝONς•\‘iŽqΞΧθ1‰Lζ¦ΊΟ\ΝΊσZ)¦~ΡΑτίχ,WΠϋiίΈt dŽŸΉ!Y愨.v‘δ³kΘeœϊΊμyža―™ŠΕΉΓΉ9,?5‹ωi€ο5ΑγŠ–˜AΖΘ*ΏGPεAψ„&|ŒU&pϋ³)‚…•Σ£MI½GΟU�ž%{vΩΐ’XδφνEG7#’Žn³Έχt š‡$yFͺB Gθω@ΌΌΆ!sΫ%’g^―Dπ¨4“»Q™,q‰έ€£άM4�ϋΗήTτυθ@υ‰˜`pΐΌφζ?| ­'ζ°ϊWΣΨ~ΙtšϋJz¨«ΎΩ`τ£v/Kυ§ΰ,tΑx€dg w>?‡+Π'Ι΄&§0γΙJAH.7ŠΆ|HαBŠ1NœYŸš―Ηx\šY–qΪuΊIž©δ΅±ρν6�žy+e…ˆT΄QVΠΛz1Iήαp�SαάξΟΖΰ\Β³vJς /ΓΉύ™CK!ZO QΏ6D°$Θ΅o"ή’ΰ^‘20ΑΥι,$HΩ«]λ’g/uξ“ϋzTΟ,Ξ=0Ξ­Γ(žTr“Ύš`I,ͺ(E‹γBp½ˆβiΤ%ΉΒ‡XίθάΧ½ )nώiŒ·�ΟC8Aف΅<δώάτοY²g— ,&wt[0_pt‘6^‘ŽnF�Y yΕ*^6&ΑΉE›¬π ž!w;%’W‘`ŽdΉ£nTzΆ’q (š*¨ΩG¦Δȍ„ΦςΉ^ψνWώ₯Δξϋ8n–cχηx:Ž’ΆH²McΪvαSsŠTVUplθƒ95τ~ΦΖζ³ΜΌ·Gή'•tfr§‡€ϊά—‡ ©΅š5\υιfΥ NœΖœ%«€P»ŽΈo¬9ΛΩ"yŊSa`7νσŠδB‘Όq8G{ ΞΝβΞη§ΡKςΖα\>χubœ{’ζ;w΅²ΐœK�ίγ4‰xΫs]ΐqT3gyŸqΤ]κg–ΑJΣΚPΎ6ηŽ猺ˆ’Ύuδ•Ύy΄ŸP ¨πV"ͺΉͺτEdΔU?_JΪŠŠΣϋ!|ΚHgvσΑxI7Ε�oŒΗώ΅%ΥGΡ‰ϊ΄χͺ[²g—]ΰΈ¦τ&ξΫэ‚Ÿ,g5ΡΣΰΗ]|n|ͺ―™'{D’ΉΑ+J ŽbεΖcaΙέ‘‚bŠCu –Ix¬ΏήPAύmUόW�k€Νοϊxσ“DGێΣ^IΘ€ƒ”ϊχ”a‹ω`NχΎ:ζ;vα΅wF²§‰QW9*E °ϋΊ©ͺͺ^AKœm³aκ™9‹ό²ΜΛ�³DςF’y!Hήβœ2΅Ιpξ…)ΘΈ±w<Εύβœƒ{Γ}pŽbZ¬ίλ΄>―>βNŒ`ά3Γf³ίgfešϋΈ[ΊŸcŠ…λŽηBςό4Χ°UU5•Ύ Τ�^ΒΫώΗ9lΏ4‹Υ/΄‘t§ΐœ˜Sp<`¨‡³ƒe·‘δ7Δρ₯^uπ:oNcύ™s4•JκF]ΐΊσPέ³dΟ�‹\μgΫ=[ΐ4œΗΦ³ΣͺΩΏΫ€ˆͺ„δ‘y‚θfώ#2MWUΈ—‚Ή1˜;w‡pj}Lύβ.ZOτP»Ϊ%„�Lςφ#x£\ΕΖYD[rwΔ€(eaXut+_U‰ͺλ Ά—$ΥΛ)ΐ㐜�؏³=ίYζσ~€το‰c`\cθMaνK³xδŸ•«‘{HΠCΐς}\ɎΈRχλ ΎgμΒsnȝ·‘X·λl’<δΩvΡHήύαάϊ33Ψόv;s.=JœƒΣΗτ»ϋhΌ³‹ζυ2Ξ™Ws^™~―‘~οmΔΫ±ΚR1Σs«―+Ίχ6†?=$A»‘m�Q –θ-Ξ₯zŸ•“›½ιΫΤχΠZ7.‘qύξό—9μό°ζ4Αœ*W’k0¦ϊjq²Ξq½κLυͺίϋZ„ζυ* es²§·Wέ’=»μšόΚ=>Τy¬ά”>­ggpχkΣHΊωΉΔΆ»Ψ§ ΐGj‡°rv3Ξ«xξυ·ν’ύDλ;pXo—H•hξN@πh%Ο’»“CV" &ύ\v£MŽπΆΔk�»„%ΰH0)ΐ8-γε*#›Ίo 9ŽΑz`N;?j‘ϋκ<šΧ,g›μ=Z1~˜Θ0ͺb�ϊΔ‰˜³ψm#G2„/AΙ…Τή;g•δΘζ] ξ©gρω'y‡ΓΉυoΜ`γ[η’ DFςˆ»ζ„8Η5ΙcξΑς.fί7 Ξ™fͺχŌΎ―i‹:‰ΊwΙGez| x π΅kζ>¦*Šs#Ε;-8W"~ΛD‰τ™ώoCδσχN°G~·‹ξγ ΈύΩI·Ζλ`ΌΖ]ΖU"ςAǍλUΧΙMΖ§pϋsσxμίΠͺ€ΉΟΛ†-Ά²g—]§<ΐ Mι&0,g8Γζ€9t_ΑΪ¦m(}Ή"yΔэˆ₯AWAΕθμfoΆ©wυ0σd^۟ipξγΈLΣΔΟ¨Qϊν‘}Χ"GοiωζΠμ#ζ²Vœ£Rq�ί·§οKυΌ‰;œ@œiΔ•ΊJŸ‚±X;,� }!icοΚcIpNBϊh_ίNφηζυ]όΒ�2ΐ­OEθύ4γM€σξdΚ“G΄_―:―aπV λΟ\Βά{δύ•“› gΌΊgɞ]!ΐ(χ+ΠΌ  Mιύ›sΈχt»?k@ΔΚΆ[&Ί‰8Λp²LΖb†3gNcZĜΜ‹Αuv³ω‹=΄Ÿθ‘y}»”εꐃVρhpa ήΩΫ{ΖΑxA] IDATΜμ;3“hΐ’>fΡyq o}*P{K@Ι8 Έ&ˆ‘pͺιP –‚8ΰ%\ο=«ˆίαΣrPΥΟΐ!\<�bΈ>…­gg1χΑ…�ώ€= Λσυθ½]Ρ•=<ܻǜΕά³ΊPY m€hIή&yΖ¨Θ9Ξ­}±Α[§ηhΕGnαͺ›?S«uΦ+ΐx o:�ξŠƒR n+{—τsmv χ— pεχC¬?αξW€OρšκΝdŽͺςα›#’›"˜£ϊχ˜Ϋΐ½―΅ΡΌ~ •Zyξ‘hNv¦ρΐ’=»Ξs€1JΚbΐoNί"ŒωΚΪ—¦±σ£)ˆΈU 2πsψ™_£gγ ‹ *άL7‘»>Ϊοξaζɝέ4 ·I²œ&ΛUžmf2c–ΰ½ύG{ΙͺP<p= ΰKΈύΉYl=ΫP}1©κDg,2IT‚κ# �ό~ 2όό�ςξϊΰžΚΎ«¬'‡Œ₯²#OIψhλc ΐ"Eψ˜SΓΖ·Ϊh=>―mφκC@rŽΛSC *ˆ;Ζ‰“œΩdμΰ™ Άβ… y}Τίή;‡$ο œ3=y9Ξέωό4z―&œ£DΟΡΏ8%χΊΊ€tΤΞHεΣVϊ` ‚%3ŽΞ“΅½·§‡τ™kBeΆωΰΉF¨,DΈύη ’ŒΧΥόYξŒCDͺ2whœ+'7υν#"¦ [ά ˜ΣΔΪ—fpεχηυΎ₯ν ΄β,ΞͺY‹%{vΗ@›:τ•₯,Τylΐ%¬?3S2_ρTSzb†Δ*Ι¦ΑՌ>BΒβ Αέ‚Λ»˜}ߎξQ(Ÿ™ϋcf�μ–MlZ‚wΆφ/½š&zs–4Ι{ΐeάώά%l=Χ‚ˆΘΨQrC–θ€*σ"Lύ�—+„S p\�ί¬|’ŽπVC5΄;.Έ‚!άαΛ2₯)ƒˆXδ‚9$έ֟žΖ�Sσ:ή€KήΒQœπήQύzͺ²'ι["•=cΧ�¬Ϊ#«θ6@΄$οT‘Ό}pnœd³Hς�πΆžΑ½―Ά!β£Α9ov—>|T8G3PΎώ³y–:¬*²g’8‘ȜuS‹tΐpΔΟ·½·τQΓ²˜?s­Τ=ΧΌ>Δ΅?Œqϋs ϊo€k€ρŠή\I0qŸ„o\―z䁹5τ~6…ξ«sh^ί"„Ο¨ΡΡCgrY²gΧy 2θά­FKYΙλΎ:‡΅/LcΈ>UU–3vUΏBJπΕωx�7v�^”IXΪοξ‘ύξΤ�šμζyh˜μ¦Ρƒο’‡ΘΗ0+ –ΰ’gφžqΫ4Dο2€G‘†KΈω§σΌΩ†V!b ©r τbp?ΔμzXόˆ1/θ`pͺu\ϋ£ξ~q[Ο·ޘκoŒƒG@ψŒαKΚ bζz`q[ίk‘υΔ,jW/‘ύΌSΐ“”·P'•pθ―TαeσυΚ•=γΔ™]ΰ jΕ₯€Γˆ–δf’7ikΒ"Μ(…ξ«σXϋBϋΘp�ω‹=ΜΎο(qŽ&pδˆΔ™Ί¦ͺGζΦ2S₯g1κΧ(y8-½Ε–τ)�Ghζή2U]J�CT†Έςήψ o¦kζ8χ ‰M’ά,τͺG˜λƒ»u¬}±ΪΥ98ΑΌ~ftτσαΜWχ,Ω³λ<e)‹ι‘*‹₯R–τ~6₯ϋΥ”ž8±M ›Νe"} n¬άά\%a™ύ]΄n ‹±ή"…‘ΏuKΩΝ{«xς4vέ7Ρ«j’7•Y_ΦDο€α"nώιoL©={ͺ·Œ •9―Δΰ~—g­$0έΥΏ§ '˜Γ�S€ΕΨz!…ΩŒyHqŸ„¨mΚf-ά­cύιi\ωƒ9€―λ½έEξ\ΙN8˜*'v΄„s»)]=v‘4_/#2—~ρΑB„½ύz6@•’χ3‰όAψ ?€W]&€tΈͺpG’)l=;ƒΉΌΫ$ W:·ψΜ-Kφμ:•lΦ‘[LΟΓTρ€E-e™Εέ―Ά £D\…ˆ|•ε4R9Μ�]5°UΝd"–9%ΥlέΨΦSϊ§N#aι—@ˆf7ΛΰKςΞ4Ρk’χTήe W°ς‰Y$έ�(€Τ=°4³‚w§vq폢,ήpΐͺV{z―7uPΥΗ�Η†¨]Mqλ3’˜M>α+`Β!c"Π{­ώM•ρTχΥTφ>ˆμρ³Τ”"―μ ξxΚ‰3«μuβ4σΦΐS0fΔ!lΏήΙ‘Όν΄qολmDλ–δ=8Ξ͐dζB†sλΟΜ`σ[Z²9 βϋΗΉKV8G­σηΘg-ίΫf_ωu_gοΫτή2Υ{Λ«ρχ²½Ÿ2αΣϋ#ΘΎΉχb²w†pΥ―~ησ)Ά^ ΧO>&‘ν‡,i9!Ωƒ~R¨‘""ΦΥ½Έ†ο΄1ύήy8Α:yn˜Φ“ˆ8sΥ=Kφμ:«ΑF9ΛI%›4Γ©²œέWg±φ…DλMΥ―P’²¨ώ¦@D7£3G€ϋ©š€g2y³= ~[$«ΉA@p›e Λ¨^<ΐVρΞβ>€A%z³zΟ]pΐ#W°ς‰$;ΖυNg©ΉXύ!jWzxτŸoΒk―ΈΰΆ&{[z1ΰε΅Φ ΅‡nύ9 @Έ‡πM’ω,`%ηŒ=πΈ†υ§ΫΈςΣ:Έl‘χ2DΡ½ς$H^Ή7RΐΓU_Wφ΄§νΔiΜYΐc‹£ͺΦ‰Σ’Ό³€sfŒΒR–ΤάώΑV�zI·©Ξq€UΛδ,ΰ\Y’mϊ= ξΈYn.γ”Zvš’Ίl*ττ^Άχρ)$}₯^ΎQ=})¨ϋψ�ΗTBsλyz—xˆH�5=<α“ZE¬œ9™zE€½&ΆžΖάηΙsΔΘ’Οlοž%{vΥ`£œε4Ζ!yKΉdσ΅–š#d€,1WCΡΣ<ϋŸŸ+I@eΕΜϋ:h^οΰ['YNc)mfΡ™] lο<=ZYš‚ͺz#EτΆ_ZΔ­OOCDuˆ‘™Ίκ{y ξ%ΰώSο`ω© 8Α*!z·υώ2δšP@›·n¨�Ήυϊ&=y™OcΦ"@ΊZήWΠ{­ŽαZ•…i}―5<6ξ˜8.£–1Nœ> ‚}„w*{%œD%fͺΰΚp’ώ•p†8Mψ–䝒·ΞΝ’dζ2rΙζ¬rٌTRi<Ξ =}Ξmk“Š“ΖΉr2§‚¬²*y6$Σ?Qj驜΅kε{ΩΌKφN'α3Θ4Ο2€ia-ύ¦ϊγΦ ¦*­χŠΤcδ}ΜαΛx¦v’ΦΥ=Χ°ρ¦ί[ξέ£ΙΝ37wΟ’=»ΞRΐqP–sAΫK°ώΜ6ώV»ljΙfa(ΊωΩΊW;ŒΚX 3™:ϊ¦7A…ιY0κΪ4,䢊w^χ‘’tsQ½«.cϋϋ‹Έυι6�¨y©£ƒ­T±τ1χ>²�Ιέ-}άA.“2δŽ“ &56(>ω™ΟQne O}Θ΄†Νο6±τ±i¨Κ^Sv_οw ΞΗ½hζΏ @HCβΔ)‹Nœ,#|ΚΠ,AeΡHΎ₯s{‘8-Ι;s87Ÿ·&DU-ΩΜηεέΞm”ΞιIΰ\ω³«Κ^ΕSγT$ƒΣL»… 0– ύΕΉ}a°˜{ΊŸήχΨCμŠ=}ω¬„₯1„w nr@rνλB žνqy’NΤ c鹐±©ξ΅Iοžy�<,c2KφμΊPΗ¨,§ 0L–sΓ΅KΈυΩ ޚ‚ŒκqEKY8DB²Bό\©ϋ¦rKϋ=* P3ΖΖ=~ε¦π=A…%yη†θ•gθ™Šήe˜ρ k_\ΐϊ7§!†5ΘΨ=Y2bι uγ.!y·χ陦πˆ;o ©bhέ`H‡«Ι8σ™υξ₯ͺwOΔxRAηΕ.ύΪœ ­ƒΟΊrŸηώ.gώύμ: ׌η˜Κ”1K>c/?M«&c›\ΤΡ’Ό3…sKθߜΗνΟΞ`ΈΎηT5ͺΉβœW”kΆίΣCϋέ”δ=,œcνͺ­ͺχR:Zž­�ΠƒQ96½—)Ω³λ“>™'εBτd)Ή§φΕ•ίsρΖpΠΏιd�Λ\2­baς†-΄Ί—ζΥ=ž˜ή½8Α¬ΖxcLV¨ξY²g—]'εLΓEl=;‡»_mC qyy–SΐYίN–αL2π«Ώ½‡Ε_ίFeq‹d7ο!—²˜€b—γϋΉY‚w>φβ~3τLφ(€eάώœ²“W£IΆΣTχdκA¦Ί―64Ω3&-UύžΏ”sœύ|Ρ–I‰-uβ$ζ,ͺ²§ͺ΅«΄pαΜY,Ι;ƒ8§fζΓ9nŽœ£r͜δ™6„υS„s¬t›}XrΨez�8ΰ‰ξ½`tο­Εΰ³CψŠ•Ψ>ŠJυΜw‚ �ώ+?�wJ1#S“ΨtΤwΆ/SgN5g–ΗU¬½…Φγ3ϊΉΧ&νβδŒΙ,Ω³λά‡Ιr.ΰξίΜcλωVf3­,γΥC’6₯πKH―ΒύΌ±…%yηšθΡzσΘgθ-«z2‹ΑM•iO<Ν0„6b 1υK],|NpΕώ<ΣΪΓθyoΨΉŠdπ°τ›>’-ƒ#}␂Ožω4ΩN2|V$xβ£ϋr iΨ„4‘ϊφjϊά›”³dŠΓKΌ‚μW}ΥΧ£ nΑ‰Δͺ];qzm3Lφλ]ˆ± –δQœ»σ_ζ±σΓύpNK5Σ–0 ηΖ‘<#Q{X8ΗJΟ²|Ζ^ΕΝϋp³Gžz81ž ²hϊmονω"|L_SVΊO*¨,T°ό[άϊ4Iς –½fΟ�CVχ2c²D“ 7θΎΪFσϊ ””³‰\ycφ; Υ=Kφμ:ΫYΞ۟Q6ΣQM°$ΊgAΗ˜+”)†«ΑοvǐΌ3PΨulDΞΠ3ύy—�.ΰΝOΆ1ΌΫP™φD9n2�ϋ!f?°ƒΕ¬krG‰žqϊΪ¨zΖFd>A‚£bζσςS¬|ΒΛ€P _υ%ΠΜηγd6hέTχͺθeΥ=c�θχϏ; ‘qA­¨ ΌSQ}‘Be� Ÿ―0vAΙ8ƒ%:“ΛHΤΞu€hIήΕΉ­ηζqχ«GsχEςŽιœ²RβŠΞΨσ”\/OT©!ی+s–κ"%zΎχφΎdΔΎΘϋ΄[WΡ_©`σΩΌΒΗƒ0IΝτΉGCφ΄1™j]π!γΆ_šBσz[“½ΉgΜσσLΰ…%{vΥ,ηv~ΨΦ6Σ&ΛΙ3γΠφφΜK2π ΩΕβG,Ι³kΏ=Ήί ½δ3τ.#\½„•?žF²Sƒˆ}mΔΒΐΈdύ.�Ξ6ΪO˜Aιo!Ÿ‘·�³„T.•™ ”χΦ!3ŸU,ώΣ@e>SγΜη‚₯’O η€RNΘ”C$.xRAχΥ:Z7Leφν™‘³ΗΡΗ0ΚΌA~ΈθͺžiΪG‘Α2N,ΥlC– Θͺα¨sžDKς,Ξα+ΗuNGŒTΩ;c/\52NκΑ’οεΜ‰K�Ίε{v]Β'‘‘8+ισͺŠKΏVΓ`5@Ε>&0yˆ€fΆ5αK30OγέΛ €aN@gΜ$9{&ŒZ,Ω³λ΄Υ“ΝκίΌ„7�Sžε”±9™ _–εΤrMξEΰώ@ ‰ύΗΫzH¬%yv΄'ΗΝΠ» eΔςΆ_ZΐΏh#Ω©j’ΗUΙQϋΝκγΪu΄ΛmBτְ׈…VτΖξ― 3ŸJnΪzΌŠξ+l�ΐ‡LσώU¦3˜8€μ•₯œ2u Eέ—«DΚi€Ο!$"=�8Ε^ž@q§’›³˜‘Λ²ψaqβOPΏfdœΤxBβυλY’w†q�ϋκ₯‚jεbΰ•£«RVι*9Ίd…ϋΩτήζFKe§P;cοl>σΧ”$·τ³« 'hΰςSUΌφV€Š™p e ‹œs.V0&ΣϳāH”3ηφK-ΜkΈκ)ΧQΣΧ#ˆ9 ω6¦­Ϊα΅Λ2ΞsΣ―gIήΗΉ;ŸŸΓΏ‡sώœ!ygηΚFκ\ W΅]ς|n&Σzž X2ζ,Ζ‰³<_Ο�³OψŒ3ΤΟ°r •….ύr kS―(…O�ΪΤ‡:ŒuΘ’›Ίw/ °ωfž4Ζd&ΙΉƒ3dΤbɞ]©½‘Ι5τd€r‹fσ„ξ™ž2vΤμνΞΕx ζ™,gζνβ�/ο`ϊΙ-8Α=wKhIž]“ΞΠ3wΥ£·ϊΕl|£1Τ$Jp5¬ΫU{oκqcΔ²Jˆήͺήƒ[„θSCχ•ŒΙ|:z_ϋŒšp‚),~΄Ž[ŸVΥG–8ΰ‰Β ͺ{Ζ₯,ΐΔ…L}τWͺšμΥ`ϊζŠdο8H^yό…2gι―ψ&ϋ_6g)τλ)s§k«φqζ,ςμεϋ'yύ›m¬~±π-KςŽηL%ϊ œ›ΖπήΤΔ8η4w1χώ³ŽseEEΎ_ΣΠSϋ‚ ‡Ο*τ)‘cJΙ3kΞrŽ`šΎIœ˜dlsj`ηΥΓ'ΥΆΐ…r‘>L:2)§Γΰ@&D›5 ΧZ¨,΄υsΣ(ZϊΗ€w–μΩuζƒκςέ¦z2―pYΰ%¬}ΙTσjΊgΑU7`~ΜIΥ!=Faϊοw1χ‘-xmP¬4ςΉ]}X’g‰ήΈz³:»¬χδ2nn[Ο΅”^β©q\χΛψbΔ²ŠΌšw(#–Μ|šͺδ½, oݘΒέ―7!βjfμΐ&©ξ)§L‘δ1vWͺ˜ϋP ωψOŸ?GΆ#q(ΡΣc>ŸιyςoU2 ηžaκdδ‚.κjΐΈy†χρƒ“Ό{_ocχ5KςŽζ:”qΞΘΑ‹8—†—ˆj₯¦ηζΓΉ܏Α½>¦ώ^ Ώv^p�܏«ŽpU T/˜³h£%UΩ£DϚ³œ³UR±€z›ώ=“ΉY 5jAͺŒΝdRAηϋuΜ<9₯ΙeCc‹3βΚiɞ]'PS‰¦>ζ A5°€ΝοΞbυ/Ϋ±ιYpΥ '™vΦKΥ!?χp›»Xό¨iJ_'Ξ»ό6uPA3œ¦²bIžέ—£f虀liΈ€›Ÿ˜A�Ν:dTκΉ©ŒXbΈΝ�ύΫĈ…φηέ—Λ}f>!|;Θ{wfQ»:‹ϊ/΄Π}ΉιωJΖ αΫ'λ)s”RΟ1J€q§―mfέΡΚήQχν•eœΉύφp­/3�ΨSΩƒ2fΙgμ%π§ιζ2ω>υΥKςNευgΒ�Τ Ϊ›§pnύ™Yάύς>8η€`Ξ(œ3}yηηΚΟb#ΡSΗpΥSc„Β!uβ&X„Ϊ΅αˆ=dΝYΞ7αKτυξi|½«“)mΜ}¨‰W!Σ Dμ€'€“›΅ŒŽYžzoˆ; xmjLζλϋγ”»rZ²gΧI ‘³˜¦ΪςyeΛ0r–Έ³€ΫŸ›Eο§*Λ)b”š¬ž’²‘θ~³οίΑά‡6α&¨X%† ΄M•yX’g‰%z δ�x¦?o ακ%ΌωΙi Χj±21Ž›Μ‹P}΄+�r ^»\Ρ{`#–ϋB#ηŒtΐ·CμM΄Ÿ˜Cο§S`^ζͺκήDYΟ¬oOκQ €pΡΏYA« (g9Žž= ˜υ7“αj;q¦,“�o3δΔT*Y5ΰLY΅Ÿz’<�Γβ―_4’μoΒb̝ 8χζŸΝbπζδ8§ϊΟ Ξ™Jή™ΖΉϋ™“€‘’jG[n6fB}iNφ‚εΉ„Σš³\G�bŒSΥ=―έΒμϋΈϋεΈ―δœ,U’Μ‰T,@QΚ©Η)c² Ί―Φ0σ€©μΥτ½ξ’=|j₯œ–μΩuάX–³«iӘώ€Et_Η­O·‘tyožΞΦ›‡»ͺζΕzXl‹ι ²@Iž .65ψu1Ϊ„AX’wᣆ₯/θύψ€e„«σXωΏΫHΊ†θqτΖ`~„Φγ=,| N`*zf~ή‘±άšΜg€'#{­¬ώ•rωc�ζ:`‰„dμ€§ŠcR;]ͺΰΛEΈκ‘uΓτΟy%ΒwDJΌͺͺˆΆόlpΌr^c{ΝYŒŒΣI5«φSOςό9UΙk=~!Hޘ€QqμI.Ώ„Ό}[ΟΞγξW¦ο4ςjήHœ‹”ΩΣίλbαΧ;:™΄Npξ.!ygη(Ω3ηQρ–!ΑLΝΨγz αMG]σΩ­9Λ9^ϋΘ9; =κΣOΆ°ρD\τοÁ₯^uΑυΨ!»+UΜrΝ@�›ι­TΕαͺ―ͺσ|ƞωcΞB‡©gζ,§ήͺέ’ΌSŸΜ₯Z1ΥΌy‚sΚ„εφηfΡύ‘ΖΉΘ‡HφΑΉω]\ώx΅kηΦ0Ί/ο<ΰά¨dŽΈγgcІKΚU7X0NS©·ζ,‡π™κž‘snfXξΣXόh ·ώs"ρΑbάaHi:°Ώ’ΕΈrŠb―z�ηωX£Ό_ύΈ’œ–μΩu&‚ιQ²MڜΎŒακ%eΒςf" ”Υ΄p΅Ν΄ΚΔsΧHYc€,Τ}Μό¬ωŠέ—“ΞΠ+½[Ÿj)WΌDυŒ€₯`n§bωwvΠ~Β$¨lσΈX&])ςκ^ŸΎZ7ΊΨψfζΥΐ"ŒsH>Ή[™©ξA8Hy`Vμ]ΘίIZ¨ρ `χu_™³–s±_˜³ζr™ρΤU,Ι;sΟ’²j…Κ6ΡΏ9‡7?9“©VDδ€sۘϋ1X’ζ+F΅²S"ygηFυ;ͺηJΤqΥ@uΙ2WR3c,Fύ»‘σ`ρύ@»ΎΦ4©Ή‘ο—Y΄ŸΖΖ·›θ―T!½\Ξ™Uχ&‘rκύ)S©†΄§.�Aύ›5Τ�Ž#{§ΆoΟ’=»Ž ΫfYΆyΐ"6ŸΗέ/·‘μθζτT¦ˆ”%Ρ.›],?ՁΧή ΰgzLpaϋςμ:hoR’Π$™1ΊŒΥ/ΞaγBτΐžj#–Ύ6b‘DοςͺrΗdΔrΘ¬'΅©θ{cΐ6‚ΕΌΩ>ĝ&„SQA=;XΚY˜U$$\ež=’£tδe|‘ŽΈRχλ ŽμzΛό;Λζ,Α’qα4„οΤT,Ι;3ΙΜILX–\ΒΪη°ρ·y5Oυώπ|fήD8gHήyΖΉ²ΘCά1ζ,,‹Ξ—«)πΣ IDAT`,c1*‹Cμ•cŸΩ*vηΚf-δ}3˜�poώ H―»{«{“>‘ρ.e‰žϊθ―šμQ“–γμW·dΟ�SLοηΆΉWΆyϋs³Ψω;58VΖΎ�CKe3 ž€{)ΈκcώocζIγ*ΈF@°l1]ξΏ±}yvo–χfy†^Ρ θφgη°υœ­Ίͺ0€XjWxτ_lΑkί…2b1�Ν»8!#–Cf=%Β]}ŸlΨAύ±>†w#5·Λqs~†1‰IϊwγΘ)Έv%5$Δ+ΠΚή‘α{‰τ(Ε€’yεNœ{Šs¦_˜³δύz!NI5ΰ$Ο KςNΒh” Λ,ŠΎ‹�.ΰΦg§sՊ6wRs9χβœrΩΌ¨8W&Ρκˆ;Ž–p2ελδ OαΝΔpšΈ‰KΔΧ� σΘU,CGΝ‘y}ώl aRGTχφ#|ε³’’Ξ˜­Β¨KrUΛ‘Ξ˜΅dΟ�Әι<ΘmSυ-„«—πΦ'§1\Ÿ�¨fθ(46Σ1Έ?ΐΤ½ƒ₯§ΆΰTΚrW›$Έ(mΆ/ΟξΝƒfθ™Js>Τψ­OΞ(ΨΈ™:*vp/ΒΤγ},|s„‹‘Ÿ΄Λ€YO„‘~;ΊhΎ³­η‡ŠΘr_Λ΅+gΊ�‚)kMψ€6i )α£Υ½£’΄°δ'ΐhΑ3©Χs=Pρΰ1* tΎήC­LHςLβlΙ›E�ζŒ%y'–0’ξ½Τ„%—m*ΥJKΛ6}5“³¬Zρ"‹s…½o’Dt Ί›ΝΞΜφ,ΣύzK1Šs2­9ΛZ#ͺ{Τ¬e##|σžΖ­O7!ΌΚαηΛ�GϋφRα:cΆŠ’ŒσΤΞΫ³dΟ�Νtξ'Ϋ4¦ ΨόΥT@­q€;‰ 4ό!όω?ΪAσΊ‘²ژN₯,v^ž]½Q3τTp–†sXωγi ή¬CΖ” xˆ0ϋž6b)½‡bΔrΘ¬'»gzχvΠΌΎ ζ ΑάΜΚ¨%Φ]ιϋ Α0Ι”3©p Bd6ΛsφŽ2€ΚeΞ�š~=žυμΡίk*ΖΠΑkΗpZΩ{(Υ€C™i‚Ό&If. hΒ2CT+^>7AUσόD°τpω·:¨]½Θ8ΗF$sr7N1ΠϋμΛeV₯MΏή¨Y™€5gΉ0’œsW'`Υ³ͺq}N]Ν²”ž™8ρσebߞZΡβ"ν›³Υ ‹ςXψΤφνY²gΧƒf:G I_B.ΫΌ„·ώLΟΞ‹kΔ„…q‘³œͺš7ϋΝΜ<Σ΅J²œ&ΐθ£h¬`%›vDτΜ ½yPΩfΈ:‡[ŸicπF2ΡΔΑ8nzC\ώέ.ΪO”7O“ΛA (KYϞ&|=4ί5ΐφχc%εδ.gϋH9₯Τ�lfIER�ΠhεͺήQ^ΛςŒ=Σ³gœ8Ι|=YδQŒCΛζRψ™U»y~œh5ΰΘHήpm«_°$οd’™œ$(šΥΚϊ+sxσ“ΣHzL΅"S27/«ζυ1�+;˜yςBγܘ{ζ9’$αƒ;ͺ²§ξm©\u™vΥ]4�Ί΄B_~ΨuŽWɁ:Ρ ƒsΦα—0ϋΎY¬}YΟ—1_–Β¨`₯³Zm*γ<υŽœ–μΩυ ™Nj7MƒiEφϊ7ηρζœFάmθ€DΙ6³‘Τn ξQ}t‹ν vΥ4Χ�"—³˜ΐz{]·¬dΣ�Qϋsά =:,}+�Ύ…€@ΗMξ&p›\ϋΧ]‹%’w*ŒX&AΰΠd= φPΏ6ΐΞb0G¨±KYΨxΊ%Ž™ΡΪ•3\uΠ|§C2›GBφJ‹JΌ*ΐ†wTΏ€ͺμί·Τί–]¨]{(Υ€##yqgχžn‘σBΣ’Ό!zζϊ˜~Ιi”G* Έχτ,ξ}eJH―¨κΰΊ_4scp?BυΡ.–?ήA°hqŽ<�P¬ήη’peœŒ™{”μEΨ;+Σ�‹Eψ€½σe·‘dΠ›˜~rχΎΦ‡pΜ|Y>Ρ|Y“δ,H9„‡h+W˜δΚƒX²gΧ™ΐq²MκBΆ€υ§ηpχ+ωμXΕM.PΩ•`Χ§O;cτŽa₯Qϊfb9γGƒφφΔ%“ΛθΏ’γΤΝν±Ψ Υ\m…s‚vεMZHήΦ›ΖήΧ2―–5ύυŠ=�LΫ… Ρ!Aά°nΐ:‚Όaœ|`¨ς.h±(€mϊbf»AΊ˜Ητ—‹(;Ϊ¦}Ь•±-`μg<όX/Ξ΅=γ‘Ǟν’xŠvΣcΟm„J°Š@4ΞήιΨ½|ΰΝwtσ—+ŸΖΠ5˘}rš—Ώ,Vχ— Šœνέ=;«ήx5n+<ΆFΦΫv¨φzΗzg’mΆ†ΣUΊ ‡Ώ<‚₯—¬ Y“ηEXΈ₯ΘU¦–±γ§]χΔΐγhŸYXF;M£GΩμk)DT‚δΩ=Kߜί7Š#_hΪ}ŽfeΌz;>1ο|ύlή1·&Γš‹.Δr`Ο>€½%DC D#Τ1?·D·3„ψh…ρωF%ƒ-�- qΊcœΐ“&νtœ‡j¬G"Ξv!λ°§˜ \—λςŒ΄³Ξ^π£ς6²X : Ί{�n΄ψά(Ž|mr±ζ,";; γX+²ΙZiοζy χz/Ξ­β±§R›7ΐΩΙXqΚސΒιΧfΟr‘w„…Ν†ΫΓζΜaτg0χƒaP^ƒf£kj)tχ Δ\+­€qφ:{½cKΐn΄Ν–άtγΥ1ΊΕzηαΌσŒ €\‚r§y�"Ζ>ΰg<₯hŽŠ(„Α―7xέ;Φκ‘ηiΕ6A;y―Χ)nj»χΩωΌυB!o­p’CBΆΩ„XΦψ<ΨσB-Λ¨νɐ½.-Ψ%ζŒs{ν7 [βΆQ a{g/ŸσRφŽζ*qϊ�ˆ³€DC«‰³˜u^“=·΅‹Ež΅*mZΦΚρ»κ˜yΘ�6hΝ ΓwσΈUΪϋΩ…3°Vzq�½˜vφμ³m<Σ  pZ:vŠ•½7 Ψ ΦζΖ#ξ-ΎΞ:ψΛϊΒ¦·šG4΄„ςŽ―τƒπRSz­³ρ…MCa4E>{ΊομΡΨλo„؍ΆιηFqς{Π’j)FΚV9QM ϋx¬Ÿ8κξP¬[7/CΟ½w¬ΎF»yθΦη8Ž~½ŽΩ§ϊœβf`­gΨω©PˆΕ½pŽf3 ±œ)ϊ$=€Έ44Pޞ‚Π|ms{ώWΐJ%N_I-£ρjμ:{N‰ΣΐΗ[.Έ™=Βj{|‚š0―K7`@^Œ¬ A Φyη"΄³F Ε’–ΆyK‹Ά©…M€›Ν‹,k%]rqξΞΜZy³Η9RΘ5laY'ξف±σ΄(Ξ’tΨ—·PξκΞτηυ.†Χ`Ά8,ϊΛ&.~[Ω‘kH¦s7ΊΐAZρn5*§·2Ζ±H`8�γ1Κ>&EΨ䊜=°Χ;Ξ”Dw£mΆδ¦ί:‚₯ϊ EFFNΎή©r§¨ΏίΞζYŠ\8œ~ν dfz ―wt[£QPˆ Ά ―�1ύϊ0N?Σ#bΐPKbΌ?sB,~MA‹ΆΉι…XΞ!z°gηφͺ—4@hΠ³˜Ϋ»ΰχ˜'―cλuΤ‰ΒιgGα$TD’Τ&ΥΎ.έ€y"‚ά‚―vi6; Š›ώyςʜ‹ξ™;ώ+–pœ₯ŽΚ©J-ήٌ.ΈΉ=΅ΡΆC=°Χ;.H\ ms―Žaϊ–!ˆΕL^ržB>™V QήΏŒΏtύWϊnή‰^•³w¬#Πσz^)Ο'iγPι^ύΛAk­ μμ(!„ITve˜ϊΜiΔC‘΅ΒVbY Ψ ©œμ•'`Υ*SŽξθ�ύ™ƒίJ!³’P…’μVκZ₯e¨4Pβ4@‹΅8 ‘•νy!A<―n@‰ώδUέΊ8?§ˆ3ΊοΌ‰sα|žg­xF€₯ŸΈk3Bh›α z;]ψ»―ΰ&\·EY φ’‘E”w&hΌ,@Έ^3•ΣΨP²Βv(=ΞQέ½ξΆC=°Χ;6zC:;Ϊζμ#8ώνA'7·h›ΤIMG)ήΎŒŸ+T9OΈd�K•³7›Χ;ΞθyƒγνΝ3=^ǑېL—a$‡uk3ΗΰΥ v|bΑu™ !Π“AΌ•²’*gkn―Ό#…X° kžcsRLΦ3,J²ΫβSvΒ+qvgρώzΞ€™P?―׍Ζy�ϋ%E»’ /ŽυuycM°Χy›)ΞuŸΟSι6Όφ…z»Ϊ¦¦Ξ‹SΉΟ>Aύ§ύ ϊ)΄ϋζuRΪΜίθŸυYή‹N{―πV­ΩΫv:v�!oΨ¬ΰ� Eα^~_όΏλφtT°&υ*§)|υΰ›ψ‹›Ύ»wΐ"†ήΩ@r¨Eευγ.TΞUl‡TZυΐ^οΨR t'Ϊζh[­�1ύΖ0žqB2h›6ςώ&n^psPa•³“ΰEQ¬™4^Θ!ε-]"}ƒ' kπΠ+š₯ΰ•ΟυC.Y g‹„网‰›|2|"HΞf λ²£R£_“[α3/Μνιμωξ^‚κξKΟ G‘2Νΰׁ3ς‚_‚νΉtɊ@/œ₯ς@ͺ‚Ζ«eY§*Ψ.x°Χ€q*€ T/Ι wΦβ,](›ΎQ ƒΘσo-ŸΌ7Θ;Ÿ=z=Ÿ‘5\GρΎησ|±ΘϋpŽγ΅Ώr±ž΅’‰Uwu’NΌΏ°VBΚ¦s .Ρμͺ΄ΉŸγŽ :–�m=λ™i:v(Ξ’λW_€ΨΤ© F±’ήΝΊœQ°—DΑWΏV‹ΐσB@ž φCΌ~―ƒ?‡§)ΑΆ�ί…^g¨œ~­,ΒΣ9ϋ―Xn£rΊFUΞΆCb~#;°=°Χ;6$>msΒΐQL�έ²S5˜α}Κ| η :TΧ‹IυFJΉΏ‘nc!ΰ˜7μB»ςΥψPˆΕR�ž­γΘWϊ­‡ž»Ν&yCΧΜΉ΅Vΰg]ε/ψΧ]Φbψ™›-ς› `{₯2+�R»$˜p) B( !π™•©YθB ’‘bpΖ ϋfWΒβSΨ1³ΤΘ|Ύ c\—₯¨Δι€žMm7 Ό½˜ †έΪ΅^k1q ΥΨ<]s$ώ}#Ό5ά»³έ·Ξι:Λλ“²œ‡3Ύ-ΞΣϋΗpδkƒΠYΰ 'Βh”aΰν‹Ξ¦εdPΘ<Ž3³VΒ΅d.pŒ[SΌΈ€ϋYΡcϞ˯F€αh^§§c3‰ςv_¬ χκΆηωB\{‡}!\c΄°‡…@.Š qπZ§Σ?†υιξ…]½θω™t�}ψηποC� vψρΞ―• ˆ†Ρ�Φσ{›…²vUΞτY°·erΥΨ{s‚bόΓ ¨_ίΙ ½“M‹^eOΩ ρ)ftσΨs §ι±gά?Σρ^™ \q–ΒžΖ¦•j’- Vς«R—―-K™vο6ψθ€=]sY°G¦Αk‘¨U‚vŸ�¬ CX~2>v£rΪN{mO‚…g4S–Κι ΦΫΨ,g\»@η9ΜΨλ›2ζAπλƒ₯…²PιΈ£mφΩΉ9c©q‘@i<)HMϋn^Ψ5 i›>α@ΰVΕ:ΏΠ[}ΐΊΣίΥΆL§κξf*gX§!νj’YŒ8yίNάέ#Kmj°•©»~m ρπΌKˆ_w ²_“™ϋ ΈϋyEJb'0­‚―ϊ"΅³]7ΎΪ™7+g ε *Q–φHΜ ΰΤ–3»J»₯}ͺ YθL ;Ε 8CχŽVχeΘνMƒϊ°ό²§ΥYΫ…pf―Ω}t- εm2Έ>ƒ•4+‚5€<Ώ_ϊnήZΌmσc8υΰ0ζž€ΙkN¦+ƒΌ΅ή»ΥφκNϐ.33ΓOšuBT Ο?�bΧ<θΤΜ ’$ΑΤδd7ˆb�Έό2=::’κ#u1ΉsG^­Vσ Xz.gœYώŠρ ωœot|μ`9$ƒίΑ�9«—,4³s{4²lJ@(ܞ|ζ²ΙlDτΐή› θ+iEΉιφ:=>Š£_FγPFΔ-2�Y–°γγσAΊ―tv’³tκζ±Be/€.π•ΫΨλ>`έm˜Zu¨κuνN]L°r½Π›€υΠΖμ“5«Έ©\!‚I Ό=ΓΞO.ƒUζέz +οΒ½ΥX]θ4˜@):Q… &‹ o3>όBͺrΪ³²]BžV�znΕM:6QΠκόb=―ͺ»Cš,€εbg₯΅ΙŸαίωδ―κ€ήΌ‚ez|Fω™=Ϊz+XΰξEcPݍBg'¬šϋθ^ά—θ@ž�ς °VύυΤƒΓ˜}l:―΅Δ«‡–Oή–‘kvJnW»wτ `/\ƒ!¬83Tήέΐw1^…ŠΦε XδU{·CΜO`ϊΦQ+Δ’9A'ε:Ϋ5Ψφσγ?獚η]l›qkΔ]m?Ω¬ρ’[ŒΠήg=hz€c Φg!h–ΚΫEτΒy½ ιμ­ςŠ΄LΤͺΈ«Ή½Βw�ύkπ•Fωπ‘£ε^|)>uςT<33Ÿyr&�Zs­3LkM uΟΔσ “§Nuύ•φξΫ’)₯†(B©’„JΚ¨ά55%Fλ#bΧ�)159)FF†³©©Ι,vΛΑΉTψκΟνͺΗ’.θ Φͺ° w­‹(O4ΐ«t&νΎΛˆ΅©P‡GzlK%Ž=°χζzEΉi_εo?`σϋFpόφΘΕ ΄ŒG‹S \€χ'˜ΈΩ›Q‡Baηd +i›ώ(ΞΎ+\₯`SνuχVV^‹ “*€ŽN³TΕ!λ^τAλU<τΒuκ}·A₯£8ς΅!,thΊόΪ‘ι�©S§b₯t€΅Ž΄Ρ\kΝ΅�LkΝ”ΦTΫΣ ; Χ‘r6…?ΐΞ4‚b!šRͺ₯šRͺŸ{ώyE@δcO<©)₯’R*eωUW^‘_qΕεΙε—_š^~Ω₯Ύ«·μ K)’­ΣƒΐFσ ϊBA(Τ²` Υ·${ΟR…Ίp¨¬φzΗ¦yΕZQn:τ&³΄ΝίΖάSύΆ*-#K!ΌσͺSV„ΕšQϋΉ…cAχd5K…NŠh^ΰ ?¨‚UΠfήΤf•©J[¬Œ‡έtpΒS<Γ.•ΔJ‘ FM<ƒ΅‚W…έΦμζ©΄ŽW?ί‡δpFE6α'„ LώrŽ‘k—ΰύuZ³’>A=›΅d UΒσBœΣ›θ­5^AŸ ξQρk؁•]Ί„k~<φZTH™Π¦>ρ,&ΠRH ;Fλ"Ξ²Šκ�/Χί`°? Ί³@£Ρθ;pΰ‡΅½ϋχWŸ{ξ…Κβ�RI+]RJEJ)�”bJ[pη€UJc Υφ+QZΡφ+ΞλcΧμQBA)1„RC)5Œ2C(Ρ„ΝӌRE)UOοΫ'χ?σŒ`”ζŒρόͺ«�LwMM6�ΏξݍΙΙΙΠΪΐΗΫΣn:€ΐe΄ σmθλ’B1{΅έ)φ ;ЍPw΅`ΨϊGμ½ρ^7[_1o™€‹ωqΊeΙ!«Bε*΅�[B£υ–0ρ ~ώΙΣ6‹žBŒc‹›{Xi ©a·yVƒ$"œΗ聽ΞΦα) :+t£2¬Κφ Κ·_ƒ‡žοζΩ�szlGnλG2[:Ÿ!•`5·όV†ςφ₯ ΒΨpλfνΓοk-"U_έχn֝žΦΕάΟΔ&|($tvνΤvK€JψŒ7!Ey΄„OˆUΊ,O„½β³½r‘3&F}ξY/χΕ'WΎ«λ片°τb :`DΠΥ3Nǁ΄n—ΡFω�ž_Giθό^XAK‰U’Ε6¨’]|₯ε•g;yC˜ylj©ZTaD:,`Z’Φ<‘�©Ω ‘:sX<Σ½‹°R&>|†Β=+sΏΓ‚{vN‘Εδ�_Ύύ΅ χd€/ΒaH-e…Βb?ζχΰΘmύΠY₯Ω…5��‚+Sΐž�V£²Λ?/~χ^eχ3σ HPμ0™-/ŠN }v+-čη³Φ$ΜQΨλ;ƒ½μ˜›Q3g)΅™©gAqΙ¬c κΔF({X³ψYbΞΜΜφο?πLί#=^{νΥCe©dY+]’RFRJ�΄βJ*¦°ΣZS­4ΡF­5ΡJΓΐ₯4ŒzΖœOW/μξV—RPκΐ₯ „F(%†2f(₯š1¦9cš2&9ηκι½{σδw~η»ωψψXς�k―I�{ο»—¦¦&}αΖӜ}\τηR—½KacΖŠ¦΅zIΒrΫΐπ*Τ”΄“Οz`―wl  w&[…vκΕη¬ ΅\¨@Λ¨IΫτέ<ޟ`η/-8O‘Χ @Ο'Ί‘K›AzaυΙT8'\υΎώςμ�ΐ+³K΅γ‹i 0ό₯™%Ί˜ »O½AίOΤϋP‹£3ήΪΎ˜›Ÿ¨χkŠͺsρ–z_Q9¬Θ)‡‘!mTΌΒŠιγΫ―β‘*nΆ=λ‘η¨Ε¨L μϊ΅βαε ’˜‘]Ώ�Τ©™κ‘#G+‡Ž`ΐ>|˜ΐtYW¦^―λΡΡΊ&„¨ΡϊHvΩ₯—&υϊΘ’[χ^τεxP˜ΠX)ζ²YΏ"•.G4”YJ&ΥNΑ²]“MΤ‰‘‘•0A +μ>‘‹‚‚Ξ0Bcq» θρΕƒ•™™™�©™ΩΨΐπΖrƒNOζ"cL¬΄.k₯ͺBˆr½––9*‘”JA©΄ΦM°GcœpαϊwL�…ι8žΫϋΪ[ή²[횚Βθhέš|βΦ‡φΩ+V(ˆΥ7ŠΉ§†W‚<ΉNc0—§ƒr:Ω*fθaΌιΣ#kΉw€aΖzθΠ4’4΅Ο1†bκ£u= ϋ€‡ IDAT:Z—”trrη�₯—ώΔιj₯r�}Φ~ή-μ‘Β:ς~+ΖΤI2ψ�‹ϋ¦)££F#ᇦ§ν4­b§1&ΦF—΄�•Ια΄,%‹₯Œ")Α”€Tλˆ£qπυEΓΨ‘ˆG„qf"ΞΑ8§Œ�ˆ2V₯„ J$‘ ΠΔ�{βωΓ’*Υ ¦¦&Οt3 355©+•ͺ"€δ GμΙ  cDx.ρ$ΕJaξ]€ -€ρTΑ€7‹8€ΆBpD Ά;4T_7q–.”Ν¨P¨ χŽzΠ·�ΐ3>ϊxίžή[UJUr!b₯t,₯ΰJ*.•bRJ’•’�‚<BkCŒΦΠFCk[Έ2ξφcšέΌυ{A—Bν"„B@)%Δ@Ζ8(%ΖuϊbΖΉζŒUgŠs.³ιL=z,»σ�οfΫΖ“χ½οϊ₯λίϋž…z}ďOΜ Εψςΰoa5Π·ŽωE'*§]Ώε‰΄’ƒ€ΚξΝ­`ΧλμυŽ­ΣΝ;[…qΏ«Ž™‡ϊ‘σR@Ϋ4.)ΙΡwΩ2&�ΡX94’.аψ?άθ‹ͺ ¬P‘‡5±έ`ςΎƒΗ·}ριWλG’~‘tYh�Œ!dίPΗΓ―ž\Σ-ΆU9hJˆ!Š’(!’Θ‰ώŠάή_–}₯H^ZοSΫϊΚb[%ί3\ΛϋJQZz ¬Έφ<ϋΕ  –€ίΊΎ5x荡­ΣεWFpθojθ―Έ9π6/ΔvσD°ζGχν?0ό­oί9ψΪk‡jJ©’R*2Ϊ0c ΡF―VD0”Pγ*Ÿš1&gιΆρρεθσψ™G,»υ]œƒ4Ψ<‚-!₯«Υυ΅4N B΅s@Ρ¬¨rIΪ:{a�RŸ|‚ξ;³΄=ρδS#{χθξΉηk‹‹Ke₯”%0šm(ͺ¦F.•bJΚ(‚‹\π,Οh–eDJI€TΠZϋξ)(%`ŒƒsŽΫξ~ž–K%^.—Λ•jεR™νΨ1�ά‡?TύΰnμΊU§Π²ίˆS@ήΠ* 6Až–π”WB7=Θ žCύ³γΆ{ρΕƒ#χάw�Κ{§5 „$ό‚1@‰₯ŠQJ$c<ηO"ΞOΏοϊλ?ώρUͺ• A»ο—§‘Σ ^ŒΊx1`ςΕ—n»γΞ»κΟ>χ|Ώ’²*₯Š•Rάc׎1€%„ψ΅Δ\g…?*%Οσœ !hž ’”$JY€Ξ%ŒqqŽ(Ž’(Š(ηœsΖJŒ1E(Q„PMάσMl~Έ)-W\'g… ‘DΦͺUΉkjJuΕ—)¨Λ/ΏLΤG†σz½zS6 ρΒ―ΙpΏm` Β…ξY±«#=n Ν¦Yi²σΑ₯&…3Ε:гt�σ”Ν*VŠ1yΥέ±™™ΩαǟxrπαGλ?qβDUYRΔRΘHΑ”ΦLδ‚(­¨’Š(­šέ;₯mηΞΔ cΰ€οΰw}λτΊ>ϋ !Αλv΅―η†1J(‘”2je`άvό8η†s�8cq™₯™8rδΨΰΧΏώΝ‘ox�ςυΧΏχτε—]κ;|'έ灟Ÿ£_,μm*Θ/Θ:ΎNέ½΅·δ8½W8 ³τΫ끽ޱy€^(rΠ_¨ŠΪS₯c8|Λ0–^μk™ΗPe“¦wžX;‰°,―‘�V€oφΉΝs;€K–sΉϋ½k�δσ'ΖR©3©ͺΉ�±Pšic¨6.Γ4ζM}]kΣF'CьM4#D/eRœY�”@ί (J‰€„(N‰μy~ωΨ@~υφατκνCιžαZ�WŠ|φsg!ηώ4Ϊ­—± ίώ<(cέfIΓ$sσOγπWͺ0²Τ*F°£7€˜Έ9œXvΧ7`°Ρhlϋ³ΟύεψŸ}nD 1 QBΖJ) ΐw][ΑP» pŒ©(Šδα#G�Ώ»ε+ƒχήχ@�o�ζ?�μάΉ“]³β\δρZγŠ14^" ΉοPΆΞHΰ±G-΄vIQMΡwυόlK(�2 `η“O}ϋ·nΏsδψρƒRŠš�Rœ‚ϋaŒ‘Ζβ@œe‘Š)¨”’!ˆRͺ θωϋg™O ”’η9ˆ•Z#Ζͺ€bΣΣGβ/ύέ—Λχά{νŸώΦ?˜œάιΑθ‚ϋ33[�φίyπαGϊE.ͺBŠŠΘE,„ΰBJ&… RY€'•‚Ρš(­ΰ)šZΫυeηπZ Ξ?3α³³‘9OΈn‹ίWά#„)‰£zRb»}”PΚ(cΤ0ΖgάπˆkΞ3GQ…GΌφ½{ολΰ‘‡ήφΦ·ψC¬_}υΫκnoσ{έλπσ눺΅œ'#¦0·g‚xΧ*`”'2œ¦ΆΘIš³Ε:UμυŽMτΊΩ*xN(WΏ ιρ:^ύΛAΘ«dΨ4‘fΦ;/K0ωK‘wή1΄<…fΡ.Β’«wδ{μωy‡1ΨYΑ©�xߏ¦ž{}a{C¨αDΘZC¨H(Νs₯‰{6ψΎΙο3)€>J(΅)Fˆqέ>C Ρ4ƒŒ½˜Iyr9S:%8₯‚’]6ڟΎsΗPςΎKΖo›Z ‚Ά§εϊͺ\1ωL \­΅Λ·Š‡^Hk)nc˜}lG�Ύ£c'Δ’@"ΙO50tνBpKAG―Φh4FώΏ?ό£ρW_}m<Λ²Α,ΛjY–Ε6 K;/atUfeΠp•N7ΜNΣqι(ŠΚεrΉ|τθ±ψ>ϋGόό—�BοάΉΣ O„IΊOJΘΕκξμ|lW©νVX|ΞF7¬ή>¨nš=₯Uρlz@p?έύ›pI#Iv}ιK_ήρƒ§χeY6˜ηy%ΟσRžηΎsG”K�΅ŸUρI‘›gQJAIεΎo―„·'KRZεKc ”VTiI₯¨”’ YЦ§—�?ύAν_ύξο LMMϊ’U †J«˜{²3 @-W»wς Τ4;y„λΝ ς:<‹α¬’/ΆLΨ ΰ’ϋxpΗ—o½m<Λ³<Λ«έξ]랬|†!†3fxιRΗZi―½v(�ΖΧΏΉτιO�Κ\PDτs―1Z£ΫL>ρδSS_ψλ/ξHΣl$Ν�Z–f%!%B„σOMf―λπΑ ^(hmˆΦv-Y0§a‚ΩJ­)Œ‘Ά£(‘RRB)₯ Ύ“ΝΫ 8“ ‡€ΎΥ,h9…Fυτή}Š1&οΰ!AΝϊϋϊ�k―ygzΕ—-]}υΫ—ͺ•ŠW;ž/|υίϋbaQ=θlmCΜq7―gχK+W(Oδh1EeΞJœe•v΄‡ Œ„m$ΏοΎFξϊξχϊΣ,/‹<…Q.sΕ*•$ZiH»Gω’Uά…]Ό κVωΊώό°Λ¬‘6πG e”ΈneŒ±8Š ’(ŽςRΗ•½ϋφχύθΗΟlΫ6>τ‹»yδ½ο}χˆ{–½oͺŸω ηu›qλ3CΩΠs/Ey"³aΗh!΄Ε`1=gοΨά@οLΆ Vnzφ‰aϋΦ€₯mͺ€ΆI%hœcΰmΛΨρ OΫτέΌ8³K§ 7ΌΆ8ΨLGμψΦ³‡'~pdvΌ!ΤΘR.ϊ–s'Bq‘4Κ@C΄Oβz·€φl΅œP›LJκͺΉŒC) 'ΤP Ν(՜Ν)QœQΉοθœψα‰ωόΦύ‡ς‘r”ότžρε_όΙΙΕ·ŒτyςνCκEϊ„»¬3vωVρΠσ4­α Šjeνά6ˆΉ§Κvΰ}{~sεν§ƒkσTb/Ε>ψν;οyνΠ‘‘,Λ“$ιO�4Ξ�Œ )©”Fk’^5Ψϊ`G 1œ3–snβ8fJ)¦¦1_½νοσίω—�Όα’œ0i- .κVQ€‘œΊˆ»Ζ’υBΨΩσ]=ͺΐͺ�Ξϊ5…|ΕήƒvOΉΫέH’=ŸύΟ²σεW^O“t0MΣJšfq.r.₯€RHβ#Kmj›¨›fEά&NvΆ₯˜0ωϋθ•λ„0.±7ΠJ)QRR©“BFBŠς|φͺ�α�·C΅j5…J$fŸˆ0σh ͺQΆ~~’τFyΆč°w €έχέ�ΰΤ—oύΚΆΔή»j–eq–―~οV2œ„‰’Θ(eν (%’QΦΨ»�ΐΐ§?ύ+^ιΣ )ν>°Ϋfff·ίϊ_oO�t$i$I’–²,εΉ�c§W\G‘cGόϊρΙ·_K­€Φ�1B(‘šB)eηπ\²©M/Vδ°χ„tƒš1f₯šq¦γ2MRyΟ½χηχ?ψPΖKήuν5wΎσκ₯«―~ϋb΅Rρ1c.XΧαŒΦ"Ϊι—EOΝΈΩΩƒ¦ΞcΟ8ΆDuwHγτ¦³fL¬ΡΗΥΗ ζΑwή5φέ»ο>}za Λ³j–fq–η‘Θs– Λ8°TM ­UΫ^Uw[‘TΌΦ"θsυ8B₯TXΰ'„ηΝ9gQœGqΕq—ς<―ώωη�ͺ�‘ΗόθΝΊό²K½Š©{¬Ξš3ζλ@η,ŠCΩβC4”P«ΘI¨ϋyδ Λγ쁽­ ςΒ`έ‰ ηi›ήVaΤΪ*§>΄T}t³΅θBΕΣ '4ΡψL θΩωB•“AχΣΏ‡OΪ†έž3ΥH’έŸύΟφDώΔSίOϊϋ“k―½fι£7ύΒB½>2ΦŒ–§λ……Βe—t-₯ΛΈyζs‘Ǟq Α’ΰ±—»zητΒΒxhο³ΓΫ_xρΰψ_�νλǏπ̐rζχ/!¨�Ο{Ϊn±ΪΊο\ΐŸΦΊ9ίG•-Œ!!8g‘TDΛ…ΰyžΗ₯R©΄o߁ʏό\υΖήίwσMΏP«ΧGΌ’/ΑJ (ƒσί)9ύܞ₯—'R€Wΐ4M[‘7¨HKμmm ·[;XŸΕ‘― "™�ΐˆ€ΆI[΄Ν]Ÿ>ς„WΫ i›aη€£Λ*‰H¨¨Φ6?xοKΗλsI>J]M„*%RρT(šIE„ΦΠΠ…κlοh1‡Z'i}ί©Λx)‘„ΐ?b!ΰŒFˆŽ5#*’΄sZ{ifiΰ�ΌχGƒ#•hψ“oίU�Wν­•’¦ς˜»‡žΞ{:θ`ω »ΦΉ† λΥaΗŒA%Γxεσ}H¦K€q@εVˆεSK`εωBβΌδΦcŸO\χψaM)UΛE^Ι³<Ξ³œ§iFς<'nfη¬q πQΫ°AΖ‹8ηε(β}>ώΔΠΥWΏΝΣS|‡ΒW―%."•³ψŠ O‘z‰Ζβs«ό?βΔF…³4Κ²ΛBW―9λυ₯ΏϋςŽƒ_O�t¨ΡhxΠΝς,'BJh­H˜ uΊηBwj%ςV/Ε«ήimˆVΚXqI9cμι'ξ~›pŸ…!€q&θš6½όšβ†[Ύ“Wά›ΓY%ŸπΡΫρW_ψβxš€CF£ΦH’8MSžη‚!ΦtοڟΖ‚ΖUΆΓN”5’φΟ -Γ��π#>>”εy-Λ²Rš¦<ΝRšη‚ΨχQm@�[Βz¦uδΧN·™¦-W μ"Θ±²cγΠ š3~„Q Κ,πcŒΞΈaœι(ŠtΔΉbœ‰<ΛΕ½χή?ψΠCŒόΤ»�Yϊٟύΐόε—]κU[‡Ρb•ƒ‚±D«ƒ[nομωxαΔY’ VΑžΐJ«—szފΓ·»"Η$€ν_½νγw}χ{#Y–υgYVqν(ΟsšηΉˆ"Zkiεot}0vcŸm[dS T₯$„`ˆ„ ‘ˆˆˆ"ζ¨ΦQ©TŠο»�Α�ΎύβŸϋπ‡ΨG~αη Zβ)I‘™°¨«θ5Ω2y†TͺVΜͺ·m‘=°Χ;.Π;[…qΜοΕρΫ Λ-΅M¦@£ Γο^ΖΆΜh›Η]’2tpVˆ°œ²W”Τχβ,Γ†yυΤ`�t_*U)΅@dJAh ©[½Π뜲7ƒ·V ‘νΎΫGν †IB%”b"F§”—8c�Κ%ΖΚ™T΅Ο=y°�Άg¦~γ]{?φΦIο³5ΰξc₯€ν!( X―b­PC;ms€:ςΉ!ϊb’io­`@˜@ύ†Ϋoν|—qΑξήΣ‚½ύ*R©²”2RrK΅θΙ�4ΐ3Ύζ�!” δ9£yEy—Ÿή»―†v£g?‹ΰηSτζZA-?¬ §ς\H[]=Π°�ž`Ο {LΨyίξψώχŸOΣt0i$ΥF’Δi’²,o)i†”̍¨€‡]>�½ΦV8AIEgδ�ž5�ζc?aˆΙΠτμ3€&‹Π7Θλ�ΥσEΒmv4’dϋŸώω_Œ§™ιθeiF„g}οlgZj›n›“%€!##ΓΕβ ‘MΏύž\XX¬εy^ΞςœgΉ Bx wnΟτZΪ7Bg¦`νfC@H)…τYζ|e”rΖ ηœ3Ξ’(ŠTE•(Žj<φxίχπτΐ 7ότΠGoϊ…Αz}Δϋ2φ»ύ‘ŒけεhcI>λ<φšϋ‘Be»hKΠ[@`Mβ,«=/ηg‹wΉ"ΗΞ/όν—Ζ^~ωίA.₯YεYΞs‘‘ "]ΑπΝςV+Žψu€΅q]|ΫνR"’’)‰΄tk¦΅¦ΖςΝoέ�fgg³Ο|ϊWΌΘZ1ί$ηZ$-ΜͺϋύΥΟ©Ϋ΅KdΗ•+,tπ—]ε(Oτΐ^οΨ° œΩV‘}>οψw†1σP΄psO†XΪ&ˆϊ-msθ!mΣWαχs€xΉΡˆ•”\JΕ€DJ ₯¬°Gk^ηά«™JiH)!₯’BJ&₯Œ”TεΓ‡W'''}ςR4šήLGwσgR†ήF€gB›£Ά;τβςΖΣ~Vo’‘$ΫΎω­o¦v^²ΪH’(M3–ε± ϊJ%͍NF<ΰ3FƒR+�A%₯„,ͺ~ ˜Σ&|šl²ι¨=oŒN^ρ.EYš‚wάρρ£ΗŽ %€Ωυ@OΩΞgΫiυˆΒu’œΣΧ\σΞΆ‘w}Ύ³Χ``ίΎύ5!DYX Ž xsξΟτ›-)_νΟEθ‹$žΐκ糑„1aBaŽˆG:.ΕQE%‹Κ½χέ_}ϊ齡�βGϋ?ψŸιsϋs՝―»ΔήΈΧk*ΘηmΟ8ͺ΄-0)”'Š`/4βΖŠΞς~’?°BD»ξ{ΰΑ�χ·ΝΝΝ₯iZu@Ϋn^{G;άΏL―"έφœΫnŸq³―vdB) ₯$ΥΪpmL šP’?όΘc‹;vlώΰn,2bΦ#nšΰk(Τb‹₯ ‰Εη΄UPλ�θό£IπΎ¬ά9~φΐ^οXG Ηƒ ν;$cAβΌ *Γτ-CXz‘fΥ6•Ÿ{²&ι•Ι;?Ήΰh›'Πn«ΰΥ6;�6Ο")zιx°Χ�Γ§ϋ€2•\ι8SŠ ₯‰Τθ‘τΞa7[ ˜6¨έχΚω©J 0’Α(W”εJΣ\Q–IΝ3₯✳�γσ•φυ§ͺΏρ�=Υ_}ηξZb(~ξ‚vΈ�&X݁ής+ƒxν―«P’ν¨P ^Λ±η·’@ˆεΪm?<}3 ?…5PζΖ€k­©�Šmp~9μi­‰”�()‰TŠ)₯’εFβΤR‡ŸΗf¬ϋ2@�R&s³+–er ₯”pΖXΔ/qΖ«/ΌπbνƒΈ1œΩ₯δuΎ)’A»P‹₯GCΦrȞf?�tψΊιBμm  ΧΝVΑ«GωЬ₯m¦Ηλxυ/ϊ!½­‚Υ”%L€ςΓοn`ΫGζΐΚ3X'Ϊζ*€ΏξRμχν?:WZΗBi.•‘ΚΟΣXε½ήί 0hΜJπGŒ…W T"©ΠBQ3M„bTpΝr₯ΉΠ<ϊό“γgŽΝ—�—ŸύΙr­…½πž‡”N†ΦŒή0ZΤ τζŸΔα[+0Ϊ+nJT¦vύΪβαΣn]Ύ½ω y6hSqΰΚ€9_­@~=g?›U2D9�.­5α…£+.ΏΜ›³uZ²^”ˆi&QΫ“‘έο DfffG~τ±‘,Λj©U@eBœXXοDΔ Phm…ΪBΥΦοκ;yͺ#Θ«_wΓορŠΕ[‘“Χ©ηηΌΠRm»ύφ;μœRš•Σ,γi–!€”ηΥΙ `Œ‚1ŽˆG&β\EQ”ΏυΚ+Σz½ξM»ΣμyIώΪΜΜLνυΧO–…”\H§Ό)U―³rwό€’0‡cΤ1¬( *₯dJͺh�ώgβί;τγυ»ΏSšœάY KnŸ΄τx0Μ±ΰpι(γ:{λτv;©/|y,I’Α€a^š¦4wTᐲy6νήΪi6 ‘‚BpIb[ εJΙhiyΩ3h!‡X―ΈιχΫ@9Κʎ&ΈΞ€Ν\V4d:ΏΨλη τB[O» “f+W?ϋψ0ŽέήomtdιpΔ€r֟bϋG—m34I_OΪfxέΐJ™€κK3K©L,΅aΚ* Ko ½ΰ―όΌ(Ž�„(j νό$š‘ •Ϊ0iώΨ‘SΡΏΉs_τ?έxU΄g€/œKCd=σzίωs‰e3φγθ7Κ€qŠ\T`πν9v~r ¬ ±Ό$� )dh͝5;ΰޟ­™ b}ηΒLs΄K#4l6-q‰πά’SήλQ4ηυ¨ξφB žΒIΤθ·οΈs8Λσώ,ΛΚYžGΉΘiž tςΖ»Ψ‰!Τu+(šFθ4ς OΎ‘@^uίΥ0~ψπαΡyt0ΟςJ–ηQžεNDE9Λ}Ž@Ο~Ζ”2qn’8�<β"ŠxϊΡ›?βηuBCu/κUP}αΕ—*Zι’R’k₯¨RŠxΠ^ΤΈΟL+‰'m³°–&―‘”υš³_UJQ­5ϋχ�α�β�έ?ώθΊλήSrρΰ΄{&†τA5J0šΉŠœe°ŠD<ŠA­°{: ·§‘$—όαg�xςΰΑ—ΗIc0i$•F’DYjgύ|j²Ή> Okm4ŒvV-ξυwΌγνβ2Šs{ΔuψΡM‹Χ6o»ΟmΨΣv -Ψ ©ηλ‘Ϊ{oB¬ΝVΑϊΑ¨t'ξΔάS5h;Ήzbgl˜@eW‚Ώ΄ˆςΔ©Π[OΪfρ ΑοΠφφ++c"©5UZ«₯<ψ IDATe‚9½θ»8ΰΟ β ϊ΄!Dΐή'C€ΦDiC΅{ζψiφ―Ώ½—ύώΝΧΠ=#}€°Ρq—Έ΄ό»<}Σ*{Ή­sOΊj. ΝQΏ1λ Δ*Β.c₯w]Χ]y#Τ}Ϊž¦'œ!†Β΄© nD…ςγ=?―ζΊz„ωy½Fφš,ƒF’ ?τπc"•,³Κu"ΔzO©MUχσŒRσπ3 ψ©‘£j–,՝F)h”€ΥPΏ~Γο™έ’Ό"‚»zM`τ{χά?”gy-ΟσR–fLHA₯lΝŝ+Π£”€1 ΞxΔuΔΉŒ£8{λUW-_~ω₯§ƒΟ°onίΊΖΚσΟ½PVJEJ*¦΄&Zy*]o�ΎΨ ΌΏΗ-$ν¬ŒQJqΗz J+ϊΉΏψ7ΖDΧ_�ފ»η©Λ ϊ‘+Ϊ­gy‡Ÿ―*ΪΌœ)©. ‚υuz/½tp,I/"₯iJ³,‘κuσΦ/œ8ΟGBœη#5”1ύΞ«―Φh·ςZOΰΎ—οκ‰Φ>M@»xΜ†/y ηbb»W­Β9ψ>φΐ^θ7«NΆ ήτsςω:¦Ώδl”·U0 T€F9κ?έΐΔMžηησN`ύi›θt‡JœΥgŽΝU€Φ‘PšIΫ-‚1Έ ΠfΛΌΝfΊ†τ h£θ3Π†1m@œΩ=ΡƐύν½ψύ›―1{FϊόFνΕέ;zYχ#PΙ Ž»³O– —aςW2 ]=/Ζ2–‚[|�ν#ΌόŠΛ°w�ώ MtŠI1ΣΣΣ«NxoΞΌr)Ψ€μŠγμ ”w€ˆ†hŸΧσ¨α{ο½HJQΛσΌ$€ΰN-Jιs ‘pPJAc QƒG‘++Π8-e ΡXί"κΧΟ»Nžz}+‚Ό.]½PwlfffδΑ‡ιχj—Βϊˆ΅udΟ=Α³]=qΗ±Žγ8γ8ωΨGo:νžwΟ,Iάηηη^Λ*―M.)₯Έ�š*₯ˆ6½NΛf~J)PJp€‰ΡšmˆΦ:ΦZ£ ύ³Ο�%ΣF—ή�Ύλϋάύζϊ –§ͺ‘ΆHMj{ZΎh-%N‰Υ»zΕ™ΤVR7[@/I†’$©4(IΣΠVα’RΞWSO]K\κτη‹»οz;([τaŒiƘΊκΚ+Δθh=+ά_…υν’νlw―Ί[€ιό]Όσ<'ο1KЌ‡Ύϋ(”'dπ^=°Χ;Ξ) wšΟDKm³5Ÿ·πάŽ|΅j±-mUŒ[…ηύv|rWωδΩ½Χ±Ž΄ΝU/ΜαUC«ͺg—Κʘ’2†9γtgžΎ±σz€°ρ΄^Ώx9Ή�} .  † OΎUΪblǏcΈ—.<Ήœβ�yΰYσίτN]+E­½΄w~‘’~ΌςΉ ’Γ±•Ψ&¬*π–ίΞPήξήλh7γ-#ό¦Jq‘iA0%H#IΒBΗκζό<Œsgτ=Λ¨ξNК­’ΊΆFcψξοέ;η’’ηy$„ RHbι΄›§2ξ£ˆ87qι(Š(ΙΐJ πώΤί7‹αχžr<_t8‰­ΧΙ+n4(ΎωβaΐΘ·nΏs0Οσjžηq–eTHyήΩ°«qŽ8ŠME2Žγμ'ίzΥβε—_:€g�œ΄³AΚ‡MΗZk�”"<\ψ|³ψξmζ„ή‹Έ€–ρ †a0v._)E�δO?ΟΛεrε]Χ^“Ίϋ£1ν Υ�Σ’ t¦p�θξ­RΠν.i$Ιξn@/Λς6+ ₯πΪΙχ°΅wώ»Nk ΅Μ οΛ‹i%B`’„Κ¨υjδ\ρˆηοίu>¦,έΆ\t½–'Zέ½\ P½ΊΧ^kBΔ�MͺΐΚ"Ψχ7 φΐή›θ…σyᬓχΟΫ` 'οΑλwΧ EΙΪ*€€Β*S ¦>³ˆxhΖ%)Η‚$z£h›Ε§ƒ*lU•—N-–΅6‘›Χ#­ΎIPΜc ( 6Οpγο·ί’Iϋ†\ Š‡`°Έ?oτŽβ&'ΪΊ|ΖhΛc°tE &΅ž8­�ΣCΟ©�νΓoχϋJΥ%oή2Ÿ«ΰΠί”ζθ)TvJμϊoΔΓ‹n]†έ“ω ‹dσ<»+‚�_ϋΎΰ±;}΄ϋ5‘ΦŠ )œ`j»Cγ[μOΓχήχΰΠΒΒB-ΟσR.¬PƒtЉ›₯«ΧJ>Ϊ;MœΗ9’j£7ΞaδΊΧΑΚΗݞy­.σ–yAl‘ˆͺ‘εΛZŸ™™~παGϊ„ε\.…€Ύ#{>·­ΨΥ‹βHΕq,β8n|μcMΆ‰οή/ΊΟ“Ιzεω^*£c₯ΣJQ₯Z’KfƒΧ ΞφΔϋBΎN ½ίΊ%ςγ™ ‹₯t²½Fͺ΅‰΄ΡD*ΙώΛύiιίύΫ�9ί½{·eiΘ%κΔY”έ‚˜Fy"μμy΅Φ”#ͺ“₯ˆ·WΈδ‹_ΊeηΑƒ/[κζEzνfφ­΅U\o$ω½d₯"V€:Ψ’y JVœWοφϊΖ>G”RPFΑ97QiΞyήίΧ—ΌγW/Ή}u-·φ�sΞI ^{@kf/8‰²=¬ζ΅ηΊ~N•š‰�„�Ut+Bτΐ^οθ΄IησŠΖΣ& �Qύϊ N¨ΑˆV5Μ�6κ7,cβ#EΪ¦―NŸΖΖΠ6‹ΏO8―Χ€γ¨Ό4³TΪ0εηυ΄nš§oθ#€ς(!ξ΄›!!ΰ#➷5·ρΣ}eΞ–šŒ1m@Π«in4π+vωμv©›ϋ7!„ΫθJΜ=/0{FϊΘ―_»‡ΑRϋ\€�£ /�yΥpζβTbΰν9&?Υ«,8pwʝ³ΠσŠEκN§ Υ ΘυΏ@ ™± —§fR|›νŸΡ’—Qλσ#”8 §νκ±jŠώ+Cπνοσ°νκέ3 „¬Ί�“Βv†6_WΟΝκΉΔ#Žcυ聓Ω?ωη�ο"Xε$€Γ8βί© YΖΦιδο9E{Η£I­Ύύφ;…Υά‰²lpW/˜ΨΆtωe—zˊPx)w…N_μ,>υ/ΖR½@o%ΐkνENΩ„C)%„�Ά?»ό,Ψ­‰iΪT˜Άr’΅1Ζ8˜Π*ΒνΗX!:³Qk$dRJΌ2Ž£ό§ήuΝr΅R)ΞμfX_Z€°³§šMBάΟ χνN5P?«G5•`e?K^ο¦5ϋ쁽Ντ:Νηy!ΫΡKΰΘmn>OF0†9“tή—aη§–0pΥΌwEΪ¦W<Ϋ(Ϊf1Ή(*qV—2QyρΤbIi jηΏ¬ ΘFe8$xŒ0J §œRPb�οτΉ‘ά ‹ˆΞιΐmΜ- gǍ§I’πoœΊd jΣκΧlΑ }ΐgΩμŠ€BJ0Ÿ{ς%rύ�Ρθ�ΡώšKŽS,Ώ,pθotͺνΊ££7dΨώΡ%·Ι‡@oΞ­ΣΪ}·ΪŠΑ³ΣθΥλmuc|λ9n$IΨEΩl]=�!ω·Οiγ5ΪΎr¨ ŽΦ‚@°Wω {8W5`ψžϋZXXθs]=.„ b“Νκ……+ΞbηF8c2ΟEκŠ§0νNίΡσ]§°»°ιA^‡Ρ€0ΎŒ™™™zθ‘ΗZχNn|Wο|μ¦y΄Ίz³..ωnqhΥS~ώ…KJi�”¦vξK―³Κ*W ”Ξ(c†1 $ή{ί ±ϋ·„Ζ)ϊNŽφμ‹ ›Τw:,h’n»€οΖ%BΠ?ϋϋΫ/ώωgŸΎΡΐhm‰!@u§Ϋ₯…œ₯‹ž5βr§IS?ρ䎯γ›M{…F’\ Ww„Ψˆ+ ζ:^”RC)!0φ{j˜5΅9 ₯€qs‘&xβ�™xehγŠ"Ϊh’΅SΏ4†(­ŒvJ˜Κ‰ιXΓσ•kd½Φ‡ί‘†sfxΔ5Έΰœ'ϊΩ,’5“ο;{a—l½Σvƒuκ% ‹ΟΩΧI§ι η5λν@QAW/ŠΡα{χΐ^οθ„‹σyΓ°έ<ίΡΗΒ³v>O.:[C[΄Ν])¦>½ˆxhΦ%(^„εu—@‡U“υ¦mvΔXArќΧ{ΩΞλEVΞ_₯M‹’³Ξ0Ε=ΫΡ#ˆ(gΤD”š˜SQͺ9#†’©ύ άη±±ZΌα*³ Κ‹hOkυsŒ ΪγDQ Œ�ΜZŠ™!�iAU³;eψ„Φ–α>^PƐ߻η‡μ―εz›TΞ?έΐ‘۝¦B&Ω ±„΄ΝΩ Ί ±47ύ.λ4ά\››y½^Χ–šaA|ϋΌΝϊΨ/΄UΥ rψπ‘P“b₯%±’� „ Ο'G-`Ϊ”=o‹+jU8ϋ―l έί°ίu‡†ξΎϋž<•,Λ")δ¦μκ?[䑆2¦(₯ΉήξΓο3ξυ$κ[‘“‡]ΠnaΘŘαΫΏύA!ςͺΘE,…`RzυT}žz«wυ�{ο{όΉ [rŸoHα,¨š>\RΪ©:*ΗΩ@q–θqΞqnΑjΘ¦ŒiJ©v 8Œ1f#!nΏ ­ΞL{Bߜ‹3†+mO΄Φ<©_ ΰσ±^) &9cŒ�[o½Ν|όέΪl‡Τ Q«&Zή^Tβ ΊKΎαs(Oίά`ςπα#;Ύ|λW­aΊzi Ζ²@o%ΐc~]ΉΒ5Œq0F5£Μ0Ξ ₯T3·Ύš'!ŠRͺ %͘† Τ†m¨Φšjc˜1š*₯‰₯ΟZa#c UR₯QJS₯€ΡJ©œ}ΧYγψ°{Ι³N«Δ›οΎδ’εΙΙIίΥ› >9Φ‘m†•ιPϊΪAe˜„’BΆΩΩ#�Ν’ζh•i³aΨlq‘φ.>Πλ4ŸWGKˆΕΝηέ7„wΧ`D ΖD€!V ηύι&nZη FΫ<Ψ+zΦ›―(mb₯5ΣΖPνι‹—�€; WζΜ”9ΣeNUΜ©ΰ” N‰"„((Ϋ0Ϋ°{οζ@ @1TCϋj\·Σ~6ZƒhXΣy₯ QΖ}ΥψI­‘νλπ�τm$ΰ“Ϊ6|χ@dŒ!ϋŽΞΡ[φ½Jυ’S GΏΖ\΄HΑͺ{~+EyϋBPΥ/&|ΦιZjœ €φΥQKΥέ@Κ•#ŒjŒaAρ†£eΑp±)θΥΑu ΣΆτΓy=OαLΠSφ`ο±ΗžZ\\μB”…”<y³«·9…[s/„Cνό–2·ΡNΫl`γ™θ»zυF’ �`οήΎΌyοZκ©λ©ΐΩ₯«η)Ϋ hωf†Ξj£Ρ¨œ|ύT¬•ζJ)ͺ­Βγ†ΝS΅%§œ#Ž"«ZŠuΗ2ŠΈδŒ J©"”¨fςM6Sjš"ZHθ©ΦšΊdή'υMΟO Œ[I½’Š(¦aΌYα{Ή‘ Ο�μ4j͈RΪ[Ÿ?»εaσο΅»9= φBOΟb%| Y°nΌΘ€$Ωώ'φω±ΉΉω‘$±†ιYš1o―°ή@o%EΣͺώ2 ξ ηœ3Νӌsq�)c’s¦(‘’q¦!‚*)΅'!DV«955©’-©Η:ΪtΒΠιιΓ¬ΡHΈ1†k£™±σ‘L+͌1\kΝ”ΦL)Ε΄R\HI•”T*E₯TTJi”’DIm¬gβω‚Ύ6Ϊ|΄¬΅Š΅Φ\+eP›άnΨΊ �έΔq¬Λ•²ͺ”Λ".•�8ŠΞYJ)MAHEˆ%XlXi‚€;Όm}PŒi&τZk¦΅fΆP¨™±σπνI½RΜΩ P)€” ”k†~aΌδBαΐ@k J)1”š»?Jώ‡_z7κεΈ:¨vfκYμuzΕYTϊΪ`Ηw|gόθ±cCišV“4Σφ λ τμœ'q3[πp]-Ε±ζŒIqρH0Ξg,§”eW^qyΎλ’©lt΄žOMNζΥJ%›šš χ‘Ά±¬΄ΈββS§fβ™ΩΩ¨ΡH’ιιΓΡΜ©SρkΣΣρk―MGΖθ’T*VREBŠH Ι₯R\δ9un"…„€ Z«σ}Α|΄αθͺ(ŠrΞ£Ζ;ήq΅Wίφ4ωE΅ ΪkΧΚH{ί’ι7K¬ή@yΒί‹ΖΉήκ‘=°χz«Νηm0ŽτxGnλGr¨lύσ ±|v.Q™J1υ™₯€Άι“η‹AΫμhΠN㬨Όtj‘€Œαʁ=©oDI„`„ ’%ΫΥγ}₯τΎίώπ’Γōf­¦­ηz9΄ΣζόςΜb΄”ΛΡρ₯”_L£γ‹ ?Ύ˜F{ΞFژHDJλXZ°εR3‘4Λ•fΉRD(CsHβ;-λ²ΏŒι��t‚Σ½1?šgόΩ[ω�Οή›ΙuW‚η.oΛΚڐ «@DHiΤΒM’ΔMjI$GnΛ–»GφΘǞΡX²=Σ1έሞ˜˜/혎·{’m—··)JnK\,’ ©„D WTa#( –\ή»ΛόΈχfή|•…­ςe-@F€!ˆBζ»οέϋοœοœχEϋ)†ή₯pέO¦`Ι’½7OΪχ)t:nf—xŸϊf-�»ΟS‘T:Ή!½MBθ0J°²*e2₯οَΌυ^+NœωβΘ„W«:3©Φ�6«GhŠς-5o)ΰπσ/Ό8|ςδ;­y/Ηκ™0β5½#· ‘΄&Π 33GΩ�©I­d�°�λˆΝλΆή%΄Y½±Z½nLu�,Ξ²,BP)V&ίlwσ/jV―ŠΆl[xϋaKφππαDJJ)™”Šj+G,μΰ’΄Σ©5 UΗΩ?�gΏΎψώχέζΟε›SE9Ζt=3j΅Z8=s4` ϊiV«Χƒ#G¦ωιΣΥπΤ©Σ]‹ϊL.²Œ₯YF²L)‚)%AO€{³oJ©A©“¨‚(­τΏϋς ς/�Ρlά‹@ιߜeΉ‚ڏ‰mͺO53sτšΏϋϊ›FΉΡhDΝf“§iF3‘uδθυδΉ&‡ηL[¨£Β ‚ ΙoάpύφϊέwίY›˜―t{lΓϋ5́ݼλcήυ<ŽU’±±J Ϊ{λnwΕ03°₯C‡'ί�ώK₯·ή>’)β,‘Θ²0M3nσQi–e$K3" •"ΐ₯5–³ƒ@AΠόΰέw.”’dΦ{ ή>°FςκˆWgX₯¬3g Fό&DG}}μ]zšΟk³yf>oG�ͺlησΈΉί¬+ήΨ‡κ6$έΕ*86ο4VGΆΉ„TC—0υ“ δθ\=Κ„©[ GZ²ΓžλΊI‰Υ!§2Xφž­#‹0Μ§?—3η*ͺ  Η7ξπ7η`GeΠήΑξ63δ�_tr‘ο?6›Ό||6ώΑΡΩdf�g\E™RQS¨0”7„b\šJ…T*B”‚TφΛXe/J‰ψ€Φ  D( ‘•Š�ύΚύΛοΆ=,ν΅=λyΌΕz—sŸζY½@Ίkj�Θ`Q„Rm\τLXj―fφμ]kŒ”²�[Ε¦§g‚‰‰q Yτ­Ζ·ίo�Ϊ[΄ζΠΚΞμ9e5“ L€% έά͘eδΡΗΎΪΚΥs³zk›ΥkΖθΓΜQ�V«ƒ.?χά ―όψΗ₯(Iš¦aš¦AšeAΚSϊ² —ΪXƘ% ^ΏσΞΫ«η²ω Š’Ι\\Φ1{NιB%@2Δ[ g7ΥάUηU ·d†ΝΝη9#–-0σyΓ8ω΄ˆ‘3·$Λΐ3\χιE+Ϋτησό.iίd›xBς`/y£ΊK­C‘–†©χΌͺ%mNFFuΘ¨ŒmΎoΌ2o―ߌ}ŸDΫϊΫ{Em4έ6gŸςˆ―)Η₯§Ά•œΪVP~£Ί0ψ7―xζSεΩzZj š„BE΅Œp–IF ‘M Š«SRΰΣ0σ‚RØhJ”9|Fΰ™ϊν곁Χμζ6xΗ<Χsωεά§Ύ VkhšP"쐻½ΦqΒζΆΩ=ͺ•b΅zέ­]^ΖΉΪμž_Ή}(‚l€fž«¨uαdΦ…σ] ―y ,«wr`½±zήg³ΰA­4…ξ˜c\kŒμJ€‚ΛsΉzc΅z}μO>5μ€z–eΜ°z+3ΥΙ³z<ΰ-Vοϋουgυόυ,ΗΠΈ±†δΘτ‘¨5―§4Yι,α…>»όΜ“adΫΆn]›΅gν˜8ŽSh³{E³{fΈ?KJ₯RιΦ=»nέ³ΫυC?xyωλ_²|ςδ;₯fš&aDΝ4 š&£”�4MA)!Y&ϊ"λτŸEB%ψϊKR�Τˆ΄σz~1v)¦—ΛΤΫ ΰš―~ν?½ώϊΓiš–ΝfΠlΰ"mC£ί―ΣΜ‡Ή(‘ŠγH†a˜FqΤ Γ°φξwέ²πΰχΝίΊg·oJβLΙΨ«y{mΓk`Jœί₯�oΈύ£Se↲ΖC•Jeδ�;+ΓwέyΗΘττΜπO>]ώΞwΏW6y©YΤl6ƒfšςf³I Ϋ' %iεε�αωŒYΆlΩΌ85ΉΣ�ώώ¬~?ό$όgŠ@6̟w½ZNœ ζLΈρ|ُW™½+δω–?ΏVΒ�ωΌΝ ŽώΝζφ'Π2΄n›&Π8o`β³ G\6‘‹U8Υ—mv+(—€½Χͺσ‘R:} S7ζ,ŒP*F·^;2gλcφπ>Ξœ'βdœώ}αƒsφgͺ"―90`7κ‘•ςπ―έσ�‘_Ό=ως+3#ωΓ#ΓΥΕf9`€P2*8Ι@h3Λη’z ψάό^Λ Ζ:«ς·‘{vlq�ʚν\.ΰ�7»& K% ’P’¬λ"zτZΏ!mΗ;0θŽΧgυΦBροξ»vΠ8B+gΠ’MΣI8Ycχϊ¬^lAΓπ—}|Πεκ­VOλ%AΤ€Νξ΅φ-ΆΖΦνrŒ>«η8+6?ύτ3£sηζΛišΖ™=‰ΚΘ³zVΆ%Β0l •ξΈγ³Ψσgυd�0”ͺΥj²0Ώ )™”’J) So?3�ƒ0TA€;nΌΑ)ANΨζΰ΄=?ζΡ™±…U<3|ΣΘ;/\Q?ϊΐ}χŽχsg§¦vΞΪ&Η΄™νs^“ήI7έΉ˜y΅œΒRk₯Ζ[~S€εκ‰Π«ΗΚvwσ»›&&Ζ7ύόη~vτ‘‡?1ϊΨWΎ6όΜ³Ο ς€Ν 8›Ν”5Y“f)!‚!Lgπ<€Ο(} γ›pτ―Q;*47#1υMhΰšOΜƒΕNΆιδ‡r1μΠΛ™δYΜ@ό�±ΩΦΌ^‹Υ+(L εˆ₯)ζŒθ€°·cSΩΩώ;SΗμυ£K›ίlς›³_hΆ4ψhΛ0œΑΒ€‘rlώμm7ΒΟυ› IDATŽ}κέγ›λ™•§_?9B  1�5( Ν–jΟπυ|ύΫ?—ΨZ‘(hςά[§άwΤή¦θΟ¬„y>oXͺqX5`―Χ%ϋr A)΅AC7 §k"Dhœΰ€’Ά†°‘ ΤHΖnl υ›Hχ=�Βΰ‰uΘκωλ§Ϋ&DCΣ3Υ*&}ω&ΑϊeχΊ±z›,σ1Άοω†Σ4-₯iΪ2Υι«G¨ff^Ι²zAνΑξ?WJw~9VΟWRψs€ €δΠ‘Χb₯ΜΌž’&f ¨zʟΉb”q¦9c’sήΌώ†λνωΰ”4�ΙΊ+τ°ΚgFΎIθ³8.j£rλžέ›oέ³{μ…ΎUω³�χΟGΞ̞-SJJihF πυ£ic]qέ~�N66Λ­ρV�œΘΫΪϋΗΥUŽ΅ή `σγmtφμΉr³ΡŒΝ&OΣΤJeO=τΒ0ΠQ©8ŽeΗiGυ(ŠηβSŸ}θ“χ§έΨΘ¬ς±4KP 3@wzθr=€ΞŒWΪεώˆΌZ’μ>•J₯²ωη?χ³c<ό‰ΚόџŒΌςΚ«ƒœση쐉K’ž΅w‰Γƒ.s6Eιz§tιφΦμλ*Ψ+ζ ΝηΎΈωΌMhΟζm0֞ΟΛB@3kρš!™hβΊO/"ήκΗ*ψΓαkEΆΉƒΠ!Ιy£Ί(P)Ν„V­ΐπΒΜY켞}kF©€”d{―qςN#Ÿο¬)τΗ Š,³)£Λν6�@η€σ"€ϊƒ“Ϋ�›6•ΥϋτBΓ†²ƒ·2 •cQ{χυΪ³{ζgΫ°w’ ƒΛφ?άuύζ–™z;΅lXj₯²IMOΟΨg³χλΉh82=CφήΊg­Ώl›`ΘF„ϊ1 όl½&FnσεΆ%ƒόΡ+―–|c^δκω@ά7Ρ)Ϊ(Β­Yϊό­ΧΧyY½ΗΎς5Ηκ…YjΦo₯ςΫ\>ζœ«0 ‚ϊGx`Ξ:οΉ\=7›δX=ΆδΎβ#oOGRJ.•2ωqΊΈ0u7―G@4c&Œs&£Mλ’˜Ο^<_ΐZ83ςE}Ι^ϋ3^ƒsq||ΌρλΏφΕμ·~ϋwυ‘C―Ή9¬HiE­œ—ΈlΌ>5pτWΏyόγ Σ-DœzM«–Γ,€±ΗΏϊ΅Ρ΄™4›Ν0m¦,•RΩY½ήΘ7‹‘*[FO”’€Q*% ;oΊιΜ—Ύτ…wJIrΜ½£^“ώœWsψ O‘30^/sΎ]°ίΪε~AξήH½šbΡ^Γ9―~Ζ―-ξΏο# »¦&·ό»ίϋύ±cΗN sΞ₯ „rB ΰSͺ+Γ―‚ œρΖ­{v/ΗκbΜ’kvέb‡ ΄cφrώ?„ΊΨΉΐ’ΑHm#&–Ζa\{WΠΛ₯z@Ο€=Ω0²ΝΉΆησ@L‘5ϊώΆ~r,>ƒΆ˚’m^ΰ@ΚwγΧΟΜGB© oζ,θΜΨγ„(NIΆslΠ=¨‹]ΐ²D�³RΘytδ€σ€tΝ«5oγ‘;*ƒψ•ήL�Υ“?’Ri*”&R)Ό�ˆrͺΖ²{Φ”Σš΅h(­‰ ‹^>vΦuc΄Mz-oμ–J  !E?ϋk4ψχ+JJ¨½Κdμim¬₯A «WΉΛŸ!aμ=ϊθγ&@=ΛzΒκΉυi‡·?Ά“Ι5§ε›΅¬1pΎ’3θ’Y½•Θo;X=›§ Β ¬έwί‡Οε€F^ώHΡiΞ’ˆ<*εΜYιE‘~ΑοaŠXm²νΫ'šή^λr&»E¬Ε3#υ�kνyiί €YJωkΏϊ+δ7σߐƒ‡3)%UR…RΆ½s0Ύ0­5™žž‘γ΄Kc0ί΄πšcΥjuτ›Οξ+§i§Y€-S‘^ΞιkΖΒt*ŽcYJ’f©”,μάySυK_όΒρR’LΓΜvE[φ›Ο=φ›τ�;ΗtξίΌ”­½½@w‚Δώ› mσ'K\τξshΟΏ_·ψ/ώηήψσ€―Ώώ†΄ϋJάjήj­E ,‘Επ3Ζt’sž^sΝ–Ϊž=οY@§)M—οΔ}©/Ώyξ9–jhjά¨½C‘ušSΧ(νpϋΑ"–šΚ\eφ�ηƒœ|PΊ;hΠیtv¦�¬ŒΪtΘΐςP| ΕΆGκžlΣ1zk]Ά‰.ΧΐŸH^―.ΔRi.΄¦Κ°M…š³†Ω# šQ’%’’νΨTnxਛIHΏ³ΞέGz™NΫ€©·Iϋ–Μ~‡™>0Ή581_ώύ·^γBi&”’™ΤTRM€&=gχ\αl˜= φ”¦B)vΈ:ω{μυ e’°ήAC·Ωဋo†ΠΚ9qJ{Έ €51r›+l…όΡ«―–²,‹šiΊbV― πH‡…ΉΡζ­ΰ2 }ήGRίY=ΈΖkΛΪB“)ΦψθχΟηX½ω\‘δφ€%ΘττL’΅ ₯’LΙVφ)�‰ΣHσ(£š1¦8cςϊνΫ›ήYQσžΉŠMΦσδE}3wfH(% �'ΏπsΑΏψ�%΄y|,Θ2bΒΧ1yx€Π†‹s5@:L'&ΖσsΟy₯KθΥX›lzόρΏΞ²¬”ω&Ν²Œ(%‰R²‡ςM―†‘ŽβH™½xqηM7±@ο€7-Ψ;fοϋsήύ³œ\³g5ά2?Gηξ»ηwδΣ¦π[τΚ₯$i|ι‹_H�χ�£Ϊ�Γ)B,6‚΄ΦΝTK»y3J5c\›“Ό|ΜΜλ)₯©TŠH₯ 7g %ΰ„°GIΊ{Ϋh>°t΅Ί³³9ϋ΄Ξuη”·QϋΓλ­YΙΟήvcό·E‡OΟ‡©€,•4Θ”�L‘bΨ=λΑ©5ˆTZK­©ΤšI₯ƒύΗg£έΫF})'σ_aϊUyΦΦώδΟvVHΪNœΚt& U <ΕΠ»›G]3Ά¨|τΡΗΜ¬^!zΒꡃˆ)ˆ&”bλΦ-8~ό$΄VDJS“ ψ¦L―χ³¨ο¬€VL)Ӝš[Vσ vηp�°Όσžτ ΞΌss|θπαXJH)™4¬ž S/ʜΕΝ1PJ5c†ΩΫ55Ωτ »nyo«vώ.WΠw‚ξΌΉ·ςκΐd||ΌτβΏ,ύΕ_ό΅aνӌfB0₯$”’Eά³Ώχ2/i΅z†{χ²φœŒkfΈ:kS΅zfδΩ}/”[χ·Tx¨^Ι7cŽ0•q݌꣣#η¬tsΪ½·aœ[£η7–}Ιζ…Ξ�Bξ{-όšb9ΠWχκ₯f)IΔεΏSΏσ»�V�π‡―΄Œ΅ζZƒ!΄Φά\#F‘2{―έyΗϋ} η\�ιS€Ί[Ξ΅—G¨  ³ηκ'γd„ –’t½cω})·,°χœόΈ ΩzRHu“mŽΑ˜°\kίΧΰΜ £xσ?#–Άl3Επξ:vώχgo;CϋO{]‘wΠp]bσΊo,Ώ{�bWηCΥrβ΄³z~rJZoΝ(Q”±gΫ¨Ϋΐό|7ώK7/yƒvotš8{κš=Pͺ0’‘cφ>:υΉχν8qV!£: Τ^RΤs­5€ΦJ©4S|1•~Ψk>{�ˆ£wMM�Κz­1А—pϊ1 joΖ‰64Φ‘Wξͺy ‘&ΰΊό£W^-₯Y¦YoX½6ΠkI~tϊσ�Ν?ΡAhΞΉfŒΩ"ΌXπ^«ΧΧϋ±΄J³zmV/ΰ\Ή<­{>xΧB₯RqΞY―ΐσe[Ύ •“p&ΣΣG#)—�Μλi₯ZΑβφY bζ»5₯LRJ³ρρλ^ΡΫ@§lkνήKΟ ις�`uŽΤ']­q�½ž\ ‚ Β@qΞ4!T“’kόσš\ΰ 4žža^c{χ‰Οκ9/„Q£υkCY––²4 E–1!«ΧSτ8g:ΰŠβHΖqœFQ<�ΛΏτωͺΡsΡ3φΪΞ’-ωλ¨7rηϊZ©)ΌθΥΗa"ͺήπ:€ι_όό?=vΣM7žI’x!‰γfΗ2ŠB†ΓP‘ ”ƒ ‚ ρΎχξ]¨T*gΡf9έ5ι0f)ψZψ΅©©C'Μύeb‡Ό¬½VyX©x«φς{ٚή�‚½•=’ΫxΡΆ�u o€Ν8ϊ7Γ8φεZΆΩ&@xΫYΐφ�ͺ » Ÿ#v³pΑ­g½.‚oCΌΦ€^όΆ†νχŸ₯�‘μ£9 £ΤΘ ¬ηδΨ`κΪυ\WF­‡{o™ ΪI\½εΪϊΰδΆ³Γ₯ZΘiP*9%šR’AϞ οW 䉑s‚(­ΩλΥyΧρo�ΏλΦΏ5κV;v’“90€Ε·bhΙmGSš=‰₯ΨΩΐΐ�£«νί)?ϋάΎ!Dœ₯bΕ¬^[²iμΛFEjttD~βγ“##Γʘ}0͘Ή]\ΡΥΗk·žΞ£eY½C‡_+„Υsf „Pp,‚ΪC}|œ~ž–Κ5"|©wιΘτL$•δJ)ͺ€U‚nΞ͘ΝcLRΚ�‰‰q�¬X3JžΒkz:ΈSή)•J³ϋΨƒ A4Ξ₯iΆΠ–ΌΊ(ΐηζ1­;2=νŸξW(”=°7ςύΌ\Ξ2eBπΤJPM#ͺw¦,f.•λ0 T†YG΅}μsžλζŒύυt�vk΅vΛέ#~S #»v±#Žˆx³”$3_ϊβNμΌι¦Ω$I“8nΔq,’(QΚ(ŠdEYEΝ0χξέΣmn7E’�ςMΟΆ O6˜•p�ΞGΪ=¦AΈ™aΨQG{ζΥίΛΦΌηU°w‡jξ`ΝP›`$›m 'λcxνίaφ[ ΄ μeΟn�cηζQΉλ:ΓZs“›Ρs�Ν,ίZ£χSАΌqf!–Ϊ†©χќ…€β$œΩM•²;Έύkκo6kυΊvέ ½‚CzΉ9 ψͺξΐΉηΖ-‹₯Ν€QΙΥΜΊ”:ιRΟRtϋWπ₯5YhŠΥʞkη"xF }YώAΣ7‘S0t†(aώΗQ{V „d ,Ε–ϋΠKνW:uκτΐ7Ÿy.ι%«g:εΤtΚMGXΖQ(κΣ?™·άΌKA γšӌ~¬έͺ�[>wWVο+Γκu Q’¨yΟ‡ξ^¨T*nςΟ²<3ΖrgF©V«%oΏ}$T�š³hecŠ«§(₯-sƘ’”Š›wM₯ήyαwρϋεΎYdAο«BζΌB|φ�;n_`œ5g‚Q¦«^δ£§΅†vξŸJΣZ­ζΗ ωMBχηώ¨Μθ‹/~{xnn�”eY˜eB%ΫχvOY½€λ0Šd…Ν­[―Yxθ“w±XΗΡΞΡ;‡.&zkΈvσY5•»GΛwΚ|o—’dζs�υgOŽŽŽΜ&₯d±T*5JI’ΖQ܌£¨GQ#ŠΒΕ‘‘ΑΉ;n�€3μΆ €σγ νϋI68 νΜΊ&Œ…5f‘kbpW-φϊi.³βΧΥ™½K+ž€ξσy~ΥΝθUΠ8>Œ7�―ˆ;£D•(O61ρΑ’sφ8eυ»AkΥmσB“_X&J/;)₯gΞ"mV’.nΣς"ˆfƳxσzλ*ε|›³=Δά̞λΖ97­³Ξ=8΅uρ�yω­&7&5ŠQB))�zuΉsNύ�5θkΥyŠώdΟyδ"TkγξΟh’7Xνη’ε €²{―Π�ή*AΈΐΐM ΫΑtΕν€�—ύΚ€”²g¬žgv 9η: B„Μ~βS „3< J*’#fvθŠX·Λυηeυ^yεΗ…±z”2Ν�ƒ0a¦AΤ~θγg= η$œNιΐ’Ο:·δΕ33Ηb₯d¨”βJJ"₯‚v^ΏΊΈ@uF©1g1NœbΧΝS~xςΊrή»ΐΛ7pq€ΟEK̍U―Ω²ΉY―73¬Ίnιe 0iΡVŸkςY5΅ο<³ηΟZΉ¦Υ½Ο‡χ=�b9Ν²8MS.„ RH’μ V/>―ˍ3*„@’°ώ©G:WJ’*:sτΞ‘Λ<Ϊzh$ηfϊςy““p|όΊΰ—ιστ·~ϋw!dˆQH))cLEQ”FQtξΑξsRa'kG�bKς.�ά«έC4OxNœ>GΪΈpRγΒIhε]‹ΠσλσuΡόΉ φ. θu JB{Fo³ύ΅‚³ίΒ±ΗbΘE« AX†-6°εώE΄Ωτ|-σZuΫΌΠ΅ι&ΙI^«ΞG™�L*E₯RVήW 9‹Ωœ5#6L]ΕAzM9v�Wtψ›ΝΊ4γσŸ/Ώprƒ9 7U9₯£D0J45έEBιωŒ�γ΄[gcΔΣ–€#—is₯�ατθ »‚ώιΆux½žο$�;δ³=ώ¬ή'€Unε+�n)Fί[χφ @|κΤι�7Ÿ}..‚ΥsŒ ŸϊΤ#Νςΐ@€qΞΐN…”dDB (8Ϋλvππα΅²n+YηUaυΒ 4œaΨΌηCwΟΫωœΣ^ρλ³ψΓ>«7 tππαDJJ)™”Šj†]Tc°₯p Œ2M“Œ±lϋΔx³Ksp]*ArηEήΰ+Ÿ=[»νΆ½Νιι£ΒDP0Η φό9heiD)₯Νl¦¦R*zπΠaΎkj’{:λφͺΥκπ«?>PBD™mD )!₯κa�ž‘*sf€Κ<0Qwήq»/ƒuŒžo€'Χρ}βώ―X¦yΘ°©Ιϊ~ώη²?ψΓ?©3J!%e”© aΞ~δ#χœ@;XήωOt\Ÿ>Υ¦ΎΏ†eŒ5ogμi―ŽpΖ,άΔ‘ΕΧΦΑb?šΒ{λ’~Ό φ.Ύsš—nϊAιθm0Šwž*γδŽ˜ΏG%x9ΓφŸ­aΰF'™¨z„λv¬I·ΝK(8–Ψ»/4³ψΨ\=RZJ‘»PΨ&ΥT·‘ ŠS*¦*ƒΎ«Z½Kα±ξoUtJ:λθk쬔›?8:k˜=SΰhsΉH!s“:ΧY#€Πμ9ΏΣή ₯­T*Š€Έ�jnjb―ƒΆM ―˜™9κšBΎ‹νh+s]πž•7Ώ(ΓΝλύΎ‰\ΠZΫF”DPibδ6ΏRPzτ±ΗKRΘ8ΛΟ²ŒŠL\6XXΒκ\‡A Γ0ΜF†‡š}β8yO@#Œ1Ξ(γ”Q =+8»­›eφVmέV°Ξk…ΥΛΒ0¬=όΠΗ}ΕΚt³΄βaΠ™—ΦžΧ{ϋH$• „”T*I”vE{QN¬ξ»MΥ”RI)M'Œ9Λ†Q‚tyε_K²:6VI)£’Rͺ¦”e@Λ™[#ΧΥΊcƊyυͺ?―7τό‹ί. !’,ΛΈΘ²½?υrV3pp.€ϊΰ]NήxΖz‹XͺΘZ—―.€Ο)gΡι¦-οΈύ sψGZbR2J‰ ‚ ρɏμL)IŽ’νA᳞ύ2f:η=Ϋ{‹o§ΟμΉ υ–9 M1t³«₯°42b]μWΑήΕ½Ό„`m3ΓθΙϊŽώuηφ‡&¨¨@2‘βΖΊ–87"ΗζΉ bΛD¬# ΧΝρ―δζυ„�,SΚJ8m^RχA7§X gΑ§D1J²=׎vfθdN7ΰ“ΰsNr•Αμ₯cg%!&τAܘ|}*^”WΔ(J©¦”ΊY Z―DCS­΅_œψΞ£tžI_…Pvs―f^Ϊ³Π Χ<ΰ7C(€ψτιΣΙ3Οξ‹3‘…B.…€�k~ω!άmχFΞΉ ΒP†AΠ|θ‘OΤάΰ~Β ˜)Ό5τΒ•°n—³Ξk…ΥkΌ�}·ΉY=ΧΘΜ³ΘάηΞη�&G¦g’ΦΌž�€W&η+ζ 'ƒΛΧΫ²esV©Tόl½ ‘Ιρώyα泚γ)ήώΩ‘’(4ο²½Φݘ™ΐ»W Ύτ�ΛBˆHd’ )¨”²ΥθUΩδ/2Ν(UŒ3Α9oάyΗνn\bKΣϋ dϊψ€ύ~ώž¨ν½³xΗν¨NξΌiΰ‰'�ž=zLίΊww㾏|ΨΖE;g³pV―Λ9θϋI˜5;z{^ڟΧγ€5―7ίμ­›Zύ*Ψ[δKƒ�Πρ€ž™ΟKg‡qδOPŸ ΫωyD`μž&Ά=΄€NΧ«ΣήQΓ:’m^DaΩ1±�ψΩΔ8q*¦΄¦JkRδIΩz² Ρ”Ε(•Œ’lχΆ'Oσσ’| ^―ΧM9'Νρ΅\˜�Ψ: J`Ί΄„lΈGγfm !Δά @KΎΪϋΒDiΥ2ΠΠyΫπ�{ηƒa3BΙ0{½$΅g?jεGD)E΅�ΞT ΄…,Eqy‚Λβͺ.{Χ €ͺΟ‡fŸ�Ϊ€=&pΝώ<+-ΦjΙwΏ�ƒX!Έ‚Hyωvζ™lŒ!…‘ ƒ°ω‘¨Άyσ˜³ωNI;Χ�73—η[·Z­”J₯Υ\·K-bhn«WyβΙΏΙ²¬o¬ή»ί}ΛΒΤδΞεX½,”Ί9Z'Σ3Gc₯ΜΌž’&L½¨zΚέK”0Γ¨en(ΛΆoŸh SφŸW‚l€sCyίM•Κ&me�)ξΌ°Ρ€ε cfΧ–δ{{šΟ—z­$₯ˆ€\ I₯”Δ½¨³/”jΞΈβŒg·νέλcη< Χ/Σ‘Υ|ώ μw^΄Ο|lŸkΗψε,;²υϊTλζc�Œ§DγD`GόΨm{ ͺ€>tKέzέbΠΦΕλ*ΨλτΊ± ζ€ž3bΔΜJ U7Ÿ—Œ§�©:βmNΟνμxf9?ΰΉήd›έ ΛΌŒs֜EΪ0u©4‘ž1 Ψ «gΒΤΉ Sί6˜4ΛQΰf/θΜzZΧ’œ‹}ζ;’ΦnΦ—οK–~¦~,ŽΝLwMMf”PA Q„RM)±SΨ†ήλ S ­Ή€ΦژQ₯›žž &&Ζ}Ϋp–E;ω.‡NςT†¬'FΒ© Bnr³z�c9 τΔO ,.,$BˆPd†Υ“R]6«Χatp Β Β ώŸzΨ½sφZE„A(Q„žλŽ—Y·™£Α�©ΙΥZ·Km²ωgΤΪFacΥjuτΩηžl6›}œΥϋΔΕ°zέ\ύZ`οΘτL€” ””L*i#ta –Β½ IDATaκ`ΐμT1Ξ$c4šάYχΐ^7%6kγνŸ ol¬βΐ–Ά³Ξϊ[@»ψ Bˆ’”¨‰ρλ|κξOxι₯—!e(„`B �K §ί`²_M•”ΡtΧΤ€›αZμb6ϋΫν^ρŸ遺ν=0?ΏPB΄BŠW2 “g„xΐ-#Τyψ“ΎCρ’½GRd-Yγ3ΦΰΊ]κYε››8ϊ €J΅Z}ζΉ}e7«'2AΕ ’2–[ΓK`υ| —·πe�ρ‘ιιP)gΞ’H/¬σ/ψl˜Ίm eΧoŸpΝΑεςn7β«c]NŸ�ΆςPuΎ²› $„C_’ο;ΈΆ<¦§g’…ωŎωNσχ^/d@(Υ„P ‚ll¬βg0¦Ήfΐ†}εŸί`X*{χ�ΏQ�Οz+Ομ™Ζ’lΔHg@1kΠβ°‘cυ$kbθ–E»—ΉΩLΏ~\WŸ+μ]ΐqsΔz†ΡKg‡pδԏ†θI°Ÿo ήΆ€v~žΛΞ›υn’uηΰs @o Ψ{ωΨl€”€�Ljm" ΦZp‘mμ‚d„d;6•—³ΠήΘ3ιά|Ϋφ€ΰβ΅5;i1B £`©¬΄G°ΧEτέεA‘m)`χŽppξqnLwZ†Ί€Χ°jΗ-,Ύ£~”CkΥbυ�ϋ΄Ο`PΙb­VώϊןBΔiš΅βΤeΪ™wc„’0Q5ίχήΫζΗΖ*³v―δŒAXG}±�‡ΛΞwφξ~1�{žiJˆς€¨Ο<ϊζ=πyϋ¨―Κη’ϊηόjȟ—u‰GσD ­ΜΜ^k{rAκ–Υ#ΌŽM·ϋγ]Mx�‚½υςς`%Dηόƒv7@―q|oόϋ²«V*PžL±ύ³u°Δ±8Ι¦ I_πΊΎΞw]kώsΕeWIΞao^―Εκ‘ΐ0u+a”€jΐ%ιξm­Ψ…Ό„s#Jrό5q!έ|–B›?ΧθΜλέ!Πω!`χφη/dψυ τε;jΣ¦QΉΈΈΨΞΪ#(€έsχ΅ΠτL΅J€I?§ x9`tΖ-œύ^`Y Ψ}o†pΤ͜€0*†'ž|ΊγEmΐ{:ο�^«Ξs­5ΥFN[dΥΠ:Έ‘!Š"―)Ηn�!?‡ΡˏβK:Ϋ›1&ηLxΕ±�CrΟH?^~–P mV/Au7³z D`μƒ)Xβ€ž΄{ίΐΎη_»οωršeq&O³Œ΄Y½Λ/€ΟΏ†ŸΈXV]šQJEJ)�lρ.U?ΜY MΖΣܚ³LLŒϋgΕΗμ x~³\=ΔkΠPW8τ:ξ/I­3h«T|°η$‚wίG4,°”D+]Π|§Yrϋ³‰2g¨―œˆ½s–y€Zk²ΡLZΦαύνΧB₯Φ{ρ cΞ�αΔIW <¨‘Όkm"Ǎb­ΫΕ+ μθ•`ΊΙnF―bΠaύ«œωvh %’Œ Œ�tρΆEδU= ηΛ67΄;SξZϊ]Ϊx�‰ΩX(τۜ…€β$œΞœ₯ŽΞ|½°±%9~Χ1Ξ§‚K­©430€ˆΩΙΌ”ΦΫSBĎMe‰Ÿqθu΅jsή­έ7fθ<³zμ @Φ#Μ~ΩO˜!Ψ$±ε_¦Φ>φΥ²ΘD�Υ»Τy―σ³z7/LMξ<ぽY»wj΄g₯2J©(*'q5Χm…λμžmgΜ2  ςΤSίž››Θ�,HS#Ώ]ITΖ…ΦpλΦkξΈύ—ΒκωgΖRs–#ζ,EΝκ΅>„΅ϊ§ΔΜλQJΕΝ»¦|ηζnf^©φŸoLL™ζJ+ͺ΅&ZiΎ΅Η€Ϋ‹Ώ0η°’„ͺJ₯βΟλi―™OŸ�†J)�΄fJ)*•,lΎΣ9φi±dJ«pzz&™˜o+' θ ν½Ο°q][Χc#£#� �ΩZ²–„6Hr1 ‹Οy@ΟΟΧ[—{Αφ.­0„6›g€ž¬βΝ�P²F,θ ½'Γψ§`Ι:e›³]Ί,ΫΜƒ _H^―.DRi.΄¦Κ±I2J€#rhFŒ9‹7―—7gQp-+cΙώγ³±Φ:ΠL΅ŒrμΑˆ‚ΐ71¬£D1BD9 R΄3wDΏΦ€¬RrΌξ�Ϊη›W^ˆϊΎͺF‘΅irŒ�” Α­Ϋ5H”ŸώΕς‰“'KY–Ei–Νκε£iΞΩΟΓμ‘ڜΏ.₯„ΚbsWuέV²ίΊH MΖjυϊθ7žxr(K[ς[*ΕΚδ›^ΓKfυ–U‚<τZ’΅κ«9‹ΙO£ΖœΕ8qŠ]7Oω.‹~'£*Aόz¨Υ¬Υκ\+Ν|yd1ϋ3\τ‚¦„€1¦ œ˜w f―ΥΔ¬VΟŽΦΦ΅΅|εdΔΦ܈ !™”2όΑΛϋKγΓ0sΞΓHΜ{η ΄ΦΈΚξ­ϊ^ΒΝ꨽2€– PΪΞλ)–°:†ou5Ύ‹ͺ­χΪ‘^A@―[ΈβΪΡ Ξˆ₯‚Ζ±!Όρϋ%ΤgΈΙ₯Ϋ©γϊŸKͺN8aί.0}‘  Π-λπ{=]‘ι€° €�k§"‘4WJSι$9Eš³ 0ΐ„SΕAzM9vσznφΒοΜl΄M˜’Σv?Ÿ\hDR[WT₯‰61 …ή”0bβ/(Ψ³mΔz):έP‹ά8υ�©ΙUYˆƒ­ΖΎζ˜’²m` @Φcœ~ŽΫj-ΓΠα’ά3α$ŸC_~μρ²2IΣ4Θ²¬0VoλΦkΊΉ7Ξ‘Νθ54K₯RJΘ’œΔB|?ΦmλœQzϊιoŽΞΟΟ;c–eRb-°zΉσ―«dff:^ sB)eš2&cΩφ‰ρf—ζΰ•’‰Ψ;pπPΰδ΄Zk‚ΒXVsk›,»Vό…«lrJΧt�1ΐ>Μ΅†£h5zϊάAk“Ϋ'₯‚’!˜"ϊώχ_r΅γ˜mΈŒΪ=Χ—t: ,ΑΥΧjΤ¦μΕπ™½Ε7#h ηΜe¨²Ζ,)ΒJ ₯λ}°·Π₯ρ³ξφ~-Ύ?'ΰηOUΌχ(Η¬γf `I†λ?—b`Η’EψŽΡsn›9Vαbvj_2θ¬m“C§ηB©œ§aφTan^Θ0‘ ŠS*¦*ƒΞB»†Ξy½jŸν67ξ­E|rΎQ8ΛΪ*žΰ1¬VΒΉm(ΙΠΆ0o"ηJΫ―g…U_Ÿ"ΧένkŽΡ3`―Ί/‚ͺ›\=–(Œ�Tκ³Τή'CΟΏπβΰΙ“'K>X(ŽΥλ`„N{SxΝ€&:C™“ΈŠλvΉλμΊΤ.Wo¬V―}γΙ§†Σ4kuΓκ]ž©Ξe¬α²¬ž—Λ•—‘v(A¦§φٜΕ}7’)£šR*)₯ι„1gΉ’• ΣΣ3±Φ*PZ3ǚicͺ\”9‹fΖUQB$₯TLLŒ»oΏΐn5—5,Π³`΄(·Pσs΄VDH)Λ�,xγ7KΩ55Ή†|ω¨α~ΞU†oΥjΣ¨τ€ζ §ΝΧ#-c– $h`σGœ³ΎkB.xυύΊ% 6,³g7€εŸΡΫdί#˜ύξ�nY3AιΙu)v~±†Nrtΐq΄Ω<Ώ›Ω1‡t…½Ό&Ί`ΰ‡Ηgc©u¨΄ζB+"U+Λͺ§²Aβπ>›Δ)QŒ’lΟ΅£ώΌžί™Ω0‡χXΦ@ι₯c³…²¬ώπ‡™trZΕ)['Z­θ‹ΆΏ~ƒΉ/‘@χŸ—žˆσ°=ƒvo„¬— «§@Ά|4K\XtjŸΫ2€Α―γ©²²”¦)/šΥσ!7«7Ÿ+�›’™“ΈλΦ£uvœ›_|ρΫ£sηζΛišΖ™άκ—kZqΎ5\Ι¬^W%ȁC‡"©€1g‘Ε›³Έ1F\Ύή–-›³J₯βΟ²^ J%,kυΜi  ΛκΐT!ΐ˜²€P’cš1&ΗΖΖ2{/ΥskΠΊ‡΄ΦΔFC,©+z?³(₯!„@šf$Ν2žfiςΨc_†Qƒm³ο­φ™²΅Pd―-k}ζ«,_?kSίHΗΜWΦή*AK;―§μκRʍ„Σ³8Rη –fλΙυZίoHfο<z±WΉ½QΓxη1N~=0‡JŒΎO`ό§5ΉϊλΫ°^Iσyη{ όk[Ϊό\"•­l°•±Wτq† ŒRΙ(Ιvoq…­αwg6š$ΗgYέ—>=ί–΅]DAsfLr(!Ωξ­ΓΎ4*οnW Έ³oνŠ„>1BΪ gBLˆ=Ν‡"φ7ŸΥs{›™Υ{η‰°ΥΈΈIbμƒΎΛ`kξλΰΑΓCo½υΦ@š¦ašeΌ¬žc„jψτgu€6'r‘9‰ύZ·€OŸΥs°cO<ωτpš¦.D}EQΛ­!Έ—«wΏ[ΓΣΈ«w=*PͺΥjΙ©wN‡JZs­lfZ1ύΈΦΌ£­0uΖ™°aκ~sp£*A–“'JŽ•RcY•’…°¬s“ΖqW1ΖΔ-»¦όζΰ’\3΄ε‘Ν€BfxνΟ—R‚RJ2!H–¦SΝΟτš:Ÿ)Kιl‚κsάΦΧ>쀴ξΰr�νπcu0ΛD’¦i 2Ρ/VΟg„\ΐΉΨ΅ΓΤδNω�KϋνήSΤsΫ^·Z½^τΊ]Ξ^ۍΥ°ωΕo}{τΔ‰“ƒ­υυnk[ΓζΠPyαή{ο™υΐήΕ²zΛ*AffŽΕJIγ�(%‘RΩΠ+]˜$₯”˜y=F©b”eΫ·O4Π)ϋΟ+AΦύΉΡŜ%Ο~$GŽLGBHΌ₯‘q�Τθ§;Π[:7I)Νv°WGgΞaGvέττt?―YkvOˆŒ4SNoŒ±�Ÿώ韏욜ΦPFzu‘«;}ΥΒ’Ψ‘«ΐ―ΝΥBεΦy·ψ–1g2Π†¬ΰν�;Ζβλ –dΨρ‹βkλԝE'›7Ώ\Αz>¨40Z‡Εk§η"©“€‰ΤΊ£3Ψ{Λ�v˜:·aκΫ“f9 κ]@ΖF”δ,Ηκ Ό^O„�‘TͺP–΅Σ˜…jN©β”нΧnΚΟMeΞβ�ΌV°z₯RQ^(·W³χΚΡџqϊ™κ,υš!‘»-ΐ}©ϋF—t�@kΟκ,€Φ €Dεƒρ΅>«Χr!>xππΠ+―Ό:ΰΐBš₯=eυ|FΘcυ|FȟsQώΪ΅Š§‚r»­ΫΜΜΡBΦm₯Ϋ–aυϋΚΧ«f©™΅ΤZ]P_²†„jΖ™ζά­aP{πϋΟ•’€Š₯ΜμΕ°z]• N€T‘ΙL3ξŠΖœ₯  κ¬ώ­!γL2Fөɝuo―κ¦Ω(y·ζ`+FΰΗFRu²¬E™³ψs“Μ€½tjjgή 5C.Ξ ^kτμv,MΡd”1J#Jiωϋί[ύ³�ρWΥΔΔΈcτ|ƒ΄ͺ­Π9ƒ(r{›mΥWΑ_OφK?žΖŒ6ΘΖΗάΌ1)q!κMŒμ]@0βT|nΌ †unΜ²αΐήy2τ΄ηXFα¬re½„7~/Bύ(‘ ρu;>Ÿ%5ϋ`žC§ KΝ ~Ψη•ψPζ»΄ξ:—N.4’£sυP(Ε„�T¨βΓΤiGμU%bοu£~^R~Vl#ΞλωΞS­Œ΅—ΟΖ�Λ;”=fYύœCJfY=N‰δŒ€{Œ”6ί-χ» ½Ύ)”χ–5EsΖ-NΡi#ά54Υh΅BŠΡvg£=zώς�Β�™UΒβ!ζ^α€Rˆ―SΈφί”Eyΰ°+«wΉ`αBŒΠξέοYŽκV£ί6χύ[·ΛyΆ/™ΥB�ˆΥ3λHΑ8³kΚ( Σς`yαή{?μ”εΧPη{Λ(AŽΌ}$’JBJ*•$J»YC¦N4cŒ3 2XΊkj²‘{Eή,ˆύ³» `ΰΰ‘Γ‰’*’Rp%%•R’’ΜYμ}¦γΰœ+Θزesjη&ΡɈω@HW*›ϊ}ΦBk !$„4›”Κ !q΅zF�λίόόOΏώ%LLŒ/k7ErVxη”ΞΥ&WΑίεΧBygj3Γ>`Z˜y=#α4¬ (―cd―?«ηφΆΌοΊ}ρ ΄Πy {@oΔzCh+αυί‹ k„(ŒΎW`όgœtΐI7ΟΩ·cσ+α?œWςΨ μ%oTb©u臩λ>„©·$œ„(ΪS―yEn>WoΠ.­ΫΰΚΚ/›M„�TΊ%©ν5ΛΪ!₯%DsF4gDpJΣ=ΫF\σd1Χ,ιu·ά ²΅IB ‰Νq‚|=%ŠZ2m³Α”¦Ϊ­G° pP—ΉΟε΄Ž�<€'Ώaχu*1ρ™ Rf—74|πΠα‘W_ύq9Οκ].XΈLFΘ=—€[Q]€‘NΏΦ­EyŸY=φ8ηšs�Β0<κ}ΰΉR’8ΐ~푆Ž‚h™ηyY%ȁC‡’ΦΌž�FΖY` Ίeυό0υμϊν�!²ˆώΝ―vAΜΡιc0xθΠα’”"”B2!%Q² Ό z`”‚1ͺ8cŠ1–έrσ”σIpλΰVκ!]«θώΙσΡ’q€Βύ{�Αšœ> ς/γ_џό‡Ÿ ϊδΗ#t*Ι|Ωϊ<:‰αέg2w6ž—ω» »Ύ|3F·gšχό΄4σzZkφΈαM μX@ιϊs^#ΛΝ!§Ψ Ζ‹|m`έΐ‡3bq�Ν!,Ύžΰ­?‘š„hŒ�Lfησ\΄ΒœχP.ty(―TΩf7 ·μ½V”²™n6ΐ[ιbOKJl§ΦΔ.HFHΆcSy9 ν8―ηΊ΄ŽΥ0τFua`ζ\=Κ€β™R…±¬†]5€›3ͺJeȘŽƒΖ{ΆΦΌg)/γμΥ‡Πή―�{VS1ΆκΚ1{½Ώ�m ΄ΦΔځS­t€Ξ bŠNgΗΛmhεηžΠ`φ»_§€Ψς D|­/£…ύ,ƒ†χν{‘§¬ή ‘nΜ™.%IaΕ[?Φm…gΪκ±zŒjΞΈ‚@a Φξ»οΓNνβΦpŠ‰‹iHu(AͺΥj²0Ώ )™”’J) Sw ΓF.(F™ΈΩ„©ΧΠ)ϋίPaκηi:S§Αο�ΰε’2R²"]Q ±s“ŒiΘfœ ΞyΊkΧ”ά�‘ΰX0ξ݊RΈ<9ΐG D«9ΥΚΣSJΡΏψΛΏαŠ~ζ3?90>>>dλΟ ΪΞΓΎΎ>ΙΌσΛ~² ψ�²ΛΥB~Γξμlσ―ΔΠ�ΊSCcžς:6dΞΫۜ„ΣŸC^χΧu]ƒ½.z>…>Γδ9 7ˆΩοĘωO΄&`‰ΐMΏ”ΩY–y΄™ΌsΉξK†«σyέ:ƒ~n‘cQγ—ŽΝvΜλ)kRX˜ΊιζiF 8‘μQ’ξξ4Y »�Υκ�ΊŽβΰσoŸ* ₯"‘4R©5‘ΚΘ8{V5΄$œf^S’F$#€ΉχΪΠ›G›Ωσ#JzzIΠΙκ₯�]S“%TPB”™Ρ!Ζω†ήλMQ ‘’”ΦΪ„όR₯;xθp°kj�±DΞ‚Ϋϊ2.ωr¦,υΗeVΎ©qΝGZΑ+Ηώ Ÿ>]~φΉ}=fυ.‹’9°Χρ―OLŒ/φu]·Σ§«ΑΨX₯—λΆ’Β₯+«χ'žξ9«ηŠpΖ(8ηC+Γ ‚Ζ‡ξΎk>·†yKς ]• Σ3Gc₯ΜΌž5!EmΡέLAc’s&&ΖΗσ1=5LέΏ―ό‚xΈZ­–ίzλνD !¨Β5A X;ΧΛx¨ η¬159Ήΰ5}°yH@φMžΏπόεŸ6ϋ—J)%ϋώK/?zυΥψ#χ|¨όπC�T*DœρκL§xρA_2Οn¬ŸΜ?_ϊ‰+ ψGΒَ!š?P†’!΄€€ˆeυhΠD0Ά€� ώ<ω9,T» φVqq‘[ΰ<£η€ž1,8ύl„c_6AιΙu;ώΫ,iΨ.ΛΉάθΎ|‡ε*mή~ωΞΆ9~ντ|(¬ΝΏ/,μCΆ9 ³ζ,{Ά¦XδξΖμεσd:r֞{λTI(mη'{Ÿ―ηnΪΚ8€* TŒ4?:Ή-φό¨’"dώαΧ ε%”J©r…z­γΰlξ-KD΅RnO ±TH.cΏ[NΎ9hA\ 'Ώ@5h"0ρΆ,½n�^μ »―|嫃^7ν«w錐?»©/Dz–,]·κ™3μ­hέVXΈ,Λκ:όΪθ›oΎΥSV0™g”2S„σ@s»†œ΅ϋξύ°›g™½ŒbhY%ΘΑC‡M¦›”L*i#t_ΓΤ ‘Νρ ¦ΎLs0ρšƒC?xyYJ !ΈΖ‰ΣετΪ…Σ“pr¦Ξ%g<έ»χΦZ₯²i>W‡5μC±Q IDAT―‰—MMM J‰|„κ"δωΛ2Κυ¦Z€Zi*₯"B*„`Y˜O<ωTςΝgŸ+Ώ�½· ί}χc{vΏΗ―3ηΠfψθσ™eΧ€φYΏn̟Δ�™Ώ%²Ο+$χΩΗώ½=ˆ³?ˆ‘…\"AΉαulωΘŒκΔ5²ζΌF–Ϊ(א―ΣMλ|ˆs€Άο2¦�<Βμw8MοO1ώ3nŽe.χπ-`©MξUΩfw€A½b6†΅ωŸOE¨”fB+τ 5g!09=Œ�VˆχδΨ`κmš~^�†δt‘.燑‡O.4žšKR©‚T**”&.c―Χk`%œšS’CFg4Žƒϊ׏ͣνbλΦ£§Ζ„w¨ΉŸ)s‡cF@$‘&˜›Šή½Φoˆ4ω`Dk0θŽθŸΊάη�«gΨΊΕΧTŸcΠJβš₯U,,zέρa΄Y½‘gž{~ Λ²(Ν2.2A…”+΄λοΞ½wοή‹a„.x‰]΅ΩΛωœ>­ΫJ —�¬ήW/jV―½†η*ΰ\„A˜ήσΑ»*•Šμ³ΉbhYVοb” GޞŽ€”\*›ι¦ϋ¦^.dγWB˜Ί[ΐΫC\­4τόσί*eY )™‚H%{n”““{λ t"xγΆ½·.z (ΏOdΰk–’$#” F©€Œ‚1 �cΕΖωŸΦ€um΄VD* )Ba–±4Mƒ0 Γ}ΟΏXϊΦwΎ;4<44zΫm·.ξ½uΟžέοYΘ=_ΊZCηΜ’“7ύ&¦Χ0θ.�lΏ+ψωγ,ޱBvΆŒϊ±ZR#α$ͺ5«V0Όη|ΖaFΒ½�ΐή2@/ς6�φΠcζΟCΜ~‡Zaβ)ŒΎ?υ,gΔ2ε³―½ε Oίb8~ύΜ|$<ηΗ–Œ³`sJFˆ“p:swxϋa§M’“iΝ¨~γΠρΑLι$•Š 鳬½ΙΧλ&α Υ£2d4½ηΖ-ŽΥs·/Ϋλuε~–oΩߚέ#„Hφz]²·}[Ώ'nL·χ(vΉ !W0sοy°ο€Ηε€Φ(ο”»'υ †šχχ =ώψΧ†²,s!ά4&WO)yY`α|ŒΠCŸόΓ‘.λι cU M�£u[aηΌ¬ή+―όΈη¬ž“ΦBuΐΉΒ@…a˜AP{葏»Ξχrσ–σ/wU‚8x(TΞζ_™L·BYΊ„©_Ώ}ϋ†SﲇΘ) ͺΥκΰ›oΎ•!Β,ˌ„S�L|^ Η¨ζά*‚ ¬νΩση—p3ή mV―  911ήd”₯Œ1ΙS”PM(ν΅ην!NβšeJi’”&RHeɐ¦YΖƒf›ΝfωΙ'�~ψ›ί|ΆΑkάrσ͍[χξ�MŒΧ§&w.z@―†N…™o4η7±ΟΗϊΐOmTΰ—Ϋ3]MΪς-0ˆΉ%ΓκIjΎ7Ρ†Υ Ψro7VoŽ’Ή φϊτςy=eθ ˜h…�3Bύ8K$��y…ςMyΗΝ9tΞ₯Έ*ΫΌX ΗrΒδυκB,•ζBkκX$] 9 ιˆ\ šcΞβΝλm8INηY_Ÿ>jίΓwθψ@&U(€βB)*”κy°½'αΤΌ%α$§€ρΰδV_rΞk€ψσzEάω9@mΪ4*ΫΓό°{n6Υhz¦Z%ΐ€ŸΣζ3²—»ηωΆήΖ}στ3γ4‘oΉoΊ�±πaΥjuψΩ}Οd"‹2!xfdG+ΜΥ[#”» »¦¦P€λ^·uCgœΙ%―[φΧΎ³zέ"3’(jήσ‘»*•Š3ΧΙg#^Œ{Y%ΘττL’΅ ₯’LIIμη'E±z¦1±4L}—q€άΠaκXš?ζξ­Q#ΟΏπνA!E’eB˜0u“EΦsc–– tΐΉή|οm·.”’δ¬·OΈš,σx~œRνζ›§š/½΄?c”*#Ι5@�˜¨Οξω€Ού{J)(%‰"„œpΞiΐ9ηAœΗœσγL|�/e/ο�aΚm2ΖΣ[nήΥΨuσTcbόΊϊδΞ›₯R)τ|@˜‡u,ύΛrδ…Ÿνη€6@›gρeœy>ώ�Ω{Σ(·�σJτ;ΣԈβ\€&VQΙ―eYΆ%Σ’lkrάβVKn;cη₯γ΄γτ°ήJΊσ£W―Χο%―W~$ιτλ—χΊγΨNά–c[²e›%Κ’5ΨER–Θ"%K¬’8’F€{Οτ~άs€ƒ[@‘¬PUda-U$E’pΟ=ίΩίήίή …‰[•„¨ΣΨ°z“ΨsY1\^ΖL+μ₯=ζΪ7w;H>²Β›Ξ ς.‚`“€«E‚Χο2zVΎιvO�Φό«@―ywΠ Η Mρ™y=₯4–mš«IρΠΖDQŒdwΐβuΉΐοΛU’“.άΆϋίύ/ΌsΎηΔd)KεΕJa.5Έΰω!TY½” §G°τ(αƒ=™ςφ }vφbΪΩ@yο/έτΙ|Ύ_ŽŽ™uάϊλο*\ ΪΉBΑ­’oVΓ¨ €xœΑ™½΄0ψY ^ΕμkΦMΜΊJv@οO^|Ή‹sς˜3Α9‚Voavχ-b„�ΩzΥλ‡ΪζΊΧ캍ŽŽ’λ· -φΊ-¦C½$¬ΖDSF5σ˜τΐ�];-«g%¨Τά•-θ™έ²esωπαΧ8Mfώ4!0€PΗ?cΠZWχ#­ €R„@(A1‘ˆRN(%”`Μ₯Šͺ(%’" ₯β•W_εζ„ΰc―Y;_Ώm[΄yσ¦Ša�*)°ηΚ?έΧttE%όά¦«\© ―‰έ*TNg!χͺ³z+Γκ• «7W«·"ΐ^ ηzΊΑeτβqήώ+ •SΊo–°ω!$Œœn·;»:Ÿ·ΐ};use 8xjΒSU'N3«Χ¦O²6cd^‹[6φG©nΧe#Ιip τœ{ o…ύίzνDW,U IΉΤ81g±aκ­» Uω&Ζΰ¬}Š%#(ϊΐΥkμ}6 υy5’€Ϋ•tΦNŽt»%>Ξ‘™� Tηq1‡4ζ4W2f�c0φ·TYΒΐ5τά;@―d~~;ΫΧ[*•zφξ}*ΗNŽj¬ήΒ€ή"!{½άΟΖ•IPνvέK_·rΉΌΰλΦ’&Ξ’±zσ’yKΟ‹ξόΰϋgςωόb]κš›³ρ₯TTΚd^O+U—ΪΪzΡιΓ›FAU ά%`γ'ΈYά.£ηΪέ�Ξη]zΡH³J„‡NMRiOΆΩœΕ²J.Ψ0ζ Š`Δ·ΦΟλ5’δ¬d —.܁τΦΐšΓ§&ϊ~66ž‹„τ"‘H,$θ΅€aEΠ³Ζ,Œ ν,Α1ΓΈτ‰›7[™΄‡-§ωνΎΗT ψuπ:Νω¨P φ=χ^³@Οƒ‰— ΜΎlδ φΊΘ†=Π»ο™g{¦gf2<ζη<1ajΡ¬ή"!έ¨»ρ„@&ζ:ΪεΊΧŠλΦΒ&ΞV―P(τvΥγžη•φάύ‘)s:—θRw1ζ,ωJ)ͺŒ9K»2έκ~V¦nάyωš5±‘Λ]vaκ Φ•ζι±λ ςί}μϋέ‚‹0Ž9εB`™D.΄τ:Μ§x�ϋίΧHΞo΄Μžέγfvξά^μκΚU’(βŒ2•(Œ€μ¬”s~Π‡ λ€FRJΐΈκ‹&€0Ζ”ΐS‚‰e“Χ$l^bŒ%£TΎςκ«βΰαΓ1yΗ„Πψͺ«·T�. •vξΈΩ“Ÿvκ―kzγ:Ϊύ8^Α /}²λ»d₯ &_ρAK’l’(aυ°WυsΧάeΟκ-k°w3zV“›€0zN`π!ύ·ΩΩ•YgΑ»Τφ° τ.©‹β9…;|kΌHmΒΤΫhΞ2u&‡@E0–#ΎcCo9΅™₯%9+ωNΛμœήX_y彑TΩH(Ζ₯"Β½ΠͺΒmήHbΚŒ`εQ,|J’ϋ�ί8».Ψ.­e\Ύ˒P§u=­Aš8΅οYF$z²Lΰέο ΓU_°! τΚf­x¦Π[*•zχξ}*ΗcΖq2—#Εβδ›Ν‘›nΌαR!wN―j¬qΎΏŸ#Œ%ΖXYf―έ8L/έ½ν˜δ Ζκε{ό‰^cͺΣ V―rΣM7' Π+ΐβ\κš³œ81ZgΞ’u{ΝY!ͺš³PBδ–-ƒ‘S+.›0υy²Ηzœz±φΕ_ξ?}ϊL.ŽcŸ N…HŠšQS+�Η| €ξξ�βξέΩL`χ μμ ep¬[oΩ5»οιύc4d”�˜Œ±¬Ξψ.ΨKƒΎΪ¬q-”=a‘‘b#€]πGˆiJ¬ &Ίj(”¬[e₯ŸGŽ;“ИRZΉυ–₯]»vΜξΨ±}&†vnή6^έ―]Ώ kd›5γ‚>΄ΟKMTN֘₯z`ζHDΡ­1€Φ€pβΐ™½�™«νš³¬^.cVoΩ‚½&ŒžΫΩΆ`/c€ƒ7�  ΧύŽ€p“λFηf™€ ;VX€―RΒ2ΗΟΟψJifΝY€Υϋ·εΈΪUfO„βkϋs�ŒΣ½Φ+φϊ:›ΫϚΓΰZXλŸšψιΨxW…K?’’ΕR!�$ΞΕΛ£jξ§μηΞ0Φ>%* „ϋ—Ήυא£Q—ΆΣΧAo‚―μψu;ztΰΑϋz­]`Έ‡d`0ϊu ²,αͺ_ΰυs¨ŸΥΰP‹eθ€ώ§ŸyΆoff&Η±ΗE’Ήœ–²zάσΌ�ƒήwь³Χ§Y½⁁<Η ŒJf­$©Ϋν³X/—JKyh±y™}°/…B‘οΗΟύ€+Вޱz>pί€s μ|75g9:rΌ£ζ,UΩ?&€1Φ„$ΜήΆα!Χ‰³sM€V\s°ΑωΙ:ZΘ:σ\σΗοβ(E1‹γ .2M„V³z›¨GpϞOfΒp>€½Ψμ ‘9ΛMΐτ�];fž}ξωe4Kσ(ešscδΞΡ-ρυ¨{M>Z›.ψC�c”˜Ξΐ8‰ ±ΰΟH?%$$”HJ¨dŒρŸΌπRό�OV‘„”ίsλ-₯]»v π›†š+ςxκuΊAƒ9€oΉžΣςδΠθ†sϋBΠ�³ ˆp@΄ λͺ¬ήΉ †Λ’Υ[–`―I`Ί τ2Π `όeο~Χ―ΰΊί‰„<ΥύIΛ6WX~M°S@|χzπΤ€Ο•&R),U"γl•t°ι]nΒΤ© SΘE9ŸΉφΔi3½B?ϋf‡Α5°6ΐΪ�s�½!³FΒ‰c©P5rΑόθzџ;ͺΚg=Bΐ£X”Ÿ’Κ}Χo,�Λn¦š;―'—Γ&ΊD$Ÿ»l/θΩλνΧ½©C¦+Θ0†žνΒ9YΥvρνΜW~οSϋΊ£(Ά!κ$aυδ‚ub„†‡Ά.”rT«YR#adwΙΪiszς cζ³Δ‡–>s0ομρ'ͺ³zqc.DΫY½y�α₯vΎέ™Σκ:9v,θ€9Kς3cΣ Δc"1Ζ|pp³eτ�ŠŸ•lΘb?χ΄σζ€i �€υ�§NξŠ£ΨyLΉ3ΓΫͺ »Ξ(%ΐ«²zžηEέέ]3»wί5Ÿΐ]Φ]Ήβ€½‰;ΆO�[»vMΗ‘Ηb?¦”pJI##—λ5ͺ~Ιk=ψ³Ν,Œ1 Δ-Τ„`„L0&˜€•{RJ5Mζ %‘„3Z~έ]]ε[o½₯ψΐ}›Ξηϋm3Άΰ|ξVΞheŸ�©‹Ήœdσ°ΦΥ(˜}; ρy/‰[Πζ€X }ο)B°ήeυ& ffvYεκ-k°ΧθQ¨wπ² !x0ώ…±ΏE‰Λ?•@B—Ρ³lžέβΚωZy :”Έ΄y™bΔƒw§ΛΎ�š)΅Ψ…6Ψq]μV #14Π§Ί΄qͺ½�»³v–§Fo# ΐ†Ώyε'&g»Λ\ϊe!I$$β²υ¬J2 χMŠuH‰ (ŽŠgΉυwNkάΩD9,½…y[‚Ή/α Poψ.ςzW£MΐY"0ϊu€μu6}RšΟΧνΚj¨χτ@ώΕ_ξ›žšΙqΞ.D•Zθ‘�•ŒΓξΩϋSB}^G€$ΒIN"NμΛ €·ψΠβ.ε …Bί³Ο=Ÿ³³z‚/.*£Χπ"ΦΎ›ν@8::Ίζ, “Œ=’©1gΩ6ΌΥ½Μπ―ΠΖ ‚Ή�Ά~ς6ΐ¦±±“λ~ψΓ½=•J”‰’Θ‹’sΞ‘T­cυj�ΩāΣΜκIΟσbίχKχμΉ{*†η Ή νΞ+œύ�jθςώ;nŸzτοΏΣνy^θωœqΞ±0VHkhiV`;€Ÿϋš�OVφ ‰š μΜ_ όa‚‰aό(₯”PΒcUΰG1ί·ο™žgŸ}��¦›n(>p�½ΓC[ν=~ΦyZπ3i֏½v_^N²ΞFŽδ–Ωλ‚ρP’‚•#ζJ°fχTƒ½-½ζ.Kl@—ΩFu! V=γ/Qύ:ΐΐξ‘§ υ΄ζσVή"ΞϋΠ€i΅σzBiσcU’ΣsϋZ•p"€p-Lέ͜I³Έ+βΊ7‘ςY Χ‰g\›ŸšXχW?}³·ΛL™K Ex Y½Ϊ™0z#λΎ©Fx@IιΎλ7N―Λ§h€NwΛΫΜ=oΧ€ ;«δΎfϋ^ϊKG›$¬ήΫ… �ώU7wjΦYο–m·Qωο<φ½nΓ 1s"ΈX Vo>FΘνήkg― BAφ:θyQ ½Ε‡ΛΪWcTό‰3«ΗxΜ1ΒDeΘeΛκ50gI+A2'FΗ|©dbΞ"ΫoΞ‚1�š³BΖXlΩ2θ6Ζ/­ΐΖ Ϋ8°@oΘ&Ψ ώΏ�ρ7ω‰‰Ι�(Šό(ŠI$’ΥΘ…Φ€=bΟƘς=OψΎ_ικΚMοή}§Λ.ΉsSζšlΉο²9€O@aχξ»&{όϋ}1cY1/f s!ˆ½G–ƒ”s10ݜtrG«ΰΟ°}ΘH=1%Τ~�c,€ŒfΌz°λ΅ŸΏήsσM7φ?p�½}ΓC[ϋΝ^ΣkšLgΜ}:nΦ‘‡±²N΅”€―ΑΩΘ]η‰|3žΜΒτk>€4œDf1δί?¬wܜQΞΑ\VoΕ›2-{°Χd³J;ΠωΥΟψKFΏ°ω!ύοf(₯€žk1»:ŸΧ:°η΍e {θΤd˜8q*’΄ΖJkΤξΆ(FΖF;‰]HΒΤΧ7 S_Χ»»γΊLYιζ:S°―€ΝoŠ�ύεΛBv•ΈπΛ\RΓκ!©[ΓκΩΟΫfκ1‚΅O‰(!#Q>λΉεw=—ΰάΧN zM>ίΦ`ξΤ?]½†ηΗ ξ‘7 ΘY›ξύ嚲Ψ'…sΟ`(jΨϊ»HΖΊoΊ³ΘΔ¬ΫΙxρ₯—ϋΜ9;ΗΕqq!ꍑχίqϋb!7ΡvςΥπΠVyΰΥƒ΅¬=Ԟλw) ½M‡–ΠΉvύ…B‘χΗΟΏPeυΈXQ¬@7ΩR©ž;{ήS�˜³θ„ΩSͺ=β‹*θΐΨΜϊν.Ξyΰω΅{%aχτŠ| ώφ/ωK\5|Α˜kJJ0!„xŒiΚσXμ{ž8p0ϋσΧίΘύ£χάΪύ™O²'ŸοοWP;o3ηRqή’Z _#™rXΗ_0³z€PΒ걁YθΏέF-Έ{[e‰Ξ(WΨkτάanχIaό% 'ΏpέΏJw·‹©{u>―u‡€z³œͺ$ηx‘θK₯™P[6IU7¨V#Ξκ]Œ€"œ€=Œβkk± i 粕δ\€Ω±�' τΦBΡ»ͺρMΌ�υ5gf£ξΩX%.iEHKRΧηκ-†Υ«Κ71Š‘φΦ%2d$(™ύτφ-ksΑ9ΣΥΓ9ΟxqΜ8cXJA�χΚJ|ώ,‹i>$₯ΜƏs”2SJ<Ÿ3Ÿssξ�δΕ—Β7ŽΝ~δ#{rχίϋΡΤtΉo)4Ξ]Jΐ—ΞyΆΞ.•,LόΜ­h-!˜F°αΎi uΚ#»ζ*p˜ψ­°w g<^τ^Δpςο5lύb α νςΈVϋ�›Πκ|^λo°΄Œ3 α«οŽϋ�„©K₯‘tŒY  w1fŒ@cΞ²sCŸΝΰq%9Λ’šoπάΞ¬Ϋ ·ώ5Μ[\ƒΕˆώ›οΏΊξπιΙήR,2₯X²2—8– ©k!κzρ@Ο Oχ)€2"BFK7�ν™~ψ–«Οΐiσ,@2O±T¬ΐs§ � ±Φš8ΐš―4gω�Q Ύωšΐ‰―"XsWZΙ`ͺΒY/vήkΰΕ—^ξ;}ϊLΛ²Ωζc„>ώ`Λ!Ί†ζέ.6o ΅Θ‘ΞνN»¬^ΎT.χύθΙ§r±™³ŒσŒΕΘηΝΥΫ³»Υ¬‚Ή‘I™Ρ±±PJεI)‰” k3'ΦΞγ…Ι5Ә`“(xppS3%Θ²cυšΤ œͺPΫ=`½in€-ί|τΫ›žή�lΎ\.w•+?Š"i°­—oRJ€Qͺ}ίׁο ?π#ίσ‹ŸωΜ§©@šΊ!¦Ÿrό3fžΟ„αω_ϊψύ½_ύΪίe…|.R )$�5·.°7ψsΖIΗƒ”8ΐ)%œsΜ=Ž=ŸŸs&„πύΦ·ƒ― �ΧίόυL>ίο‚=·fΑRΎ”<Ό«Χ3oψ gi²TD8δΆΞBΧυΞz;΅ό_WD—Α†R]mζrκήΉύnό#$c΅ΫuZΟkOϏ…9S¬„Sξ ₯ˆPΛ6…©Χ ‰™Γƚ`€(Fbh Λwΰ~Ι%9©">ά§ηZμζ5XFo$f,›ή*7ώΙώ7Φ:=Ρ[ŒE¦ ―Μi₯|ӝΣΓfNΟΘ7UȈΘx€RRόύ»n8 ›χ�)ΰ…%μ˜iX’`n{WΪXΘ+5hΫa Ÿ}j˜+]·χXRlO~ —W°ώ>{Π)9Ok“m‡Τ«¬ήή'Ÿ^)¬^#°ΠΙ&ΝΕƒτvZάϋ=Ώoίώžιι™,9‹γ˜!”ͺm¬^«�αV― Χ0­±rτπΔθ Ώ:―§4Z,[y1`―¦žT‹«Άl±ΝAWφoχ«κύΩA`€ζω7hŠ» € 8™šθm€₯ryγύ―΅φΠαΧϊΚεrWΉ\J₯2­DE1!@)ΥΗJΧ}ΣΞιyž'ߏƒ (mXΏ~κΎϋ>fήi³Φ&/±^€Ω={ο€žΟξα��π�PJιI)‰J XUMγ–·;g;φ6{m”� ͺ}B)%Rβδ~TδΟώό/ιΗ>ϊϊ©OώqjΣΌΝΉvΎ&Tυ¬ήδ Δη©Qύs@˜ΓΪ»‹Ζ”%νϊZ+ΐ”eY€½*χPTλjΏ€afDΑΆkp6p·’y«F,νzsΐήρB¦.•&Rk€Ϊ”«WwŠH™³`„Δ΅ω\δ¬+αtί{§˜7ηΘ]γnG§˜+WΆ‡Ύ^θΩά΅_=π‹�ηε7{ΛBvΝΖ"H€›‚T„B±ˆ«Ϊœ^+䛉σ&Š‘5d‘F␑٫z³“οΊκœz–Υ›€9IΌsoκ@0· €”�Z'9“XiEŽŽcΫ†‡,p` _ZΎkΗd ΑΙoiΈζ7Œνf——p}6›m{όϋ+•Υ›σΑζσν{—�[�!˜'7³Χ\»όΣOοο›™™±Ζ,„sŽ„¬Ήo.WV―A]Ÿ£) aqfΦR)%–R΄5LέΎ¦Ž –±mېmΊy¬:°:ΑπΝW/ U/� Οθθٚ±~δΨρ΅ρ_ώοόΔΔdoΉ\Ξ”+ΏR�X ‡€\œ«kcPΜιyίσU"ƒŠψӟύεOΟ„αi§9hέ/J�€έ«@MΚΩ]ƒƒ›ΊώΙg?•ωΪΧΏαI)iς3j/½Ζ�ΐ—~ ³ ΰkdžZJ㌫VZα'~ψ#\*—πη~_ΔΎ—Έ^·Ÿα³χ₯+{ο6Οœ}�ƒD•!°oMϊί—nbΉ#&W «·d`―Ι€±{ψM=…Ÿ`(Ž(Έκ Ϊ9ΐΉrΝκ³qVXΪΣM±7ΌλΔ@πκ»Υy½*«ν SO›³`„4B »|ΚΧε‚8U΄]pj,άΝ^šΑ±¬Œ›Σo~k`ΰ𩉁ΏxαXολg§ΊK±Μ”ΈπK\�2—ΨJ7-ΠkιœF@IτBFTΖ#<γΡrΖ£ΣtΟφsYŸ½λξσNΗl)ž—,˜ΫFΰ€΅R�Μͺ™Œ³ΡμM²~N> °ε^ήέϋJΞ!ά�λŒ9π ΐš‘cΗϋ~ώσ7ΪΖκQFΞχΉ‘Θ-gυζΎ—ΦχτΡΡ1Άyσ  �IκΎΦ‹ά'μ‘ŝ±(•Λ{ŸΪΧΗ<Œγ˜qΞIΒκΙEεŸ-3ΫP 22r�μ³ΟυFQ”«T*aΉRρ’(’Qα(ŠAJ‰€-OΗ@)1νϋΎ _AP ό`ζΞάQΨΉc»z§ ’ξ†w_μΩ-ΝξM›fc²»?tW8:z�{ζΩ3ΐP­4ЈσΪlΫ•ψμΟ+₯Œ±‘w*ΠZ!₯$ΦJ#₯”Ζh�ώηPΉTF<ςdΒpΈK=ΫVο/ΐκ%’“BˆΗk³z˜F°ι“Σ@—Υ³Ν…2\!¦,KφltιΓ0†ς(‚Έ αͺ/ΨN½=ΜWœ―Σlή*Πkί§Ί*!OMxBΥΜY”cxΠ�EcάΥ4If°Τ� }�9˜Ψχ˜uB ήa­έk79ΐSηΰθ; œΣu w�™b₯οoώ὏9ΩU2[ζ�/sΙΚΖu3 ₯λ€›-7d!BFt†‘a΄’adϊίάyΓωkϊs§ΰ$ΤX½I˜k_ή‘ŽΩrζ��y­‘LdQHk  λ�}0 †{‚³Oiθή! ΤP›Q±³¨‘ωY}§ΓiY½|;Y=Ƙe„’ξξ\qχξ;' >+«•¬^ •i»Ώƒϋڐ^*—ηι­<΄Xω]Φ:tΈozj&Ηqΐ…¨‹ΚXθΑ| ˜Ω¦JccΎRŠ))‰T�D.΄·fΨ=!€‘aώ‡†·Ϊ1rjš΅—gKX/άΟ-έ¬:š:έ>»~JεrίΣOοούΡή§z&§¦Ί’JFQδW’ˆU*ΗΌ:£—0zΊ%qΦy“�ڜ^2 Γ( ƒΩλ�½fβsόΣ3¦^œ4ΝΑqX€ $Εξ!sΝ¦‘~.4ότ§�q0::ζ;ώ&IΐžΆ2h ƐF]q _΅#jœ;8―ΛE�Ϋη^zωgPŸ€ω»Ώ ™0Τ)R%‘![d\•ή3Σ ²ΐμ›φΜ”MX=‰”D$tέ\‚μ5nk<ՌΎβˆ‘ε"γ¬—.”G’qt%Y.Θs‡©WX:s\©m™· ΕPiπ”�Dhe^ΫΜY�]3Ο―·tΤ2ΖμαI˜χ9[ΥζΟΘ~νξ=˜+Qv‹ΆΟ³Ε»ηπ©‰ξŽœξϊή‘“ΉHͺl…K?�+ I"‘Hdβ„ΤH(•˜±΄θ Ε:dDe=*²­δ<:σΐυ›ΖοZΚ€Ό“Μ^ΨΒ]·‘vψ~\²`nνČXh,{―Ι7'_ !.Πd7ΗH.‚MŸœ1η’s ‘W”|sΉ€½Ή7p\P sΠI³z�μBΐͺl³“€Ον�Ό9>γ ₯X»ΝYκ.u«iΠdη†^{pκ5‡&iήc:g§f¨Σ�²ΎS΄C§pg w¦XΙ½πφωά7_;‘,…±Ta$€KεU„€‘P5Ι¦1bQΦus@9ŸkΥu[ G•eτ²-ώ£ΑώΒ—>xύ)€π΅’p·τΝ ζξοο“³³³m ζΦU ³| ρx‘€†.πjύ Y’P吿Γώ ζJΦ΅sˆv™‘ώ'Ÿz¦ΚκΕqŒΉ‹bυͺs8Τδe1OϊΎηΊrΕέ»οš0ΧΌI1]Μ5¬>· »�ŠΥχΆ ½]¬ή¬i•±˜υωX½žž�βν··mή²‘δΘΡO)kΞ’P«d„ZNUζVkΌyp:@{­Ω·$―R³��ιW—±ͺΦ‹R©”;tθ΅�WΌšϋΩ+²‚‹ ζ<ΰqμŜ³8ŠH«ΐM`z―P“ξ΅θQ`,‰Xπί½R__οΤo�ΦoœΝ„Ủf΄Dβ°{vΜgθ>/†μχο_²?ωΟZ•Ζ›}ƒ a„ΐ‡TYσ+QΦ™όΜΨφΔ]Ε]πνφ9©Gώ'sT3©ϋ₯eχMΧbί©yμeαμ“ ΄Β S–=% ΅pI³‘«`―4Y’ Kά=€Ήf Vƒ�— θΉ)α‘S“Tš ­±�Πsϋχk Hj€�tλ@Wΰtg•)ŒξΝ­:φ\“4Π³―α™b%x«P šό‹³ΑΙιrΐ•βΰ±X*KEc ς”FΒΊmκϊω<€Φ=Ÿ0’²Œˆ¬O£¬G‹7­λ�wwί|FΝσ$ΤLYJ°ςΝ&ϋH#Π'σω~5::fאnύΊΤs ¨ΦΞ i³…ωΐ„�(†όΦ0ΝμΩ}Ο7W/_(ϊž}ξykμAX,,Έ`Rͺ)₯Κ󘠌•?²gΟt& ­„ΣΤΪ(ˆ…vNݘ”κυC ͺus΅Β`§H‡zi4ζ έK/†Υ[΄όΆώ6ž·άsχέ“™0lυΌeS%ΘΡ‘γ‘ΦΚ“J%«ξ’¨έ‡l y€΄BRJrύΆa›_jƒΜ€ξY¨wr^Šzα;_»3zώΘ±γαΘΘ±πΘΡ‘π7ކBŠ@ppΞ=!‹9'<ŽIœΨι£δ)‘”²*ΫlUΌB­‰=Οσt*†<Μ„ε0 ¦~υWώΩΉΑΑMθYVϚxΥΉ!^j½hψ*PυαάΔώΥ—ΏDώψ�ϊSϋ?d1B>Ζpc)UIλ•*λlψb"„τώg¬6mΪ vθ.KΎΈ‰.ω’[Θξ5bυΊΜ3“―ψ(@XBn¨ωχM™šd‘Σ°D#&«`onq―jΒΝΚ)°ξaΗν ¬½φw|Irl—6€πψωO(M•�X*…€Rm3g©;k@Ri-”ΒΧφηh—ΟBS¬‘yoωΤΝέξNN#£!Ό· Eo6ώ›γEVŒΈχκ»ή©™²wrΊμ ₯™RΪγ ;JΉT”KEb©0Ož ΘS €J‚�«υ-m$ #‚xƒGˆ+݌²Y“υΗ�ΞNe}f½Q¨Ή©ΉlŽ\&›hGƒΉ›€>dώΣ(<½1Π‹ ΊΆiηΟΨƒŒ«d°ΕΟe†ϊ �ρǟθαœ[ ζ"‘.Δq―ή]kJ¨fŒIζ±Ψc¬ταίe ͺeυŠ©’Ώ¨³Gͺ.(Œ±Άζ:–™m'H?:2‚�ί6Τμš-t/½dVo‘ς[{ —hή2ΝV…ŒRJ&₯$2aυL˜z;^ς”J‚ !θ–-›}sXTζzt;?{'²φΥ ήωσo|bΒ+•Jttμ€wβΔ(;Ύΐή9qΒSJ1)€§”ς„ŒsN—” NLcq!aρR €l-›Wί2�MίO½pꑇ~ωάΞΫO:ΝΑSζπ=­΅½·λΤΚ9η˜ύ nΒ�ϊχΏςŸ�TOL˜}c#LB˜sB κ_ϋeΕ+πυ † 1Βκo�ξ›bhλΦxpp“5 ³9•�‡†‚EΊs¦ΞŸn|KMΒ,Λ!œέK͟γ@26~�f/ΪΦ™©·άΐ^ϊ@&€d4ΤΛe”sΰY5bYΊ‡[Ό­18V˜ρTΥ‰ΣΜκιv.˜ΰ(­AjΈTψν‰"ϋΥ�ωbθŒAŒbœΕB¨Κ0 Τ~Κ> mMtOM š( L)M₯ΦTUŸ@₯�D(E”ΦD(M„Τ˜+…₯�X(…ΈΤ TUͺ‰dΙ&ΐβησ’ΐtŠ10‚t@ θ1ZΙxtfMΞ�γϋn9}Mnή6]Z[Έ—*<ύROΞ=p!°`χ2Ό<˜Ο1έsmαν}hΩ‰>θ/ ½?~ώ…*«ΗΕβX=0k„ ”Rπ<«Ό�ŽχΝ€X½™uN5̍Ο€@ 21ΧA ΰk)nΏHοδ¬^λY½ΞΞ[Ί²+[/BGGOϊR**e2―§•ͺ‚±v5^,ΠQR“˜―}νΎη1 ŒJHˆ ξC€"„Π@+mcEΪάbηΟ'η ˜T’h₯™�Šh₯‰ωž(₯ˆTŠΘ$²‚˜μE,€ΐRJ$ f6­ ςZ \j9zΠσι¦z™03υλΏϊωs·ΏχΆ“¦1ψ$ςM›ΑκΪή/κηsYΉ{ZφgεΧ08ΈIύ»?ψίΔωΛ�&ή|λ-‰1ΞŒL0%„8Š!€”-Θ+π%�Acμ7λή�ώ•―Ζ_ώ�K™0,š½ίUuΈgtέ’=“9 ²³wf πΌρ8­% $aΣg*ΰυM™ύΜΚ7§Φ)ί\R°ηά€υ:_υΦΦ:Υε]zΎT©Ν³:?pθΤD •φd‡ΝY4H₯K>SŒ`�[g=ŸbμμŒ2!‰’θ™L΅Άw.Φk¬΅ΖRk¬5©5V*ω^iEΒ�!χk©4’Zƒ°ω7 ‹ΧZΠ#UΣ}’Μθe="²­d=:sσΊžρ�tο�SYŸš’}Β€½4±0^�χe»½ u΄ζ6*¨π±{Ÿ+S²Ζ}Πχψχ~ΠΓyœα1χηDnX½…e³ΩpdŒ‰¦”ijX=JYιϋ?6e ι„9ΐΝawρQΩz`ΩΜ8ίίΟΖ�ΨζvΆf-ι ιPwœΥλδΌe%ˆeŸ«3Κ'FΗ|©Lž—4J6³(Ιί­@*…8γΏό³ŸQίχcŒPJ‚±ΐKΠIC:Π ΘHέ°΅�&"@­–R!­Ν―™όG%%’J™|ΌδUJ RHP:™L3S­ϊ\ν|ž L±šλfΔ–Ρ3@oΜΤ‹w &χwΓΣ[&₯sδœvΝΏƒR€Θηϋω—Ώό»ρ_ε«ό§?}₯P�E *”z„‚£qΞΖI©�ΨYΎΰ“€ Ε€0Βc’yλ­_τ>ϊθ·ΛŸ{δ‘Y¦f ή Νο±{σd‘Zωf7ΘrΞ?GΝ"ΰΠ½=‚ξ§Νžfχ5Ϋ\ˆ  1@«`oa^₯ΐ^ΊψW»τ« ―γ9@Β·Ζ‹Τ&L½Cζ,VΆ¨@ƒTΕRiΐJiΔ%Ζ” Jφ1… ξμΤ_'mZd$–H)@ Μη’ΐ~>HjmΑ2 eΒΪ€WΕ«ώΌ‹yξ© AΠ£Dg<’2Œς¬—Δ+ά΄�§`€ή$ŒžΣ;ηένrΎ7υΆα!8πκΑŽ�Γ£'F/΄�i§Ή%€=7θΩeΧ» a†ς₯rΉοΘŜ\{υ„Υ»τ{�Κ$-«Η(UŒRα1/ΎσwσωΌ•pΪΞi«ζ!�¬^ ρΐ@žc„FH!Œ5ΖΘ4pz―{N‹�ΖyY½ηςb›X½ŽΞ[BjΊ5#S*•Βwή9α)iΜY΄2± ͺνχ RΊvp”�XJ‰(₯˜Rj ν¬έΤ‹$κΣΗTŒΌ5o ΄2Ώ—¬€€₯¦RƒFI$Gr|rΝnZuO€Γ� !Ϊσ$9zUΧΝRSΏVcτή€_˜šαΊ5»RΏfoΦΕ1€ωw Υ<βeΒ°ς[ΏωλΡζΑVΎύέΗb‚I7!$C ρ !4ŽcΜ9Ξ(…©Ίβ@Ÿ½/…@ˆ#cLHD)!‘$ϋμsΟχνά±}vǎ›§ΜyΐΚ%­kώ‚€Π©¦†Ζ¦,8υ˜²D4’°ιS%σ>,«7 fC―δƒό’½©�v£nο*›ΧαKsη ͺ]ΪƒοNϊJifΝY€�m5g©ˆ5(@(�@` \)‚Β$9¨Ί[wθΓ22VΓk0§,ψs\βώ‡\Ζ. π,€lΘKΛ6-Π Ρ%2λQžυH9ΓθτMλz �ϋΗvžΚϊΜΚpF‘&Εq;΄+θ5Ψ{:χo•Κε‹YΪ:uΉ�—==»`­§ϋ Ώoίώžιι™,9‹γ˜!”jΑ¬ž•�!„5£T3)Οσ8c¬τΐχNΓόŒP+fpάΠδb„‘°‡ς„}i±Ž3υ(—J­8Π_Υ9vΌοη?£}¬^gη-Qͺ)Q {7PJzJ)ͺ€DR*ΠFtΡvsσj>S€€)"„`»ž0ΒΙ&ŽP[ œμAH›‚ιYw�DβκΈd-ΥΎ·Ο:ΙΆˆ`Œλζσ ηiίσU2‚ΘΈnNώλί��ΉΑΑM�tΣ֌q΄νΠέπΉϋ¨u4Ά³eεϋοϋhyηŽ›+ρ—�-:uϊte4G)υ#ΖXT‰%s!cJIͺe[?οeΧ!5?£Δ€1B„J) (₯]ίψζ£ύ;vάΌΖiό₯UΤΜZ�œ6’*1e©ΌΐΔ?δ”… ώrH8z gC―d ±€Μ^ΐΧπΟ­b―Žήδ�{`#INxΌ0γs₯‰T K₯P=iŸ9K‚ϊΐ§•₯…@`ΟΪΞΫtZΊW=XTΏ€°t.(¬Ίϊ`αVΩXΉ Ο:nR‚΅G°ΧM™a$Ξz΄22΅ϋΪu…/}`Ϋ»ŽtΣm;μ\‚zgΘ•τΪΜ½ΐεœξ|κ ?k…mY½RΉά·χΙ§ΊyΜΞ93³<‹zL=|ߏξόΰϋ‹ω|ήΚ�ά9―x1λΑ©hΜ±χF€$ΒIN"FΈνΧν„qnmΡmΨ”Υ{μρΦΟκΩ†F‡η-]°η²zYΘ=v,”RyΙΜ™ΒZ)cΞ�™}Ή"­A)Œ€R€±Hœ]ΖA'·ƒκή_ϋ4XwR¨»“WνΖ„΄ pX€‡²l0–ξyž ƒ@AP Γ$Gο·λ7,Π³5ΓΝ_ν΄ά_CγΉgι4JPά4ϋ‡πogŸ~z�Ϊoηρ>JiŽE,γ1ζEqL£(Β‚ œ4_¦O) RΚ9@»ξ:^F`/ΉoΐΘ 9ΔqL"Ζ‹XζΤι3έί{β‡χίϋΡ §yTtφ“KrAŸ'jΑ67» aυ($ŽΙz�—Ίoœ1`³`^'sΚmΚ²lΐ^£›ήU€·,iIN™bΔƒw§ΛΎ�š)΅Ψ…Α'd ¦29Ω‰-»zK΅AB πiόUϋ}«8 Ο J§ι$Zλ€P"2ŒF!#₯ŒG§�Εϋ†Ξ}όΖΑS¦h[ΧΝSMφΛΕyσ’U;ƒΉ/αΐο^’‹½τ�E»eυ,3”ϊιύ}333֘…pΞ‘υ�r—zΠkΐ3ΖJwΌοφIθY gZΆ³¨[Ϋω{μ BAφ-Ω^x© ³Ž³zŸ·t~V€ϊΉ�ΪΌή;'|©$Rb©€‘%Φƒ—N4β€Τ€™γ“φ¨±tŸΉ@Aρ �\[ΞbΥfO2›‡’cI†žοΛΐχγ *Μά|σ�ό7νL& OB2ΛmƒgS�Ί¬ΫvՌy ύĚ\qμ@1†ΕϋοϋXqηŽνkΏρΝoυ�όυ7ΊYΔ²”�ΐ‚>γlŒ…ΰΜHb3ͺ‘Ίw^Nΰ/|ΙΗ‰1Bœsˆ*a”zŒ±μ“Oξλέύ‘;2a8υ™vvOΉΤΩ½΄)Kj¬^&~Ζ ψ&ΠXΏ€ ”Ν>ΦLmrΕΛ7— Ψ»ψ[},)ΠC©Β€ΜαΣSΤš ₯ WΚH8MG²#³νPzτYτ„-‹/ύ9΄Ψ],Θ3F,Ϊ3σy#2 „g©„ŒΜ^Υ›ό£{ΆŸ»¦?gήh“ξl£·άλ”ϋ\“Ο·%˜{ήCœ„Y°ζΎ ξ}Ξ:ΝκΩ\½ΨϋΤΎž8ζanjsNVO"­Υ’Y='€;ΊιΖf†‡·NBΝͺΏUΖ,�›+ε 7Κ£GΥ²φPGηi~©Ζ-gυ–pή ^ b›ƒYOŒŽωΥy=•ΜšuZ WψΥg5.‡Cυ|ί·ωΜU½χJ$›͘aφ=OΎ/|ߏ‚ (ω?ύ‰?XΈ�Ύž1uΒ‚½SP?Σ]ιΠK€³YfsΕΌΗ˜ά4υε/}qνΑC‡σχoφž>}Ά‹sžυβ؏γ˜ΕœSsΧύ”’v.ΥΟυ©9 μJφ>•Rη(α8ζœFQLMMw=ύΜ³ύχίϋΡ5PŸΧY†K˜έkΠsχ„Ρ“eΟD-($ΰκΟGFΎi™Ετάΰͺ|s9ƒ½ΥΗ²{ΦB»:ρfa&Lœ8Q‰Γ$jg(Q³a£oτ2Γ Ί©Θ³³y#Ν0Φ>ΕΪ§D…Œˆ’8`€22σιν[&ήuΥΉ¬ΟήuŠΆνΞΪ.έxί”)ζη`nPm«rOuΠ'Γ0TNγ€mko! ύ".sY½‘γ½ν`υ–pή2=―gσυ2…B!<{φœ'’Ψ,₯θH˜ϊΕ‚«+1DΫm|!U&!¬cΐ(Ռ1εωž |ŸϋωΎ_ΌξΪk¦ΎπΟ9?8Έι΄τή5Α΄Τ_tθ₯χSs]΅[·œRdκZΡμeS0ΎsΗφ΅;wlxρ₯—ϋΏϋΨχzNŸ9›σΈ—‰γΨ\0ΞRΑ’Jb)$’JκΔ]Ά\ζšΊ¬\Ω§+ηT #.DQ„=Ζ<ΞyφΙ'χυμώНύ™0ΜC2G>υ3shΎ½₯IcΣeυ²BαΗβ ΠΦ}$†`γl θ₯›W«ςΝU°·ϊΈˆ ^’SΝKzυέ _*Ν„�Ψ8q‚�₯‰— ZŽ(< πX—0y$aς€b€F΄OˆαMΨΌΝ=™©uηqυώ IDAT γΫ7τž5ΩwSEΫ¦—‘fƒΏ’ΜX:Μ]ί­ΏΚε vξ#μ+ίtΈgaράλΩN�Q£ΓΛ<Q}ζΪ  …ΎxεΥ�(ŠΪΒκ5š·|π{Ϋ9oιΦζ€½£cΎRŠ))‰T�D.hΠ«…’£ΟuiΕFXc#χ₯”iF©bS¦ΙϋΎ_ρ<―tΣM7L}ώsησύ.Π³5ΓmώΈςΉe!‘k"λ΄υΑ@Y†Ο>+UΟοά±=ΏsΗφΎBaΌgίΣΟt92’}ϋ‘άRzR*O A₯L€Ÿ+)η‚?₯‘²Μ_ⴊμ<\3ιηr~ivΗΗqΜ<ξeφξ{ΊϋφΫoλ5 ­³ηSΐΎZηž5ms3kφΖΎA΄’pΥη#σwO8`oΆΑyeυ± φVQΈJrΞ+αT…{B)"’ πŽ„©_‰ΰΞ".ƒgžaς΄ G§[§<‚₯O±π)‰}ŠΛ%³ΫΆaςŸίΆu"λ³³¦PŸN<»iZ)„X@tpch{0·-ΠJ›n��XƒΆŒ5|jž₯p1¬ή’™‘ωX½k―Ήz>V/†Φ³z ΩL¦S«gΑ ½Α΅³{g—{yθμρ'ͺΧ.ŽcΜ…h9«ηΞ[ήωΑχΟδσωΆΜ[6Swe�!GŒψRJ*m@ΈVWŒ}ύ�ƒ»d9Z€—4°¦„‘DB΄ΗŒ‰₯Βσ<ξϋ~δy^iέΊ5Σ_ψόη&‡‡ΆL8νԌsP?χYe¬IΙ:Σ,_z†Ο2ΰ}fΟνΛηϋϋ>σιOv@w‘PΘ8x(sτθ±Μ)…'₯ς₯’LJΕ\π'ΝΣ€>d?$₯�ZΝ'ύTΛNφ™fχΈΨ4«ό·ί~';66Φ388Ψη€½κξš1ΒάY½Œστaκ5³opΈκσ1ΠfκY η)XqP«`oυ±ΤΈcN—φΠ©‰@j퉆©_.ΰmώb4ΨUQwβ2ZΕCΰ™`tM Φ,zΚ#Xx ŸΰΨ£ΈβRΊwΫ†ι‡o½fr].(8ΰξŒyZ7sΩΌ•8ΰά0˜{ΫπP[ƒΉλμΣM@²R k₯™Ή‡VoϞέKΑκΝωX6ox‘‡˜ΕƒτπIg"ζ _(ϊž}ξyλžJX,‚‘½˜kxΗ·wbή Ζ~Ί²�ΰΘΡO)kΞ’ΠBζIWAέ\€g*H5>ͺ Α°Ι]€”jB‰bŒI1I=Ο‹eευλΧΞ|όϋ§nΏύ6Λr5kΗyi6oΩ7ΈuΊΞΏάΤΎ2ΤζψΖΝΟΫγ<»σω|מοΞνωπξ,dFFŽg;=29:Rώ”T” A”RDJI”R Lβqͺ쟐2ώTC·Ο₯nΨ₯$‚ƒΰσ˜3α‹πΐΑΓ]ƒƒƒ6"!gj—έ74VC€k]ΰ½d™ΑΨίa%`νG$d―KΛ7νΉ₯n �½U°·ϊΈ4 7μ½yΎθ+₯™TšH �;Λ™£•π’τ{Fσώ9δΪ °s˜<Α8‘k"Π”`MΩ¦ς–Α’Μ}ŠcFp₯/πJΌfΝΜΓ·^3΅.L8‡½³Pcς&œΓ{s;ύ+ΥΙj‰‚Ή5h€”Φ:ɞΔJ+rώ| δ-p`P“Φu·©ΙR€‚—Θ7ΏŽ{�ώ‚Fζμ2Z“­Κ]{«+ξα²{ξΖΎU(IΎž&B+ τΪnΞb(믐BΛ€cλ‚7 ήκί§-ΤΥS½ω>aο&)‚‘Ζ)š|-1B’`$F‚`Μ Fρπ@W喍}ε;�(nίΠηfM˜ΧIηižν„E©Ξl+ηv– θ›Μέίί'gggΫΜ­θK>YY �„ΙΐήΌ.Ž##Ηϋ^ύHΛ\ӌe΄«ηεv’Υsτ–Νƒu‡χVήο‹ι+λξ™.«—/•Λ}?zς©\Μyΐ… 1η¨Ζκ-ό Ά”¬ήŘ³92β+₯¨2ζ,RuƜŝY3¦$‰A‰Sνˆ^YΠ΅K·ΔDικ―' UWλ 2MŒy?!!€I!XaL$ΑXJ%$&„ΖΉ\Άςž[w•· ΟξΨΉ}&†3 jΖ”πf fΦ5y—M͘τI¨I νΐL=₯Ξύξ5.¬²™L&³kηŽΜ�;,( τ3“Μύ …ΎT2\0!„Η1R�8Šq"δΐ9₯JφέQ–/a5(©@*…€DJɎŽσwνάaVΏΑή©‘^ΎIΐQT�Ÿ3?ΐ+|H@Έ)†„a΅λΥzK‘0Y{«Λζ1θ@xθτD ΅™Χλ€9‹aΌjωqI†œ6Ξ“€“ά-m™>³“,Ι ŸšΉK€6 ]φ4‚*Λ§νŸ3P%2M€0 ‰1’#A0!Žβ»ΓxΧΖΎhΗ†ήʎ }₯uΉΐ$ŸvŠυTͺXΟBm€ΉΘ»ά€^#Π'σω~5::f‹U[~V]•5ΐ§Š›ΫPIg·Νλβψ“^l™‹c³L6Οσ’ξξ\qǎνΨλ$«§‘>vA€Ξ„…Υ€:ΤΛςΊ-€7˜?±ϋ¦5fι€όΎ}ϋ{¦§g²<ζ,Žcb$Z—«η֌9ζ,'NŒΦ™³t‚¨Ε 8r„&D[&, €:.\°žΘΚ1FΥgκ*t^Ν―+Œ±"Ι ²ΔKŒ±ΐ Œ1Οε2ρ Χ_mή²ΉΌmh¨4<Ό΅”ͺΣN­˜N5νΤeς.τY–™Ο›Š`ξώΠόΩ™_?-σ—€μππ֜‘~ζ ϋκΑCΩ£#Η²―Όr sφμΉLΜ½@pȱ˜sG1‰Fœs@#!tFΦι6k’,A‰Μμ!=ρΞ¨ ά,³Gύ1ΝκΉ 9z‰|SΓΐ]ϊoγP“�¦½Υ9½U°·ϊX Ύr:.ΤΩ 2ΗΟΟψJi&΄ΖJ’&+I·λT½$Z€ΧIbM1RŒ`MR‘*°TέΪzπI·!…˜W€€� #σ^“y=…0(1B‚ $†Ίψ†�0Ύ.Ÿ‹vlθ�νΟUr>³σegσ+¦6AΰΉ²;·&!5—·ά ·sx^Μϊu ζv‹¬[ …σ`¨Y(·;ΗΠ1Η*#dd_”ZV•ξΩsχT& PΟ• ³³zΚyJPΈώ άr£–4H/—KιφMjq–Υ€Ύ½O>ΥΝcpΙºρ΅ΥΫsχξNΞκ54g9:rΌγζ,.Π³!α”%Aα”… Q6~c Zk½Аfΰ ›W„‘B€”ΦZ!Œ΄Σδ0οI„±Δ‹5kςb`` ΪΆm˜―ΙηγΑΑMΡζΝƒΆ^”œΪ0γΤ‹4Έ³υ"rκΕςšΎdkΠξ­œϋίmδ5VΪ̜&ž—~¨7ιή΅sGΧ�;Ί>ϋ™OυŒŽŽuΏπβK]ϋό\nzj&EQΐ(υ %4ͺD8Ζ#„’*λ΄.ν}In F2ΉΏΙΡ‘—έ΄x υ³ΞiS–«'ΛFΏ½†ΓΖOH³έ5[†yz«¬ή*Ψ[}\ϊ!ΫέΈκ†fŠ>W:qJŒ @ιφ™³Xι¦z>Α:`D…”HF0χ#3‹h΄t]ž*xCω%Nή›ΐ Z�Ο…j]W €ZΧ¨υ]‘ΞyT^—Ο‰u]!_— ,Wq °x.ΰ›u~έ-֍ ΆLžέ<Ίεξš}έτΏΘίΧώ™κ~†BaΝFέ?ΫG7\™PbX=OϊΎηΊrΕέ»ο²s;nφb+έ/tΝ�ρ„@&,HΘ-9§7ι'FΗΠ-»v’yΦ£{$ �έδ_|ρεΎ™™ Τ η )‘]¬ήϊυλŠ;fΆ“¬^z^/ :iΞβ�kJ)xŒiΟσ”η{Κσ<Α”PŽ1–'σ» -Ι>Ψ ΌI„@%ϋ6΅mhHΤΆλ‡5ΓΓCL€Œs]έύΦ© ³°sέuΤ΄L‰[/�8wΰη2~ξλ|ΰAcφΟ6FκΤSPsώμ2ΝΎΎΝ›{7oμΰώ{ϋŸΪχLο΄·gbb*K) !ŒDΕa)²d;Ÿ³OšΠx…e/Aϟ/ψωκ Z,ΐΣ)μΥύΉΣ?@ •€«M›5Yv₯@oUΎΉ φV‹θΤΊ{™bΔƒcηg|₯5S °�Δ.΄ι $ηΉDΒI0�Α:dTe<Β�πΓ7EΧ―ι)#e„ ‚ΝΙωY’Ο.ι–'EZ@ΌcCŸp ©»I©TQumŸmΑ.§:�φΥχ8υ΅hP°]Ό{@u%œ¦ΑέΉM3%ƒTΧ―‘Δq> ηJ�šͺNvπσϋ…~χ=wΔΕ1u(Φ”RεyLPΖΚΩ³g:†VΒ9ξY·Σί�λ^Sι¬ε8†a,-+cέΫ �Ρά΅Ω蹝j7*#oΐήΐw�~Oσ0ŽcΖ9' «'‘5…Y8°Y.ͺn“‚9‡ΦπθΘ±Ž›³XL)ўη)?πε]w~€όΑϋ+α2B¨‚PUρV9,A½@uυbσΰ&‘Ιd.΅^ΈΟŠσ,§jHΩωs.ΐ³ŸN5WY“y€Μeύtƒ=§φwΝ™Ώ΄Ϋg7Τbwϊ3™ΜΪΈoΝο{οš�χΏ₯�΅Χ^οΑg0Ζ>ΔEQlώ]Z£6K:“%’°{IdRŠ:ξΣnF)N`χηMΐβ1 /)Έξw4›5[tvνJXΣ[{«Εγ,ηΠY Έ|kΌH­™Pšpυ�³χ¦A’]η•Ψw·χςe-]UY½Wucλn,₯E‰΅!,�ˆ" Qαz(Q’(m9&Β3ΪŽπό°ύΗαΠHΤ,֌₯‘δ±$j‘%R\΅ Aμ$Et7(½$ΊͺχΚ�-+3ί»χ~Χ?ξ½™7_eUo™΅ ίΘ(¬]UoΉχ;ί9ί9θ$œN’ύ›Ωcn>/bΤ”UeΑӟ>xσ"t“‡Ξ’ΈŽ€s‡±\α`Ε\ηTΕt3wˆgΑΏΛKkόW+<ύ&kΊΰΊΛ�oΧ πεlκσ‡ …ε�‡΄Λu―w‡3t—Ϋ™ϋχΑ뇏¬ωCaV~Ό»5X–Ή8>χυη{ζβΨΑκΉ€e!„‘Θ†‡‡κ?ώ¨·_χ¬^ :ηvΦβr…¬^Ωδδ„€„*JΪΉ&g;oΓ{VΤ\%HΟƒπήU`λ«―}kτάΉσƒY–•€R-S­�5\|#Έ¨v1gΙ+AΚ§¦¦c—+FQχߜ%ΐBE“RIή{ο=υ»ξΌc!83jΑ>ΫaP΅ Ο‹°Ω—­ΠL»œ!yI�2ΕGQH_7ψƒ\RεΐίJ¬_X{…rώK0W©TνWώUγK_ωZφ—ω…1λΒJΈ1@³,s΅YgψZsΞΞ»AkMŒV―7ςΖVαΜ³N9«­UtΒΤg Lώ΄„dΒ?Χ^‚6Όσg}± °W¬λ{‘«ZŽžK¬'2›CeH?•w#£„N‰‰9Γgςξ[–ΐΊ‡>γρ0ΰu=οόaΰέאΕΠΠΙͺ.6,.Γ�tι0šάχΩ™ϋώ°κΫ!Ÿcσ,—΅ˆ ΫJ?Β’Ϋ�lώZΆΨŸΰZQXyΎΚ�κ}θΞκUκΖθ /½άSGBΐ±’Θ³Ρ|δ‘s¬žŸ‘Xk{λΠI΅UΐJ΅†φgθ•Žσκ@z·ϋ—gυ|TΖψΏτUoͺ#d&™’κύΔκε­"΅^―'/ΜD¨9‹±Μ^Ώ€e!ζΜI“γXΕ₯Rσζ›nZpΧγœϋ:` Κ€Νv^Θ.ηEΨTΉsDwi―?ΰ―Δάy2^:Yςχ>yWmŸ»§?ϊ‘tΌ2¦~η?�vίΗζΣYλ€Φώ�ΐ—7Άr‚ˆdjzšΌοΦεχx#”pΪϊεΤ¨<"aΛ=ώΩ�η>2ΧΨ.žΥμλ:Ί΄Ό ‘«Zr²Z‹5‘‚|= ^ψήoš”xfΑˆŽ9ΛξΨ6ΌδŠ˜Σ0νpx‡`oΝ/cˆ„έΪdXž—Ο… ™7“+(½>q_σ€―ΫΟd‚οJ}BΧ5_ @?_—ΠπPΚCΫͺΉ”ϋ½Β�/ό™|‘Σ Ί€a·ž­τϊΜ½ςjΤλέ δπ―ΧΜΕњW0 Έ0ά±zœ‹ϊγ=κmΨ½{8«ΧΧn*!ΔE˜»Ώ­’ u3N„φύΎUgͺ+›<ΠρΟ±gυΖ`λ‘£oŒž;w~¨%Ώ X½kέ37X6"Ι]ƒVspzϊL QGˆΘQk’5‚KΖφOZζ@0³!β‘*Ž’Ζ·οŸsηΔ)wnΜΈ‚yMžν5W1jcšq&χξέγχ»πΌπ`/œΩΫΜη�ςο x›ό…ΐƒ}PηšΨύ°8όϊQqψΘQ‘΅ΆΡεηFΓwS@[zΫbυΎτε―φΥγ‚·X½‡|ΐ7ΐf`mX=€εJoŸœš:·ζυАλ™1½²λβ€�”₯cš1–έ~`Ϊβ³Α™±¬υf>/ `χ>AΜCΨ΄ ΩάPώŸόΏψΟ“�ιώΧ%)eΕ�9¨՚φέ»ŠΖX»`χιΟΨύqVφ@/1 %Φ…σfφŠΥ · 읬Ϊ0u†iclδ‚ιο iY=JΐP H ‘wϊό nβ–ΌΰzšΛόύ•‚Οκ »Brηs'Ονω­νΜ4Žeb„h8 �nƒ=B #Δ4€Vϝ<Ÿ½ψύ‹Οώώό/=|Η–ν©$nγ ϋΓ�αu±{Ή9=„#άMΐ$L|�RmϋοΎv²ςέ3³CRcI" c€k7‡ΠnRB4§DrJ—^?=;τΖΉ9q~1U�μΎ½KΑs‘‚�¨$ΊŸΑάWq†˜ζp_\W�eBˆϊΣO}d.z^ΒιεBλυ>u“ͺθ{Άu˜B€δΝΒ{Έ#»υψρ“#ύfυ3ۚΧk±zΠΗ0u—―g3φ¨a”’ Dί6>δ8Β;o%m6�ζp΅‡q#“° ½PknϋΜ‹ΗΆ§Joo*=’*,KB!2΄�Zβ *Λ^‘ £#FUΔhͺΠ ώοΟΎQϊ•Cw°ΗnݎΑλg΄$τŽέ ΩI/Eυ εMpΣs'Ονώ7ί|kΌ©pKͺt9ΣIν]_x# jd”hΑ¨ŒΡ0m>sςά–vί^/iεA'Ρ΄€lƒΉ―’8¦°²‹cϊΰ⸊ό/ύΰ.V*•ZSc–Λ¬u˜₯jΏΗNœοSϊ΄-ΔCχΤ­PyζΩηF€”ύaυ5Œ3ΓΉgυDύGž|bΎœ$UθœΥ«CY=Xμ%SΣ§Kˆv^΅ SοηΆμ3 €aΜ΄0¦)errr’ aβ‘4Lmφσ’X7θ X>•{χΌ©Χ(TžxμΡΡΏόό_ !Κ\pΑ2RΣ㈚φ{ξO­ρˆnΎ ν֘�$“‘clΆΠ+ή‹μ«·+ φNœ₯“3‹‘ΒΆ9 :s–ΎTΓ€ύ•Q”€α„ ₯DήZ ‡wC`�b£ήG›BΎs^€gOžΞ4nI•�gz¨.Uœ*δ ‘’—{^Oβ]M1bKœΕ 0ΐΨ�ύκ όΠΔXs 5W ϊ"ΡϊδZ ‘\!Μ +ݜ€½Ο<7ωλΟΏ΅£.υhCͺr]j!5ςL#ΡhZΰΥ"Œ#(Α˜3n JHJ ))‘IM(ίτnŽτ=˜;w ιιΣy°ήWX·X½#Gήι₯‹γj¦O>ωΈŸgΊv^/4fΩP‡ν@ΉάΧ{Υ2›rΟ�cφBϋp–»‡α½€ρj΅:ϊχί=<”¦iOY½ΦLw±"�qeƒCƒ΅Η{ΤKpWš·μΧώΤU rμψ‰jΝ4jΉ`ϊ¦NP†CJ©Ίύΐ~^ψΏ&q6E!Y¬MψόyΞ·Υ\ƒ� —ΚεςΒ‘‡¬?σά7†9ηš1Ζ(£„jΪ? 5΄A^Λ@kΉIm:ι:€ͺ:ŽŒx°Χ΅Ρ\ΌŸΨ+Vο Π”ί­Φ’ΕLEˆ†)ƒθυ՜ΐ³8Δ0J £D3Bδ­cC^ΟνY½υ”™­Υ= ε~B£‰”ΖRͺ0N•ŽR‹Ti&΅!Ίrίώό΅d”PΑ¨‘ΪJq ΈΤΘδί:S�Δ}{CΖyθœΧΊκμ½\hΊ[.C{No7L~�RmχoΏrbG-Scu©—R5”fR#•Ϊ@+Λс=`8%FsFΐ2|L!%ςδΎωbhG.@ΨΏORBuΏƒΉέΡbˆκF7g²•²Ω*Ο<χυž1C«™zάuΧ΅ύϋn[ΙΤc­BΤ―ψ‚NNNΌ�οοb/›³Ο‡η3£<«7κ€ϊΨ—Ύό΅Φ¬^–eT*Υ#VΟ‚=ΞΉˆ„βB4~τΙ'rو5hηR΅@M― ¦+Q‚œzo*ΦZsH‘ Y‹0uŒR Μ*A(₯jΟήΙnΝΑ°Θ,V±6ΰƒΰlσξΪ‹Πφ1X YΉ\Φm GlL›d„ ”b!H)ՌQY.—3θ EΟΛ4Γ―…KφŠ΅Vψ:]=σ=;[�h"½†ζ,ΔE.X©1ΜhΛ`^οFδδ»vμΥ ~οΞΡ&§D2J΅c> ]e3χ€Οƒ> ψ ‘Ϊ0©QΜ5eςn΅6θ€™{^ΆΊ[�zp瞣΄έ7·9°·γΏ·υ½Ω₯-u©“ΊT’)-£§“—wz%τgΤDŒš’{Y‰³ϊ§?xΛάφΑ� œwΐΕƒ–,Χ-l*•Κ˜@±ιΗ­λΖΝΜT[ο•ϋ:€…±j΅:ςΝ_ξ3΄«χOoψY½­oI˜ΛI 6P‡Nλολ+<[A4"ΈY3¦hD¨{SJ½Ρ}α₯—3)KR)žΩ0cΈή q ‘FpnD$0Š")„¨?υΤG`uΞ߻+1gyoj:FDŽΞœEc�ΜYό=§”%v^QŠŒ2yΰφύ­ύ2‚ίΟ��Mς Σ„,ΤΐοΪλ hHlߌ’ŒcΜPFυώύϋς YΨ…―£Q_½μkmžƒ Ι»—j%m\˜ϊZ™³ΨίX&‡ §D—DΆ}°δοI’γ7pί%[€Ϊ{Ηλœ’”S’₯†S„€i +V!ξώΗξ©‘KΔθθΉΩ²gƒ�˜Νƒ½ή.ςMίιχ™z;`η»ΥΪφΟ=5Z—z ©t”*dNΊITŽhΙyνs‚RS ΑT9β4QYψΙ»&«θwΰ%>ώZφ=˜;Ο!"―V/E]ΐB8ο5¬”μ 3t9Vοώh³Μκ…Ł€v6Δ=Qtϊkλ₯Π­b 5 –Ώ‡Α½«<χάσ[d&E–eL)E΄ΖλfυΊ…¨Ηqœzδ‘Z₯Rρq a�^XXυ»xκjΞrκΤT‡9K�ƒΤ[ ƒ‘”"γL3F³Ι‰έμ… CΑ"l ΊΪζη™Γ¨(ί”Nl;%LυBu%VΒιLΟΤΦρŠ{aΤIΎΙ�πŠχ°{ΕZ»M"t τ`―|rf1F4Β›³h—•Τ·1{�Tw‘ Θ)Uϋ+­0υI’Σnο%°2Ε₯‡oΪΦ”JF‰ζŒFΪrN/‡Ν�aώ+π3qT‘aJ›θΘ™Ή²+bάύsο•-ŠcH΅ZeΠι Z‚6#[©7£χΜ³Γ2“%)₯PJQ­�OΎΉΚ=Μ„υ§ŸϊΘ\τf»49̝ζ,ǎŸ\Ss–φά1ŒQ`œfΓΤ³ύϋnkζΐ^‡Ό΅8ϊΧθΟOςΒ&X|BΰW°|W^Ώ ˆfͺUfŒ‘nO#ΠγwΗ¨3η­ΐμΌ^e|ά›Ε„ζ+ω¦kΡh)ΐ^±Φaσ Cžγμ%GΞΞΕ ӈT[IZKfΧKΠηητΠs`S‚Œyο�Q/α\I’σώΪΑΫ‘€ΎψφΨuφjχνipJ2A©¦Q�δεHΗόžeΤ¨6&zρ=γδ™=φΨjέΑ S뛐ωΨκήg_�ΑΦ7/Μoi(8 G%Άε›ήτ'ύΐΕȚ˜S]β,+ Άτιή2{ΛΨΰτΌ„sΎKΑ»¬°λ/£ΧΙ΅ζφ Rš @ΰΐωάן™_X(ΛLFΧΛ ½X½ ‡NnY9I$±N‹HZζD=ž·„Nvof¦Jr0οΐY€ρ―ύωΡΕΕE/ΏeRJ’t{Ζ²¬^pΣC<΄X©T<`Ÿ΅7fθœΛυ Œ�ρ'JkiΞ�Ε.Lέ± rλΦρ¬\.ϋζX–Ητkύjp~ٟ7CξγΒ ­. Β –―{ƒ•ε@stqfF8°η_λϊυgB eΜPΚ4₯TΨ·Ο»¦7Wzο  W€½b­ο†£εZ*Kg1#‘»°m*ΫΉG7“&οΩ9βξ•$9οW)€Y μ=°wk3š F΅°Μ^˝σrRN7ΓG΄1ΰ²ϊ˜BŒή8;λ™=Οξu8r�rˆϋξ’w)άvNoμzχRmΫο睑ΊΤ ©£¦�W$ί”BΜĜaYpU¬qΗΆα…Ÿ>xΣE8η>ΐ²WRπšϋχ­EqΣΙ‘‘`:ή―Aφκυϊθίώέ³CJͺ$Λ$Ώ^f(ΟqΑ»9pΞΐΖwΰ Y½ ²ΙΙ I U”΄²=ο2Gzj²Σfg­Ω,g¬Η`Ό^―ύέ3ΟmΙ2gͺ#%³χN/ε½f³b6βGζ‘ΝΜ^‚Nc–²κγ=Μ33^Β™LM­½9‹u$-sΘ޳g" Ξ Ο2aκ§Ξ( ΑŠ;+Ά»ΟΈϋg~~<Ος€―³\Zn’45]2ƍΘP#hέΫi—\ƁRŠŒRΕXGάI³Λy\€Όu\E¨z±aδΓ―ΛPφσz “ˆNΒι$9}xg}˜Ί΅Πn1{š’έ26Κ8Cs–χϋζ½™ϋύ—`qϋ`iiΧP�¬gJ J‘Qb(BI;M|΅‚ΦΕ2χα M|τμ|rχΞΡμu›ΫΛ’yωζ`p€ο€νΏρό[γM…C ©γ«•oFŒ’—o–[όΥCwΞ8€wΞΒςY=έε�phυθ… Α6j–gλ @επ‘££ -f(».fh₯9―(Š�ααΑΪύχh6{=W/βχVή‘D9Η‚�~€C;BΓ΄H<Qvιψ‘£oŒ-,,΄Mu€$�Ιo―5HόrوnV―3ΫΧυ.ζ,y%HωΤΤt¬Q[sέs––μŸ2kύΞ,³799:q6‘Σ °ΘρZΛleεǐ{ΓξωρΔƒrχmΡνλ<8χ[{ƒ1†άΐχ0?£Ί<=}Ί€΅¨5CD -)uo›,ŒQ`Œήι€ŽΫΘ’Ι²qVΑμ`/οͺV€£gηλΔ‰Μ: �ο֌eυl˜:wωz;‡’|`vήqξF|^Κ–‚eφ vpχhƒS’1J4·974―<·ηε·A•ˆT£‡Ο\jέχ,”`…Ή½.ξ›žΥσςΝ`ε›γo^˜nHUnH-RuuςΝ’`Ί1Y¬ώιή:wKeπœΛκy 2+J8;.EƒΉW¨t¬”Σ `|qμY½1ϋΒ_}y8K³$Λ2‘”Ί.f¨Εjg†sΟκ‰ϊ<ωΔ|9IͺΠ9«W‡ ΔκίΫžΩk}M¨ΝI€„φU– έو1ΧΜϋ«/}₯Εκ)©˜’ͺΟ¬ή†˜·Μ+AJP�ΧλΙΕ 3jgΞb,³‡ˆ}~f¨k�–”l�Ύ}žΡkΐςy½b­Oαg<½qΧv˜€›ΰΆγΗOξ€ύ°n€½`½*.‹ΊΎœ$6Ό¦ƒn3k4B‘‘Ξ‰³•}Φ—œ=gΜ�Ž] ((QϋΖ‡Ό<΄‹›χηιHˆq³�=}όΒ’{ξέZ��›§3N‰βŒD.—hC:rκΊ`0lΨ:’΄‘Ϊqψμœ/hύ'?·Gμγ³Μ}“C›±ͺΈƒ|ηωZsΫηή85ΪTmωfͺ(D³Ÿ- ν<½PΎ™pΦΨ320�±;w{ωζY°³z— »)KWœ }ζ^~­Œ©1€‘#/½όΚ–³ηΞ dY§™eυ�•jΉ£Q Œ3ΗκE:Ž£lph°φΨczχΖ•¬ϊ7RsΠξgs(Bˆ&`ΑΎm- ^_Z 'ύlQεεW^=ξό`š¦qše<“Yλή]λώx9Vo�ΎΫΦ{ή2/k5‡ŽŸx§„¨#Dδ¨5Ρ+Ϊos06cξΜY&'vy —ΒςΉ‘’Ψ\ϋ:#l˜xγ�]°{zϊτφίωΟΏΏεάΉ |`  ?�©Oͺ{οΉ{Ρ55Β3¨1q£2|ω&khϊ5πΦ±γ%₯•PZQΤHόY�‹χ0gŠd¬)WŒρlίΎ}ž‘]‚εςιβ½[ηU0{ΕύΟΛ8 9Y]Œ΅ SΧh–η½6gρ_[NBR"o΅ωzΎSJn$IŽŸΫ οβέ;ΆΤ9#©`T Jρj"ΒΉ=eJάΝν…�œ%χ\t3i œ±ς™zΫ?σΝ·*3KιP=ΣqSiž*M2 Πύ¬ ߝςΝ΄$ΨΒ―Ίγ�@,Όϋζ9Wπ.ζ–?žΑά[+•Ύsϋξi‡I‹AR―/…2ΧQyα…—‡₯Tε,“B©λc†B°Η97œsŒ"‘Έ}ςΙ…r’x η%°μpΪ³―Έί£0‡Ι?�81±+ŒhYοχΈ”"€ϊή{S!kνΩΚ_}q9«g c°%Α½–Bj°z:Y½(OMO'Zc€΅fZ#5ˆΞœ₯― ±–9 c %jϞ‰¬\.‡gFώΌ(ΦϊJό<χD½ΡΈιίώ‡ίΎωόω‹7+₯nŸ_Έυ·�γοάϊ‡ό§ϋΰ6h3|» =Η·,θFaψrjšόθΔžššΌpαBI+Ν΅ΦTiνΖo°g`RΪΚΦγœλHΕkήwοέ-OX.γ,ή½μk7αnΎεσ΅f2ί”‘Bd Υk¦`™=.LέΞλΙ{vެ¦~£laFœΟ°Y€Ϊ`,jwŽΆ"\}[ΖΉŠ;gnnj4L‘‰_~o&”„„s{!»8‘)Λ8XωζφgNœ�φτ₯-M₯“¦�"UH3;§Χακ pYωζ�ΗοΩ;wχΞτΞBΫ}3ΜΛK7»sW*•ΎswՈ`Π°όΰ”—Ϋ ΐΘ[o>ϊΖχΚY–FY–v°zW{&w°zŒΞΈBh‰,’ώψγΞ;ηY½t2δρ™οϊt’$Ψ~†ϋnΏ“gdΡΟH y χ�Λ―Œœ9{v MΣ8“²Εκ)₯ί—¬ή*JΦΌήΫo5j‘΄¦5AγΩiθ³š³X“¦φξΩΊ6rϋC17΄φΐ„uin€έίxώ…‰Ε…Ε‰,ΛveYΆ+MΣ]Νf:ρΒ /NώαύΙΝπέΎρπui²zFo lyωΥΧ₯R±RŠ+₯H;₯GΝ'αd”gά±ι4=xπ^Οκ…Μ^8+[¬u^…Œ³|ΛܜޭΦJژH­e˜Ί-΄ £8‘Ɓ—μž£ωΩ‹f^ΟI9 tF0xf―΅‡nήΪxmͺš9“d”P šΝΚU΄0€cnψΉ½#gζΌ$Δ›Qx?dLPτ‰ �8 ·c)•Ϋ~ϋ•γc +ίl±z^Ύ‰W ίLkΙ7ζΎ½Θ; ֜e:ε›«uνΧ$˜»˜v†FΤΞ½’οlΔ_ύΪ_fR&Y&E&e«w-Μ!ŒQΰœC9c!š=ψΐbŽΥ[μRoTvάδξ‘q†ι—)Kxο‘Δp&†ΐπ /Ό<$3YΞ2)dξ]»ΪΠ¬žo‡JΏW$§¦NΕ­y=4δZ3"―¦θΜ›³0FΥδδξ4ΧTω3£˜Zs`:ΩΆά7Ÿ}φλΫΣ4­4šΝA™IApaυΐσί|1€θg?ω‰°ΡH‘3pϋ˜φΟΪϋρώvaυ|“uμœόlωξ뇔R±r »F„λΝόμl†Ί}ΚJ8‘ �Έΰιέ· μ₯Α½Αβ½+ΐ^±Φθ-{GΞΜƈFh4LC°ΗΉz+ž Μ…9s–Ϋ*C> 3”δάhyI!³·,‚αž£uΞh&U‚ΡΘE0X“B³2›ŸΫsχZ―.Δ΅T–c‘WχƒρΨ₯³8 Φ”e;μψo―Φ³ŽπτL#h³\Ύ ξΎηε›eΑSλΎΉLΎι³Ε–/ΰ8ΌvWΜέSμ`άΕE4 ‰Φš4Ύΰα.\Œ^yυ[₯LΚ(K³λbυΐ1Μ. w¬η’ώΤGll4Ε,¬O&Ϋυ.ΜΏ~Už‘%Ζ†m°‡ ή|λΨΰΡ7Ύ7hη,Sže7Δ¬ΐ*J©©ι€ΆΈΩSS­U_ΓΤΫΧmΉ9ΛΔΔdhΞ^£’Πάΐ€Ϋ^}ν[γσ #iš 7›i)KS�³Μ(­"D#ΎρΝ˜`?χΙO„Vψρΐ/sίOΫooή―Yn!xφL©―{εΥΧΆœ;w>‘™2Λ¨R Πξc=k²δΤ#Θ—œ±Ζ=χήνέS}3± W9R€½bέpqή‰3€�‰`^―ΕκAοΓΤΫ›HۜΕάy§9ˍž—”—r6=Ψ»₯2Έ4σζ�UF0Ψ;Jΐ ‘ˆΔΞν™ψsσΙ{Η½”ΣΟνqχ½»Ι7½ϋζΞ7ΞΞmύΚΫgΆXSεά7[Λδ›φž[ωfδ䛉°ςΝΎeϋμέ;G˜…Ό|s%VΓtΙ~ζ±oΑάΉλK ’ΡZm-°…n�μΟ�?‘IeYΖ₯”4tΰΌZfΘ³žΥœ£ΰ\E"Κ=ό`­R©x η,t:—ntV―λ(—ϋ½A:VΟeΓΑ=ƒE ϊ‹Ώψ|9“Y)΅Ήz΄ί¬ήCήΏQX=Xμ%ΣΣ§Kˆv^ΟB~ /V3g9ΠΞω*ΜY60ρσ˜ŒΐΨΛ―Ό6š₯Ω`š¦₯4Mγ4M)jΙ(h­Ρ a }ώωh³Ρ Ÿόδ'X9IxP―ψμ=4έχT­†Ό?XΎŒnόœή8X¦tό‹_ϊκ°Μd"₯βR)ͺ΅"½dυ:$œœΑΉBd?tπΎz9IB°ηηΑ‹χ�{ΕΪ ΛwΖB'Ξ�‘³³‘ΒΆ9 4»Ι IDATφ0£%δΝY(Γ AF‰Ί΅2JrnτΌ$ €K‚†GnήΦψόχ¦|g”ΡZ†…+t?·‡¦=·§ΡDGΟΞ&μχF-αά‡ΞŒ­0So'l�ήͺ4₯jH7%ςL#΅αιVΎΩy�-ΐη”BΔ”œ|³,Xc¬-ό�Γ.B›Ρσαιέά7WΘΛ‚Ήμί·R07!„\χ‘hάΠ—½ΆH΄ΦD)E5j€Ά΄d^|ω.³ŒgYF₯’T]3δ!Τ΍ˆFQ$…υ§žϊΘ¬ξΐΉΩή399πJߊ*0`@k€Ώϋξ»‘»VβΒ…‹βπ‘£-F6Λ$Q{κ΅Q+±z;vl―έ�αmV/zΛΐή©ιιjΝ4jΉ` €ήŠζ,°\ R˜³¬Ψ ›‚C` •j΅:ς�πΦ`&³$Ν2‘¦)Kӌ j „j€€ΪΉYC^ύΦ·ιLυϋW�ς_°r’ψg/vvδΞξξ9 ξω¦wλΜ=ΌwΎΙΊΆΎςκk£gϝ”RΖRI�”"ZιžK8}¬γ g’qΦ‚ΑX)η*ζ,Π™·§ss{'gΓΉ½Ϊy{ωΜ±0Soηg_�ΑΦχf—†J' ©yͺ4M•…Ψ%Sp³zΔDœb"˜N8KK‚-ώΪ£wVb²zUXEΎΉ @Ύ’`³VΒ©΄F"₯’ˆΘ@|ώ _Œζfη…‹Z R*ЈΧΜκu Qγ8=τΘC>€» Ήzο›9Š>₯g0†h₯‰’ŠΞΟ/ψβ²ό§φΉ$“2Ξ�”K)©R’ψ{w­Υj¬ήO<½ώ³zW’9υήT¬΅ζ‘""AΣΏ0υφsΏάœεŽΫζ,œ„Ž‘œŒΐθ³_~‹Rͺ,3I9|ši“4 Z―7D½^OκKυα'NŽζ·ώΓd½ΡπΖ-·ΐM°ΫžΡ.ΝΙ–yΛf4p �e~Nv,£·ΆΧ­_ό�WF²,+§i&²,³Šƒ=qα ΐΌ §BG"’CCƒ^Β9μOήH―ΰŒ.Φ­‚Ω+:oΎ˜)@ιK‹±BΑœε»PHr:Α‹ŸΫσμ^νώ=γ>‚AsB‘SΒ(%†"±σ–—™ΫΓεy{1΄ΓΥ½I‹o θΆχ™z[�ΰ;οŽ4d;S/³ŒΡΈ‚Xq~&—·G4�ΠΔGΞΞ•ΨσΣ’ϋΎΪƒαΫ`ϋ«§fVΝΤΛΛ7‰—oZχMs¦Kœ₯eΑ–~ώƒ·Μ:χΝ³θ]rΙ2οΦ]ƒΉΗΖFυ��R_ƒΉ-Sη-r A­αK_ώdYf: _ΰ\KaΌͺU�Σ?>…KΠiΜ²Ω€5‘َΩ39Ρ’υŠΥσοgd‘’΄ Π ςgώ$Λ2’•τ’Ύ.ωζ&dυςσz₯ιι©’ΦZhkfC S_{s–ΙΙ‰B ²1ΙycŸ7ϊςΛ― fR–²,γJ)kNe:₯Πφ«πA4Τ1Η©Φš�ξούA45}:ώ§�δ§Β\X^Ν@›i σy»‚Ύ°ζ@^>Ÿ0œΡΫ{`O½ΡΨωo~σߎ§i:hMnμh€RϊΊΛΟ™N0ίTΌσΞΫ—&&&ζƒ3:ΜΧ+$œΨ+ΦYααν»b₯ΥΕ[NœnVoύΝY IΞrwΙ,|΅χn­�ή·ίIΦηνYz―Λ \!oΊΉ=~ψΜlι“?ts9wˆjϊZ¦,.So΄‘τ@=ΣΡ•gκpξ›XL&‚5?816�wN\t ο<΄έ7½λΥ0+sW*c855νΩ~sψ, 8}ϊ ωΦ·ΏΣΨΘσν^…ΛXυo&+μ°©ΣΊ倌H¨Τμυμψϋ‘5!š(`’ίϊw�—™cgωΌρΞuS›ˆΥΛπώΌH ™š:k\k;―g\€s?ΝY(₯ζ,”Ruϋύ~?,” λφBVΟ©U«Υα7ίzkΠεΐq%QZwΘ Ϋ�bΪ­Q RDγΑΥZ³―|υkβνcΗKŸώΤ'“‰‰έ^β8δΎ†�εΛςοΡzΏ. ΟK₯½6οx½lΘόδo~ζίoŸΫ�h4’fšŠ4Kν ψu*ςυΙ€=ωΔcθΝηL> ΉXΨ+Φ:nΖωξ[ ’£ggKM€7€9Λ­cC…$guΐηe�Kΰ"Ά”DV—Z J‘ΩŽœ±DΉ’Ό½–+§1βdu1kψΐύ½gυΆ}αΝΣγSsυα¦Τ₯Tk‘*\1SΟΚ7Ϋ™z1g¦Δωζ‘;ͺ`]7σςΝΠ‰Υ\Γ5X£`ξn€„Ώϊ�—sΧάτΕΤcpχjaπΡ€”RγΝu|ηΉ—χΝ₯3‘˜Ο~φψ{Ž—±±½fυΆlͺέsΟέ‚Υλ’ρn€^Φ]>55kΤ)j§ι³9 ±*μl–Τž½“ip^ζ,k}0­nΜ2֘eX)εrΰ$•JcΊ…M0)%c5E‘|ǏŸ�η―�fτSϋ‰ςγ=²ˆ#ξ,ρ¬“ϋφΟ† §&ό:¦z½WζΜbς ΟΧd>ΓΦ›±μ€Iφ&�Λ�ϋΗ;Nž|g΄Ρh 4Σ4n6›Tf’ΨΈέΣΈΟκqΡfυvlίV»ΧξSΥά9S©`―Xh-zΰηυŒ S_Gs–αXζ,«ƒ—Π•sΙu.λw5/?›qJΞ¨aаΎrC>oO‘‘ ‘Ν7et~±™l* Έnι{^†ΠΫ~ΎΦάϊ�|ηέαΊΤε†Τ’)uKΎΩ-St†§›g:,K[ϊτo™Ϋ>XΊVΎy.θΚφΒΩkMƒΉ;ƒιζ š^… ΐέΛΖFθB«€€"΄5Χ!πυ·‡R₯Œ δpΈ‚Φm{ΖκqΑ[`ύΙ'ž˜+'‰Ÿ΅ά(`=”•΅ζυκυzςή{§"ΤΜŠ‹]θΟcš³JQf(cš1&μί—W‚„²½Βœeνž“•ŒYΆΌψβΛƒRͺX*Ε₯’DλΥ#g<ΰ#„€RΖA4€ˆD£ζΚe˜*₯ψώΡgK―Ώ~$yϊιάΏοΆapl"΄η–=ϋTƒ6ϋ›Ayη ΰ―WηΗJLžgC£ΰڍB›ΡΫ υFcχo~ζίo?yς±F£1Τh4βf³Ι2ž•κ|3ΟκΕqŒQeV}πQΟκU‘s¦Ύγ¬.VφŠ΅~›qΨ}γa—φj-F4Β›³΄\ϋ‰:I§9 §Dέ֞Χ+$9AwΡmΰώP�ΠξdΧ`ιΑ½γυgί9—qˎ:)ηε#Γ۝x  Qh˜6&zη�bφFά}¨@Ϋ”e¬‘τPSzω&Zωf.S/”o J½) &‚ΙDπϊ?š¨,όδ]“!Π»θp@χͺ`λ'£·ˆθ•+Ϊ& ΰΎ^ 66Ό„;I(Υ”RτΜιω½²xΕ,{V�—΅Z).#Š’txx°φΨc‡fsκzƒυp†(”– LOŸ)!κ9jλ¦h½ ϊiΞβŸb\삦”f“Φœ%φ4¬ήZμoWdΜ²ΈΈΨiΜ’ρ²‘ߝ�Ξ΄ΜP;&YijΔ΅8|δhτζ[o—=ςΠΠ?ύ'?΅₯\.M“NΌ3±�φ#*8gt°χ„Νελiϊ„ζή©°‰βς†‘“ΡΫ»«ΥK;~ϋ?ύξΦ“οΌ3�l4λυzάh6Ή7eQJυŒUo1θŒ‚„θb}š7ί΄wαώθ’;gςϋΤf4+ΐ^±ήwrΈΩδ%9ΙΙκb,Ρ0H5Z'šώš³°’FiΛ‰σΎ]£…$gυBΨ@ηάή"ΤξΩ5Zη”fœ΅σφά¦MH—m—Nμ”r2›·W+=Έwλ λΞ¦ξ?έ ΫŽž­|kϊ�pκLY2T’ OΧyS–εςMLS‰`i‰ΣΕ_|`ί XΩζh›²Τϊtx˜ϋχΑ뇏lFΐΏΩL=�χ9Y½ ²ϋχIJ¨’„ ±†N]Ψ§Ό' #ddσ^oŠ(Ϊ 'ζά³z’ώ#O>1_N’j¬ΧaύY½ΠΜΛ³εc'N$ΪJκ˜ΦHsμησο>†QŒ1δŒιΑΑY©TAρξ„B ²–ΟΙΥ³ Β•6,($«n%Γ5SZ₯Λ²ŒΕq=ϋά7Jίωϋοzψ‘‘ΗtΜεΞζί¬;cjΉ:#TεƒΑΝ5Ύn΅WδωlΫ-ΠΫΦysϋ‘£ol�½?ψ/c³—憛ΝfΉήhDMτ²Μ=DΌ.ΕΑr Η|¦ž‰βHΗqœEq΄τρ�W‘Σσ%θtα,ŒY °W¬ ΄)‡MεZ*Kg1#‘»Π―κ!0gqαΪ>_OήV*$9WVg�ΐY€ΪφΑR}�ψPσπ™Y;·gg ΕΆ/δςΒ֟bΖ¬†*4βπ™KΙΟ}ΰζAwψhχΜl€mΏώό[£©jgκ…¦,ωL=/ίδ”tΘ7Λ‚-ύΒo»elπBτ|¦^δχεπ ›(jw“™zτb…NͺΎΛ%ŠΪY-ϋϋτJΗΩτυ¬PJqζX½HΗq”  Φ{μQ?s ΦΩX'`k:-ΰΫσz5j‘΄¦΅›©je€υ μYNkΞΒ8S{χμρηE=83 Vo}κŠ+6f±ΰδΚY¨NY§gΨZiͺ΅&J*"•bRJ‘eYιK_ωΪΐ_�έ3[yψ‘±§?ϊcσ•JekτfΑΞςyIϊ|³ΐΧž!Ώ«Uεη^CWrΧΚ7UΗάgΆΦ­Ÿϋά_VžαΕ‘f³9Ψl4KfS΄€›­L=έ Χn*R`ΜνQQ„₯8–q7ξΊσŽ…ύϋnσ³υ°·Ω ΐ °W¬χύ†LrwΚoœ›/ic„BΓ$’“pΊΌ€>Ϊh»Ψγβ4#Dσz…$geΐηe'~nojχνm|οά\ΖќRΞΒεζφ<θCcP φŸ^h” ©GέαTωόχ¦Ζή›[nH]rςM*»˜²δε›1g^Ύ©ΞwnΫ2�ΣoΊVΊyΖ ³]Šά~€”žsχθέ¬^.f Νμ΅>ˆ&Τζ$R›·Ά‰ΐΊ-€8η†sŽQ$’ρ£O>ΉPN’}π³E²ΟοΑενRΘ@ςφργqk^•qφј€R”Ψy=F)2ΚδΫχϋ<Φz�ITΈq�]SΰšY�Ά³ά­‰�”�L*Ee&™ˆ2 EQ”<σΜsίόζ‹ΓwέyΗΨCήΏp�ύZpΝ°yχΥ;v.ΟPωδΩ>4Zύ9l� ή &"W{ @[Ά9νYΓ±―|υ―G�ζoŸž››JΣ΄άl6£fšŠ4Νh–¦ΔΞθυŽΡ³ο ΟE‘)Ε±ŠKqEΡβ§~φg.@o–ΗϊΖ,Ψ+Φ{ήB»΅αΌS]L¬'24†’1€ŸϊošNgŏœ=\ΩφΑ’?Ό IΞςB‚M5tδ\€ΪC{Ηλzδ½”Sͺ%‚Α@ \ε0pDcœ“OΟΧ=ΨΛ|‘WKεΨοϋΡ¦Τε¦�QͺID9S–V/oςNωf3|ρW¬ϋ¦zηƒΓ# Γν™|Φ ˜»Ώ@‘»©Gΐκm$Sλ½WΌσΎpW !š€{@6Σύσsj8γF‘E$²αα‘ϊγ?κ <«WƒNσυn†ωzεj΅šΤ—"₯5³Ξˆͺοaκ>ΣΚw)2Ξ4c4›œΨέΐ^3Θ­σ’(>ϋώœt‹ Έ&c–+}νy?JmΎ£R ”’ŒKA3ΞYK!8’(JΎϋϊၣίϋήπ�ɟΥ?πƒυƒχέ[»χž»kΠσ`Ο»v† ηn± Wς^RθŒSΘ½!w­Άΐ–j΅:ςΚkί~ρΕ—‡Ξž=WΞ²¬œeYlA^Κ�4%RJ’΅vf,ΨSFΟ2η8ηFD‘‰γXΗqœΕQΌτ±Ÿ|jΆR»νh$/…-fυ °W¬ Φ} ;LΎK›@rψΜl¬Ρ…†ϊ0llKτλΰ¦Nœ9 Uw₯ΠΩ₯-$9έW(εlΈjαKœ’”S’"F §—`πσuΔ/H<ΣΏ~ϊ�ΰΑέcθ¦�Ÿ=5xa)¬KΧ3+ίΜ4‚FaDG^Ύ3jbNu‰[ωζ§?xΛμ-•Až~Φ•yX.ίμHιζήZ©τ%˜»Ÿ@a5SΗκm$S^‚ΎPΚ©ΗΖFυ��R;kΐ¦`χBσ(rχPˆζ\Μ±z‹}z�΅Οƒ½djϊt ΡΞλ‘ΆaκύμΕ΅ΓΤ‰aŒγΜ0¦ΎίmΝΨλΈnΕQΡχΊ"oΜβ ½F^yυ΅αk1fΉRΐ²|ˆ(E‚¨AJ’s&₯€B–f\DQ$’4MŸ}φΩσΟΏΠdŒ5θ‡6ήwOcbχξΖδδ„x!ΓΧ„NWpu `ηΐž3¬Χλƒ'NΎ3τ�K― ~η»―H))U,³,Ξ€δYšςΤI6₯” ΅Mο“ήvίlΛ7γHxωf}ηΞσ=φ¨Ÿ­Ολ:³zΨ+Φ†[yIŽίt’“ΥΕX»0ufΩμU―ή^?§ΰ€ž{܁ΛΫΪΞ•$9Εκn��κP>|ΣΆΖί?+%š3Κ―,‚Αήk…$"Ι42‘1žoΚχά¨³ ψ?½z"I:SΝ€‹ZΝ|ΊΙ7cΞ°,Έ* ΦΨ32°Θ7ΟB;)μφ‚κΜ]©TϊΜέ―Bw5S°lήF2υθΥ3ή τιJe §¦¦}#jSό^‘9ηΒpΗκq.κO}τΗζ‘sŽh£8Ϋ…f`οΤΤtŒˆ΅f΅‹\0} Sw¬Pb™=J©άΊu<+—ΛΎΩΥ€ε1=ΕZ›ΊΒƒ™ΠMrΛλ―’Ru³XŸή1ΐa&Ÿ7piƒ> BJΒ8#œs&RΞΉ‘ΰΌΔ9/3ΞΤK/½"_yυ΅Œ3– d{χξΝn?°?œ˜HΗΖF›““θ…3|W’4κ&qκυziϊτ™�ργ'’·OήzλX’΄*)©JRΚH)%2)ΉΜ2šII₯TD©6›gLg}οΞάΝΗqlJ₯’*•JiΗ‹?�©OVΛIr¬ '?«'‹χ­{ΕΪ@5#¬ Ιy·ZKζ›2RˆLΩPνΎ‡©·vA›—…ŒRΝ(‘χμρχJ’œZήEΚιηφκπέ·k€ράΙs™ TSΘ(‘d…€?44PڐL!cD‹7/,$?|λvψ_=Ξλ֐E4€f©B") ††,9ωfΔhKΎYlρWέ1γή™ KΨ/ωfx­Φ$˜»χ@os˜zττ!„σWΜζšΥk³z‚sœ«HDΩ‘‡¬U*/ατΆπλΚκu Se� ”޽}<ΦZsHmΰuΏΓΤύ<‘7gαŒι={&�ΰ¬π LαάΌ>uEGt@΅Zέςχί=\–JΖR)ζY΄ΖždΑε€ΦΊΥ°CΤ@%ˆ¨€ΐ8#γ„sΙ8gœQ*ηΘGΖ¨fœιz£‘gͺ—δ‘#G5₯TΊ*—ΛrΟδ€*$zrr²ƒ5>°_ןιΨρ­λS©²™™*;5=%––κΒΓ DŒ”VBJΙ•Τ\*Ι”TT)E€RDkE”s.ΥZwΘW{ۈ’ΐ9ΞΉ‰"aβ8Vq§qΧ>φ±§gχο»ν‚kΞsgw¨Β)X½μknΜΛΊ΄ο\Z,ic"΅ΖaκΦB»ΕμiFHvΛΨ`(γ,ΊF«Α]Αή{·Φ9;ž F΅`Τ0@½R€wγ@cˆB„LB α ‘;=_‡??zŠeY¦e iΦΕ”ΕΎPΎYL—#&Λ‚Υ?~ΟήΉ»wŽ\€Σξΰ˜ιc»ζΑάύ{›ΘΤ£Χλzς­6Θ=€@5‚s#"QI!Dύ©§>²p°ΎήΏ³—‘…²��ΫǎGˆήœI―‹χn`™pμ(5ŒYforr2tβ 39 ηζ΅zήa² VΖ9ψς«ίJ–,QTιk7fΉZΰηA!v¦J4"PͺH–QΓ#”QΚ3”ΪZ7jΏ2†ŒQ$„jΖ.-Υρβ̌¦„"ΐ«φ\!WπnšΦ5"h‚†6ڊΩΘΝγI•VΤ±wήx₯δυ£™�6d±@/ŽbS*•tR*Ι$Iκ·έzΛάS?ώcΎ1λMΤΊξSΕ{V€½bm, —7gIή©ΦJΠ6LΫF.τ9Lέ²z6L»|½CI: ―™ov)zŠΝd9ΨΛG0,n,-νJšυLIΑ@ JVŽ`π FŠ )ΐ‘3³θoΎπ,eš(D*΅zͺεΪΪ δ­αN§|3ᬱgd`ώgξΫΚ7}¦ήbŽu ζξΠΫD¦kr]6ΣΟΪmή2ŽγτΠ#Υ\X:%ΈYXΦqV/4ήhKLMM'Ζ`€Q3ΤΪΟ_υ՜Ε^Kκ„ΤPΚ4₯Tξί·Ο3z‘m~Ρμχα³<·—C €—_~5ΡZGJΩ,<;gΦ_©oτω™>B|lXΰ§5PκM―a”‘”2feΒ„Γ3V2LŒ32`(₯`ŒΉ"eΛλt™‚@‘c(jM4"Ρφύ!~O+ hlσ$œΙλΗ»Υz֐%Žc—b”JY’$υ‘‘-σΏψί�sτN»σΪοS^uUΌkΨ+ΦΪ”σ9/-VΚ'gc…~^Ο†•zӎ~…©ΣŽΨŠ‚΅o|Θϟ…ƒφW3 }#Ύ0‚‘ζ€Sνα›·6~0[Λu ”ΡfΕΉ=Ÿ h4$³φβß”Ppϋ)ρ³έ—Ύbάακb¬ZΝΤhP#°Ρ%>h>dΚϋq­–½Θ”J%,'‰LΚI£TŠηαΣ?w‘R; ΣΠv̞ƒΆκκύ|Ξ`―X›vεε-fου3³‘v`Ο‡©£ιΟ†©·$œ„ ₯Dή»kΔ»ͺyvoΩ0t±©tΫ“‹`ΈwηhύOι{§DqF"ZO΄YΑ`ά½±RNΪ†„ο‡�γλ�@Rν>7Ϋη^Λ”%ž.˜N“eΑ–>zϋ�Ή»wŽx wμwθζΥ―ƒc]ƒΉ―�@ξnκρΐύΪ¨¦k†72Π[!1=τΘC‹•J%ŒΜΨhχpEs–cǎΗZ#ΧΪΞλΔ3―~½ ޜ…1†”R΅gΟDΨμv^kmž“|€?|δ ?ΟIQ[ΆΚ �k#-žΌΜ³ύΧšΒΉόίhψ«ΩwZ&wξ%1`�8θ<ΐkηφ wzIE­’Ξ IDATτ’$™�o~αSο½ηξ3p Ϊ#ύ0Q+VφŠΥ‡ Ωά­œ—7ΞΞ–΄1Γ•±²<θk˜:€eφΈ0uκΒΤΗV S/6“ΥΑLθΚY€Ε»wl©sFRΑ¨”"§„]6‚Α1 ΐ όfλ)ωΒχ¦!Sؚλ3¦ ΠΛΤrJ!b J.S―,Xc¬-όwΊν’y~ΐ{ϊ#ί|_sw3υh… h걡›―ˆ6j|Ζ ¬žŒ’¨ώΰƒχΟ;ηνςαΔλbxp%ζ,οY'NŽΞœEc�ΝY!ΐ(΅ζ,ΆI£φΪy½πΜŸ�B ²6uE7ΠGŽ=J Ά€ž$Ό—fΙ=―α>βέ=τ(πš‡ε,]άuϋYΦθ%I"“$i$₯dώΡGžΉ�Γ:ν€ή)° Ϊ•fλ‹U€½bmP°ΧΑκ½s©V²aκ†YfϚ³τοie›F pB-Ψ£$»gηh8{QΜλ]IΑ۞IΛά5[€Ϊ`,jwŽ6^zoFΊλΛ™kLΆœ9Mη`  Ώπζ4Tλiϋ›²Νπ1‘€{VSLΣ giI°Ε_{τΞκ@,BχΝ*X&£Ÿ36WΜ½‘²ΪΪΞƒ–Υœ›(ŽBS·ΰΑήF3υθη³nyηv%Ά17ά•Y½ζ]wέQΫΏοΆYhΗelTΥМ₯δ?§NMEΞ\ΒΟ ­‰9 ‘e†2¦crrO˜%lvœλ°φο'˜Σ§ΟxCύμ£vΧuΰ}£Eάt“M†gB/Ξ‡πΟ^λί5 W.—εr2�θ‘G.ώμ'?1ν@ή{`Y=ߜΝgκοΧ&Z΄Έ7ΠXA’s²ΊX�hΈDμS7Π?Y $œ6L¨Ϋ*C‘«Z#8Έ‹Rχ"(oI―‚λW€ΪC7ompJ2g�‚ŒΗ¨�ŽŽ‚€uxχR Π@‡λf(Ω η/™—or†%Α²Dπϊ£7oŸΏΟΈzη\›?8ϊ)1Π» @r―u`΅˜ FABŒ£HEQΤ<τpWS%Ψ¦kRHn­TZΧh3°zBˆΦ¬^Eυ§Ÿϊq/ίΌ˜»‡%q%s–δψρ“%D‘9fo ΜYZF†2j¨5Wςaκέ” «·vοeψ~*w²”1&ΉΰJpŽBΓ9ΖXΛκ3-…Œ5"^χg-dš+7Wz‡yψ‚zοΐ` ΪςΝeσθ`―Xg£Κ;f…Μ^Ια3s‘B΄ζ,Ζ@ΏΒΤ=0%֜… #Dέ·s4 ξB’suE°1 χμ­sF3Α¨ "–‰wϋ=θσΐ―eΦc–•aNΎI bJ‚™D0Uβ¬Y)G ΏόȁhησœΛD-ΒΪΜ&™άΧVΞήψx}V[Θκ­w�z ³@!.Ε(ΔΡR`κ1“»–ΕΤ£@½΄W*$@< κx–7BιY=PάrΰŒ’(½λΞ;Waυ² ΄ΧεgΌc(?q"ΦZsDdΞt£οζ,ν˜Œ1œkΞ™œœœ›ƒ>τΊP‚¬Ο;κ₯ς ¨}τ£?Vγ\,EB4έή₯…9Πη†7 θΫ¬« τά8€ΰ¦TŠM’$X.—;€ήΟύμO{ χ}χυ,΄ητšP4ί7υ*dœ7Θ;ίνπ>_kΖgBΓwpχηMΕϋ”€³ηwωz”Κ{wvλ�’œ«|™Ϋ”kP»₯2Έ΄k(i.]eΓ•€|€wFZαι&ζL—8KΛ‚-ύ‹φΟ:χΝ0fa>(l[ΗάΫ|°ΊT7‰jG-Έυ(Β8ŠdGυC?΄P©Tͺ`e5μέΖ,:κ ©³Bχχo£Τ‹Λ$ΈB˜(Žtτ�·w¦Ar]Χ}?w{KwΟΪΛ`@‚ƒ’ HQά$.’²Έ(T—-‰”E+‰œHe9–Ϋ•RΎδC*±?8•�T9.;.Ε©r%�؊y‘%K)Y–@ZΈƒΔF`φžιν½w—|xχvί~θf€0œ_Υ«ž¬α»οΎ{�χœσ?A†aΨόπ‡―Y‘·Q£zώzQ\3‚W^}MhcλυΜ•7gρ£ά�™:eTNMMΉυ’α­Υ»ϊΈυYzkOν<ΌpϊΤ™…oηω₯t¨΅fy·‚Ž&(₯ΐ•iŽ…^·^…ͺcΖυ€n…žsΛφ͏°y:Š=d =€nύE§Χή›su.΅φ©C7%η μ ΝԍM-”œ’do΅βNi‹ΝΤqρ^έ‚λ’{-πZ0ά΅kdM-V½$~O=Φ}S—ΛbΑΪχŒΦŽμΫ>cEή9θΊoΊzΜ«΅±υS;‚@·f―_tθZl<Ί©6η&…‘ £0 ƒ°ώδΝ[‘7ηΜ_3S«4v�ΕH$₯DQJ5%Τ@>†"@ΰ‹υ δь, ΓφΑ·.Mν›tQΩYθφFάhQ=π„ΟeρΔ‰“ΜhC]ΓgWΞ"ή‰xJ PΥγŒiΖXzλ­S.‹‘Ψ_έ8―Ξsξ‘ύƒΖ–}'ΝΐΉgžώθ€)}ϋΉηXƒJiΐ(γmJˆ”€$D©Ό ‚s§DΡ·>$.m³{p(\ΓτΤΉn>ςΠϋgΧ τ4 ={ΘΖ|~ϊζμ21y«’ο¨�μΞλ»Ίυz‚R-(•;γd¬5μΖ§ ηwΰΛεΒ �ŸFγ§r.?ΈgkγΛ?9έΣ‚�C €ΨS/δΜDάKί|ψ€‹@Σ7―E:ˆ†‚AΛώ[§ΤK/�(7 ԐŽΥφ΅] ]M…†‘ Γ0 ƒ°ρ‘§ž\¨VG§=ρΌ½6Ψ›-ͺgϊd€ )₯4c”κ<ΪCΉζ}ύStΑΉBθ0 eEiυψqΧΡwΰΌZ΅«k=„βzΡl6i£ΩΜE^'’g�ψ―γκυc†q&₯ιώ}ϋšGυށ‡τΧ ά”^΅yκΪ�ψb― Ρ3OLLNήBώΰΏd¬ϋq‰QRFyš¦,Λ2Θ2 ZS"₯c4ŠΎΛ|uœhΎ–‘7LCGQΗq3ŠΒΪ?ύΤ'g<ΧΝ·Qθ‘ΨC6—θ+.θWGiZH ”š€Q%MοΪ9�ςξ¦'°™ϊڍ«ΫsΡ½ΊΧ‚Aq’·` ”ͺ ρ’―y,‹=υ8S±`i,Xγ—ξΩ»8V‰όV ncλ"ΆW3 ε§Ήvš«€”Rj{νC)1„Π<^y•ƒ Κλ*„ Γ@Ga˜…aΨ¬}ΰΜZ‘χ.tϋ²έΡ`³ΈηΪd΄¦¦&یρŒ1&9η&wh€„*z…ι€KωζJΕQ”…aΨΈνΰΑΪΤΎI7†ΣvN[lΔ΅’ΓΙS§{Oξΰ|·ΒυΌ�^𱇠š3.9ηΙΤΤdΣήΏ†]/2<ΌfsTΩηΕΉA;Sδώϋξ5γ»vΙ?ό£/eo{˜ ^ζœG‰"i'Œ³ŒfR₯΄VΆΉΈΊj}η6ΕΖ�Σ°Sονœς΅$Šd†IΗΝαα‘Ϊg?σΛΣSϋ&]½w 7cA‘‡bΩD/f' 4!`!ξfS,)Ι…sIi~ύāukšGτŒ`T F³€Ρφ;FκV ψΞ‚X―wi‚¦§Αz%}Ձφ±w3Έ­ΫλΆ`°^W5Ζέ“‚όY”BΘD‚ιX°,Όωήρκ�S·νφ…ή tkΛz’zWIθwhΰΪS΄wοΟέβ8W,ίDΖ2£%Ƙ«"ψόtJY·€>ŒL†YGν( —Ÿύδ'ζJqό.䎦3‘°ιzQϊ$ΊΘ^[oΉ\j΅ΫνŒ3¦8η“Iˆζ‡-{‡ƒΎ‘Φλ]ϋ=±ΟŠo¬”Ψ?[€₯ju΄φ/ωŸm{νυ7ͺ_ώςW‡ό“ŸdYV�4LΣT€YΖe–±4ˈRŠ(©@iJiβjϊnDαηϊuy”2Γ9οΤ a ’0Μ¬±Wγ–½7/=ϋΙgfΗΗwΉ΅ω΄wπ4λ­)mz(φλΓd β7V‰–Ίiλ�7ή<7k* %@SJ@j*OŸ ~“υ‹Υy9gΓnΓm0cpͺc‘‹ƒH°VΘhν©ΫΖμ©RΡ°@¦ε\ͺΰK½q�ο­VC‘H›™’‚R§λ‚Ι΅YΕxvΗ΅Oϊ¦Œ9kά6Tϋψ]7ΝΨΔmj�±0)Š`·αX€…#|tαΔ‰“CRΚXu7­‹=ιƒm}ι’‘w#K€1„R`”³BϞΔͺ(ŠΪqΧGF†η?�«ΏrΆΗ§‘›fγ¬ϊo”΄5ØΪgiξ»ο½σ_ωκ�Ν2Y£P(­©1†B¨Μ(HͺΦeμ.6†œ1°‘'—2•ΖqԊ㸢wοΝӟώηŸ:m…ž_γΖp#λhθuY\€ΕξΏoρΕc/R–”RBM€’»+^ʜ9οήjά} ‚@‡QξHAγύ>P«VGέzQσή/Υ»v"ΔOη”Π?*οΪ28‘>?΅orΫ>�Ή-―½ώΖθ—ΏόΥ‘�τ§• Jiš†2“Aše,Λ2¦€’RJ"•$Zi"σƒΈΎΒΟ=o›Eψυ <°fEΤ =Γ9Ξ…œλ ά‘H†akhhpω#O}xαƒέzo�Ππ\aΟUŒŽ£ΠC±‡\g/a€σo» οΜ§ο›yιμbEcν‘AsJ…PŠfJ₯ QΖγ;YeΆ<’G 1μψi›5=νjœ�:3‹ŽΓιC7ok�ο—O¦œ�˜3j˜$„²κ ~ϊ&οMίlΗ‚/!wί<λ-&³ή¦ΦwΚ»χďPΤέ³ί½ούζ7Ώ5πζ[ΗΘ#z@)œ₯L*I•RDJeΐkGr©bΟ΅B\­“a<―­B¨@ˆΌΕBΦ‡‡†ιΩOœίuςšŠSvŽ,z s'ς½Ιη‡Ÿžά΄ΕΉRΟ9ςΑα?�σ―”ŒΡ JHHεΟ¨RŠ(₯Ρϊ²ΖξBcΘ(sc¨!T†iE­0 k·ά|σΜη>χ™Σ₯8vNw§!tϋsb£φ­ςEvζ‰μΉCwά>sχα;‡ώώG#c ·ο†1Ζ²μ�ξ»o‰­΄uG: BΗQRŠγΖππΰβγΘΉ™ΞAΧ€Ϋτl,Α§ �ζΦ9¨ΦΌή-Sϋ&·~ασŸ=uκΤΘΧΏώΝ‘o=χ|YJY’R†i– ™I!•ΚŸ―U?°e'EΡw=ΐσΣό}Χ=ι aΈ]?‚ Θ‚ haΠxψύο[zς‰Ηζ=gΙs=Zkή~+-<ហΕr}ošά†iΩNψ‘m•¨ςŸ;,~ύ«GaΎ™*NI9‘:LR–ι\πicl›ΣΩ ¬$τœ8 L§Ε£*,‹8kEœΥn)ΟόκϋχΏ έ†ΫξΔΫ?aBΦ&κύΕΥΥI4 qhϋpσΟ~r*ε”(F\*ηΕmYϋ₯o†ŒšSρ<}σ—ξΩ»°·ZqueΎ[d1}σZ ?JΡ°božyϊ£Ρώ/ΏΛˆaŒ*ΖYœBd™δRJͺ•&:Tgϊ±–=C'9wJ3” ŒΫ3L !η< ‚ Asp°²πkΏϊ+Σγγ»Ξ@―Kš; ρλY7­Π[‘WΣΎ#f`豟ω‡•ωΩΉπΉη_  g!D¦—JR%ΥeέΕΖ0w‡dJ!…I­0—ξΎϋπΜ3Oτl)ŽίρΔΊ;I?/΅Η°x8Ψ3gž~ϊ£₯ωωώΦρ·ͺg%‘ˆ Λδšοϋωχ6?αŒk!Έ £ΌυH… Ώπσ?wΞΦ>:γ'ί‘φF9όΨΠsΦU~€Υν=ό7'ψ\ΟΙκψψxυ“Ÿ|fτΙ'zα»7πάw^¨œ›žŽ•TQ–e”2Rς•„Ÿ� ΄�`ΐgθβ‹?χ»mXt²uβΞ₯ˆ{›ξΈ­aεœk!„œKˆ4‚DΡΌνΐϊ³Ÿ|z±Z­ΊΦGn]žρ ύh^ꭏ(τ6σόΔ[°Ή±§m�_R%€m°φΐΔρωϊ�ίϋή[pz~(UΊ”)eΪ©5ΧΪPe€c¨=ς%+½$σSZ0ΐΠά D F§$ mEœ-ί΅sdξ_=tλΩm•θtyž±/ύž΄?|ρ¬iœ)δΆΧ%€qΨwœ«·oύΨηζZ;έRkgεεvΖ›™"i½έ'bλ wΫ8"ΑLIp5‰t0δΛ·Tf�ϋ/<πΌ―ΐ›vs;·Ζ�»/B¨@vΊgΏΩjMόήϋƒν―Όςj5M³Š”²”eY •βJ)ͺσAjŸy²– ‚]Ѝ]Μ5₯Τ0J˝3ΖxΒ9o‰@4Ž|πΡΕΗϋ™ΉRŸ…<δΠχ«ΥΫΤ ³χξbήs= c07ΐž―~ν/wύυ_�ΝΦ₯₯₯Α,“ε,Λ₯•ςςΖξbcΘ9WŒ±”sžΑ›ccΫ–ž|β±ωϋξ}―?~.*;½™ r£n�ΌϋΞνšQ±ο“νvΝΈ©ΩjνωΪΧώrη_}ύΥ4MeΦ™3b-s¦Ο½Υφ $B€"ΝΑΪ³ΏψΜΜ‘C·ŸWφΩ€Qοs{yπωΎ[9ϊβ±R–ΙX)�(ό”$J)’΅&ZiP�$qu€.εΣW{EŸΣΛƒEAΧ+ξΊ+mWάυ¦ˆκEςΣ\-8wL©<α\4~�ƒυ#G­/ΨwΝ΄x.ΛΖO{nΓω†i8oPμ!›H0oρ€-vΣ» vΐΆο˜~ώΩ‘οΌ=SZJ²Xj#”6BΓltZY°�³cς”Π”Ν‘œ‘LPΪ~ίM[λξΩ²ψΑΙν3v{ ΊυH³G›pcO\© š°‹ι ΣIΈ nϋϊ½[ŽžY«΅Σ₯v&©€‰ΤD^@μ’χFŒBΘ™) ¦B‘ F’9ςωίyς=§οΨ1|^€Χν&lz-œ―ΩbR8μpχfΐ>ϋcφΩί ;N:½είόΦπ‰'ή>q"VRZk‘΅aZkb »y]Λ―`ΣΤ¬e=Q„RΙ(K+•r{�ώ©ζ­ϋχ5ξ»οήΕR»¦Ϋ�¦Β‰?}σ†š}ΔzΩ|»μΖΞΉΉω±c/Ώ<ςβΡ—^yυΥ’R*TJs­5ΏŒ±[q )c’Q––Λ₯φέwnΎσΠς‘C·/Ϊ ΦYος[\NwήFέΝ™° ψvΪϋΎcnn~Ϋw�ξϋ#GΎX9qβδ₯άχσξ-c,cŒ%»›wέuxωώ{ί»P­Žž+¬}͊p½Ψ°‚―Ÿθ ­θ+Ϋwς ΫCφ±ŸƒΝVkπ₯c/WŽΎxμ|α§€Y.όςl E₯RΔeeX‡e’΅Κ[8Xΰ ^ϊ9/wΕΰ₯ˆΎ^3§όΈθ/φΙ#w„`”eΆ―€Mg”i.ΈιdžqΖS!x{`` ωž»ן|β±₯j΅Z³kΕ¬χ9kί=‹Πu�υΫυ`4Ε²I…ρο’χBέy€―jOΥ†μΛ·τ�» 0 Lώ²v›― ‘χρP@ ˆ“±Jδκmά†Φ₯ψ}ΓzN½ρtIcΜμψΨ1½ΐν�υ…Χ§ώθθρ‹­th©…T²Dj’i‹=―nΟοh£z¦$Έ)‡\E" Eνc‡o:ϋιϋ&CΡ{ςχ³vŒ{�«�εX"ΒΫ`ŒΪg~›½Fν5₯Χ^{#6`‚ξ³oVσμ―0ˆqσΙ¦¦&] ‹λ1ΉhοΫΌwΉΝ¬_§wCE/ b½(ψά{k ΆzcW9yςTάl΅Φcμ.4†mo kήψΝ6ZΝΒ&kΓGeϋDUύϋΎΥ›3[ΌMyωο{ρή¦SS“�ΝFΝ»§ξdnΘkMIWό­Mπ…!4W–^O<H^‡g¬αŠ‘yΟPΖ4ηΉΐcœIΞ…δŒeœ³„qή>°γ}ο{ ρΐύχΊucΡ[3ζΌ΅cΙΎ›šΠ5υκ‰ζ‘ΠC±‡ln1ΰ"|ξε:蝦9‘7`�>²χ^�«{ΠΫΫΜΥ ϊ/§Eθ¦`—υc·9sΡ«έNμ½tfa�g�μο'jνt€ΦΞβz"Y[*’) Κ{ΕτMΑ(Δ½ι›υ[ͺ3Ώϋ‘{N”CρΌ ,}s…ΝυξOdŸο»Ά›V·Ι([QΈΦg�BσΑwuB‘aη…s©s_»”Ώ ξ΄7ΪΌ(Ό»œΰ‹‘ φΗΠ:έjΖΠwt=CWJ™Ίnήm+>7gάA‘»�—3gϊέ['φόƒEθmΡΣ“Š†λΕu-ϊ„χŒΉ4Ο~βo¨π9πβ±—Λ―½ώzε‡?<OΟΜ†ZιPειΔ\kΝσφ Š)₯©Ξ ΡZS£5ΡΪ8±GŒ6ΰΎv@°)Θk1xκ1s0€c�’ ;χI 5”QcS— £T3Ξ£LrΞ%c,cœ%œρdΟΔξΦƒήί<|ηυj΅κή5n^,xσcΙΎ‹άžΚ7HΓΪΌ4hΉQT}oσΣ’5rb7,‹ή 6ΆWh…!·Χͺ#{Πλ ι >ηιo†2|­ΟšκτΖ79΄s$«\.%™¦„Χ~‘ίιOΥλ6Oν6Olι_?|`Ύ ίεΛo}-έ7Wzφ]Ο=�>ω.Λφ�‘μ=�ξΔy-ΟώJσΑxγ‘zs’ε͍faN½sΓΦUτiάlΌwŒ{w-ΨMαzέjΗΠ χFŸ1ΌžS¦LŸϋξYέΪ]ξ}/ήΫΔ»·ώZαίΫ2�}½Οc+ϊΐΫƒο=Μ‘λΪx‡r‘χ^.„ίΐα;ο<|ηƒ?�s?[š››+Ώφϊρ‰“§’W^y5_μ₯ή#*́΅<ϋ›Ύw‡ ©χ™y—ΫΘjœη ~=%—―ΐΨ]Κ¦…1”Χλ&λ"vϊλuίWΊ·ώ=mΎ?/JlΡ'νΧ δ‘?·'©A7κW~+ώΚΥj΅ς@΅ZzΰώϋJ7›ž9½PzIDATΝθδ©3Ρk―ΏΝΝΜοœ<ž8qRhm„ΦŠ\k͌1TMM^ŸMu.©ν½Ίjƒ§‚αι˜Qͺ)₯šP’IΥ”PE)Ν(₯rΟΔξdbχxΊ�Φ©φΤΎΙV΅Zmy‡.sΊ‘»eοο›ήΪλή?(ς{(ψ:σ]Αωι3n‘fή§ŸŽ³š “Ώx+ος7IͺΟ†_F—Ί ξ­ΛτΣ}#φmΰπ*P0ύǐΌyzžΒI!` "ΫS―$Xk΄,}ϊήΙ+ς\ίWΎ‘λg ‚ϏΉΝ₯Ϋ\πΛxφ/6ta>ψŸώ|ΠΈH―(Ÿ«Ω?{^DψBZϊXDVδ9…Qϊνoύ΄ΌΠΞ© WΖeΞoΉ@‰κ1›ΎΙ™ŽKcΑ›άz±`²π΄$r3–_δΐτ‘Ινg<‘wϊ§nb‚ ‚ Š=A ·'^ΡΩεV,΅2₯Eͺ4O€f©ΤDuΜSrJˆ‘$―‰+ζXkcΐ­swM₯σšσΡf{%Rc‘ˆΣ±J”XαΦ²"nΩ Ίš½–<Χ€�Λf?‘‡BAAΕ‚ «|.Ί' €-GϊF`Θώyl…!·�ξ<±η‰3iE[ۊ½¦'θά§sΦtQΌςtΝ E‚ ‚ ȍΦμ!Θ:k>+ΜΐŠ.WΓΧ†<β6k…^ "+ YAμO”Ή�ž„<:η_Λ^ξ뀏ΐΓH‚ ‚ Š=A.BˆρΪ'(Oό)+ԐGσBΘ#zzάzΤϋwξ�žΰΛ�ωŽ€0Wš‹¦Ο§.|<AAΕ‚lα·Φ9hVψ‚ ‚ ‚bA6¨ψ[ύ„Ea‡ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ²yψ�ͺ·H―ΨΚ¬IEND�B`‚pywps-4.8.1/docs/_static/pywps.svg000066400000000000000000001227121526123257100172000ustar00rootroot00000000000000 pywps-4.8.1/docs/api.rst000066400000000000000000000042601526123257100151470ustar00rootroot00000000000000############# 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.basic.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.8.1/docs/api_rest.rst000066400000000000000000000241521526123257100162060ustar00rootroot00000000000000.. _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.8.1/docs/conf.py000066400000000000000000000037701526123257100151500ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import sys import datetime project = u'PyWPS' license = ('This work is licensed under a Creative Commons Attribution 4.0 ' 'International License') copyright = (f'Copyright (C) 2014-{datetime.datetime.now().year} 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': 'restructuredtext'} 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'] for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() todo_include_todos = True pywps-4.8.1/docs/configuration.rst000066400000000000000000000233201526123257100172430ustar00rootroot00000000000000.. _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.8.1/docs/contributing.rst000066400000000000000000000000641526123257100171030ustar00rootroot00000000000000.. _contributing: .. include:: ../CONTRIBUTING.rst pywps-4.8.1/docs/demobuffer.py000066400000000000000000000104331526123257100163330ustar00rootroot00000000000000############################################################################### # # 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.8.1/docs/deployment.rst000066400000000000000000000237611526123257100165650ustar00rootroot00000000000000.. _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.8.1/docs/exceptions.rst000066400000000000000000000011101526123257100165460ustar00rootroot00000000000000.. _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.8.1/docs/extensions.rst000066400000000000000000000076011526123257100165770ustar00rootroot00000000000000.. _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 ".[processing]" # 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.8.1/docs/external-tools.rst000066400000000000000000000033101526123257100173510ustar00rootroot00000000000000PyWPS 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.8.1/docs/index.rst000066400000000000000000000020601526123257100155010ustar00rootroot00000000000000.. _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.8.1/docs/install.rst000066400000000000000000000110311526123257100160360ustar00rootroot00000000000000.. _installation: Installation ============ .. note:: PyWPS is not tested on the Microsoft 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 3.10 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. On 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:: # Determine version of GDAL installed on your system $ export GDAL_VERSION="$(gdal-config --version)" # Install GDAL Python bindings $ pip install gdal==$GDAL_VERSION --global-option=build_ext --global-option="-I/usr/include/gdal" .. Warning:: If you are using setuptools 63.0 or higher, you need to install the GDAL Python bindings with the following command:: # Determine version of GDAL installed on your system $ export GDAL_VERSION="$(gdal-config --version)" # Install GDAL Python bindings $ pip install --upgrade --force-reinstall --no-cache-dir gdal==$GDAL_VERSION --no-binary 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@main#egg=pywps .. 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/ To install PyWPS system-wide with all dependencies run:: $ sudo pip install ".[dev,extra] # for developer tasks and GDAL Python bindings (if python-gdal is not already installed by `apt-get`) 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:: $ pip 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.8.1/docs/max_operations.dia000066400000000000000000000112571526123257100173570ustar00rootroot00000000000000‹ν]ΩrγΈ}ο―Pyͺς€±/QΊ§’Τ€ς­f&I*ε’-ΪV’Ε‘¨ξvςGωŠόXpIyEj!Κ”αͺξ«a^Ζ=χ\ΰ.Ώψξλl:ψœfΛΙbώρ‚ |1Hη7‹ρd~χρβΟ?ύζ[}ρέ§ΏO’ŸΫ?wY2؟˜/ỏχyώπσΛΛ/_Ύ ιγ2ΙšNVh™^ώ'™N“K;θςβΣ‡ΑΰυΖIžΐgλO“<Ο&Χ«<Μ“Yϊρβ:Ήωη]ΆXΝΗε¨υΈ›Εt‘ >'ӏίά_—λΗ\n?}χjAΧ�HoςυL�όϋί Ύό˜'φmJ³ΩΕ ±d2ώxρGΌΉVΥΩΩΗYάZ¬‡ΕσŽbρ!©N<šž~}]}xf'Μο¦ι³&†1bq1€‹vςfiž\μΩζγΙMήξιι4]έ,²ω6ϋπ΄^…„£ΆΧ¬ŸŽ~τ}:Ή»Ο½?ΫΪ·τ ΄{•m―ώ&%ΔΈˆΫΙtz€ˆMB{œKn'σdZ}ώ.[Z\—/ŠΪ€ΉVgηγ$[υύ)ύšΏR\²V\βAq͐ Vu h.Άͺ+6JXRΠ–2sX‰=κ[ΣDΊ ZΏ1�β|�5½ρYϊ―UΊΜ܁6΄i£.ζy\ψ|p›Μ&ΣGϋzΙ|y1X揀χ©Λύm:ύœζ“›δ΅M:ώ Ά”ΌFΡ1�xσ‹» έ܊‡mΗ6r6iŁ@ΣF%νwσYZ�ΛLη«Ωz!”RKŽΣŽΟΕ+UυγεU˜#²ΦεύβΛUΝqΔ~_k—”›Ε|nΡΘbζr뽟ώepoQv­cω’`Cƒ—ύx‘·Y]ε‘~°œ:c9CB yH(/$0ΔΛKiˆτΙ#t‡ξκξ‹Θέ)rϋ!ζ B,8C2$ ­e`\xƒΕ�«C†κχ_’SPΘVΤ‹±MξE½p―ςμWY2ΏΉ―9ύβ�˜G μ9‰LH7Ϊ ΡΤ0£•aπ)¬†Δ L±κοY˜λκrFž‡Ρwqζ‡^?τB…U΅RˆDJI:βq gVΚΤΫηXΝ�Σl°Έ΅–‘₯γ§Σ«ε‡ŸMσΡ‡Ώ-ΣΜώ^ώ~9KΎ>d‹›tΉL—‘—tΓKbŠΠHMQO·φR,Žaύ�#'ωc Η’�ΐO0RCJ όVˆ…”Hς!εύεU�+v―βHkσϊK…ΌxDϊ=_=vΙ!~XΝkr©Ahj@"ΡΐεgvσEp/φHΉΪ#ƒμoΜ Π™"ΞΕX“4"jh €θqΔ‹Ϋzb‹”]� Ε₯ѝƒ-ϊ΅]u;|™'ωjiYȍF©£D©2K"M4JRˆYc‘΄‰XŒm‘ιP‰€ em’D‚Ή¨²ͺΧn’Σ²bšΚ°·WκK’i:Στ§�Ed«ωά~­R�’ΡzH%�Ρ,΅>G΅ο’=^%Ε)_UWv†*#dΌ-!ύ:Ι½ΙπβVw·RC&¨[iΆ•‘ �¬ρc`ΟϋμZΊ¬Ωaφ[K]Λsu-‹ŒŽθYvθY2p-92Ρ†ϋt-#œˆs‚‘~eA]KŠˆ2lΘ2Τ°αˆQ¬†#¬ϊΰδΆv1Ό©?αM„x�³ό#b•L#a4<©••ˆ8&ϊν›μ³η|Όε`2Ψ�[₯ίE³άl―w&’„˜σ²Ϊ4F0υ)‚‰TτpΫ&˜ΚΓΖϋEͺθΎκΫpίύ¬7Š[ςc υw[z₯,Αz Ljl+ΏΒ±bYΗΚ¨gZA–³–pν’E­Τ Ό? υδλ =Ά²P;zφΤz­ͺ-©σ‘EΤ�ιB; II%©•Ω’2Κ”ƒπzΦ΅—›ΓΞͺό bΞΧu~H—ƒΣFrΆΣ’NМ)Z›χ¨₯iΗ΅6’·ΙZγΞ₯ŠPα.±Ž±ΘΩڈkβmϋbΟw3Δzφvt­*ƒΫM'[β©/8=šΝΉΧΆ«εt°:Ϊ˜ήΘλj™έe…Ϊω zάΥγ]R=V‘z²`z䌨T/R=ακ‰Ξ¨’zVG-Ε³’{Hυ "3’»‘;Ι]$w}!w<$Ή;.F‡8η"R(V!£!+ž‚5n¬ͺC^bϋ o’Ξƒγͺrͺa‘^²Œ:ϋDτ!PηΗδσσ­ŸE”ς�/ήωuP΄kZ ‰‘:]dηΔDͺζDE ‡Œ#  S ’Β€#£1gΦE„ι±™ς°~‡˜*€½Χks4Uu"noۊθ4u~m₯–Φd£‰κ b΄6š@8Η<š¨˜ςΆ2Bˆs^§FrΘiΠ‚ͺΊ(έKνί#’³Ύ&ηˆχΨΧt[³CΜ·±ζ›o”Ύ‰ζϋ<Ν�’jήΪΩάf‹Yτ6;K αBYμaHFoӏ·ωκφμw κ ”ρ�C††ν #˜¦C²n�D₯υΟ¬›)Ϋ¬ΓYσU:“8fj #xλfTvƒάtύKlΊΥψ©ΚρPΔΎž`Ε'Β>ιϋ c AL†f} Ηώ-•=K$RΏˆ~M»0"`ϋ²Sεη€Ιn†π~({fdV&ο…3)YW”¬Ί #$υ‡”‘‘2νjEG @DΏ @†FŒΨΌ #φι‰ΠKœ�! z‡ΏŒ(‘βBΔ1]΄"Ž<0ΒΰξΝ‘°?HΘNƒ„Μ=Κ™!9$ 1 1 ©ŠB_…L.γα\ΔΏ†-Ρ―‡gsΜ=J›"-)x`@*ε€ Dz’*"$EHjά…“ϊˆIΤ&IŠΚИ$‹]'e‰I₯Tο0#&5ξˆIo“άŠͺ3ζ­¨ϊfrWΰ’κ@˜ ?αs�(ε|Vύsά–3U?‡’κŒ{+ͺnΡ[vSTΈ+•lΔ bnή8RκuγΝ·_Tύ«Ωuš ·ƒl5ŸΫΩβ&].Σε‡Ÿέε£[¦™ύmύύς!Ι’ι4>�{¬Δ~’Jμβλ ‹•ΨwΠ•X‰ύmTb―fW¬ΔΎ?m’9gXΥ#\ ) ›8i ‹&–ϋY9Ϊh9²bΕ:s�„Οœ¬Ÿdύ8£5Ό•ŽΉ“η•;)ΚιΣ$E3ιC»‹ ­‚;νB‚Α /΄[2―νޞdν8 ‰Ρ©¨άη₯άΪAΉΥ‰”[yPnΨΟ–A‡Άά�j”:ΰFΊm(‡2υΈέޞcύ0ˆ…f`Q·ΟK·•ƒn›ιΆφ‘ΫΕ†mΥΑΚ­™1’›‚–K¬ΘiDΊQξνI֏³£"%?3Ν6.Bρ‰TΫψPm1δ8€Z3„Ή¦Cfα’‚Z+iŠ υŠށZoNpG©κXUπόͺ b—2EA|ν½]&ΈsH,ζί2pΖ:T+|Xˆ{AK@© ~L[GΎ½…~ξ«wXwCLΤλ–Q$φœΨ%Fυ‘eԏōޯVΛΗΑχ_o�@‡Z:΄ˆRd°VC† [ϋM΄μ7qPPΡοpπSšΝκ¬ρ $ΟOT’WΓ‰�§C\–vŒ  «BΊΘ~S@^Vσ°X <ˆΌΛX A“εΥνdžLχ\Gί&ΣeΪaΔ§žP@ƒ&βŽP@ @ΚςKEΊΏN€θSΜΡχσρ ΏOŸ"bQχqDMΫ6Ə#b1ލE―ԁΡαβˆφΔ‘sηΪλ„"’!’#E {aAΘ‘’ˆΈ%‘„)Β=ξ˜νeω9� ;h�;61&`rηή4Ž„Φ8«ZTΣBηΤ ~–L‘n+ΈΛ³‘ρΰρΙWΗ�—mLψΉU¦o'?:"§;pMΜφΡ2�Ν5NT…;ΗpSb‘σ‘AZsΙC YΖΖJ"L>ς"ΉSk°ˆ ό^ΰyS(F  ΤI@0'*�Η₯/0T‘ͺ€‘Š`Αp{;F0μ-ž¨d3W‚’ηjHΑyΗ&l\R!‰X«oΩ� ₯¨›δX΄τάόύ—ύΘ¬‘ŒυhN‹†οΪί§ςχ}$‡I t“ζŒΛ°δ₯$‰43Ε… f‚ƒd‘‰7‚ϊŽνA½7₯¨EθVq ι4ά9ζ9 •ΐ. zo%iΞ HΒF©Τ� ˜Bό’$m+ΐœUΣ8Ο¦X˜jθaL�9‹ΰΨ!i�s�U�œΐ>(ΟϊΦ9|„‰�b]PՐ"ίC- Š¨μ€–Eν<›‹ΪPD’–Ÿ›–3‡:y"%w�β^ΤΞa;^Cj¬βzΘ‘ΡR˜Θ”ŒCx‹–’†6βΫ3܁.α`Q·ίlz,o€ΪXpv"ε¦^”Ϋ„WnΘsΧΘAu‘ά‚αE˜hΡ…r›¨άοZΉi•Ϋ=œwŽ ’+$IAΩάbM(δŽsIUhzή4Οfέ)©=*z_:=JΡOTΌF8ǝΓΩ—†"Š™° 0[1=¬X†2Š˜"‘Yzύ$λ[0R‘α­¨0QΕΟΝ–»”Œ%ͺ1½+¬Š ]n΅5©Pΰ‘ ‘ΒQQV’dΊΜ�•Z'ΧZΘΠJή0Νxœώ�t\υρ8]zqΖ‰ [σξ˘)δhKΡΛ£6¨Σl ^ŒξΒ―L1²τχΖ�]ΌρΠ$½!>H8G}ZήΚ1ηΠο@ ΙšpΑˆ–j€G^$Ηψ 3 Ωo6“Š!π} —§‰ΪΥaH†�· a‘v‡3ͺJ¦£X3@Œι„ι°½Ν/b΄ΐ™²ΖhŽ<Λ1>ύ 7ΈJΞσ+ΔͺΔΘ‡ΨΘpΞ/Ιo½'₯±ŒE/Ž>M:ˆτΙΣChΪ–δ”b0RX@*—„T.h€π1&ψu’ R³c"HoA˜9£'‰‡”’xlΰΰcL‘ρ}‘Ζ³$κŽ2ΕΒǞTζW{UEΉYB%±Ϊ™wV‘.·UΊ+‚γ: 58¨έΥ8θ9Ξ“ ¨HMFΰψQhuh:ϊz‘αμ،‘γτ†γΘΠυ\ώ:ΉϋkrWa:Οxθmk€¦+!‘Π „Ώ1¦ιˆΰ"σ­Ωφœ-Λο―φ2œνym±/vΰ ϋϊ»»Jb‹κ•QGΟi‘M6΅j—Fνj1ΣόsΗΌS²ΚΩb•oΪ —�‘Η…oiΛzΡ3ύρŠCuΎ™sΨπ·Π’]AξΌ΅Ψ"h–ΎEω“(Lň!F!₯«pŸ{δωYΔCκΔKč}ς.1tΚ{ϋώΑιΏVι2dφ―E–Ž£·Ϊ[ΐΪ¬…ΦP·ΓπΨ%―΅Γ`ί%{ΌJ ΛVΥ—§ύ]»Ÿ2^„–~δήdxuKœ4’CΞPP㎭1§\ u%ΥHΓέ�ΞBΆ§Cθdœίa±v©!Βσ"Ί8"qNpx₯bα= —-„C²Υg]™hR™LƒBӎ™ξLΩΎB©κ(οθ@ή+:U†8Δ,²�n²άλqK€-Έ zQύ$Ηβ‘£Wb9οC¬NtLƒ_cΥοΔx‹Υι-V—Κο§Ιcš}ϊP~c�άeΙμΣ‡�μ)‡ψώRpywps-4.8.1/docs/migration.rst000066400000000000000000000076141526123257100163750ustar00rootroot00000000000000.. _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.8.1/docs/process.rst000066400000000000000000000513351526123257100160610ustar00rootroot00000000000000.. 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.basic.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.basic.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.basic.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.8.1/docs/pywps.rst000066400000000000000000000033121526123257100155550ustar00rootroot00000000000000.. _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.8.1/docs/show_error.py000066400000000000000000000023161526123257100164070ustar00rootroot00000000000000from 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.8.1/docs/storage.rst000066400000000000000000000036131526123257100160430ustar00rootroot00000000000000.. 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.8.1/docs/wps.rst000066400000000000000000000232401526123257100152060ustar00rootroot00000000000000.. _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.8.1/environment.yml000066400000000000000000000007241526123257100160040ustar00rootroot00000000000000name: pywps channels: - conda-forge dependencies: - python >=3.10,<3.14 - pip >=25.0 - humanize >=4.5.0 - jinja2 >=3.1.0 - jsonschema >=4.20.0 - lxml >=6.0.2 - markupsafe >=3.0.3 - numpy >=1.22.2 - owslib >=0.35.0 - python-dateutil - requests >=2.32.5 - sqlalchemy >=2.0.44 - urllib3 >=2.5.0 - werkzeug >=3.1.4 # extras - fiona - geotiff - netcdf4 - tifffile <=2025.5.10 - zarr <3.0 # tests - pytest - ruff >=0.14.0 # docs - sphinx >=7.0.0 pywps-4.8.1/pyproject.toml000066400000000000000000000063361526123257100156360ustar00rootroot00000000000000[build-system] requires = ["setuptools>=80", "wheel"] build-backend = "setuptools.build_meta" [project] name = "pywps" description = "PyWPS is an implementation of the Web Processing Service standard from the Open Geospatial Consortium. PyWPS is written in Python." readme = { file = "README.md", content-type = "text/markdown" } license = { text = "MIT" } authors = [ { name = "Jachym Cepicky", email = "jachym.cepicky@gmail.com" } ] maintainers = [ { name = "Jachym Cepicky", email = "jachym.cepicky@gmail.com" } ] keywords = ["PyWPS", "WPS", "OGC", "processing"] requires-python = ">=3.10" dynamic = ["version"] 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.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: GIS", ] urls = {Homepage = "https://pywps.org", Repository = "https://github.com/geopython/pywps"} dependencies = [ "humanize >=4.5.0", "jinja2 >=3.1.0", "jsonschema >=4.20.0", "lxml >=6.0.2", "markupsafe >=3.0.3", "numpy >=1.22.2", # not directly required, pinned by Snyk to avoid a vulnerability "owslib >=0.35.0", "python-dateutil", "requests >=2.32.5", "sqlalchemy >=2.0.44", "urllib3 >=2.5.0", # not directly required, pinned by Snyk to avoid a vulnerability "werkzeug >=3.1.4", ] [project.optional-dependencies] dev = [ "bump-my-version", "coverage", "docutils", "pylint", "pytest", "pytest-cov", "ruff >=0.14.0", "sphinx", "tox >=4.56.1", "twine", "wheel" ] extra = [ "fiona", "geotiff", "netCDF4", "tifffile <=2025.5.10", "zarr <3.0"] processing = ["drmaa"] s3 = ["boto3"] [project.scripts] joblauncher = "pywps.processing.job:launcher" [tool.bumpversion] current_version = "4.8.1" commit = false tag = false parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] [[tool.bumpversion.files]] filename = "pywps/__init__.py" search = '__version__ = "{current_version}"' replace = '__version__ = "{new_version}"' [[tool.bumpversion.files]] filename = "VERSION.txt" search = "{current_version}" replace = "{new_version}" [tool.coverage.run] relative_files = true omit = ["pywps/templates/*"] [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", ] xfail_strict = true pythonpath = ["tests"] testpaths = ["tests"] markers = [ "online: marks tests requiring network", "requires_fiona: marks tests requiring fiona module", "requires_geotiff: marks tests requiring geotiff module", "requires_netcdf4: marks tests requiring netcdf4 module", ] [tool.setuptools] include-package-data = true [tool.setuptools.packages.find] exclude = ["docs", "tests", "tests.*"] [tool.setuptools.dynamic] version = { file = ["VERSION.txt"] } [tool.ruff] lint.select = ["E", "W", "F", "C90"] # Flake8-equivalent rule families lint.ignore = ["F401", "E402", "C901"] line-length = 120 exclude = ["tests"] pywps-4.8.1/pywps/000077500000000000000000000000001526123257100140745ustar00rootroot00000000000000pywps-4.8.1/pywps/__init__.py000066400000000000000000000074511526123257100162140ustar00rootroot00000000000000################################################################## # 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.8.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 import UOM from pywps.inout.formats import FORMATS, Format, get_format from pywps.inout.inputs import BoundingBoxInput, ComplexInput, LiteralInput from pywps.inout.outputs import BoundingBoxOutput, ComplexOutput, LiteralOutput if __name__ == "__main__": pass pywps-4.8.1/pywps/app/000077500000000000000000000000001526123257100146545ustar00rootroot00000000000000pywps-4.8.1/pywps/app/Common.py000066400000000000000000000046531526123257100164660ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/app/Process.py000066400000000000000000000517241526123257100166550ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import importlib import json import logging import os import shutil import sys import traceback import pywps.configuration as config from pywps import dblog from pywps.app.exceptions import ProcessError from pywps.app.WPSRequest import WPSRequest from pywps.exceptions import ( InvalidParameterValue, NoApplicableCode, OperationNotSupported, ServerBusy, StorageNotSupported, ) from pywps.inout.outputs import ComplexOutput from pywps.inout.storage.builder import StorageBuilder from pywps.response import get_response from pywps.response.execute import ExecuteResponse from pywps.response.status import WPS_STATUS from pywps.translations import lower_case_dict 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=None, profile=None, metadata=None, inputs=None, outputs=None, 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 if keywords is not None else [] self.metadata = metadata if metadata is not None else [] self.profile = profile if profile is not None else [] self.version = version self.inputs = inputs if inputs is not None else [] self.outputs = outputs if outputs is not None else [] 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() if maxparallel != -1 and running >= maxparallel: # Try to check for crashed process dblog.cleanup_crashed_process() 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 != -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: {} with pid: {}".format(self.uuid, os.getpid())) # Update the actual pid of current process to check if failed latter dblog.update_pid(self.uuid, os.getpid()) 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() # This request was saved by PyWPS, so it is trusted. new_wps_request.restore_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 geo-referenced 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: # FIXME: This will fail as location is not set. 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.8.1/pywps/app/Service.py000077500000000000000000000351131526123257100166340ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import copy import logging import os import sys import tempfile import uuid from collections import OrderedDict, deque from typing import Dict, Optional, Sequence from urllib.parse import urlparse from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Request import pywps.configuration as config from pywps import response from pywps.app.WPSRequest import WPSRequest from pywps.dblog import log_request, store_status from pywps.exceptions import ( FileURLNotSupported, InvalidParameterValue, MissingParameterValue, NoApplicableCode, ) from pywps.inout.inputs import BoundingBoxInput, ComplexInput, LiteralInput from pywps.response.status import WPS_STATUS LOGGER = logging.getLogger("PYWPS") class Service(object): """The top-level object that represents a WPS service. 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.8.1/pywps/app/WPSRequest.py000066400000000000000000000764741526123257100172720ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import base64 import datetime import json import logging from urllib.parse import unquote import lxml from werkzeug.exceptions import MethodNotAllowed from pywps import configuration, get_ElementMakerForVersion, get_version_from_ns from pywps import xml_util as etree from pywps.app.basic import get_xpath_ns, parse_http_url from pywps.configuration import wps_strict from pywps.exceptions import ( FileURLNotSupported, FileSizeExceeded, InvalidParameterValue, MissingParameterValue, NoApplicableCode, OperationNotSupported, VersionNegotiationFailed, ) from pywps.inout.inputs import input_from_json 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 POST 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) # Load client input and validate any local file paths. self.load_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 returning proper 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 tag name. 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): """Check for accepted versions. :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): """Check and 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): """Check and 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.load_json(value) def load_json(self, value): """Load a request and validate its input file paths.""" self._set_json(value, validate_file=True) def restore_json(self, value): """Restore a request previously saved by PyWPS. Input files are already in the job work directory and do not need to be checked against ``allowedinputpaths`` again. """ self._set_json(value, validate_file=False) def _set_json(self, value, validate_file=True): 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, validate_file=validate_file) self.inputs[identifier].append(inpt) except FileURLNotSupported: raise 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 = { 'identifier': identifier_el.text, 'data': str(value_el.text), 'uom': value_el.attrib.get('uom', ''), '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 = { 'identifier': identifier_el.text, 'mimeType': complex_data_el.attrib.get('mimeType', None), 'encoding': complex_data_el.attrib.get('encoding', '').lower(), 'schema': complex_data_el.attrib.get('schema', ''), '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 = { 'identifier': identifier_el.text, identifier_el.text: reference_data_el.text, 'href': reference_data_el.attrib.get( '{http://www.w3.org/1999/xlink}href', '' ), 'mimeType': reference_data_el.attrib.get('mimeType', None), '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 = { 'identifier': identifier_el.text, 'data': [bbox.minx, bbox.miny, bbox.maxx, bbox.maxy], 'crs': bbox.crs.getcodeurn() if bbox.crs else None, '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 = { identifier_el.text: '', 'mimetype': output_el.attrib.get('mimeType', None), 'encoding': output_el.attrib.get('encoding', ''), 'schema': output_el.attrib.get('schema', ''), 'uom': output_el.attrib.get('uom', ''), '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 = { identifier_el.text: '', 'mimetype': output_el.attrib.get('mimeType', None), 'encoding': output_el.attrib.get('encoding', ''), 'schema': output_el.attrib.get('schema', ''), '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 = { identifier: '', 'mimetype': output_el.get('mimeType', None), 'encoding': output_el.get('encoding', ''), 'schema': output_el.get('schema', ''), '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 = { 'key': header_element.attrib('key'), '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.8.1/pywps/app/__init__.py000066400000000000000000000010651526123257100167670ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/app/basic.py000066400000000000000000000124441526123257100163140ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ XML tools """ import logging from typing import 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. Versions 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 else: raise NotImplementedError(version) 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.8.1/pywps/app/exceptions.py000066400000000000000000000037571526123257100174230ustar00rootroot00000000000000################################################################## # 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 logging import re LOGGER = logging.getLogger('PYWPS') DEFAULT_ALLOWED_CHARS = ".:!?=,;-_/" 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): """ProcessError class. :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 alphanumeric 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.8.1/pywps/configuration.py000077500000000000000000000233621526123257100173260ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ Reads the PyWPS configuration file """ import configparser import logging import os import sys import tempfile import pywps __author__ = "Calin Ciociu" from pywps.util import file_uri RAW_OPTIONS = [('logging', 'format'), ] CONFIG = None LOGGER = logging.getLogger("PYWPS") wps_strict = True class EnvInterpolation(configparser.BasicInterpolation): """ Configuration parser class to allow env variable interpolation. With this interpolator it is possible to use env variables as part of the values in an cfg file. Example (given an env variable HOSTNAME=localhost): [server] url = http://${HOSTNAME}/wps ==> http://localhost/wps This code is an adaption from the configuation parsing in the pycsw project. See also - https://github.com/geopython/pycsw/blob/3.0.0-alpha3/pycsw/core/util.py - https://stackoverflow.com/a/49529659 """ def before_get(self, parser, section, option, value, defaults): """Expand env variables when returning values.""" value = super().before_get(parser, section, option, value, defaults) return os.path.expandvars(value) 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 :param default_value: default value for configuration :type default_value: 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_hardcoded_configuration(): """Load PyWPS hardcoded configuration.""" global CONFIG LOGGER.debug('loading harcoded configuration') CONFIG = configparser.ConfigParser(os.environ, interpolation=EnvInterpolation()) tmpdir = tempfile.gettempdir() 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', tmpdir) CONFIG.set('server', 'processes_path', '') CONFIG.set('server', 'outputurl', file_uri(tmpdir)) CONFIG.set('server', 'outputpath', tmpdir) CONFIG.set('server', 'allowedinputpaths', '') CONFIG.set('server', 'workdir', tmpdir) CONFIG.set('server', 'parallelprocesses', '2') CONFIG.set('server', 'sethomedir', 'false') CONFIG.set('server', 'cleantempdir', 'true') CONFIG.set('server', 'storagetype', 'file') CONFIG.set('server', 'storage_copy_function', 'copy') CONFIG.set("server", "default_mimetype", "text/xml") 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]))) 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', '') 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 # noqa load_hardcoded_configuration() 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 # noqa 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 match = re.fullmatch(r"([0-9]*\.?[0-9]+)\s*(g|m|k|gb|mb|b)?", size) if not match: raise ValueError(f"Invalid size format: {mbsize}") value = float(match.group(1)) unit = match.group(2) if unit in ("g", "gb"): value *= 1024 elif unit in ("m", "mb"): value *= 1 elif unit in ("k", "kb"): value /= 1024 elif unit in ("b"): value /= 1024**2 LOGGER.debug('Calculated real size of {} is {}'.format(mbsize, value)) return value pywps-4.8.1/pywps/dblog.py000066400000000000000000000177561526123257100155550ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ Implementation of logging for PyWPS-4 """ import datetime import logging import os import sys from multiprocessing import Lock import sqlalchemy from sqlalchemy import VARCHAR, Column, DateTime, Float, Integer, LargeBinary, String from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool, StaticPool from pywps import configuration from pywps.exceptions import NoApplicableCode try: from sqlalchemy.orm import declarative_base except ImportError: from sqlalchemy.ext.declarative import declarative_base from pywps.response.status import WPS_STATUS LOGGER = logging.getLogger('PYWPS') _SESSION_MAKER_DATABASE = None _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 update_pid(uuid, pid): """Update actual pid for the uuid processing """ session = get_session() requests = session.query(ProcessInstance).filter_by(uuid=str(uuid)) if requests.count(): request = requests.one() request.pid = pid session.commit() session.close() def cleanup_crashed_process(): # TODO: implement other platform if sys.platform != "linux": return session = get_session() stored_query = session.query(RequestInstance.uuid) running_cur = ( session.query(ProcessInstance) .filter(ProcessInstance.status.in_([WPS_STATUS.STARTED, WPS_STATUS.PAUSED])) .filter(~ProcessInstance.uuid.in_(stored_query)) ) failed = [] running = [(p.uuid, p.pid) for p in running_cur] for uuid, pid in running: # No process with this pid, the process has crashed if not os.path.exists(os.path.join("/proc", str(pid))): failed.append(uuid) continue # If we can't read the environ, that mean the process belong another user # which mean that this is not our process, thus our process has crashed # this not work because root is the user for the apache # if not os.access(os.path.join("/proc", str(pid), "environ"), os.R_OK): # failed.append(uuid) # continue pass for uuid in failed: store_status(uuid, WPS_STATUS.FAILED, "Process crashed", 100) 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, _SESSION_MAKER_DATABASE database = configuration.get_config_value('logging', 'database') # Check if database has changed, and clear session maker if needed if _SESSION_MAKER_DATABASE != database: _SESSION_MAKER_DATABASE = None _SESSION_MAKER = None if _SESSION_MAKER: return _SESSION_MAKER() with lock: 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_DATABASE = database _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.8.1/pywps/dependencies.py000066400000000000000000000006241526123257100170760ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/exceptions.py000066400000000000000000000126261526123257100166360ustar00rootroot00000000000000################################################################## # 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 https://lists.ogc.org/pipermail/wps-dev/2013-October/000335.html """ import json import logging from markupsafe import escape from werkzeug.datastructures import MIMEAccept from werkzeug.exceptions import HTTPException from werkzeug.http import parse_accept_header from werkzeug.wrappers import Response 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.8.1/pywps/ext_autodoc.py000066400000000000000000000135701526123257100167720ustar00rootroot00000000000000# -*- coding: utf-8 -*- from docutils.parsers.rst import directives from sphinx.ext.autodoc import ClassDocumenter, bool_option from sphinx.ext.napoleon.docstring import NumpyDocstring from sphinx.util.docstrings import prepare_docstring 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 isinstance(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.8.1/pywps/inout/000077500000000000000000000000001526123257100152325ustar00rootroot00000000000000pywps-4.8.1/pywps/inout/__init__.py000066400000000000000000000010071526123257100173410ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from pywps.inout.basic import UOM from pywps.inout.formats import FORMATS, Format, get_format from pywps.inout.inputs import BoundingBoxInput, ComplexInput, LiteralInput from pywps.inout.outputs import BoundingBoxOutput, ComplexOutput, LiteralOutput pywps-4.8.1/pywps/inout/array_encode.py000066400000000000000000000010311526123257100202320ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/inout/basic.py000066400000000000000000001036201526123257100166670ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import base64 import json import logging import os import shutil import tempfile import weakref from collections import namedtuple from copy import deepcopy from io import BytesIO, StringIO, open from pathlib import PurePath from urllib.parse import urlparse import humanize import requests import pywps.configuration as config from pywps import OGCUNIT from pywps.exceptions import ( FileSizeExceeded, FileURLNotSupported, InvalidParameterValue, NoApplicableCode, ) from pywps.inout.formats import Supported_Formats from pywps.inout.literaltypes import ( LITERAL_DATA_TYPES, convert, is_anyvalue, is_values_reference, make_allowedvalues, ) from pywps.inout.types import Translations from pywps.translations import lower_case_dict from pywps.validator import get_validator from pywps.validator.base import emptyvalidator from pywps.validator.literalvalidator import ( validate_allowed_values, validate_anyvalue, validate_value, validate_values_reference, ) from pywps.validator.mode import MODE _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.get(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.warning("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.8.1/pywps/inout/formats/000077500000000000000000000000001526123257100167055ustar00rootroot00000000000000pywps-4.8.1/pywps/inout/formats/__init__.py000066400000000000000000000167451526123257100210330ustar00rootroot00000000000000################################################################## # 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 import mimetypes from collections import namedtuple 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, ' 'LAS, LASJSON, LASXML, LAZ, ' '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/vdn.las', extension='.las', encoding='binary'), Format('application/vdn.las.las+json', extension='.lasjson', encoding='binary'), Format('application/vnd.las.las+xml', extension='.lasxml', encoding='binary'), Format('application/vdn.laszip', extension='.laz', encoding='binary'), 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.8.1/pywps/inout/inputs.py000066400000000000000000000401151526123257100171270ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import base64 import re from copy import deepcopy from pathlib import Path from urllib.parse import urlparse from pywps import xml_util as etree from pywps.app.Common import Metadata from pywps.exceptions import InvalidParameterValue from pywps.inout import basic from pywps.inout.formats import Format from pywps.inout.literaltypes import AllowedValue, AnyValue, NoValue, ValuesReference from pywps.validator.mode import MODE 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, validate_file=True): 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'): if validate_file: instance._validate_file_input(Path(json_input['file']).absolute().as_uri()) instance.file = json_input['file'] elif json_input.get('href'): href = json_input['href'] if validate_file and urlparse(href).scheme == 'file': instance._validate_file_input(href) instance.url = href elif json_input.get('data'): data = json_input['data'] if data_format.encoding == 'base64': instance.data = base64.b64decode(data) else: # 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.decode(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, validate_file=True): data_type = json_data.get('type', 'literal') if data_type in ['complex', 'reference']: inpt = ComplexInput.from_json(json_data, validate_file=validate_file) 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.8.1/pywps/inout/literaltypes.py000066400000000000000000000263541526123257100203370ustar00rootroot00000000000000################################################################## # 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.""" import datetime import logging from urllib.parse import urlparse from dateutil.parser import parse as date_parser from pywps.exceptions import InvalidParameterValue from pywps.validator.allowed_value import ALLOWEDVALUETYPE, RANGECLOSURETYPE LOGGER = logging.getLogger('PYWPS') LITERAL_DATA_TYPES = dict() """ Store converter functions """ # Decorator to use on converter function def register_convert_type(name): """Decorator to register a converter >>> @register_convert_type('type_name') ... def convert_my_stuff(value): ... # convert value ... ... return value """ def _register_convert_type(function): global LITERAL_DATA_TYPES # noqa LITERAL_DATA_TYPES[name] = function return function return _register_convert_type # 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 convert(data_type, data): """Convert data to target value """ convert = LITERAL_DATA_TYPES.get(data_type, None) # noqa if convert is None: 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)) @register_convert_type('boolean') 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 (ValueError, TypeError): val = True return val @register_convert_type('float') def convert_float(inpt): """Return float value from input. >>> convert_float('1') 1.0 """ return float(inpt) @register_convert_type('integer') def convert_integer(inpt): """Return integer value from input input. >>> convert_integer('1.0') 1 """ return int(float(inpt)) @register_convert_type('string') def convert_string(inpt): """Return string value from input lit_input. >>> convert_string(1) '1' """ return str(inpt) @register_convert_type('nonNegativeInteger') @register_convert_type('positiveInteger') 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 @register_convert_type('anyURI') 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)) @register_convert_type('time') def convert_time(inpt): """Return value of input time formatting 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 @register_convert_type('date') def convert_date(inpt): """Return value of input date formatting 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 @register_convert_type('dateTime') def convert_datetime(inpt): """Return value of input dateTime formatting 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 @register_convert_type('scale') def convert_scale(inpt): """Return value of input""" return convert_float(inpt) @register_convert_type('angle') 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 isinstance(value, (tuple, 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.8.1/pywps/inout/outputs.py000066400000000000000000000506561526123257100173430ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """ WPS Output classes """ import os import re from typing import Dict, Optional, Sequence, Union from pywps import configuration as config from pywps import xml_util as etree from pywps.app.Common import Metadata from pywps.exceptions import InvalidParameterValue from pywps.inout import basic from pywps.inout.formats import FORMATS, Format, Supported_Formats from pywps.inout.storage.file import FileStorageBuilder from pywps.inout.types import Translations from pywps.validator.mode import MODE 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 # otherwise we fall back 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 ]]> pattern if is safe to use CDATA # otherwise we fall back 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 list 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 jinja2 import PackageLoader from pywps.response import RelEnvironment 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.8.1/pywps/inout/storage/000077500000000000000000000000001526123257100166765ustar00rootroot00000000000000pywps-4.8.1/pywps/inout/storage/__init__.py000066400000000000000000000056531526123257100210200ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/inout/storage/builder.py000066400000000000000000000022311526123257100206740ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import pywps.configuration as wpsConfig # noqa from .file import FileStorageBuilder from .s3 import S3StorageBuilder 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.8.1/pywps/inout/storage/file.py000066400000000000000000000153031526123257100201710ustar00rootroot00000000000000################################################################## # 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 import configuration as config from pywps.exceptions import FileStorageError, NotEnoughStorage from pywps.inout.basic import IOHandler from . import STORE_TYPE, CachedStorage from .implementationbuilder import StorageImplementationBuilder 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 >>> from configparser 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() >>> (store_type, path, url) = store.store(fake_out) >>> store_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 math import platform 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.8.1/pywps/inout/storage/implementationbuilder.py000066400000000000000000000013261526123257100236460ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/inout/storage/s3.py000066400000000000000000000140031526123257100175730ustar00rootroot00000000000000################################################################## # Copyright 2019 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import os import pywps.configuration as wpsConfig from . import STORE_TYPE, StorageAbstract from .implementationbuilder import StorageImplementationBuilder 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=''): extra_args = dict() if public: extra_args['ACL'] = 'public-read' if encrypt: extra_args['ServerSideEncryption'] = 'AES256' extra_args['ContentType'] = mime_type return extra_args class S3Storage(StorageAbstract): """ Implements a simple class to store files on AWS S3 Can optionally set the outputs to be publicly 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): """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. :param data: Data to upload to S3 :param filename: name of the file to upload to s3 will be appended to the configured prefix :returns: url to access the uploaded file """ 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): """Uploads a file from the local filesystem to AWS S3. :param filename: Path to file on local filesystem :returns: url to access the uploaded file """ 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)) extra_args = _build_extra_args( public=self.public, encrypt=self.encrypt, mime_type=output.data_format.mime_type) url = self.uploadFileToS3(filename, extra_args) 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) extra_args = _build_extra_args( public=self.public, encrypt=self.encrypt, mime_type=mime_type) return self.uploadData(data, s3_path, extra_args) 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.8.1/pywps/inout/types.py000066400000000000000000000005431526123257100167520ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from typing import Dict, Optional Translations = Optional[Dict[str, Dict[str, str]]] pywps-4.8.1/pywps/processing/000077500000000000000000000000001526123257100162505ustar00rootroot00000000000000pywps-4.8.1/pywps/processing/__init__.py000066400000000000000000000025051526123257100203630ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import pywps.configuration as config # api only from pywps.processing.basic import Processing # noqa: F401 from pywps.processing.basic import DetachProcessing, MultiProcessing from pywps.processing.job import Job # noqa: F401 from pywps.processing.scheduler import Scheduler LOGGER = logging.getLogger("PYWPS") MULTIPROCESSING = 'multiprocessing' DETACHPROCESSING = 'detachprocessing' 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) elif mode == DETACHPROCESSING: process = DetachProcessing(process, wps_request, wps_response) else: process = MultiProcessing(process, wps_request, wps_response) return process pywps-4.8.1/pywps/processing/basic.py000066400000000000000000000036661526123257100177160ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import os 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() class DetachProcessing(Processing): """ :class:`DetachProcessing` run job as detached process. The process will be run as child of pid 1 """ def start(self): pid = os.fork() if pid != 0: # Wait that the children get detached. os.waitpid(pid, 0) return # Detach ourself. # Ensure that we are the session leader to avoid to be zombified. os.setsid() if os.fork(): # Stop running now os._exit(0) # We are the detached child, run the actual process try: getattr(self.job.process, self.job.method)(self.job.wps_request, self.job.wps_response) except Exception: pass # Ensure to stop ourself here what ever append. os._exit(0) pywps-4.8.1/pywps/processing/job.py000066400000000000000000000110351526123257100173740ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import json import logging import os import tempfile import pywps.configuration as config from pywps import Process, WPSRequest from pywps.response.execute import ExecuteResponse 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() # This request was saved by PyWPS, so it is trusted. wps_request.restore_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 @classmethod def load(cls, filename): LOGGER.debug('load job ...') with open(filename, 'r') as fp: job = Job.from_json(json.load(fp)) return job 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.8.1/pywps/processing/scheduler.py000066400000000000000000000060461526123257100206060ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import logging import os import pywps.configuration as config from pywps.exceptions import SchedulerNotAvailable from pywps.processing.basic import Processing from pywps.response.status import WPS_STATUS 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: https://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.8.1/pywps/resources/000077500000000000000000000000001526123257100161065ustar00rootroot00000000000000pywps-4.8.1/pywps/resources/__init__.py000066400000000000000000000004141526123257100202160ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## pywps-4.8.1/pywps/resources/schemas/000077500000000000000000000000001526123257100175315ustar00rootroot00000000000000pywps-4.8.1/pywps/resources/schemas/__init__.py000066400000000000000000000004141526123257100216410ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## pywps-4.8.1/pywps/resources/schemas/wps_all.xsd000066400000000000000000000006501526123257100217130ustar00rootroot00000000000000 pywps-4.8.1/pywps/response/000077500000000000000000000000001526123257100157325ustar00rootroot00000000000000pywps-4.8.1/pywps/response/__init__.py000066400000000000000000000011471526123257100200460ustar00rootroot00000000000000 import os from jinja2 import Environment 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 pywps-4.8.1/pywps/response/basic.py000066400000000000000000000041111526123257100173620ustar00rootroot00000000000000from abc import abstractmethod from typing import TYPE_CHECKING if TYPE_CHECKING: from pywps import WPSRequest import os from jinja2 import Environment, PackageLoader from pywps.dblog import store_status from pywps.translations import get_translation from . import RelEnvironment from .status import WPS_STATUS 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 between <0-100>) :param pywps.response.status.WPS_STATUS status: process status - user should usually omit 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.8.1/pywps/response/capabilities.py000066400000000000000000000101531526123257100207350ustar00rootroot00000000000000import json from werkzeug.wrappers import Request import pywps.configuration as config from pywps import __version__ from pywps.app.basic import get_json_indent, get_response_type, make_response from pywps.exceptions import NoApplicableCode from .basic import WPSResponse 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.8.1/pywps/response/describe.py000066400000000000000000000050021526123257100200610ustar00rootroot00000000000000import json from werkzeug.wrappers import Request import pywps.configuration as config from pywps import __version__ from pywps.app.basic import get_json_indent, get_response_type, make_response from pywps.exceptions import ( InvalidParameterValue, MissingParameterValue, NoApplicableCode, ) from .basic import WPSResponse 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.8.1/pywps/response/execute.py000077500000000000000000000262641526123257100177630ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import json import logging import time import urllib.parse as urlparse from urllib.parse import urlencode from werkzeug.wrappers import Request, Response import pywps.configuration as config from pywps import get_ElementMakerForVersion from pywps.app.basic import ( get_default_response_mimetype, get_json_indent, get_response_type, ) from pywps.exceptions import NoApplicableCode from pywps.inout.array_encode import ArrayEncoder from pywps.inout.formats import FORMATS from pywps.response.status import WPS_STATUS from .basic import WPSResponse 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 = { "language": self.wps_request.language, "service_instance": self._get_serviceinstance(), "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.8.1/pywps/response/status.py000066400000000000000000000002721526123257100176300ustar00rootroot00000000000000from 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.8.1/pywps/schemas/000077500000000000000000000000001526123257100155175ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/geojson/000077500000000000000000000000001526123257100171635ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/geojson/README000066400000000000000000000001261526123257100200420ustar00rootroot00000000000000This schema comes from https://github.com/fge/sample-json-schemas/tree/master/geojson pywps-4.8.1/pywps/schemas/geojson/bbox.json000066400000000000000000000004611526123257100210110ustar00rootroot00000000000000{ "$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.8.1/pywps/schemas/geojson/crs.json000066400000000000000000000032471526123257100206530ustar00rootroot00000000000000{ "$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.8.1/pywps/schemas/geojson/geojson.json000066400000000000000000000043111526123257100215210ustar00rootroot00000000000000{ "$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.8.1/pywps/schemas/geojson/geometry.json000066400000000000000000000055121526123257100217140ustar00rootroot00000000000000{ "$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.8.1/pywps/schemas/metalink/000077500000000000000000000000001526123257100173235ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/metalink/3.0/000077500000000000000000000000001526123257100176235ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/metalink/3.0/metalink.xsd000066400000000000000000000261211526123257100221510ustar00rootroot00000000000000 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.8.1/pywps/schemas/metalink/4.0/000077500000000000000000000000001526123257100176245ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/metalink/4.0/metalink4.xsd000066400000000000000000000177231526123257100222460ustar00rootroot00000000000000 pywps-4.8.1/pywps/schemas/metalink/4.0/xml.xsd000066400000000000000000000211001526123257100211360ustar00rootroot00000000000000

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.8.1/pywps/schemas/ncml/000077500000000000000000000000001526123257100164505ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/ncml/2.2/000077500000000000000000000000001526123257100167515ustar00rootroot00000000000000pywps-4.8.1/pywps/schemas/ncml/2.2/ncml-2.2.xsd000066400000000000000000000254241526123257100207300ustar00rootroot00000000000000 pywps-4.8.1/pywps/templates/000077500000000000000000000000001526123257100160725ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/1.0.0/000077500000000000000000000000001526123257100165265ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/1.0.0/capabilities/000077500000000000000000000000001526123257100211575ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/1.0.0/capabilities/main.xml000066400000000000000000000114041526123257100226250ustar00rootroot00000000000000 {{ 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.8.1/pywps/templates/1.0.0/describe/000077500000000000000000000000001526123257100203065ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/1.0.0/describe/bbox.xml000066400000000000000000000004601526123257100217620ustar00rootroot00000000000000 {{ put.crs }} {% for c in put.crss %} {{ c }} {% endfor %} pywps-4.8.1/pywps/templates/1.0.0/describe/complex.xml000066400000000000000000000022731526123257100225030ustar00rootroot00000000000000 {{ 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.8.1/pywps/templates/1.0.0/describe/literal.xml000066400000000000000000000036141526123257100224700ustar00rootroot00000000000000 {{ put.data_type }} {% if put.uom %} {{ put.uom.uom }} {% for uom in put.uoms %} {{ 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.8.1/pywps/templates/1.0.0/describe/main.xml000066400000000000000000000066301526123257100217610ustar00rootroot00000000000000 {% 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.8.1/pywps/templates/1.0.0/execute/000077500000000000000000000000001526123257100201705ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/1.0.0/execute/main.xml000066400000000000000000000136511526123257100216440ustar00rootroot00000000000000 {{ 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.8.1/pywps/templates/2.0.0/000077500000000000000000000000001526123257100165275ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/2.0.0/capabilities/000077500000000000000000000000001526123257100211605ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/2.0.0/capabilities/main.xml000066400000000000000000000116761526123257100226410ustar00rootroot00000000000000 {{ 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.8.1/pywps/templates/metalink/000077500000000000000000000000001526123257100176765ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/metalink/3.0/000077500000000000000000000000001526123257100201765ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/metalink/3.0/main.xml000066400000000000000000000024231526123257100216450ustar00rootroot00000000000000 {% 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.8.1/pywps/templates/metalink/4.0/000077500000000000000000000000001526123257100201775ustar00rootroot00000000000000pywps-4.8.1/pywps/templates/metalink/4.0/main.xml000066400000000000000000000015721526123257100216520ustar00rootroot00000000000000 {{ 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.8.1/pywps/tests.py000066400000000000000000000164471526123257100156240ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import json import logging import re import tempfile from pathlib import Path import lxml import requests from werkzeug.test import Client from werkzeug.wrappers import Response from pywps import Process, __version__ from pywps import xml_util as etree from pywps.app.Common import Metadata, MetadataUrl from pywps.inout import ( BoundingBoxInput, BoundingBoxOutput, ComplexInput, ComplexOutput, Format, LiteralInput, LiteralOutput, ) 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 pywps-4.8.1/pywps/translations.py000066400000000000000000000030131526123257100171640ustar00rootroot00000000000000def 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 dictionary to be converted. """ if translations is None: return return {k.lower(): v for k, v in translations.items()} pywps-4.8.1/pywps/util.py000066400000000000000000000012471526123257100154270ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## import platform from pathlib import Path from typing import Union 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.8.1/pywps/validator/000077500000000000000000000000001526123257100160615ustar00rootroot00000000000000pywps-4.8.1/pywps/validator/__init__.py000066400000000000000000000040041526123257100201700ustar00rootroot00000000000000################################################################## # 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.base import emptyvalidator from pywps.validator.complexvalidator import ( validatedods, validategeojson, validategeotiff, validategml, validategpx, validatejson, validatenetcdf, validateshapefile, ) 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.8.1/pywps/validator/allowed_value.py000066400000000000000000000012221526123257100212530ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/validator/base.py000066400000000000000000000007651526123257100173550ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/validator/complexvalidator.py000066400000000000000000000301211526123257100220050ustar00rootroot00000000000000################################################################## # 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 import mimetypes import os from urllib.request import urlopen from lxml.etree import XMLSchema from pywps import xml_util as etree from pywps.inout.formats import FORMATS from pywps.validator.mode import MODE 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 >>> from io 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"}}}''') ... json.close() ... file = 'point.geojson' >>> class FakeDataFormat(object): ... mimetype = 'application/geojson' >>> fake_input = FakeInput() >>> fake_input.data_format = FakeDataFormat() >>> validategeojson(fake_input, MODE.SIMPLE) True """ # noqa 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 json import jsonschema import referencing # this code comes from # https://github.com/om-henners/GeoJSON_Validation/blob/master/geojsonvalidation/geojson_validation.py schema_uri = "http://json-schema.org/geojson" schema_home = os.path.join(_get_schemas_home(), "geojson") registry = referencing.Registry() for fn in ["geojson.json", "crs.json", "bbox.json", "geometry.json"]: with open(os.path.join(schema_home, fn)) as fh: schema_data = json.load(fh) registry = registry.with_resource(f"{schema_uri}/{fn}", referencing.Resource(schema_data, referencing.jsonschema.DRAFT4)) validator = jsonschema.Draft4Validator({"$ref": f"{schema_uri}/geojson.json"}, registry=registry) 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.8.1/pywps/validator/literalvalidator.py000066400000000000000000000076321526123257100220050ustar00rootroot00000000000000################################################################## # 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.allowed_value import ALLOWEDVALUETYPE, RANGECLOSURETYPE from pywps.validator.mode import MODE 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.8.1/pywps/validator/mode.py000066400000000000000000000006361526123257100173640ustar00rootroot00000000000000################################################################## # 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.8.1/pywps/xml_util.py000066400000000000000000000004051526123257100163020ustar00rootroot00000000000000from 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.8.1/tests/000077500000000000000000000000001526123257100140545ustar00rootroot00000000000000pywps-4.8.1/tests/__obsolete_init__.py000066400000000000000000000054051526123257100200650ustar00rootroot00000000000000################################################################## # 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 import test_capabilities import test_configuration import test_describe import test_execute import test_exceptions import test_inout import test_literaltypes import test_ows import test_formats import test_dblog import test_wpsrequest import test_service import test_process import test_processing import test_async import test_grass_location import test_storage import test_filestorage import test_s3storage from validator import test_complexvalidators from 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_configuration.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_async.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.8.1/tests/basic.py000066400000000000000000000025131526123257100155100ustar00rootroot00000000000000# coding=utf-8 import os import unittest import pywps.configuration from tempfile import TemporaryDirectory class TestBase(unittest.TestCase): def setUp(self) -> None: # Do not use load_configuration() that will load system configuration # files such as /etc/pywps.cfg pywps.configuration.load_hardcoded_configuration() # Ensure all data goes into ontime temporary directory self.tmpdir = TemporaryDirectory(prefix="pywps_test_") # shortcut set = pywps.configuration.CONFIG.set set('server', 'temp_path', f"{self.tmpdir.name}/temp_path") set('server', 'outputpath', f"{self.tmpdir.name}/outputpath") set('server', 'workdir', f"{self.tmpdir.name}/workdir") set('server', 'allowedinputpaths', self.tmpdir.name) set('logging', 'level', 'DEBUG') set('logging', 'file', f"{self.tmpdir.name}/logging-file.log") set("logging", "database", f"sqlite:///{self.tmpdir.name}/test-pywps-logs.sqlite3") set('processing', 'path', f"{self.tmpdir.name}/processing_path") os.mkdir(f"{self.tmpdir.name}/temp_path") os.mkdir(f"{self.tmpdir.name}/outputpath") os.mkdir(f"{self.tmpdir.name}/workdir") os.mkdir(f"{self.tmpdir.name}/processing_path") def tearDown(self) -> None: self.tmpdir.cleanup() pywps-4.8.1/tests/common.py000066400000000000000000000103621526123257100157200ustar00rootroot00000000000000################################################################## # 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, BoundingBoxInput, BoundingBoxOutput 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 class BBox(Process): def __init__(self): super(BBox, self).__init__( self.bbox, identifier='bbox_test', title='BBox Test', inputs=[ BoundingBoxInput( 'area', 'An area', abstract='Define the area of interest', crss=['epsg:4326', 'epsg:3857'], min_occurs=1, max_occurs=1 ), ], outputs=[ BoundingBoxOutput('extent', 'Extent', crss=['epsg:4326', 'epsg:3857']) ] ) @staticmethod def bbox(request, response): area = request.inputs['area'][0].data response.outputs['extent'].data = area return response class Sleep(Process): """A long running process, just sleeping.""" def __init__(self): inputs = [ LiteralInput('seconds', title='Seconds', data_type='float') ] outputs = [ LiteralOutput('finished', title='Finished', data_type='boolean') ] super(Sleep, self).__init__( self._handler, identifier='sleep', title='Sleep', abstract='Wait for specified number of seconds.', inputs=inputs, outputs=outputs, store_supported=True, status_supported=True ) @staticmethod def _handler(request, response): import time seconds = request.inputs['seconds'][0].data step = seconds / 3 for i in range(3): response.update_status('Sleep in progress...', i / 3 * 100) time.sleep(step) response.outputs['finished'].data = "True" return response pywps-4.8.1/tests/data/000077500000000000000000000000001526123257100147655ustar00rootroot00000000000000pywps-4.8.1/tests/data/geotiff/000077500000000000000000000000001526123257100164105ustar00rootroot00000000000000pywps-4.8.1/tests/data/geotiff/dem.tiff000066400000000000000000014157461526123257100200510ustar00rootroot00000000000000II*:5β5@Ϊ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_"A€ΟΚRA±‡!±‡!Ž# 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.8.1/tests/data/gml/000077500000000000000000000000001526123257100155445ustar00rootroot00000000000000pywps-4.8.1/tests/data/gml/point.gfs000066400000000000000000000006551526123257100174040ustar00rootroot00000000000000 point point 1 1 -1.25967 -1.25967 0.20258 0.20258 pywps-4.8.1/tests/data/gml/point.gml000066400000000000000000000015331526123257100174000ustar00rootroot00000000000000 -1.2596685082872930.2025782688766113 -1.2596685082872930.2025782688766113 -1.259668508287293,0.202578268876611 pywps-4.8.1/tests/data/json/000077500000000000000000000000001526123257100157365ustar00rootroot00000000000000pywps-4.8.1/tests/data/json/point.geojson000066400000000000000000000003001526123257100204460ustar00rootroot00000000000000{"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.8.1/tests/data/netcdf/000077500000000000000000000000001526123257100162305ustar00rootroot00000000000000pywps-4.8.1/tests/data/netcdf/time.nc000066400000000000000000000055221526123257100175140ustar00rootroot00000000000000‰HDF  ��������R ��������`OHDR ΰ"������������������������ ω Y������ __NCProperties7version=1|netcdflibversion=4.6.1|hdf5libversion=1.10.1timeKτϊOHDR ’  ?@4 4�žG žGωP������������������������ 0CLASSDIMENSION_SCALE %NAMEtime 4 _Netcdf4Dimid  5unitsdays since 1900-01-01 ,standard_nametime�υ™π?@@@@@@ @"@OCHK������������������������ )title Test file2·>pywps-4.8.1/tests/data/point.xsd000066400000000000000000000027451526123257100166460ustar00rootroot00000000000000 pywps-4.8.1/tests/data/shp/000077500000000000000000000000001526123257100155575ustar00rootroot00000000000000pywps-4.8.1/tests/data/shp/point.dbf000066400000000000000000000001141526123257100173610ustar00rootroot00000000000000_A idN 1pywps-4.8.1/tests/data/shp/point.prj000066400000000000000000000002171526123257100174250ustar00rootroot00000000000000GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]]pywps-4.8.1/tests/data/shp/point.qpj000066400000000000000000000004011526123257100174170ustar00rootroot00000000000000GEOGCS["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.8.1/tests/data/shp/point.shp000066400000000000000000000002001526123257100174140ustar00rootroot00000000000000' @θ.Β3Ψ‘Ω¨?`]Uz Œ?.Β3Ψ‘Ω¨?`]Uz Œ? .Β3Ψ‘Ω¨?`]Uz Œ?pywps-4.8.1/tests/data/shp/point.shp.zip000066400000000000000000000020531526123257100202250ustar00rootroot00000000000000PKEΌ”D‚u4,€ point.shpUT R=TS^=TSux θθc`Pηbΐ^0300z‡Œo,ΌΉΒ>!V6΄Š»ΗC?2Γ…Ν,PKEΌ”D’$ ό*l point.shxUT R=TSL=TSux θθc`PηbΐΜ^0300z‡Œo,ΌΉΒ>!V6΄Š»ΗC?20b.PK DcDίCO ε point.xsdUT 0SΖOSux θθ­TMoΫ0 ½ηWΊΟJ²ΛΔ)†t[uΐn…’0�0}ΈΥ8�~’e§v“`έ°£Θ§G>>JΛ«F+ς ΞKkJ:+¦”€v+MU�χλwθΥj²lόΒ‹GМ wΰW�ΑΧ\@IλcΆr…ζ5Z«|a]Ε(‰ΤΖ/bβΟ Ζ1ϋύΎΨΏo³σιtΖ~~ΉύޖV#°­ΑT�Ε\ @ƒΑ΅uϊv<(,ιSΰJξ$lιXmΦ&ums’κ,žΔ­[ž›Γώ5žΝ‹Y1g;ΰίR–«v}ΆeKΊΞ€k«ˆDL ꘈC[œ$οc*v6%†ωδl¨K+.NΉΊ’ΒκZA“n_*Ϋ2―&„ πΧΦ`μ4EsšxNS$ξ!Wύ{q\ΰeΖώ6Gtr°οAYρλσΆΧ›v-ζMEIHάΆN$\% —9Ό°i"o£X²‘†V,;£vL"ΞΩV[iphUxx“?—]ό+N ππβ3‡>Φ1„U`5 ;άΉ8X‡‡^cβΏKνυ‰,ΥH₯ψFEΊΟZšoBvόS4oϊΣμΕΗseεφ―Έ^¨:e2M)›EΘ8η -E»’έ¬�ӏγ¬ΐΡ1<_@‹\έΘJ’'Ο\…xc6Άά₯υ¨#v‘₯Ό€YψΡvβΜΏο);ώΨ«ΙoPKEΌ”D‚u4,€ ΄point.shpUTR=TSux θθPKEΌ”D’$ ό*l ΄opoint.shxUTR=TSux θθPK DcDίCO ε €άpoint.xsdUT0Sux θθPKν(pywps-4.8.1/tests/data/shp/point.shx000066400000000000000000000001541526123257100174340ustar00rootroot00000000000000' 6θ.Β3Ψ‘Ω¨?`]Uz Œ?.Β3Ψ‘Ω¨?`]Uz Œ?2 pywps-4.8.1/tests/data/text/000077500000000000000000000000001526123257100157515ustar00rootroot00000000000000pywps-4.8.1/tests/data/text/unsafe.txt000066400000000000000000000000641526123257100177730ustar00rootroot00000000000000< Bunch of characters that would break XML <> & "" 'pywps-4.8.1/tests/processes/000077500000000000000000000000001526123257100160625ustar00rootroot00000000000000pywps-4.8.1/tests/processes/metalinkprocess.py000066400000000000000000000033671526123257100216500ustar00rootroot00000000000000from 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.8.1/tests/requests/000077500000000000000000000000001526123257100157275ustar00rootroot00000000000000pywps-4.8.1/tests/requests/wps_describeprocess_request.xml000066400000000000000000000011361526123257100242720ustar00rootroot00000000000000 intersection union pywps-4.8.1/tests/requests/wps_execute_request-boundingbox.xml000066400000000000000000000014771526123257100251010ustar00rootroot00000000000000 BBox bbox Bounding box 189000 834000 285000 962000 pywps-4.8.1/tests/requests/wps_execute_request-complexvalue.xml000066400000000000000000000046161526123257100252650ustar00rootroot00000000000000 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.8.1/tests/requests/wps_execute_request-responsedocument-1.xml000066400000000000000000000033351526123257100263110ustar00rootroot00000000000000 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.8.1/tests/requests/wps_execute_request-responsedocument-2.xml000066400000000000000000000040161526123257100263070ustar00rootroot00000000000000 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.8.1/tests/requests/wps_execute_request_extended-responsedocument.xml000066400000000000000000000051221526123257100300270ustar00rootroot00000000000000 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.8.1/tests/requests/wps_execute_request_rawdataoutput.xml000066400000000000000000000026441526123257100255460ustar00rootroot00000000000000 Buffer InputPolygon Playground area BufferDistance Distance which people will walk to get to a playground. 400 BufferedPolygon pywps-4.8.1/tests/requests/wps_getcapabilities_request.xml000066400000000000000000000011031526123257100242360ustar00rootroot00000000000000 1.0.0 pywps-4.8.1/tests/test_app_exceptions.py000066400000000000000000000031321526123257100205050ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase from pywps.app.exceptions import format_message, ProcessError, DEFAULT_ALLOWED_CHARS class AppExceptionsTest(TestBase): 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.8.1/tests/test_async.py000066400000000000000000000053711526123257100166100ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase import pytest import time import platform from pywps import Service, configuration from pywps import get_ElementMakerForVersion from pywps.tests import client_for, assert_response_accepted, assert_response_success from common import Sleep from owslib.wps import WPSExecution from pathlib import Path VERSION = "1.0.0" WPS, OWS = get_ElementMakerForVersion(VERSION) class ExecuteTest(TestBase): def setUp(self) -> None: super().setUp() # Running processes using the MultiProcessing scheduler and a file-based database configuration.CONFIG.set('processing', 'mode', 'distributed') @pytest.mark.xfail(reason="WPS request sometimes returns 500 Internal Server Error depending on infrastructure.", strict=False) def test_async(self): client = client_for(Service(processes=[Sleep()])) wps = WPSExecution() # Build an asynchronous request (requires specifying outputs and setting the mode). doc = wps.buildRequest('sleep', inputs=[('seconds', '.01')], output=[('finished', None, None)], mode='async') resp = client.post_xml(doc=doc) wps.parseResponse(resp.xml) assert_response_accepted(resp) # The process should not have finished by now. If it does, it's running in sync mode. with pytest.raises(AssertionError): assert_response_success(resp) # Parse response to extract the status file path url = resp.xml.xpath("//@statusLocation")[0] # OWSlib only reads from URLs, not local files. So we need to read the response manually. p = Path(configuration.get_config_value('server', 'outputpath')) / url.split('/')[-1] # Poll the process until it completes total_time = 0 sleep_time = .01 while wps.status not in ["ProcessSucceeded", "ProcessFailed"]: resp = p.read_bytes() if resp: wps.checkStatus(response=resp, sleepSecs=0.01) else: time.sleep(sleep_time) total_time += sleep_time if total_time > 1: raise TimeoutError assert wps.status == 'ProcessSucceeded' def load_tests(loader=None, tests=None, pattern=None): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExecuteTest), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_async_inout.py000066400000000000000000000042431526123257100200230ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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 ) class TestAsyncInout(TestBase): def test_assync_inout(self): 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.8.1/tests/test_capabilities.py000066400000000000000000000156521526123257100201270ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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(TestBase): 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(TestBase): def setUp(self): super().setUp() 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): # Check service=WPS (parameters values are case sensitive) resp = self.client.get('?Request=GetCapabilities&service=WPS') self.check_capabilities_response(resp) # Check service=WPS (parameters name are not sensitive to case) 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(TestBase): def setUp(self): super().setUp() 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 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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(BadRequestTest), loader.loadTestsFromTestCase(CapabilitiesTest), loader.loadTestsFromTestCase(CapabilitiesTranslationsTest), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_complexdata_io.py000066400000000000000000000114561526123257100204640ustar00rootroot00000000000000"""Test embedding different file formats and different encodings within the tag.""" from basic import TestBase 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(TestBase): 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.8.1/tests/test_configuration.py000066400000000000000000000052611526123257100203400ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Tests for the configuration.""" from basic import TestBase import os import random import pytest from pywps import configuration class TestEnvInterpolation(TestBase): """Test cases for env variable interpolation within the configuration.""" test_value = "SOME_RANDOM_VALUE" def setUp(self) -> None: super().setUp() # Generate an unused environment key self.test_key = "SOME_RANDOM_KEY" while self.test_key in os.environ: self.test_key = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=32)) os.environ[self.test_key] = self.test_value def tearDown(self) -> None: del os.environ[self.test_key] super().tearDown() def test_expand_user(self): """Ensure we can parse a value with the $USER entry.""" configuration.CONFIG.read_string(f"[envinterpolationsection]\nuser=${self.test_key}") assert self.test_value == configuration.CONFIG["envinterpolationsection"]["user"] def test_expand_user_with_some_text(self): """Ensure we can parse a value with the $USER entry and some more text.""" new_user = "new_" + self.test_value configuration.CONFIG.read_string(f"[envinterpolationsection]\nuser=new_${{{self.test_key}}}") assert new_user == configuration.CONFIG["envinterpolationsection"]["user"] def test_dont_expand_value_without_env_variable(self): """ Ensure we don't expand values that are no env variables. Could be an important case for existing config keys that need to contain the $symbol. """ key = "$example_key_that_hopefully_will_never_be_a_real_env_variable" configuration.CONFIG.read_string("[envinterpolationsection]\nuser=" + key) assert key == configuration.CONFIG["envinterpolationsection"]["user"] def load_tests(loader=None, tests=None, pattern=None): """Load the tests and return the test suite for this file.""" import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(TestEnvInterpolation), ] return unittest.TestSuite(suite_list) @pytest.mark.parametrize( "input_value, expected", [ ("1024k", 1.0), ("2g", 2048.0), ("20MB", 20.0), ("1GB", 1024.0), # case-insensitive ("104857600B", 100.0), ], ) def test_get_size(input_value, expected): assert configuration.get_size_mb(input_value) == expected pywps-4.8.1/tests/test_dblog.py000066400000000000000000000036641526123257100165650ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for dblog """ from basic import TestBase from pywps import configuration from pywps.dblog import get_session from pywps.dblog import ProcessInstance class DBLogTest(TestBase): """DBGLog test cases""" def setUp(self): super().setUp() 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 """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(DBLogTest) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_describe.py000066400000000000000000000454401526123257100172540ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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.inout.basic import UOM 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 def get_single_uom(el): ows = el.nsmap["ows"] unit = el.text href = el.attrib.get(f"{{{ows}}}reference") return {"uom": unit, "reference": href} def get_supported_uoms(el): uoms = xpath_ns(el, './UOMs') if not uoms: return None uom_default = xpath_ns(uoms[0], './Default')[0] uom_support = xpath_ns(uoms[0], './Supported')[0] return { "default": get_single_uom(xpath_ns(uom_default, './ows:UOM')[0]), "supported": [get_single_uom(uom) for uom in xpath_ns(uom_support, './ows:UOM')] } 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) uoms = get_supported_uoms(literal_data_el) inputs.append((input_identifier, 'literal', data_type, default, uoms)) 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(TestBase): def setUp(self): super().setUp() 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(TestBase): def setUp(self): super().setUp() 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 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(TestBase): 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, 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, None)] def test_multi_uoms_literal_float_input(self): def hello(request): pass hello_process = Process(hello, 'hello', 'Process Hello', inputs=[LiteralInput('the_number', 'Input number', data_type='float', uoms=[ 'metre', 'feet', 'undef', UOM('pixel', 'http://schema.com/#pixel'), ])]) result = self.describe_process(hello_process) uoms = { 'default': {'uom': 'metre', 'reference': OGCUNIT['metre']}, 'supported': [ {'uom': 'metre', 'reference': OGCUNIT['metre']}, {'uom': 'feet', 'reference': OGCUNIT['feet']}, {'uom': 'undef', 'reference': None}, # no mapping to known OGC URN {'uom': 'pixel', 'reference': 'http://schema.com/#pixel'}, ] } assert result.inputs == [('the_number', 'literal', 'float', None, uoms)] 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", None)] class InputDescriptionTest(TestBase): 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', 'feet'], 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" assert data["uom"] == {"uom": "metre", "reference": OGCUNIT["metre"]} assert data["uoms"][0] == {"uom": "metre", "reference": OGCUNIT["metre"]} assert data["uoms"][1] == {"uom": "feet", "reference": OGCUNIT["feet"]} 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(TestBase): @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): import unittest 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.8.1/tests/test_exceptions.py000066400000000000000000000045361526123257100176560ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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(TestBase): def setUp(self): super().setUp() 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(r'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(r'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(r'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(r'text/xml(;\s*charset=.*)?', resp.headers['Content-Type']) def load_tests(loader=None, tests=None, pattern=None): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExceptionsTest), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_execute.py000066400000000000000000000761741526123257100171460ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase import pytest from pywps import xml_util as etree import json 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 netCDF4 = None try: import netCDF4 except ImportError: pass 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_input_process(): def handler(request, response): response.outputs['text'].data = request.inputs['complex'][0].data return response return Process( handler=handler, identifier='complex_input_process', title='Complex input process', inputs=[ComplexInput( 'complex', 'Complex input', supported_formats=[FORMATS.TEXT], )], outputs=[LiteralOutput('text', 'Text output')], ) 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(TestBase): """Test for Exeucte request KVP request""" @pytest.mark.online @pytest.mark.requires_netcdf4 @pytest.mark.skipif(netCDF4 is None, reason='netCDF4 libraries are required for this test') def test_dods(self): 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') if resp.outputs["conventions"].data is None: pytest.xfail("Network is likely unavailable or test.opendap.org is offline") 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_json_file_is_validated(self): request = { 'identifier': 'complex_input_process', 'version': '1.0.0', 'inputs': { 'complex': { 'type': 'complex', 'file': '/etc/passwd', 'mimeType': 'text/plain', 'data_format': {'mime_type': 'text/plain'}, 'supported_formats': [{'mime_type': 'text/plain'}], }, }, } client = client_for(Service(processes=[create_complex_input_process()])) resp = client.post_json(doc=request) assert resp.status_code == 400 assert json.loads(resp.data)['name'] == 'FileURLNotSupported' 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(TestBase): def setUp(self): super().setUp() self.allowedinputpaths = configuration.get_config_value('server', 'allowedinputpaths') configuration.CONFIG.set('server', 'allowedinputpaths', DATA_DIR) 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(TestBase): def setUp(self): super().setUp() configuration.get_config_value('server', 'language') configuration.CONFIG.set('server', 'language', 'en-US,fr-CA') 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(TestBase): """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 = configuration.get_config_value('server', 'workdir') 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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExecuteTest), loader.loadTestsFromTestCase(ExecuteTranslationsTest), loader.loadTestsFromTestCase(ExecuteXmlParserTest), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_filestorage.py000066400000000000000000000071311526123257100177730ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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 class FileStorageTests(TestBase): def setUp(self): super().setUp() self.tmp_dir = self.tmpdir.name 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 """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(FileStorageTests) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_formats.py000066400000000000000000000072031526123257100171420ustar00rootroot00000000000000"""Unit tests for Formats """ ################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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(TestBase): """Formats test cases""" def setUp(self): super().setUp() def validate(self, inpt, level=None): """fake validate method """ return True self.validate = validate 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 """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(FormatsTest) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_grass_location.py000066400000000000000000000075351526123257100205060ustar00rootroot00000000000000################################################################## # Copyright 2019 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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(TestBase): """Test creating GRASS locations and mapsets in different ways.""" def setUp(self): """Skip test if GRASS is not installed on the machine.""" super().setUp() 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.""" import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(GRASSTests), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_inout.py000066400000000000000000001050521526123257100166260ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for IOs """ ################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase import requests import os import tempfile import json from pywps import inout import base64 import pytest 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 FileURLNotSupported, 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(TestBase): """IOHandler test cases""" def setUp(self): super().setUp() tmp_dir = self.tmpdir.name self.iohandler = IOHandler(workdir=tmp_dir) self._value = 'lalala' 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(dir=self.tmpdir.name) 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' @pytest.mark.online 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(dir=self.tmpdir.name) self.iohandler.workdir = workdir self.assertTrue(os.path.isdir(self.iohandler.workdir)) # make another workdir = tempfile.mkdtemp(dir=self.tmpdir.name) 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(TestBase): """ComplexInput test cases""" def setUp(self): super().setUp() self.tmp_dir = self.tmpdir.name 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(TestBase): """ComplexInput test cases""" def setUp(self): super().setUp() self.tmp_dir = self.tmpdir.name 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_file_outside_allowed_paths(self): complex = self.make_complex_input() with tempfile.TemporaryDirectory(prefix="pywps_not_allowed_") as outside_dir: some_file = os.path.join(outside_dir, "some_file.txt") with open(some_file, "w") as f: f.write("some data") complex.file = some_file with self.assertRaises(FileURLNotSupported): inout.inputs.ComplexInput.from_json(complex.json) def test_complex_input_file_url_outside_allowed_paths(self): complex = self.make_complex_input() with tempfile.TemporaryDirectory(prefix="pywps_not_allowed_") as outside_dir: some_file = os.path.join(outside_dir, "some_file.txt") with open(some_file, "w") as f: f.write("some data") complex.url = f"file:{some_file}" with self.assertRaises(FileURLNotSupported): inout.inputs.ComplexInput.from_json(complex.json) 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_binary_data(self): complex = self.make_complex_input() complex.data = b"some data" # the data is enclosed by a CDATA tag in json assert complex.json['data'] == '' self.assertEqual(complex.prop, 'data') def test_complex_input_data_with_binary_data_format(self): complex = inout.inputs.ComplexInput( identifier="complexinput", title='MyComplex', abstract='My complex input', keywords=['kw1', 'kw2'], workdir=self.tmp_dir, supported_formats=[FORMATS.ZIP], metadata=[Metadata("special data")], default="/some/file/path", default_type=SOURCE_TYPE.FILE, translations={"fr-CA": {"title": "Mon input", "abstract": "Une description"}}, data_format=FORMATS.ZIP ) complex.as_reference = False complex.method = "GET" complex.max_size = 1000 complex.data = b"some data" # the data is enclosed by a CDATA tag in json assert complex.json['data'] == 'c29tZSBkYXRh' # 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(TestBase): """LiteralInput test cases""" def setUp(self): super().setUp() self.tmp_dir = self.tmpdir.name 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(TestBase): """LiteralInput test cases""" def setUp(self): super().setUp() self.tmp_dir = self.tmpdir.name 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(TestBase): """ComplexInput test cases""" def setUp(self): super().setUp() self.tmp_dir = self.tmpdir.name 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(TestBase): """ComplexOutput test cases""" def setUp(self): super().setUp() tmp_dir = self.tmpdir.name 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) @pytest.mark.online 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(TestBase): """SimpleHandler test cases""" def setUp(self): super().setUp() 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(TestBase): """LiteralInput test cases""" def setUp(self): super().setUp() 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(TestBase): """LiteralOutput test cases""" def setUp(self): super().setUp() 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(TestBase): """BountingBoxInput test cases""" def setUp(self): super().setUp() 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(TestBase): """BoundingBoxOutput test cases""" def setUp(self): super().setUp() 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' ) @pytest.mark.online class TestMetaLink(TestBase): def setUp(self) -> None: super().setUp() self.tmp_dir = self.tmpdir.name 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): import unittest 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.8.1/tests/test_literaltypes.py000066400000000000000000000115731526123257100202150ustar00rootroot00000000000000"""Unit tests for IOs """ ################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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(TestBase): """ValuesReference test cases""" def setUp(self): super().setUp() 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(TestBase): """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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ConvertorTest), loader.loadTestsFromTestCase(ValuesReferenceTest), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_ows.py000066400000000000000000000140171526123257100163000ustar00rootroot00000000000000################################################################## # 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" from basic import TestBase import os import tempfile 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 import pytest 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") class ExecuteTests(TestBase): def create_feature(self): 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(self): 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(dir=self.tmpdir.name) 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') @pytest.mark.online 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 @pytest.mark.online 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=[self.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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ExecuteTests), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_process.py000066400000000000000000000060121526123257100171420ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Test process """ from basic import TestBase 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(TestBase): def setUp(self): super().setUp() 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 """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ProcessTestCase) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_processing.py000066400000000000000000000132241526123257100176430ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for processing """ from basic import TestBase 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 common import Greeter, InOut, BBox class GreeterProcessingTest(TestBase): """Processing test case with Greeter process""" def setUp(self): super().setUp() self.workdir = pywps.configuration.get_config_value('server', 'workdir') self.uuid = uuid.uuid1() self.dummy_process = Greeter() self.dummy_process._set_uuid(self.uuid) self.dummy_process.set_workdir(self.workdir) 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, self.workdir) 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, self.workdir) self.assertEqual(len(new_job.process.inputs), 1) class InOutProcessingTest(TestBase): """Processing test case with InOut process""" def setUp(self): super().setUp() self.workdir = pywps.configuration.get_config_value('server', 'workdir') self.uuid = uuid.uuid1() self.dummy_process = InOut() self.dummy_process._set_uuid(self.uuid) self.dummy_process.set_workdir(self.workdir) 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, self.workdir) 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, self.workdir) self.assertEqual(len(new_job.process.inputs), 3) self.assertEqual(new_job.json, self.job.json) # idempotent test class BBoxProcessingTest(TestBase): """Processing test case with BBox input and output process""" def setUp(self): super().setUp() self.workdir = pywps.configuration.get_config_value('server', 'workdir') self.uuid = uuid.uuid1() self.dummy_process = BBox() self.dummy_process._set_uuid(self.uuid) self.dummy_process.set_workdir(self.workdir) 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, 'bbox_test') self.assertEqual(new_job.uuid, str(self.uuid)) self.assertEqual(new_job.workdir, self.workdir) self.assertEqual(len(new_job.process.inputs), 1) 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, 'bbox_test') self.assertEqual(new_job.uuid, str(self.uuid)) self.assertEqual(new_job.workdir, self.workdir) self.assertEqual(len(new_job.process.inputs), 1) self.assertEqual(new_job.json, self.job.json) # idempotent test def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(GreeterProcessingTest), loader.loadTestsFromTestCase(InOutProcessingTest), loader.loadTestsFromTestCase(BBoxProcessingTest) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_s3storage.py000066400000000000000000000044131526123257100174010ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase from pywps.inout.storage.s3 import S3StorageBuilder from pywps.inout.storage import STORE_TYPE from pywps.inout.basic import ComplexOutput from pywps import configuration, FORMATS from unittest.mock import patch class S3StorageTests(TestBase): @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=configuration.get_config_value('server', 'workdir')) 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() 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 """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(S3StorageTests) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_service.py000066400000000000000000000013001526123257100171170ustar00rootroot00000000000000from basic import TestBase from pywps.app.Service import _validate_file_input from pywps.exceptions import FileURLNotSupported class ServiceTest(TestBase): 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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ServiceTest), ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_storage.py000066400000000000000000000042531526123257100171350ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase 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 os class FakeOutput(object): """Fake output object for testing.""" def __init__(self, tmp_path): self.identifier = "fake_output" fn = Path(tmp_path) / "file.tiff" fn.touch() self.file = str(fn.absolute()) self.uuid = None class TestDefaultStorageBuilder(TestBase): def test_default_storage(self): storage = StorageBuilder.buildStorage() assert isinstance(storage, FileStorage) class TestS3StorageBuilder(TestBase): def setUp(self) -> None: super().setUp() configuration.CONFIG.set('server', 'storagetype', 's3') def test_s3_storage(self): storage = StorageBuilder.buildStorage() assert isinstance(storage, S3Storage) class TestFileStorageBuilder(TestBase): def setUp(self) -> None: super().setUp() configuration.CONFIG.set('server', 'storagetype', 'file') self.opath = os.path.join(self.tmpdir.name, "a", "b", "c") configuration.CONFIG.set('server', 'outputpath', self.opath) def test_recursive_directory_creation(self): """Test that outputpath is created.""" storage = StorageBuilder.buildStorage() fn = FakeOutput(self.tmpdir.name) storage.store(fn) assert os.path.exists(self.opath) def load_tests(loader=None, tests=None, pattern=None): """Load local tests """ import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(TestDefaultStorageBuilder), loader.loadTestsFromTestCase(TestS3StorageBuilder), loader.loadTestsFromTestCase(TestFileStorageBuilder) ] return unittest.TestSuite(suite_list)pywps-4.8.1/tests/test_wpsrequest.py000066400000000000000000000141221526123257100177070ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## from basic import TestBase from pywps.app import WPSRequest import tempfile import datetime import json from pywps.inout.literaltypes import AnyValue class WPSRequestTest(TestBase): def setUp(self): super().setUp() self.request = WPSRequest() self.tempfile = tempfile.mktemp(dir=self.tmpdir.name) 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 test_json_inout_bbox(self): obj = { 'operation': 'getcapabilities', 'version': '1.0.0', 'language': 'eng', 'identifier': 'arghhhh', 'identifiers': 'arghhhh', # TODO: why identifierS? 'store_execute': True, 'status': True, 'lineage': True, 'inputs': { 'bbox': [{ 'identifier': 'bbox', 'type': 'bbox', 'bbox': '6.117602,46.176194,6.22283,46.275832', 'crs': 'urn:ogc:def:crs:EPSG::4326', 'crss': ['epsg:4326'], 'dimensions': 2, 'translations': None }], }, 'outputs': {}, 'raw': False } self.request = WPSRequest() self.request.json = obj self.assertEqual(self.request.inputs['bbox'][0].data, [6.117602, 46.176194, 6.22283, 46.275832], 'BBox data set') self.assertTrue(isinstance(self.request.inputs['bbox'][0].crs, str), 'CRS is a string') self.assertEqual(self.request.inputs['bbox'][0].crs, 'epsg:4326', 'CRS data correctly parsed') # dump to json and reload dump = self.request.json self.request.json = json.loads(dump) self.assertEqual(self.request.inputs['bbox'][0].data, [6.117602, 46.176194, 6.22283, 46.275832], 'BBox data set') self.assertTrue(isinstance(self.request.inputs['bbox'][0].crs, str), 'CRS is a string') self.assertEqual(self.request.inputs['bbox'][0].crs, 'epsg:4326', 'CRS data correctly parsed') def load_tests(loader=None, tests=None, pattern=None): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(WPSRequestTest) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/test_xml_util.py000066400000000000000000000030471526123257100173260ustar00rootroot00000000000000from basic import TestBase from 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.8.1/tests/validator/000077500000000000000000000000001526123257100160415ustar00rootroot00000000000000pywps-4.8.1/tests/validator/test_complexvalidators.py000066400000000000000000000271411526123257100232170ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for complex validator """ from basic import TestBase import importlib.util as ilu 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 import requests HAS_NETCDF4 = bool(ilu.find_spec("netCDF4")) HAS_GEOTIFF = bool(ilu.find_spec("geotiff")) HAS_FIONA = bool(ilu.find_spec("fiona")) class ValidateTest(TestBase): """Complex validator test cases""" def get_input(self, 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(dir=self.tmpdir.name) 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 @pytest.mark.online @pytest.mark.requires_fiona @pytest.mark.skipif(not HAS_FIONA, reason="fiona libraries are required for this test") def test_gml_validator(self): """Test GML validator""" gml_input = self.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() @pytest.mark.online @pytest.mark.skipif(HAS_FIONA, reason="fiona libraries must not be installed for this test") def test_no_gml_validator(self): """Test GML validator""" gml_input = self.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.assertFalse(validategml(gml_input, MODE.STRICT), 'STRICT validation') # self.assertTrue(validategml(gml_input, MODE.VERYSTRICT), 'VERYSTRICT validation') gml_input.stream.close() @pytest.mark.online @pytest.mark.requires_fiona @pytest.mark.xfail(reason="gml verystrict validation fails") @pytest.mark.skipif(not HAS_FIONA, reason="fiona libraries are required for this test") def test_gml_validator_verystrict(self): """Test GML validator""" gml_input = self.get_input('gml/point.gml', 'point.xsd', FORMATS.GML.mime_type) self.assertTrue(validategml(gml_input, MODE.VERYSTRICT), 'VERYSTRICT validation') gml_input.stream.close() def test_json_validator(self): """Test GeoJSON validator""" json_input = self.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() @pytest.mark.requires_fiona @pytest.mark.skipif(not HAS_FIONA, reason="fiona libraries are required for this test") def test_geojson_validator(self): """Test GeoJSON validator""" geojson_input = self.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() @pytest.mark.skipif(HAS_FIONA, reason="fiona libraries must not be installed for this test") def test_no_geojson_validator(self): """Test GeoJSON validator""" geojson_input = self.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.assertFalse(validategeojson(geojson_input, MODE.STRICT), 'STRICT validation') # FIXME: MODE.VERYSTRICT should fail here self.assertTrue(validategeojson(geojson_input, MODE.VERYSTRICT), 'VERYSTRICT validation') geojson_input.stream.close() @pytest.mark.requires_fiona @pytest.mark.skipif(not HAS_FIONA, reason="fiona libraries are required for this test") def test_shapefile_validator(self): """Test ESRI Shapefile validator""" shapefile_input = self.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() @pytest.mark.skipif(HAS_FIONA, reason="fiona libraries must not be installed for this test") def test_no_shapefile_validator(self): """Test ESRI Shapefile validator""" shapefile_input = self.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.assertFalse(validateshapefile(shapefile_input, MODE.STRICT), 'STRICT validation') shapefile_input.stream.close() @pytest.mark.skipif(HAS_GEOTIFF, reason="geotiff libraries must not be installed for this test") def test_no_geotiff_validator(self): """Test GeoTIFF validator""" geotiff_input = self.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.assertFalse(validategeotiff(geotiff_input, MODE.STRICT), 'STRICT validation') geotiff_input.stream.close() @pytest.mark.requires_geotiff @pytest.mark.skipif(not HAS_GEOTIFF, reason="geotiff libraries are required for this test") def test_geotiff_validator(self): """Test GeoTIFF validator""" geotiff_input = self.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() @pytest.mark.requires_netcdf4 @pytest.mark.skipif(not HAS_NETCDF4, reason="NetCDF4 libraries are required for this test") def test_netcdf_validator(self): """Test netCDF validator""" netcdf_input = self.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() self.assertTrue(validatenetcdf(netcdf_input, MODE.STRICT), 'STRICT validation') netcdf_input.file = 'grub.nc' self.assertFalse(validatenetcdf(netcdf_input, MODE.STRICT)) @pytest.mark.skipif(HAS_NETCDF4, reason="NetCDF4 libraries must not be installed for this test") def test_no_netcdf_validator(self): """Test netCDF validator""" netcdf_input = self.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() self.assertFalse(validatenetcdf(netcdf_input, MODE.STRICT), 'STRICT validation') @pytest.mark.online @pytest.mark.requires_netcdf4 @pytest.mark.skipif(not HAS_NETCDF4, reason="NetCDF4 libraries are required for this test") 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') r = requests.head(opendap_input.url) if r.status_code == 200: self.assertTrue(validatedods(opendap_input, MODE.STRICT), 'STRICT validation') else: pass # site unavailable opendap_input.url = 'Faulty url' self.assertFalse(validatedods(opendap_input, MODE.STRICT)) @pytest.mark.online @pytest.mark.skipif(HAS_NETCDF4, reason="NetCDF4 libraries must not be installed for this test") 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) 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') with pytest.warns(UserWarning) as record: self.assertFalse(validatedods(opendap_input, MODE.STRICT), 'STRICT validation') assert "Complex validation requires netCDF4 support." in record[0].message.args[0] def test_fail_validator(self): fake_input = self.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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ValidateTest) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tests/validator/test_literalvalidators.py000066400000000000000000000106401526123257100232000ustar00rootroot00000000000000################################################################## # Copyright 2018 Open Source Geospatial Foundation and others # # licensed under MIT, Please consult LICENSE.txt for details # ################################################################## """Unit tests for literal validator """ from basic import TestBase 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(TestBase): """Literal validator test cases""" 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): import unittest if not loader: loader = unittest.TestLoader() suite_list = [ loader.loadTestsFromTestCase(ValidateTest) ] return unittest.TestSuite(suite_list) pywps-4.8.1/tox.ini000066400000000000000000000016101526123257100142230ustar00rootroot00000000000000[tox] envlist = lint, py3.{10,11,12,13}{-extra,} opts = --verbose [gh] python = 3.10 = py3.10 3.11 = py3.11-extra 3.12 = py3.12 3.13 = py3.13-extra [testenv:docs] commands = make docs allowlist_externals = make [testenv:lint] skip_install = true extras = deps = ruff commands = make lint allowlist_externals = make [testenv] setenv = PYTEST_ADDOPTS = "--color=yes" "--cov=pywps" "--cov-report=lcov" PYTHONPATH = {toxinidir} COV_CORE_SOURCE = passenv = CI GITHUB_* LD_LIBRARY_PATH download = True install_command = python -m pip install --no-user {opts} {packages} extras = dev extra: extra commands = ; # magic for gathering the GDAL version within tox ; sh -c 'pip install GDAL=="$(gdal-config --version)" --global-option=build_ext --global-option="-I/usr/include/gdal"' pytest {posargs} coverage report