pax_global_header00006660000000000000000000000064146011021470014506gustar00rootroot0000000000000052 comment=1f55faa934ed821cdc0f29186d28ad4614493673 respx-0.21.1/000077500000000000000000000000001460110214700127305ustar00rootroot00000000000000respx-0.21.1/.github/000077500000000000000000000000001460110214700142705ustar00rootroot00000000000000respx-0.21.1/.github/workflows/000077500000000000000000000000001460110214700163255ustar00rootroot00000000000000respx-0.21.1/.github/workflows/check-docs.yml000066400000000000000000000006051460110214700210540ustar00rootroot00000000000000name: check-docs on: pull_request: paths: - 'docs/**' - '.github/workflows/check-docs.yml' jobs: check-docs: name: Check Docs runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.10" - run: pip install nox - name: Run mypy run: nox -N -s docs respx-0.21.1/.github/workflows/lint.yml000066400000000000000000000002141460110214700200130ustar00rootroot00000000000000name: lint on: pull_request: jobs: lint: name: Check Linting uses: less-action/reusables/.github/workflows/pre-commit.yaml@v8 respx-0.21.1/.github/workflows/publish-docs.yml000066400000000000000000000013061460110214700214440ustar00rootroot00000000000000name: publish-docs on: push: branches: - master paths: - 'docs/**' - '.github/workflows/docs.yml' jobs: build: name: Build & Publish runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.10" - run: pip install nox - name: Build run: nox -N -s docs - name: Publish if: github.repository_owner == 'lundberg' run: | git config user.email ${{ secrets.GITHUB_EMAIL }} git remote set-url origin https://${{ secrets.GITHUB_USER }}:${{ secrets.GITHUB_PAGES_TOKEN }}@github.com/lundberg/respx.git ./.nox/docs/bin/mkdocs gh-deploy --force respx-0.21.1/.github/workflows/test.yml000066400000000000000000000021701460110214700200270ustar00rootroot00000000000000name: test on: push: branches: - master paths-ignore: - 'docs/**' pull_request: paths-ignore: - 'docs/**' env: FORCE_COLOR: 1 jobs: test: name: Test Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - run: pip install nox - name: Test run: nox -N -s test-${{ matrix.python-version }} -- -v - name: Upload coverage report uses: codecov/codecov-action@v3 with: name: Python ${{ matrix.python-version }} files: ./coverage.xml fail_ci_if_error: true check-types: name: Check Typing runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.7" - run: pip install nox - name: Run mypy run: nox -N -s mypy respx-0.21.1/.gitignore000066400000000000000000000003731460110214700147230ustar00rootroot00000000000000# Byte-compiled __pycache__/ *.py[cod] # Distribution / packaging .env/ env/ venv/ build/ dist/ site/ eggs/ *.egg-info/ # Unit test / coverage reports .nox/ .coverage .coverage.* .mypy_cache/ .pytest_cache/ coverage.xml # Editor config .idea .tags respx-0.21.1/.pre-commit-config.yaml000066400000000000000000000044411460110214700172140ustar00rootroot00000000000000default_language_version: python: python3.11 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - id: debug-statements - id: detect-private-key - repo: https://github.com/asottile/pyupgrade rev: v3.4.0 hooks: - id: pyupgrade args: - --py37-plus - --keep-runtime-typing - repo: https://github.com/pycqa/autoflake rev: v2.1.1 hooks: - id: autoflake args: - --in-place - --remove-all-unused-imports - --ignore-init-module-imports - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 additional_dependencies: - flake8-bugbear - flake8-comprehensions - flake8-tidy-imports - flake8-print - flake8-pytest-style - flake8-datetimez - repo: https://github.com/sirosen/check-jsonschema rev: 0.23.0 hooks: - id: check-github-workflows - repo: https://github.com/asottile/yesqa rev: v1.4.0 hooks: - id: yesqa additional_dependencies: - flake8-bugbear - flake8-comprehensions - flake8-tidy-imports - flake8-print - flake8-pytest-style - flake8-datetimez - repo: https://github.com/pre-commit/mirrors-prettier rev: "v3.0.0-alpha.9-for-vscode" hooks: - id: prettier alias: format-markdown types: [markdown] args: - --parser=markdown - --print-width=88 - --prose-wrap=always - repo: https://github.com/mgedmin/check-manifest rev: "0.49" hooks: - id: check-manifest args: ["--no-build-isolation"] exclude: | (?x)( /( \.eggs | \.git | \.hg | \.mypy_cache | \.pytest_cache | \.nox | \.tox | \.venv | _build | buck-out | build | dist )/ | docs | LICENSE\.md ) respx-0.21.1/CHANGELOG.md000066400000000000000000000343301460110214700145440ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.21.1] - 2024-03-27 ### Fixed - Fix `files` pattern not handling `str` and `BytesIO`, thanks @pierremonico for input (#260) ### Added - Add support for `None` values in `data` pattern, thanks @slingshotvfx for issue (#259) ## [0.21.0] - 2024-03-19 ### Fixed - Fix matching request data when files are provided, thanks @ziima for input (#252) ### Added - Add support for data\_\_contains lookup (#252) - Add `files` pattern to support matching on uploads, thanks @ziima for input (#253) - Add `SetCookie` utility for easier mocking of response cookie headers (#254) ### Changed - Enhance documentation on iterable side effects (#255) - Enhance documentation on named routes and add tip about a catch-all route (#257) ## [0.20.2] - 2023-07-21 ### Fixed - Better assertion output for `assert_all_called`, thanks @sileht (#224) - Support for quoted path pattern matching, thanks @alexdrydew for input (#240) ### Added - Enable content\_\_contains pattern, thanks @rjprins (#236) - Added initial `CONTRIBUTING.md`, thanks @morenoh149 (#238) ### Changed - Docs about retrieving mocked calls, thanks @tomhamiltonstubber (#230) - Docs about `Router.assert_all_called()`, thanks @BeyondEvil for input (#241) ## [0.20.1] - 2022-11-18 ### Fixed - Support HTTPX 0.23.1, thanks @g-as for input (#223) ### Added - Officially support Python 3.11 (#223) - Run pre-commit hooks in CI workflow (#219) ### Changed - Bump autoflake, thanks @antonagestam (#220) ### Removed - Drop support for Python 3.6 (#218) ## [0.20.0] - 2022-09-16 ### Changed - Type `Router.__getitem__` to not return optional routes, thanks @flaeppe (#216) - Change `Call.response` to raise instead of returning optional response (#217) - Change `CallList.last` to raise instead of return optional call (#217) - Type `M()` to not return optional pattern, by introducing a `Noop` pattern (#217) - Type `Route.pattern` to not be optional (#217) ### Fixed - Correct type hints for side effects (#217) ### Added - Runs `mypy` on both tests and respx (#217) - Added nox test session for python 3.11 (#217) - Added `Call.has_response` helper, now that `.response` raises (#217) ## [0.19.3] - 2022-09-14 ### Fixed - Fix typing for Route modulos arg - Respect patterns with empty value when using equal lookup (#206) - Use pytest asyncio auto mode (#212) - Fix mock decorator to work together with pytest fixtures (#213) - Wrap pytest function correctly, i.e. don't hide real function name (#213) ### Changed - Enable mypy strict_optional (#201) ## [0.19.2] - 2022-02-03 ### Fixed - Better cleanup before building egg, thanks @nebularazer (#198) ## [0.19.1] - 2022-01-10 ### Fixed - Allow first path segments containing colons, thanks @hannseman. (#192) - Fix license classifier, thanks @shadchin (#195) - Fix typos, thanks @kianmeng (#194) ## [0.19.0] - 2021-11-15 ### Fixed - Support HTTPX 0.21.0. (#189) - Use Session.notify when chaining nox sessions, thanks @flaeppe. (#188) - Add overloads to `MockRouter.__call__`, thanks @flaeppe. (#187) - Enhance AND pattern evaluation to fail fast. (#185) - Fix CallList assertion error message. (#178) ### Changed - Prevent method and url as lookups in HTTP method helpers, thanks @flaeppe. (#183) - Fail pattern match when JSON path not found. (#184) ## [0.18.2] - 2021-10-22 ### Fixed - Include extensions when instantiating request in HTTPCoreMocker. (#176) ## [0.18.1] - 2021-10-20 ### Fixed - Respect ordered param values. (#172) ### Changed - Raise custom error types for assertion checks. (#174) ## [0.18.0] - 2021-10-14 ### Fixed - Downgrade `HTTPX` requirement to 0.20.0. (#170) ### Added - Add support for matching param with _ANY_ value. (#167) ## [0.18.0b0] - 2021-09-15 ### Changed - Deprecate RESPX MockTransport in favour of HTTPX MockTransport. (#152) ### Fixed - Support `HTTPX` 1.0.0b0. (#164) - Allow tuples as params to align with httpx, thanks @shelbylsmith. (#151) - Fix xfail marked tests. (#153) - Only publish docs for upstream repo, thanks @hugovk. (#161) ### Added - Add optional route arg to side effects. (#158) ## [0.17.1] - 2021-06-05 ### Added - Implement support for async side effects in router. (#147) - Support mocking responses using asgi/wsgi apps. (#146) - Added pytest fixture and configuration marker. (#150) ### Fixed - Typo in import from examples.md, thanks @shelbylsmith. (#148) - Fix pass-through test case. (#149) ## [0.17.0] - 2021-04-27 ### Changed - Require `HTTPX` 0.18.0 and implement the new transport API. (PR #142) - Removed ASGI and WSGI transports from httpcore patch list. (PR #131) - Don't pre-read mocked async response streams. (PR #136) ### Fixed - Fixed syntax highlighting in docs, thanks @florimondmanca. (PR #134) - Type check `route.return_value`, thanks @tzing. (PR #133) - Fixed a typo in the docs, thanks @lewoudar. (PR #139) ### Added - Added support for adding/removing patch targets. (PR #131) - Added test session for python 3.10. (PR #140) - Added RESPX Mock Swallowtail to README. (PR #128) ## [0.16.3] - 2020-12-14 ### Fixed - Fixed decorator `respx_mock` kwarg, mistreated as a `pytest` fixture. (PR #117) - Fixed `JSON` pattern sometimes causing a `JSONDecodeError`. (PR #124) ### Added - Snapshot and rollback of routes' pattern and name. (PR #120) - Internally extracted a `RouteList` from `Router`. (PR #120) - Auto registration of `Mocker` implementations and their `using` name. (PR #121) - Added `HTTPXMocker`, optionally patching `HTTPX`. (PR #122) ### Changed - Protected a routes' pattern to be modified. (PR #120) ## [0.16.2] - 2020-11-26 ### Added - Easier support for using HTTPX MockTransport. (PR #118) - Support mixed case for `method__in` and `scheme__in` pattern lookups. (PR #113) ### Fixed - Handle missing path in URL pattern (PR #113) ### Changed - Refactored internal mocking vs `MockTransport`. (PR #112) ### Removed - Dropped raw request support when parsing patterns (PR #113) ## [0.16.1] - 2020-11-16 ### Added - Extended `url` pattern with support for `HTTPX` proxy url format. (PR #110) - Extended `host` pattern with support for regex lookup. (PR #110) - Added `respx.request(...)`. (PR #111) ### Changed - Deprecated old `MockTransport` in favour of `respx.mock(...)`. (PR #109) - Wrapping actual `MockTransport` in `MockRouter`, instead of extending. (PR #109) - Extracted a `HTTPXMock`, for transport patching, from `MockRouter`. (PR #109) ## [0.16.0] - 2020-11-13 One year since first release, yay! ### Removed - Dropped all deprecated APIs and models, see `0.15.0` Changed section. (PR #105) ### Added - Added support for content, data and json patterns. (PR #106) - Automatic pattern registration when subclassing Pattern. (PR #108) ### Fixed - Multiple snapshots to support nested mock routers. (PR #107) ## [0.15.1] - 2020-11-10 ### Added - Snapshot routes and mocks when starting router, rollback when stopping. (PR #102) - Added support for base_url combined with pattern lookups. (PR #103) - Added support for patterns/lookups to the HTTP method helpers. (PR #104) ### Fixed - Fix to not clear routes added outside mock context when stopping router. (PR #102) ## [0.15.0] - 2020-11-09 ### Added - Added `respx.route(...)` with enhanced request pattern matching. (PR #96) - Added support for AND/OR when request pattern matching. (PR #96) - Added support for adding responses to a route using % operator. (PR #96) - Added support for both `httpx.Response` and `MockResponse`. (PR #96) - Enhanced Route (RequestPattern) with `.respond(...)` response details. (PR #96) - Enhanced Route (RequestPattern) with `.pass_through()`. (PR #96) - Add support for using route as side effect decorator. (PR #98) - Add `headers` and `cookies` patterns. (PR #99) - Add `contains` and `in` lookups. (PR #99) - Introduced Route `.mock(...)` in favour of callbacks. (PR #101) - Introduced Route `.return_value` and `.side_effect` setters. (PR #101) ### Changed - Deprecated mixing of request pattern and response details in all API's. (PR #96) - Deprecated passing http method as arg in `respx.add` in favour of `method=`. (PR #96) - Deprecated `alias=...` in favour of `name=...` when adding routes. (PR #96) - Deprecated `respx.aliases` in favour of `respx.routes`. (PR #96) - Deprecated `RequestPattern` in favour of `Route`. (PR #96) - Deprecated `ResponseTemplate` in favour of `MockResponse`. (PR #96) - Deprecated `pass_through=` in HTTP method API's (PR #96) - Deprecated `response` arg in side effects (callbacks). (PR #97) - Stacked responses are now recorded on same route calls. (PR #96) - Pass-through routes no longer capture real response in call stats. (PR #97) - Stacked responses no longer keeps and repeats last response. (PR #101) ### Removed - Removed support for regex `base_url`. (PR #96) - Dropped support for `async` side effects (callbacks). (PR #97) - Dropped support for mixing side effect (callback) and response details. (PR #97) ## [0.14.0] - 2020-10-15 ### Added - Added `text`, `html` and `json` content shorthands to ResponseTemplate. (PR #82) - Added `text`, `html` and `json` content shorthands to high level API. (PR #93) - Added support to set `http_version` for a mocked response. (PR #82) - Added support for mocking by lowercase http methods, thanks @lbillinghamtn. (PR #80) - Added query `params` to align with HTTPX API, thanks @jocke-l. (PR #81) - Easier API to get request/response from call stats, thanks @SlavaSkvortsov. (PR #85) - Enhanced test to verify better content encoding by HTTPX. (PR #78) - Added Python 3.9 to supported versions and test suite, thanks @jairhenrique. (PR #89) ### Changed - `ResponseTemplate.content` as proper getter, i.e. no resolve/encode to bytes. (PR #82) - Enhanced headers by using HTTPX Response when encoding raw responses. (PR #82) - Deprecated `respx.stats` in favour of `respx.calls`, thanks @SlavaSkvortsov. (PR #92) ### Fixed - Recorded requests in call stats are pre-read like the responses. (PR #86) - Postponed request decoding for enhanced performance. (PR #91) - Lazy call history for enhanced performance, thanks @SlavaSkvortsov. (PR #92) ### Removed - Removed auto setting the `Content-Type: text/plain` header. (PR #82) ## [0.13.0] - 2020-09-30 ### Fixed - Fixed support for `HTTPX` 0.15. (PR #77) ### Added - Added global `respx.pop` api, thanks @paulineribeyre. (PR #72) ### Removed - Dropped deprecated `HTTPXMock` in favour of `MockTransport`. - Dropped deprecated `respx.request` in favour of `respx.add`. - Removed `HTTPX` max version requirement in setup.py. ## [0.12.1] - 2020-08-21 ### Fixed - Fixed non-iterable pass-through responses. (PR #68) ## [0.12.0] - 2020-08-17 ### Changed - Dropped no longer needed `asynctest` dependency, in favour of built-in mock. (PR #69) ## [0.11.3] - 2020-08-13 ### Fixed - Fixed support for `HTTPX` 0.14.0. (PR #45) ## [0.11.2] - 2020-06-25 ### Added - Added support for pop'ing a request pattern by alias, thanks @radeklat. (PR #60) ## [0.11.1] - 2020-06-01 ### Fixed - Fixed mocking `HTTPX` clients instantiated with proxies. (PR #58) - Fixed matching URL patterns with missing path. (PR #59) ## [0.11.0] - 2020-05-29 ### Fixed - Fixed support for `HTTPX` 0.13. (PR #57) ### Added - Added support for mocking out `HTTP Core`. - Added support for using mock transports with `HTTPX` clients without patching. - Include LICENSE.md in source distribution, thanks @synapticarbors. ### Changed - Renamed passed mock to decorated functions from `httpx_mock` to `respx_mock`. - Renamed `HTTPXMock` to `MockTransport`, but kept a deprecated `HTTPXMock` subclass. - Deprecated `respx.request()` in favour of `respx.add()`. ## [0.10.1] - 2020-03-11 ### Fixed - Fixed support for `HTTPX` 0.12.0. (PR #45) ## [0.10.0] - 2020-01-30 ### Changed - Refactored high level and internal api for better editor autocompletion. (PR #44) ## [0.9.0] - 2020-01-22 ### Fixed - Fixed usage of nested or parallel mock instances. (PR #39) ## [0.8.3] - 2020-01-10 ### Fixed - Fixed support for `HTTPX` 0.11.0 sync api. (PR #38) ## [0.8.2] - 2020-01-07 ### Fixed - Renamed refactored httpx internals. (PR #37) ## [0.8.1] - 2019-12-09 ### Added - Added support for configuring patterns `base_url`. (PR #34) - Added manifest and `py.typed` files. ### Fixed - Fixed support for `HTTPX` 0.9.3 refactorizations. (PR #35) ## [0.8] - 2019-11-27 ### Added - Added documentation built with `mkdocs`. (PR #30) ### Changed - Dropped sync support and now requires `HTTPX` version 0.8+. (PR #32) - Renamed `respx.mock` module to `respx.api`. (PR #29) - Refactored tests- and checks-runner to `nox`. (PR #31) ## [0.7.4] - 2019-11-24 ### Added - Allowing assertions to be configured through decorator and context manager. (PR #28) ## [0.7.3] - 2019-11-21 ### Added - Allows `mock` decorator to be used as sync or async context manager. (PR #27) ## [0.7.2] - 2019-11-21 ### Added - Added `stats` to high level API and patterns, along with `call_count`. (PR #25) ### Fixed - Allowing headers to be modified within a pattern match callback. (PR #26) ## [0.7.1] - 2019-11-20 ### Fixed - Fixed responses in call stats when using synchronous `HTTPX` client. (PR #23) ## [0.7] - 2019-11-19 ### Added - Added support for `pass_through` patterns. (PR #20) - Added `assert_all_mocked` feature and setting. (PR #21) ### Changed - Requires all `HTTPX` requests to be mocked. ## [0.6] - 2019-11-18 ### Changed - Renamed `activate` decorator to `mock`. (PR #15) ## [0.5] - 2019-11-18 ### Added - Added `assert_all_called` feature and setting. (PR #14) ### Changed - Clears call stats when exiting decorator. ## [0.4] - 2019-11-16 ### Changed - Renamed python package to `respx`. (PR #12) - Renamed `add()` to `request()` and added HTTP method shorthands. (PR #13) ## [0.3.1] - 2019-11-16 ### Changed - Renamed PyPI package to `respx`. ## [0.3] - 2019-11-15 ### Added - Exposes `responsex` high level API along with a `activate` decorator. (PR #5) - Added support for custom pattern match callback function. (PR #7) - Added support for repeated patterns. (PR #8) ## [0.2] - 2019-11-14 ### Added - Added support for any `HTTPX` concurrency backend. ## [0.1] - 2019-11-13 ### Added - Initial POC. respx-0.21.1/CONTRIBUTING.md000066400000000000000000000010471460110214700151630ustar00rootroot00000000000000# Contributing to RESPX As an open source project, RESPX welcomes contributions of many forms. Examples of contributions include: - Code patches - Documentation improvements - Bug reports and patch reviews ## Running Tests Tests reside in the `tests/` directory. You can run tests with the [Task](https://taskfile.dev/installation/) tool from the root of the project. - `task test` ## Linting Any contributions should pass the linters setup in this project. - `task lint` Linters will also be run through github CI on your PR automatically. respx-0.21.1/LICENSE.md000066400000000000000000000027671460110214700143500ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2019, 5 Monkeys Agency AB All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. respx-0.21.1/MANIFEST.in000066400000000000000000000003701460110214700144660ustar00rootroot00000000000000recursive-exclude .github * recursive-exclude docs * recursive-exclude tests * exclude *.yaml exclude *.xml exclude flake.* exclude noxfile.py exclude CONTRIBUTING.md include README.md include CHANGELOG.md include LICENSE.md include respx/py.typed respx-0.21.1/README.md000066400000000000000000000055211460110214700142120ustar00rootroot00000000000000

RESPX

RESPX - Mock HTTPX with awesome request patterns and response side effects.

--- [![tests](https://img.shields.io/github/actions/workflow/status/lundberg/respx/test.yml?branch=master&label=tests&logo=github&logoColor=white&style=for-the-badge)](https://github.com/lundberg/respx/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/github/lundberg/respx?logo=codecov&logoColor=white&style=for-the-badge)](https://codecov.io/gh/lundberg/respx) [![PyPi Version](https://img.shields.io/pypi/v/respx?logo=pypi&logoColor=white&style=for-the-badge)](https://pypi.org/project/respx/) [![Python Versions](https://img.shields.io/pypi/pyversions/respx?logo=python&logoColor=white&style=for-the-badge)](https://pypi.org/project/respx/) ## Documentation Full documentation is available at [lundberg.github.io/respx](https://lundberg.github.io/respx/) ## QuickStart RESPX is a simple, _yet powerful_, utility for mocking out the [HTTPX](https://www.python-httpx.org/), _and [HTTP Core](https://www.encode.io/httpcore/)_, libraries. Start by [patching](https://lundberg.github.io/respx/guide/#mock-httpx) `HTTPX`, using `respx.mock`, then add request [routes](https://lundberg.github.io/respx/guide/#routing-requests) to mock [responses](https://lundberg.github.io/respx/guide/#mocking-responses). ```python import httpx import respx from httpx import Response @respx.mock def test_example(): my_route = respx.get("https://example.org/").mock(return_value=Response(204)) response = httpx.get("https://example.org/") assert my_route.called assert response.status_code == 204 ``` > Read the [User Guide](https://lundberg.github.io/respx/guide/) for a complete > walk-through. ### pytest + httpx For a neater `pytest` experience, RESPX includes a `respx_mock` _fixture_ for easy `HTTPX` mocking, along with an optional `respx` _marker_ to fine-tune the mock [settings](https://lundberg.github.io/respx/api/#configuration). ```python import httpx import pytest def test_default(respx_mock): respx_mock.get("https://foo.bar/").mock(return_value=httpx.Response(204)) response = httpx.get("https://foo.bar/") assert response.status_code == 204 @pytest.mark.respx(base_url="https://foo.bar") def test_with_marker(respx_mock): respx_mock.get("/baz/").mock(return_value=httpx.Response(204)) response = httpx.get("https://foo.bar/baz/") assert response.status_code == 204 ``` ## Installation Install with pip: ```console $ pip install respx ``` Requires Python 3.7+ and HTTPX 0.21+. See [Changelog](https://github.com/lundberg/respx/blob/master/CHANGELOG.md) for older HTTPX compatibility. respx-0.21.1/Taskfile.yaml000066400000000000000000000026411460110214700153610ustar00rootroot00000000000000version: "3" tasks: default: cmds: [task: all] all: desc: Run test suite, mypy & linting label: all -- [nox options] silent: true deps: [tools] cmds: - .venv/bin/nox -k "test + mypy" {{.CLI_ARGS | default "-R"}} - task: lint test: desc: Run test suite against latest python label: test -- [pytest options] silent: true deps: [tools] cmds: [".venv/bin/nox -R -s test-3.11 -- {{.CLI_ARGS}}"] mypy: desc: Statically type check python files silent: true deps: [tools] cmds: [.venv/bin/nox -R -s mypy] lint: desc: Lint project files silent: true deps: [tools] cmds: [.venv/bin/pre-commit run --all-files] docs: desc: Start docs server, in watch mode silent: true deps: [tools] cmds: [.venv/bin/nox -R -s docs -- serve] reset: desc: Delete environment and artifacts silent: true cmds: - echo Deleting environment and artifacts ... - rm -rf \ .venv .nox .mypy_cache .pytest_cache respx.egg-info .coverage coverage.xml tools: internal: true silent: true run: once deps: [venv] cmds: [.venv/bin/python -m pip install nox pre-commit] status: - test -f .venv/bin/nox - test -f .venv/bin/pre-commit venv: internal: true silent: true run: once cmds: [python -m venv --copies --upgrade-deps .venv > /dev/null] status: [test -d .venv] respx-0.21.1/docs/000077500000000000000000000000001460110214700136605ustar00rootroot00000000000000respx-0.21.1/docs/api.md000066400000000000000000000316701460110214700147620ustar00rootroot00000000000000# API Reference ## Router ### Configuration Creates a mock `Router` instance, ready to be used as decorator/manager for activation. > respx.mock(assert_all_mocked=True, *assert_all_called=True, base_url=None*) > > **Parameters:** > > * **assert_all_mocked** - *(optional) bool - default: `True`* > Asserts that all sent and captured `HTTPX` requests are routed and mocked. > If disabled, all non-routed requests will be auto mocked with status code `200`. > * **assert_all_called** - *(optional) bool - default: `True`* > Asserts that all added and mocked routes were called when exiting context. > * **base_url** - *(optional) str* > Base URL to match, on top of each route specific pattern *and/or* side effect. > > **Returns:** `Router` !!! note "NOTE" When using the *default* mock router `respx.mock`, *without settings*, `assert_all_called` is **disabled**. !!! tip "pytest" Use the `@pytest.mark.respx(...)` marker with these parameters to configure the `respx_mock` [pytest fixture](examples.md#built-in-marker). ### .route() Adds a new, *optionally named*, `Route` with given [patterns](#patterns) *and/or* [lookups](#lookups) combined, using the [AND](#and) operator. > respx.route(*\*patterns, name=None, \*\*lookups*) > > **Parameters:** > > * **patterns** - *(optional) args* > One or more [pattern](#patterns) objects. > * **lookups** - *(optional) kwargs* > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. > * **name** - *(optional) str* > Name this route. > > **Returns:** `Route` ### .get(), .post(), ... HTTP method helpers to add routes, mimicking the [HTTPX Helper Functions](https://www.python-httpx.org/api/#helper-functions). > respx.get(*url, name=None, \*\*lookups*) > respx.options(...) > respx.head(...) > respx.post(...) > respx.put(...) > respx.patch(...) > respx.delete(...) > > **Parameters:** > > * **url** - *(optional) str | compiled regex | tuple (httpcore) | httpx.URL* > Request URL to match, *full or partial*, turned into a [URL](#url) pattern. > * **name** - *(optional) str* > Name this route. > * **lookups** - *(optional) kwargs* > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. > > **Returns:** `Route` ``` python respx.get("https://example.org/", params={"foo": "bar"}, ...) ``` ### .request() > respx.request(*method, url, name=None, \*\*lookups*) > > **Parameters:** > > * **method** - *str* > Request HTTP method to match. > * **url** - *(optional) str | compiled regex | tuple (httpcore) | httpx.URL* > Request URL to match, *full or partial*, turned into a [URL](#url) pattern. > * **name** - *(optional) str* > Name this route. > * **lookups** - *(optional) kwargs* > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. > > **Returns:** `Route` ``` python respx.request("GET", "https://example.org/", params={"foo": "bar"}, ...) ``` --- ## Route ### .mock() Mock a route's response or side effect. > route.mock(*return_value=None, side_effect=None*) > > **Parameters:** > > * **return_value** - *(optional) [Response](#response)* > HTTPX Response to mock and return. > * **side_effect** - *(optional) Callable | Exception | Iterable of httpx.Response/Exception* > [Side effect](guide.md#mock-with-a-side-effect) to call, exception to raise or stacked responses to respond with in order. > > **Returns:** `Route` ### .return_value Setter for the `HTTPX` [Response](#response) to return. > route.**return_value** = Response(204) ### .side_effect Setter for the [side effect](guide.md#mock-with-a-side-effect) to trigger. > route.**side_effect** = ... > > See [route.mock()](#mock) for valid side effect types. ### .respond() Shortcut for creating and mocking a `HTTPX` [Response](#response). > route.respond(*status_code=200, headers=None, cookies=None, content=None, text=None, html=None, json=None, stream=None, content_type=None*) > > **Parameters:** > > * **status_code** - *(optional) int - default: `200`* > Response status code to mock. > * **headers** - *(optional) dict | Sequence[tuple[str, str]]* > Response headers to mock. > * **cookies** - *(optional) dict | Sequence[tuple[str, str]] | Sequence[SetCookie]* > Response cookies to mock as `Set-Cookie` headers. See [SetCookie](#setcookie). > * **content** - *(optional) bytes | str | Iterable[bytes]* > Response raw content to mock. > * **text** - *(optional) str* > Response *text* content to mock, with automatic content-type header added. > * **html** - *(optional) str* > Response *HTML* content to mock, with automatic content-type header added. > * **json** - *(optional) str | list | dict* > Response *JSON* content to mock, with automatic content-type header added. > * **stream** - *(optional) Iterable[bytes]* > Response *stream* to mock. > * **content_type** - *(optional) str* > Response `Content-Type` header to mock. > > **Returns:** `Route` ### .pass_through() > route.pass_through(*value=True*) > > **Parameters:** > > * **value** - *(optional) bool - default: `True`* > Mark route to pass through, sending matched requests to real server, *e.g. don't mock*. > > **Returns:** `Route` --- ## Response !!! note "NOTE" This is a partial reference for how to the instantiate the **HTTPX** `Response`class, e.g. *not* a RESPX class. > httpx.Response(*status_code, headers=None, content=None, text=None, html=None, json=None, stream=None*) > > **Parameters:** > > * **status_code** - *int* > HTTP status code. > * **headers** - *(optional) dict | httpx.Headers* > HTTP headers. > * **content** - *(optional) bytes | str | Iterable[bytes]* > Raw content. > * **text** - *(optional) str* > Text content, with automatic content-type header added. > * **html** - *(optional) str* > HTML content, with automatic content-type header added. > * **json** - *(optional) str | list | dict* > JSON content, with automatic content-type header added. > * **stream** - *(optional) Iterable[bytes]* > Content *stream*. !!! tip "Cookies" Use [respx.SetCookie(...)](#setcookie) to produce `Set-Cookie` headers. --- ## SetCookie A utility to render a `("Set-Cookie", )` tuple. See route [respond](#respond) shortcut for alternative use. > respx.SetCookie(*name, value, path=None, domain=None, expires=None, max_age=None, http_only=False, same_site=None, secure=False, partitioned=False*) ``` python import respx respx.post("https://example.org/").mock( return_value=httpx.Response(200, headers=[SetCookie("foo", "bar")]) ) ``` --- ## Patterns ### M() Creates a reusable pattern, combining multiple arguments using the [AND](#and) operator. > M(*\*patterns, \*\*lookups*) > > **Parameters:** > > * **patterns** - *(optional) args* > One or more [pattern](#patterns) objects. > * **lookups** - *(optional) kwargs* > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. > > **Returns:** `Pattern` ``` python import respx from respx.patterns import M pattern = M(host="example.org") respx.route(pattern) ``` > See [operators](#operators) for advanced usage. ### Method Matches request *HTTP method*, using [eq](#eq) as default lookup. > Key: `method` > Lookups: [eq](#eq), [in](#in) ``` python respx.route(method="GET") respx.route(method__in=["PUT", "PATCH"]) ``` ### Scheme Matches request *URL scheme*, using [eq](#eq) as default lookup. > Key: `scheme` > Lookups: [eq](#eq), [in](#in) ``` python respx.route(scheme="https") respx.route(scheme__in=["http", "https"]) ``` ### Host Matches request *URL host*, using [eq](#eq) as default lookup. > Key: `host` > Lookups: [eq](#eq), [regex](#regex), [in](#in) ``` python respx.route(host="example.org") respx.route(host__regex=r"example\.(org|com)") respx.route(host__in=["example.org", "example.com"]) ``` ### Port Matches request *URL port*, using [eq](#eq) as default lookup. > Key: `port` > Lookups: [eq](#eq), [in](#in) ``` python respx.route(port=8000) respx.route(port__in=[2375, 2376]) ``` ### Path Matches request *URL path*, using [eq](#eq) as default lookup. > Key: `path` > Lookups: [eq](#eq), [regex](#regex), [startswith](#startswith), [in](#in) ``` python respx.route(path="/api/foobar/") respx.route(path__regex=r"^/api/(?P\w+)/") respx.route(path__startswith="/api/") respx.route(path__in=["/api/v1/foo/", "/api/v2/foo/"]) ``` ### Params Matches request *URL query params*, using [contains](#contains) as default lookup. > Key: `params` > Lookups: [contains](#contains), [eq](#eq) ``` python respx.route(params={"foo": "bar", "ham": "spam"}) respx.route(params=[("foo", "bar"), ("ham", "spam")]) respx.route(params=(("foo", "bar"), ("ham", "spam"))) respx.route(params="foo=bar&ham=spam") ``` !!! note "NOTE" A request querystring with multiple parameters of the same name is treated as an ordered list when matching. !!! tip "ANY value" Use `mock.ANY` as value to only match on parameter presence, e.g. `respx.route(params={"foo": ANY})`. ### URL Matches request *URL*. When no *lookup* is given, `url` works as a *shorthand* pattern, combining individual request *URL* parts, using the [AND](#and) operator. > Key: `url` > Lookups: [eq](#eq), [regex](#regex), [startswith](#startswith) ``` python respx.get("//example.org/foo/") # == M(host="example.org", path="/foo/") respx.get(url__eq="https://example.org:8080/foobar/?ham=spam") respx.get(url__regex=r"https://example.org/(?P\w+)/") respx.get(url__startswith="https://example.org/api/") respx.get("all://*.example.org/foo/") ``` ### Content Matches request raw *content*, using [eq](#eq) as default lookup. > Key: `content` > Lookups: [eq](#eq), [contains](#contains) ``` python respx.post("https://example.org/", content="foobar") respx.post("https://example.org/", content=b"foobar") respx.post("https://example.org/", content__contains="bar") ``` ### Data Matches request *form data*, excluding files, using [eq](#eq) as default lookup. > Key: `data` > Lookups: [eq](#eq), [contains](#contains) ``` python respx.post("https://example.org/", data={"foo": "bar"}) ``` ### Files Matches files within request *form data*, using [contains](#contains) as default lookup. > Key: `files` > Lookups: [contains](#contains), [eq](#eq) ``` python respx.post("https://example.org/", files={"some_file": b"..."}) respx.post("https://example.org/", files={"some_file": ANY}) respx.post("https://example.org/", files={"some_file": ("filename.txt", b"...")}) respx.post("https://example.org/", files={"some_file": ("filename.txt", ANY)}) ``` ### JSON Matches request *json* content, using [eq](#eq) as default lookup. > Key: `json` > Lookups: [eq](#eq) ``` python respx.post("https://example.org/", json={"foo": "bar"}) ``` The `json` pattern also supports path traversing, *i.e.* `json__=`. ``` python respx.post("https://example.org/", json__foobar__0__ham="spam") httpx.post("https://example.org/", json={"foobar": [{"ham": "spam"}]}) ``` ### Headers Matches request *headers*, using [contains](#contains) as default lookup. > Key: `headers` > Lookups: [contains](#contains), [eq](#eq) ``` python respx.route(headers={"foo": "bar", "ham": "spam"}) respx.route(headers=[("foo", "bar"), ("ham", "spam")]) ``` ### Cookies Matches request *cookie header*, using [contains](#contains) as default lookup. > Key: `cookies` > Lookups: [contains](#contains), [eq](#eq) ``` python respx.route(cookies={"foo": "bar", "ham": "spam"}) respx.route(cookies=[("foo", "bar"), ("ham", "spam")]) ``` ## Lookups ### eq ``` python M(path="/foo/bar/") M(path__eq="/foo/bar/") ``` ### contains Case-sensitive containment test. ``` python M(params__contains={"id": "123"}) ``` ### in Case-sensitive within test. ``` python M(method__in=["PUT", "PATCH"]) ``` ### regex ``` python M(path__regex=r"^/api/(?P\w+)/") ``` ### startswith Case-sensitive starts-with. ``` python M(path__startswith="/api/") ``` ## Operators Patterns can be combined using bitwise operators, creating new patterns. ### AND (&) Combines two `Pattern`s using `and` operator. ``` python M(scheme="http") & M(host="example.org") ``` ### OR (&) Combines two `Pattern`s using `or` operator. ``` python M(method="PUT") | M(method="PATCH") ``` ### INVERT (~) Inverts a `Pattern` match. ``` python ~M(params={"foo": "bar"}) ``` respx-0.21.1/docs/examples.md000066400000000000000000000113101460110214700160140ustar00rootroot00000000000000# Test Case Examples Here's some test case examples, not exactly *how-to*, but to be inspired from. ## pytest ### Built-in Fixture RESPX includes the `respx_mock` pytest httpx *fixture*. ``` python import httpx def test_fixture(respx_mock): respx_mock.get("https://foo.bar/").mock(return_value=httpx.Response(204)) response = httpx.get("https://foo.bar/") assert response.status_code == 204 ``` ### Built-in Marker To configure the `respx_mock` fixture, use the `respx` *marker*. ``` python import httpx import pytest @pytest.mark.respx(base_url="https://foo.bar") def test_configured_fixture(respx_mock): respx_mock.get("/baz/").mock(return_value=httpx.Response(204)) response = httpx.get("https://foo.bar/baz/") assert response.status_code == 204 ``` > See router [configuration](api.md#configuration) reference for more details. ### Custom Fixtures ``` python # conftest.py import pytest import respx from httpx import Response @pytest.fixture def mocked_api(): with respx.mock( base_url="https://foo.bar", assert_all_called=False ) as respx_mock: users_route = respx_mock.get("/users/", name="list_users") users_route.return_value = Response(200, json=[]) ... yield respx_mock ``` ``` python # test_api.py import httpx def test_list_users(mocked_api): response = httpx.get("https://foo.bar/users/") assert response.status_code == 200 assert response.json() == [] assert mocked_api["list_users"].called ``` **Session Scoped Fixtures** If a session scoped RESPX fixture is used in an async context, you also need to broaden the `pytest-asyncio` [event_loop](https://github.com/pytest-dev/pytest-asyncio#event_loop) fixture. You can use the `session_event_loop` utility for this. ``` python # conftest.py import pytest import respx from respx.fixtures import session_event_loop as event_loop # noqa: F401 @pytest.fixture(scope="session") async def mocked_api(event_loop): # noqa: F811 async with respx.mock(base_url="https://foo.bar") as respx_mock: ... yield respx_mock ``` ### Async Test Cases ``` python import httpx import respx @respx.mock async def test_async_decorator(): async with httpx.AsyncClient() as client: route = respx.get("https://example.org/") response = await client.get("https://example.org/") assert route.called assert response.status_code == 200 async def test_async_ctx_manager(): async with respx.mock: async with httpx.AsyncClient() as client: route = respx.get("https://example.org/") response = await client.get("https://example.org/") assert route.called assert response.status_code == 200 ``` ## unittest ### Regular Decoration ``` python # test_api.py import httpx import respx import unittest class APITestCase(unittest.TestCase): @respx.mock def test_some_endpoint(self): respx.get("https://example.org/") % 202 response = httpx.get("https://example.org/") self.assertEqual(response.status_code, 202) ``` ### Reuse SetUp & TearDown ``` python # testcases.py import respx from httpx import Response class MockedAPIMixin: @classmethod def setUpClass(cls): cls.mocked_api = respx.mock( base_url="https://foo.bar", assert_all_called=False ) users_route = cls.mocked_api.get("/users/", name="list_users") users_route.return_value = Response(200, json=[]) ... def setUp(self): self.mocked_api.start() self.addCleanup(self.mocked_api.stop) ``` ``` python # test_api.py import httpx import unittest from .testcases import MockedAPIMixin class APITestCase(MockedAPIMixin, unittest.TestCase): def test_list_users(self): response = httpx.get("https://foo.bar/users/") self.assertEqual(response.status_code, 200) self.assertListEqual(response.json(), []) self.assertTrue(self.mocked_api["list_users"].called) ``` ### Async Test Cases ``` python import asynctest import httpx import respx class MyTestCase(asynctest.TestCase): @respx.mock async def test_async_decorator(self): async with httpx.AsyncClient() as client: route = respx.get("https://example.org/") response = await client.get("https://example.org/") assert route.called assert response.status_code == 200 async def test_async_ctx_manager(self): async with respx.mock: async with httpx.AsyncClient() as client: route = respx.get("https://example.org/") response = await client.get("https://example.org/") assert route.called assert response.status_code == 200 ``` respx-0.21.1/docs/guide.md000066400000000000000000000506371460110214700153120ustar00rootroot00000000000000# User Guide RESPX is a mock router, [capturing](#mock-httpx) requests sent by `HTTPX`, [mocking](#mocking-responses) their responses. Inspired by the flexible query API of the [Django](https://www.djangoproject.com/) ORM, requests are filtered and matched against routes and their request [patterns](api.md#patterns) and [lookups](api.md#lookups). Request [patterns](api.md#patterns) are *bits* of the request, like `host` `method` `path` etc, with given [lookup](api.md#lookups) values, combined using *bitwise* [operators](api.md#operators) to form a `Route`, i.e. `respx.route(path__regex=...)` A captured request, [matching](#routing-requests) a `Route`, resolves to a [mocked](#mock-a-response) `httpx.Response`, or triggers a given [side effect](#mock-with-a-side-effect). To skip mocking a specific request, a route can be marked to [pass through](#pass-through). ## Mock HTTPX To patch `HTTPX`, and activate the RESPX router, use the `respx.mock` decorator/context manager, or the `respx_mock` *pytest* fixture. ### Using the Decorator ``` python import httpx import respx @respx.mock def test_decorator(): my_route = respx.get("https://example.org/") response = httpx.get("https://example.org/") assert my_route.called assert response.status_code == 200 ``` ### Using the Context Manager ``` python import httpx import respx def test_ctx_manager(): with respx.mock: my_route = respx.get("https://example.org/") response = httpx.get("https://example.org/") assert my_route.called assert response.status_code == 200 ``` ### Using the pytest Fixture ``` python import httpx def test_fixture(respx_mock): my_route = respx_mock.get("https://example.org/") response = httpx.get("https://example.org/") assert my_route.called assert response.status_code == 200 ``` ### Router Settings The RESPX router can be configured with built-in assertion checks and an *optional* [base URL](#base-url). By configuring, an isolated router is created, and settings are *locally* bound to the routes added. Either of the decorator, context manager and fixture takes the same configuration arguments. > See router [configuration](api.md#configuration) reference for more details. **Configure the Decorator** When decorating a test case with configured router settings, the test function will receive the router instance as a `respx_mock` argument. ``` python @respx.mock(...) def test_something(respx_mock): ... ``` **Configure the Context Manager** When passing settings to the context manager, the configured router instance will be *yielded*. ``` python with respx.mock(...) as respx_mock: ... ``` **Configure the Fixture** To configure the router when using the `pytest` fixture, decorate the test case with the `respx` *pytest marker*. ``` python @pytest.mark.respx(...) def test_something(respx_mock): ... ``` #### Base URL When adding a lot of routes, sharing the same domain/prefix, you can configure the router with a `base_url` to be used for added routes. ``` python import httpx import respx from httpx import Response @respx.mock(base_url="https://example.org/api/") async def test_something(respx_mock): async with httpx.AsyncClient(base_url="https://example.org/api/") as client: respx_mock.get("/baz/").mock(return_value=Response(200, text="Baz")) response = await client.get("/baz/") assert response.text == "Baz" ``` #### Assert all Mocked By default, asserts that all sent and captured `HTTPX` requests are routed and mocked. ``` python @respx.mock(assert_all_mocked=True) def test_something(respx_mock): response = httpx.get("https://example.org/") # Not mocked, will raise ``` If *disabled*, all non-routed requests will be auto-mocked with status code `200`. ``` python @respx.mock(assert_all_mocked=False) def test_something(respx_mock): response = httpx.get("https://example.org/") # Will auto-mock assert response.status_code == 200 ``` #### Assert all Called By default, asserts that all added and mocked routes were called when exiting *decorated* test case, *context manager* scope or exiting a text case using the pytest fixture. ``` python @respx.mock(assert_all_called=True) def test_something(respx_mock): respx_mock.get("https://example.org/") respx_mock.get("https://some.url/") # Not called, will fail the test response = httpx.get("https://example.org/") ``` ``` python @respx.mock(assert_all_called=False) def test_something(respx_mock): respx_mock.get("https://example.org/") respx_mock.get("https://some.url/") # Not called, yet not asserted response = httpx.get("https://example.org/") assert response.status_code == 200 ``` --- ## Routing Requests The easiest way to add routes is to use the [HTTP Method](#http-method-helpers) helpers. For full control over the request pattern matching, use the [route](#route-api) API. Routes are matched and routed in *added order*. This means that routes with more specific patterns should to be added earlier than the ones with less "details". ### HTTP Method Helpers Each HTTP method has a helper function (`get`, `options`, `head`, `post`, `put`, `patch`, `delete`), *shortcutting* the [route](#route-api) API. ``` python my_route = respx.get("https://example.org/", params={"foo": "bar"}) response = httpx.get("https://example.org/", params={"foo": "bar"}) assert my_route.called assert response.status_code == 200 ``` > See [.get(), .post(), ...](api.md#get-post) helpers reference for more details. ### Route API #### Patterns With the `route` API, you define a combined pattern to match, capturing a sent request. ``` python my_route = respx.route(method="GET", host="example.org", path="/foobar/") response = httpx.get("https://example.org/foobar/") assert my_route.called assert response.status_code == 200 ``` > See [.route()](api.md#route) reference for more details. #### Lookups Each [pattern](api.md#patterns) has a *default* lookup. To specify what [lookup](api.md#lookups) to use, add a `__` suffix. ``` python respx.route(method__in=["PUT", "PATCH"]) ``` #### Combining Patterns For even more flexibility, you can define combined patterns using the [M()](api.md#m) *object*, together with bitwise [operators](api.md#operators) (`&`, `|,` `~`), creating a reusable pattern. ``` python hosts_pattern = M(host="example.org") | M(host="example.com") my_route = respx.route(hosts_pattern, method="GET", path="/foo/") response = httpx.get("http://example.org/foo/") assert response.status_code == 200 assert my_route.called response = httpx.get("https://example.com/foo/") assert response.status_code == 200 assert my_route.call_count == 2 ``` !!! note "NOTE" ``M(url="//example.org/foobar/")`` is **equal** to ``M(host="example.org") & M(path="/foobar/")`` ### Named Routes Routes can be *named* when added, and later accessed through the `respx.routes` mapping. This is useful when a route is added *outside* the test case, *e.g.* access or assert route calls. ``` python import httpx import respx # Added somewhere else respx.get("https://example.org/", name="home") @respx.mock def test_route_call(): httpx.get("https://example.org/") assert respx.routes["home"].called assert respx.routes["home"].call_count == 1 last_home_response = respx.routes["home"].calls.last.response assert last_home_response.status_code == 200 ``` ### Reusable Routers As described under [settings](#router-settings), an isolated router is created when calling `respx.mock(...)`. Isolated routers are useful when mocking multiple remote APIs, allowing grouped routes per API, and to be mocked individually or stacked for reuse across tests. Use the router instance as decorator or context manager to patch `HTTPX` and activate the routes. ``` python import httpx import respx api_mock = respx.mock(base_url="https://api.foo.bar/", assert_all_called=False) api_mock.get("/baz/", name="baz").mock( return_value=httpx.Response(200, json={"name": "baz"}), ) ... @api_mock def test_decorator(): response = httpx.get("https://api.foo.bar/baz/") assert response.status_code == 200 assert response.json() == {"name": "baz"} assert api_mock["baz"].called def test_ctx_manager(): with api_mock: ... ``` !!! tip "Catch-all" Add a *catch-all* route last as a fallback for any non-matching request, e.g. `api_mock.route().respond(404)`. !!! note "NOTE" Named routes in a *reusable router* can be directly accessed via `my_mock_router[]` ### Route with an App As an alternative one can route and mock responses with an `app` by passing either a `respx.WSGIHandler` or `respx.ASGIHandler` as side effect when mocking. **Sync App Example** ``` python import httpx import respx from flask import Flask app = Flask("foobar") @app.route("/baz/") def baz(): return {"ham": "spam"} @respx.mock(base_url="https://foo.bar/") def test_baz(respx_mock): app_route = respx_mock.route().mock(side_effect=WSGIHandler(app)) response = httpx.get("https://foo.bar/baz/") assert response.json() == {"ham": "spam"} assert app_route.called ``` **Async App Example** ``` python import httpx import respx from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route async def baz(request): return JSONResponse({"ham": "spam"}) app = Starlette(routes=[Route("/baz/", baz)]) @respx.mock(base_url="https://foo.bar/") async def test_baz(respx_mock): app_route = respx_mock.route().mock(side_effect=ASGIHandler(app)) response = await httpx.AsyncClient().get("https://foo.bar/baz/") assert response.json() == {"ham": "spam"} assert app_route.called ``` --- ## Mocking Responses To mock a [route](#routing-requests) response, use `.mock(...)` to either... * set the `httpx.Response` to be [returned](#mock-a-response). * set a [side effect](#mock-with-a-side-effect) to be triggered. The route's mock interface is inspired by pythons built-in `Mock()` object, e.g. ``side_effect`` has precedence over ``return_value``, side effects can either be functions, exceptions or an iterable, raising ``StopIteration`` when "exhausted" etc. ### Mock a Response Create a mocked `HTTPX` [Response](api.md#response) object and pass it as `return_value`. ``` python respx.get("https://example.org/").mock(return_value=Response(204)) ``` > See [.mock()](api.md#mock) reference for more details. You can also use the `.return_value` *setter*. ``` python route = respx.get("https://example.org/") route.return_value = Response(200, json={"foo": "bar"}) ``` ### Mock with a Side Effect RESPX *side effects* works just like the python `Mock` side effects. It can either be a [function](#functions) to call, an [exception](#exceptions) to raise, or an [iterable](#iterable) of responses/exceptions to respond with in order, for repeated requests. ``` python respx.get("https://example.org/").mock(side_effect=...) ``` You can also use the `.side_effect` *setter*. ``` python route = respx.get("https://example.org/") route.side_effect = ... ``` #### Functions Function *side effects* will be called with the *captured* ``request`` argument, and should either... * return a mocked [Response](api.md#response). * raise an `Exception` to simulate a request error. * return `None` to treat the route as a *non-match*, continuing testing further routes. * return the input `Request` to [pass through](#pass-through). ``` python import httpx import respx def my_side_effect(request): return httpx.Response(201) @respx.mock def test_side_effect(): respx.post("https://example.org/").mock(side_effect=my_side_effect) response = httpx.post("https://example.org/") assert response.status_code == 201 ``` Optionally, a side effect can include a `route` argument for cases where call stats, or modifying the route within the side effect, is needed. ``` python import httpx import respx def my_side_effect(request, route): return httpx.Response(201, json={"id": route.call_count + 1}) @respx.mock def test_side_effect(): respx.post("https://example.org/").mock(side_effect=my_side_effect) response = httpx.post("https://example.org/") assert response.json() == {"id": 1} response = httpx.post("https://example.org/") assert response.json() == {"id": 2} ``` If any of the route patterns are using a [regex lookup](api.md#regex), containing *named groups*, the regex groups will be passed as *kwargs* to the *side effect*. ``` python import httpx import respx def my_side_effect(request, slug): return httpx.Response(200, json={"slug": slug}) @respx.mock def test_side_effect_kwargs(): route = respx.route(url__regex=r"https://example.org/(?P\w+)/") route.side_effect = my_side_effect response = httpx.get("https://example.org/foobar/") assert response.status_code == 200 assert response.json() == {"slug": "foobar"} ``` A route can even *decorate* the function to be used as *side effect*. ``` python import httpx import rexpx @respx.route(url__regex=r"https://example.org/(?P\w+)/", name="user") def user_api(request, user): return httpx.Response(200, json={"user": user}) @respx.mock def test_user_api(): response = httpx.get("https://example.org/lundberg/") assert response.status_code == 200 assert response.json() == {"user": "lundberg"} assert respx.routes["user"].called ``` #### Exceptions To simulate a request error, pass a [httpx.HTTPError](https://www.python-httpx.org/exceptions/#the-exception-hierarchy) *subclass*, or any `Exception` as *side effect*. ``` python import httpx import respx @respx.mock def test_connection_error(): respx.get("https://example.org/").mock(side_effect=httpx.ConnectError) with pytest.raises(httpx.ConnectError): httpx.get("https://example.org/") ``` #### Iterable If the side effect is an *iterable*, each repeated request will get the *next* [Response](api.md#response) returned, or [exception](#exceptions) raised, from the iterable. ``` python import httpx import respx @respx.mock def test_stacked_responses(): route = respx.get("https://example.org/") route.side_effect = [ httpx.Response(404), httpx.Response(200), ] response1 = httpx.get("https://example.org/") response2 = httpx.get("https://example.org/") assert response1.status_code == 404 assert response2.status_code == 200 assert route.call_count == 2 ``` Once the iterable is *exhausted*, the route will fallback and respond with the `return_value`, if set. ``` python import httpx import respx @respx.mock def test_stacked_responses(): respx.post("https://example.org/").mock( side_effect=[httpx.Response(201)], return_value=httpx.Response(200) ) response1 = httpx.post("https://example.org/") response2 = httpx.post("https://example.org/") response3 = httpx.post("https://example.org/") assert response1.status_code == 201 assert response2.status_code == 200 assert response3.status_code == 200 ``` ### Shortcuts #### Respond For convenience, `.respond(...)` can be used as a shortcut to `return_value`. ``` python respx.post("https://example.org/").respond(201) ``` > See [.respond()](api.md#respond) reference for more details. #### Modulo For simple mocking, a quick way is to use the python modulo (`%`) operator to mock the response. The *right-hand* modulo argument can either be ... An `int` representing the `status_code` to mock: ``` python respx.get("https://example.org/") % 204 response = httpx.get("https://example.org/") assert response.status_code == 204 ``` A `dict` used as *kwargs* to create a mocked `HTTPX` [Response](api.md#response), with status code `200` by default: ``` python respx.get("https://example.org/") % dict(json={"foo": "bar"}) response = httpx.get("https://example.org/") assert response.status_code == 200 assert response.json() == {"foo": "bar"} ``` A `HTTPX` [Response](api.md#response) object: ``` python respx.get("https://example.org/") % Response(418) response = httpx.get("https://example.org/") assert response.status_code == httpx.codes.IM_A_TEAPOT ``` ## Rollback When exiting a [decorated](#using-the-decorator) test case, or [context manager](#using-the-context-manager), the routes and their mocked values, *i.e.* `return_value` and `side_effect`, will be *rolled back* and restored to their initial state. This means that you can safely modify existing routes, or add new ones, *within* a test case, without affecting other tests that are using the same router. ``` python import httpx import respx # Initial routes mock_router = respx.mock(base_url="https://example.org") mock_router.get(path__regex="/user/(?P\d+)/", name="user") % 404 ... @mock_router def test_user_exists(): # This change will be rolled back after this test case mock_router["user"].return_value = httpx.Response(200) response = httpx.get("https://example.org/user/123/") assert response.status_code == 200 @mock_router def test_user_not_found(): response = httpx.get("https://example.org/user/123/") assert response.status_code == 404 ``` --- ## Pass Through If you want a route to *not* capture and mock a request response, use `.pass_through()`. ``` python import httpx import respx @respx.mock def test_remote_response(): respx.route(host="localhost").pass_through() response = httpx.get("http://localhost:8000/") # response from server ``` > See [.pass_through()](api.md#pass_through) reference for more details. --- ## Mock without patching HTTPX If you don't *need* to patch `HTTPX`, use `httpx.MockTransport` with a REPX router as handler, when instantiating your client. ``` python import httpx import respx router = respx.Router() router.post("https://example.org/") % 404 def test_client(): mock_transport = httpx.MockTransport(router.handler) with httpx.Client(transport=mock_transport) as client: response = client.post("https://example.org/") assert response.status_code == 404 def test_client(): mock_transport = httpx.MockTransport(router.async_handler) with httpx.AsyncClient(transport=mock_transport) as client: ... ``` !!! note "NOTE" To assert all routes is called, you'll need to trigger `.assert_all_called()` manually, e.g. in a test case or after yielding the router in a *pytest* fixture, since there's no auto post assertion done like when using [respx.mock](#assert-all-called). !!! Hint You can use `RESPX` not only to mock out `HTTPX`, but actually mock any library using `HTTP Core` transports. --- ## Call History The `respx` API includes a `.calls` object, containing captured (`request`, `response`) named tuples and MagicMock's *bells and whistles*, i.e. `call_count`, `assert_called` etc. ### Asserting calls ``` python assert respx.calls.called assert respx.calls.call_count == 1 respx.calls.assert_called() respx.calls.assert_not_called() respx.calls.assert_called_once() ``` ### Retrieving mocked calls A matched and mocked `Call` can be retrieved from call history, by either unpacking... ``` python request, response = respx.calls.last request, response = respx.calls[-2] # by call order ``` ...or by accessing `request` or `response` directly... ``` python last_request = respx.calls.last.request assert json.loads(last_request.content) == {"foo": "bar"} last_response = respx.calls.last.response assert last_response.status_code == 200 ``` ### Local route calls Each `Route` object has its own `.calls`, along with `.called` and `.call_count ` shortcuts. ``` python import httpx import respx @respx.mock def test_route_call_stats(): route = respx.post("https://example.org/baz/") % 201 httpx.post("https://example.org/baz/") assert route.calls.last.request.url.path == "/baz/" assert route.calls.last.response.status_code == 201 assert route.called assert route.call_count == 1 route.calls.assert_called_once() ``` ### Reset History The call history will automatically *reset* when exiting mocked context, i.e. leaving a [decorated](#using-the-decorator) test case, or [context manager](#using-the-context-manager) scope. To manually *reset* call stats during a test case, use `respx.reset()` or `.reset()`. ``` python import httpx import respx @respx.mock def test_reset(): respx.post("https://foo.bar/baz/") httpx.post("https://foo.bar/baz/") assert respx.calls.call_count == 1 respx.calls.assert_called_once() respx.reset() assert len(respx.calls) == 0 assert respx.calls.call_count == 0 respx.calls.assert_not_called() ``` respx-0.21.1/docs/img/000077500000000000000000000000001460110214700144345ustar00rootroot00000000000000respx-0.21.1/docs/img/respx.png000066400000000000000000013261611460110214700163150ustar00rootroot00000000000000PNG  IHDR\0jQKiTXtXML:com.adobe.xmp I: IDATxwW].w_LL!$$!A:ҹX. ( A^A^z+"HAU 6P%HKHrWV3"`ʙg漟y('{7Z"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""":ĬJJi!=@&x\[:m}ŞD# >e#wı+\DDЄCR@_)q!YG:mmJĀ^ !ۤe۶+\Dt*3e.c1FBhzH¶Yg7(C({B๶~وhk ضON2UV%L?mu.:TȔ k?k[;S,\[ Na[@3SzY碭eK1:Sƿd"":XNq7(% /RzyWm)7{ Gu&"툅4 x 3օDŽm֙hvUOߦzT%DD]pKs(S;XTe:\ЈCLWk%M< iDQ1c>_J)=u֙ *J ?bR͸c""!."ӈm\ B*41 +:1qP[[fhc"":=OJq ʢu :";C ^Żou&""=\DD-S,F)P)ŵJ+~aֹh))+3n4\Ds)2mD$^ͱm" m2eѧJ~\I??S(js4&>o,\c>E."NLi>W;T3S\+OWw?GJB*Su%:EJ+h2׍RҤ}fkslۧEDۉ>]W$xxa[Yg;Fg'C!@g.q4+d+%TZ%ezs^RpmmYh;IN&qm˺ƽ`֙莑Z=_*+!]ou&"| )JRRRWdY6Ƕ},\DL $5óDD'^LR%#OW.ͱm" mWYɔҋBF"xK#gTZ<v1!r % ^~BpmmYh;˫\_" &Jdz9pa+K7ԹI RgoDtzarK)xNqч#~yֹ R‹cB"ehc""ڦlcM!c&􄢗 g%O)PBmeֹ?ED<bHџ=hna}$[BH?} u ""m*p}0F!5Z?gB Zy8LD= !9o@W{w8~"4?ig秄_)Ai:ѩ:dz=ޒg?\ەR x .W:~,\DDt\h|MƘ![䦜u/ĀJ" NMvp}cB6k:vQ0!uJk3۱s3\+\DDDs|JSW+Ü'XTJלOB}!,[DD6~x,C fi Y"";1FR{|q֑NWy?qR_u&""XKB ⛼~y֙NGʈ)wKkqHih]+"7?;Lް81T:/u&""Ye ޒ|\g;ij?L<׻!.&[B:ө,/RJxk |)Džg6߶\""cKEe䓛Ҭ3jz|BH|t;_u."+\+\DDt;Lj_gZu1DʔŘ4-" .瓔WCzMφu*,𰔰4;ϋNc,\DDt%TW'OLϟu.ˍIOkeě/u."":L""L/F՚»b B(\zJ;aو\"";V 1Uf׬m5YTJ ?̲ED=p&C+)WUvsmJG^b(*7a2lDDtrluBn)$"<;|Xxq4B+:Y皥Buݾ1Fm\u6"ۂ[ 7WN?zbiB|lJYgo12>_PDzEDp!kS !=-/YG䂏>=RkMv}eֹc""Mэ$x)~)&q0&b5(fd y@̇x&RQɚiֹh6L"":1AQX>C<Ou 4_2ⶶou.";g6WhSգ5F] [U8w֙NS(X XV?²EDD,\DDRvB:;ֲ?L'>ƟM)Jw:u ""=.""t&*)Qj9⽃PUoֹN^xw!< u&""L"":^vm'T]wuYgLA!cLB~vCgh3 0Bk .}[V3mWJ/J_y碔{3f."":aqRPZ= *فY$ws.T%; EDD[˶]'B"gu$ WuMr֙ޠ}ۺs:P_gֹ6np ׎SL\:fG'ۺ7_e."":IDžc~,:핗YR|wgR#W}YY""K:mQW'Hgzd9~vֹn_x^ )]{8ys."":i|oRZ}J1'=c֙n~cPpB + ;?~ŏ:m},\DDtr \'=!w=Oe*|x%}շ<({߹:m},\DDtRٱMPHo]?9L߈ɕ1n==2osu-""M*H OD4[2;ӇeJ7dR㵳t%cCKtWܬs  9EDD3;Xe!KkypI~s/βEDD ͌Oe&ucL^m"*{CsѩgۮrK!`l?*ʈ6,dByWUɷE"":}luBn)$"ZA>p6IJ~OQ.zE/#z]ۓDD[npіЌ1L َKUG .W~Sߝ&"ӟu""c F|B6w@ua7?rߓpіe&Rl+ "2ɸwq/;re6 m)BO@wYsT.:QSBMX[Dm_,\DDU/IKs Eﱰsn1!~r؟G.y#a߇˛B.gnk- EͮsJ\{_'?G6=a""-]3B]ڟ6*J>ݰT/6ѿEDD[ IRokܻۻ|qQW oތ%""FXh2/Ӿ cqOٻ3/u·oy%,?n}=,\DDeŵtkqqw浚{`=R޽ f$""FXhKSJ T֦ M,<3ћF/s soyf$""zXhKk5Iފ cCk4͕E{xvMDDD_uYIDDWBOC+{o?D/zh .\s{NTN"Ӊ۶*l*pі'd1|c񬹥eNLB""v% |\I9y[95']z3'2'b""SBVJ!oכRɧsG؉HDDXP5ZϾG?V(|dqVƻm~Gwc""SBH~Q}^r@JB2wdOzr[,\DDtJ8zZ5)hkK5}ͽde$""Z,\DDtJÿETa2Jخ}с?; L^qu?M:]]onh{ӳ@DDt[8΅3Ӹ{o8#^ջY{>1Y&"퍅>]勿~xOr%]\CHӤr~phk:k۳kW=E~mO8h+u m~=2Ko J$}D`m Y%S.t8|߿/z2T"n=umۧEDuy5>jXyBq/ߌ*_خ2~&#"W\t)x}t$謿7"S B""rܡg>(>O4qƎ3gN. +GV w>w&|3FwsξsKqϾU_lf)DD]ڿtڳw1NFrwAY܃:v_K.w2WtUatG]知DDD+\DDe\𳖚Qxj6:;+h; "%YܧC0#+r Je0}{w"131ռX=t՛\?om1g=fVn<̈{e"y $ yUu=B۵(&ט_V HA۳Vao}{&"xkslۧED5.UhW cyaVs@[C 9ndYoJZߵ6R@-u#Ln@z_ODepmmYfOB,7;ۑyN~.+U4J+ 8v@ HRB$@ cD /RwzӵP ",\c>E."ٺǣj|T׈յz"*x2AW{y!ڶiJ !%ciCLZ@ UQ@("kQbait "2(E^u-,\c>E."sdzϔ^T(\_9壇GZd&V1FxTH@T )h !"F­v@ٍ\>|Am,\cቈ褒*^:\1FJ-%LC*Ԩe.vڮPU @Q!4!1(%brY(@ R! PJP1"ń\H0!WⷭZ/=|M7lL"":  Uzh=Bj5RL( %!΢ 8綜C4B z 2h1 #R0?7@)5uEx+X_'L ͑?8ZNK<õ9Sd"".dZ+*U&I&s Ʃ1;z築~>k|9~>^E:\`ѫ],Y2&. \T!Cwÿފ8ޥ=q3i+T+܇7bkoTvc/4ZR3ʼԙ)".[eԗ슍w$Lhoz]9l h5*$\&Kp-2är^CỤ*:ס+ܜ4\>|iyu]#IWQytE{BDBJV+T)&cdžgxC۶p/TpaEU6 QB"B&#LVO@ 1({V*pQ`娀s4͙ăgw;ҡ>xTU^CCU `m=nwl| g=&!(t,᝻놕͵lV^ s=ʗFSu=?hͱm"  .}F*|T]vBm?e.D[2^ՃsWuUs0V [H3ݖ4 ®] @SO J&uݠ d kv &C i,PRh1$"]DGgJ* >~u;yc0Bm_|B1UUM0Ո1i1CPJBpc1K)W`8Vv#^pt(&u *QL&b @ݎ |kЎ&MKڄO\\] å gDD[ B"-O0s6 wڝ.>?Ow^h֧agmnj] RbmO0p~a$RJ@T@c b1A yo`&C@S!C'XBay} 07>x$R)[$eFk [[ "Lg(r87.BQh``59#G!7\) M,RAkqF,B 4ud;vDO(+#FM! zBΏ׊ /# S]~kuG?x|m#TmnED4Cw R .\ЮsQ&7ʍhὝ{(+xE^B9 Cy4B!"!C(8yA) =:#= J%Q@.R%DnxRr:-0N QBa@BiB s )MW,M9,u^mLoܻo ױr1Dxa;hQ 0y9X$!BY4M)mOA ! %{(k+[䙆= =Mmt.yQ)j-ܥɍV1HjrCOG v yo #iu":XN{?B1HU %'/MV\ae`,B /R6!RfQ( NgQ?Bb2{0 I)!0 5}e,C]h,C:(cCqcE@mJAi(+O0FAJnYV 2msN9#Gvs./ctB@!%, 6.LN!0Z,]hy^9uh]"sTeGcB R@/?엣u`Lb) A6GDtμ:-]n-\ň{%ӬEDt]ݗC%:y`pf♓V#%v6Ε՛ Y^H( v@X_[PҐZ;~UA*"ڮ ~kku%ضEH HWΘ_xm :b#h5]R"z硴"XY>9$HLeY VV " 9,y!ňi17Î%$tx 0Z#:thcYnPV%RLx2ƭnئ ƍ>v!Bbi9Q%̠i4MsNx~~ly뎏nXYYAeXh22R-R")5%$UU:AkE,.̣Mpmb4! c 8-B ;AX[İ?uFȋ s84: \7]@f2h-DQMX[ۀ7IeWܲ0W~뫣KM9~¡+\DD`%2ʃRܽ[/ ]9r(i`C e,POj8ᎏp eu@JXXGZu׃1<J1%4cnnY80Rx"G׵ c DӶFλ|C|_@[7JZL ˰b8B)5䅂1~GA0@ mpEӴ0F#E taǷ<+X޻70NVlm"Ϗj7vzjZئ#z ζCRBLӯO)"D5O)y!!" 0xKBbb/M)H!ۈ+>x${=D!>#xεHaZݻw#믿IXĸqQID/{0QVFMpwJ_?ԛ+>:| ,\DD÷>2&]*Е:K:X),Tك F z=&md wIMYtmTH!L2ʔr#[BWU1=]ZmD[,ː`Gtrga-n&,.#)Y\H Cu[ Ǿ}0pܘ:2R"e@۴n:/tdcE _?@4@2$ڶbձ{׵C! =@۶ hY/^UYM;%5RXB0!tB< `W`KvQz˞%*G۵H!cޣ( ڵm;k* p.-fU3ln2{{iO^+ϗ灿?gf"":AXn{BX>|u@H L`i"Cuz`yN هja2h#V1 u3A4ȳ Fk4m q:BT= kL&hG*ˑA# BѠM1:Y^5]gLj2r.x䑪g>>w}ɾoOEDt'p}?l_j}/_ܮ"9Ԣ+0=4B¤ 79 VHcti5_) =Ye@(@YB"z%Fhu0l1n&wjE?!:gDS7cR0B"7:^\Bz%ƣ o>M=R@ЃJ ]c1Qӕ1`zW2!<ڶ=>tvT! yV P*C)^SG;e9~9*6,N+yOnH !ρ) t# ؿƓ7 aqV04TflR9bXk4PU%?{os˚g]\j߼vwwӉ;n6v6x '! !ADPY%P!q&+9` {5ժށ}!!!pi/Rzx+rgy2m+ }Edt}7+c&"8豼ypaM8g cz$ EudmX?\ _uo/ȑ#G?Mz{n%@?@Q'>I'laᏼ |^>? ֱm|"Wy'~O%|A"mfGUD?1j($:P%QG0FGJH~mrNMR=0“RҚ_Ahz#1!Exz؟ywK{e[C+| IrfZk6hbq$nc5]o8JKG([8ŨobipXk j6-eSS8(,3NwL)YQ%(EdB B糜/F֬[U]uMo ˓U/Fswk<%˱`:GfDQ$V(Ȳ,˸*<(+Q#BɄn OU] bAmK4amR!F ݀Uc11]PL0^FњO*>7C8*9K|15ܼPiy nOQH;*a45Z%)^m-Mcm i"7 |Ea/= BXKeN4C+Bi8a:>PbNy|6Ps}?#׎#G1p9r䛖O?e5?Rbx!YD8Sb֎(_+pf?P B @I "c1C(H"$"Q6E I4+_җs~zSn$a"v=z0H)Rmb&4g߳lFU!9Gq8>a~ohMeH9"Ŏ4$ Xk)v{l;m*Tx}?~K>~>8#8,H끛d1i[$ϨACS4AS55A)FkڶEIqm+T!Uۢjr4!LtCѺuDa@"zݱޓcOW|/=rȑo,ȑ#TMU3t=](jE8)9 DªнF[+`:[)$K& JyeeQro }m-I4A0*}YRmnwH!̧mKm ~ fr{wǦܳ-OR Y4ZAkvuAFxRRƻ(Lϓ8n-u6cf@|0@O8ij4ϟs8UYQ9>CWAƘњvf]VW=`5R;oM̖+|o'?L2L'[,SV%zSгMg5c!t4aH~e'B8!Puⰼa,o!&n/hCU7%Ҙ8N<$9]EY8kyxr~ݯ͟;=#G9Mgݦ}R^dYDmqjF|v&C0Zf{{0 ~OYꡣ3lrD^ܶm ʶYsww$zj]ɳϊuEG_{!|$1RSvH'88K]LIӾ|vc8WeQ6 C' HI4hkh{j9릡p5;2ل IDAT@F vk$}LfOPQJ` }M=$B׺5x߭'ex?'l޻o#G|}r \G}g~ Vi#5_yy "1Im7_y+N҈hfRҵ-MTqeRXw=.:$!SDᬠݪ,yTunC BC/51Y<)Vs"1iJ./.}[q[swsK ]O]Մa4OyAc37fQ#̫5 my>G(k~NNNJnf z@IIı}lCI8fqM& f _"# ./aHeM]F1ezA8 O44mG{(`@zu~S4Am)7z8NrB'd42g 8z=`x?T FH!;8[8ɗ4u-/okGGMLqw{?rȑ+ȑ#/|(}V88&iQ٭~8k)ΦH͡'LiLtDa<14ա <#Nb~`ww;s("|)Y0s9YJu۠(-Þ rg^3&ODalo18S$iؔ[5!Q\}}b8S &9Yhzie C0h|>A -:a<,H -hc黎'''Nin5y>!ϧl[ad5jᛆ,iT{w{+ϟS6-mQ5{fz$njv5>ݚ0Y⭯Hj$ƩzE1AS e'M" TtMCDdYJ]Cs}sCSW2 [ʲ$cB/@-M1v͎$MȨSx=bO7 887b` n#g)F4I)K'$q%ٜX7H)-6^,oTӳa)S8kqUu =T,K;+Г1RDaB%eykp8 N8ze{0Ǝ"Z~50 ?Y)WJ)PAz bOԤID7QnK^dyJŞ< g5N[YnOGLnOP03`! (~@ @Dٔv˺(}]!|fCJan@(Knn98| 6xPl7DO>IG7ų\^^%1o>{yà1fm+֛-A>DF| zijR}sUx7y>Ύ+8sp֑)v;K|$/^RZu]#ѣGL& p8MuIӶ5C>AFՊ JQRFa+E]7hcQіlr'yiǟ;  {U I(@IE$4z-Ug{ye]% -}!"L&x~AO($J"@5i |Ak֛ uU4QeA=iE AtTeyi,GlO<~|O;qB= )$:ڲ]#Na5 i)mbɊ(0 b=z$NGZb<18| iV% mU>WSTmE[ʪd2'E5Г,9~F1d2_ni?g;#G|Sp \Gԇ`ȇF~ONiԃȳ"OBf3hB?%"/"ci MiB Ł0 ㄺ%=4 JZ{GƔU Ŝ|~Gy %=`(PUB$9}? GiEf8m gcl"Oʱy躞3HQ6BZ{v0 dJI\È8=;c{H]pcOX͗,&+zL&JSQ{(i{ R,i(w{YV8 %\~ۧ8lv|+_B(LҌ=O=ߡڮlt9,|6β=Q~3_c:rkȑ#g}g ғ=Jvck4җL:Rmh<&9Msfc! <5 !2$B|?hu,K m' Fk8d)Y6 uMQ+non8{I6!G 7hێ/_@x*"K#г/1Ph t )YMU1 ,4c8 l3vr2a1__8J%BVRJvfiREΎ!AEٌ4M$b7pc@@GV`۞9BIMY>]Qw5PA,+5eIqf*f9YѶ5rD RzP l-gBA)NxΖ쫊/cz殬]5)icrl;zAoqჇVUy(W,77 mGeF\^\`n$@[{;7|<73?k{9rȑXב#GyGK;ޖ^ZV Ii!Hb,IX3`ma9M/]QiS>Jڶm;DQr>g> i0z@G_0(_1f a!za~w-M1Ib(,KVgd7/8g׆$ i7/vf`:ɲ Y &ӧ18Yn*c1<]J2Hi>_d}sKq 4Xn|@GtMM )59]d(-Y,(m Ä|FdX#P99=zi0@:߅wY^Bz>\Q-h_&1QRUBHs( C$W'8f>_P%|:C"ʊm< u( `=E)r_]S% x`c}QBld5!$ϰ/_!]ŷG)<8CO`8ͧ5J^x-q03dՃ US|w8]lcB+oE4q,,i>kgOɻiÞrDgj>Bgqsȑ#P8#G|c* D)S:#^}(A<xȋT|2'MQ,EQ兴Ua1-Kzc魠\kGABw|)8mF5IϘDQHy$QtU;nkna`pn4}Ow()YV%srr£p֢ae$ã|C7л=lml6gvrB6!E~CY r)i'>rӷ al7=eg$#񼘩R7[H ݞ(€ksQn[*`1_)eU3 }?) ]Ə:;P4o}d2!dh=pyuAռ(+8F*~YS0mM۶cH Yli)d)%]1 +GK]7f ank|dB7ttŁ8@ vŎMgM~8mj1jJ"98E=g$iBSTuMbt:COC]V]f͍/z6W7<"=>JbʦaYH7:Jj`\ô`CMow$ICCOMX.(%u3py|}+{..v;X.s}1:C/~2t??gq>9r(uȑ+>S\tֵWSON|̀""<<_aEGY||+}:mQ <38AzJ9[10 |eO"~~vϛo=׆MQpP mo@FL3(c8[.xpzr6=-m0({4Z}uβX-9;cNADuX7|75èL_.$C|D4nf2_|,XZ4e5ϑA!ٌtJH(7z=e۱;BY$$=V ("Pp\{CUUQ%~.+3P65B)Cp>z˳s14M0longP q\,X.DIg''4Mu隂\]=控_faBh}qr1'Xc9=`2EI0#20kY0  k-Rʱ*I}ǂXM?H65Q $q:a\;0tikI8vo9ֆ 9Wig-qѶUZKl|JF4us]=hlX:--Aو4Kp8Fk)|'%/Ti鋗<8Yɏ]Uhw7bl7[X.Hv󷞑?-z'O& t"}ov*;n׼xw6wT})W_#Js|%YN',TU-(`50 #(́:ٜ$#K"|myrwxE uYmh_*i4O|q\L?/ߪӑ#G}q \Gy!}oj(x>AL,"lj)"? bڀwmGB?/ ( hk)$%eQ%!'—S0jrqr^ ŋg|_p845u!Ne:͹8$=<)8FA%xAʡ΀'Tųg,,Ψ5\h%WW4M5MӐeK` Si$`aøF gY IDATmҴ g4UYX,@ $&KbJڶZs(ǰ^m0fd}tJzie=% 4;NW*%qU;e[4Vh8Y,8͸d辣3<ʪڴr(Kˎݮ\ZFL~'? b~ǼG#G9?1އxoZ}o ?dQJq, 6T]9 IYNUjP`*b]*W 8kb9=C[7^@ur+ꮧ*nk6I|/tl:! i,ObA"_qww-lF6Yy]۰q:Ŵ]ww4]KFi̓OX.1 J_xӧB`&g./Y,麎S햯&6%}XP+9<&ʂCYc~}CD\]^_ikzu7Ok^{Nnm!qP RIl $$BL B$,EQB!ıb[np:=5~k})[!;%ՏZU<׃U7z8GtS彞|fY>mS9JCQ,W a!aZhkꦦm[4EJ֚iI6[@)vE)aH( (Ҕ2-<%NYo3ʲ:^˿ f< eAc8.ҴH8>9&ݬi1ݛ`[6Ϟ?UbNaiil6,f30Bu5YU>Qb#iJ뱎,(h0Q Ҥsm4QVUYeU ߳Bױ@,8Ҷ-iuwLk4MX}ADm`={5MFNN)Y<{Ccچ,ݲlA92NBJR"|vUg 4y!Eg/S ހϰmzn3L8;`:z~KӶ+^={Aq\5<} B)C_)?1Id)ICZNjMQU PUª.90?8u}ڦ2 Li!آ.-(Kz[U;vS.pر?mJ[Տ$1o֔x=~aIkuLm*,E(YUYd >EZNI֢VgW<}ٌJBZdۄj0 c}&۪M)$mbYMBHb( Ͷ(+u((ɲ& www4mKg229 4F,8rjZ($]Ɠ ucmKYU$YDn|̲hmA5-EF>.elb4@>rrkVo^29U\^]lUh,^0$a`Vgׁ'|Ҭ`IӔ~V4M<ǶlѴ-͖ބ/_9r9Uw2R `8G95ih2 ZB9i6c!lMZp{s aeQb ,Ĵ9~pr{wEe>E3Nxx|cf|3eJZ|ʥGX` >sBXE]Tu6$fvs}sme  !crbn>Sm2ƄwL[ރ&ǧn|'(EW*w|\C\`0"Uf[SUүl['x?WvO=?10jzئk; SLiQlIF) @mw\}Q~86f^ԚՊMڍ K3Ӷh08}mk CW7A(kV1LҤ3 /Brxtgqr:/Q#zuBxS?{FY]㹜>oɚYqh¦,0˒RiF2!!or<(s0 1- CSR*(Z&7"S?_7܎;v;vϽmgM5GZR 썇z>Mŵ]@s<ϩs)$-Hm@ne0 C<ö,Ľ=O^$Yze^fRXDGd |wsTRbC6ⴓtIX՘CkR-,Wkxb6cdh! SR(\]_s{iHg:^qtxy\n ban6񖪮9<85xMu-P$y4-d Zsr|BzdYj4%msy}:N9:Q-xC]!mw;1FU0d' 2ZiRd9LJǸCh6k84eATlWK|F! `Z4]c^(imeY@'@kaeU2L&SonXl6($Bnڶn)J9C_A>ɓ'$-~i A:;2ۣ1b2`Qe z<>~؏0dSf3VJr阼(ږrqmb!/_]n ɶ)/ї-6a24/ G"jq݌_ļ/EƃuU)UAU8ɋ ۱ham hJ ҴFv/A-MQ|s'رc?q׎;~Wx맿 :;/4yS9?@HQi{[-mʔrZVIBZJc>c6I,|&]~paE!e>A.I'3n۬AfL8:<8>шͦ : f<^tZ ]~_ꖃ6 =<\\}5E1Çc&RV5(!~G7~} ,C8xpxě{=)I[r~qE<_py |&Ĭ -p`E "]S֛mSS5-כ5s!: cLg}Z.d%Y6Y8¤L ,C9ϲAU\Jjix$ |ߤ?us ަ,Ru3?owyy۱cǎ0cǎ|}Iq9RWE16 maDc[l71p`oXl6lEkYJ"-HǶ{hp lxj%M2nn#$t:4,<DO&}vƿF!Mj"K njxmZOSVi빸CewXײ,<Ĵ,%nFc1uLYV-LJ~ċOwMVDah2"d\p{;.eY)ib6UUae>+\ViTQ qFHc " ʒ,M) 088=BTqLUu{WZ4 mvH];CH!Idz۱#fA1eՒe e44I󊋫+\nXcʲ@*,d8#`-GoA֖h ͆j}e9uU!2 !#WH6 u̓O?e2`+i pB`j+$ahJ?6rAltO>cap1Os~1nqGZ&l҄cH@8B6V7 4mQeszpL7i<Qa)ZZ6]n:nIS_&ۿNcǎ.pر?궍ӫO_GQhK] TnHnXkC(h%U= l;\z 9?biʆ(}ws>{Ƨw,7 eYtw486Bk1~f4r8XPaR$Qa$U`_VM`8[4ߧ1LL&az CtkBߧm<~=fwLƏG r&h뚺m۵|`\rӇq0mKJ CSѐ,QqiI ,AJ<)ˮxR rhMQtczU]4%u[# Ӱ{]h5~vuw+ pmzu&Wk EQ @JIsqk?,i(Z;mpHEe u])aB*Fqyyj5Aj,c2އxDmmghMK|:K},;m`d駟rzzʃ~Vz7w^Q))8m).4Etܼ 5ykɴu]צ*eSdՊ傏>`iJ@7 8=;c8SaG0 }ϟ#QNR.CF!ဦYeEIh+ dϰt*wץnjy1@xp0b8aY/3w>~.k9EAQujƴL>ߛ4iղ]oOUլ)~!+q0nfXr~yAb].p4<,h]T75u$Q). J-A೿uoJ}>qL!LATUqpnQd)Jk< ڂn>o(,K x:=mwyJyEobI 5V@IeY|O(/m>H=ޠel[lף(l/ꊗϟC`jO(F76iJ\ m*6˘fË W73Q>E]₲.ѭq\,cZRU5^zx!L6iʊMVjFUhƑ4A[0 "ct:lknb-Uۀ jҤ"7;kC邭eQY"c FLTlf_,_pړ__ÿ>Eرc.pرG?|b4uP 0 ~ϥi[D&Z5h0=A P DgPmz>NGn԰kIP-”9r,,6z!hA4W/`a;Bzi6U״=4qL/&f[LϞKtV E"lҘ,-ѐѸσL1W\^jj~@,_ؖIe McYnTx8DF)4 E*cy7/ΣsI~og3ر]ڱc''i9nJ犍GLa7f?Mcv pǰmMU(-HnATduNFbc`d}\xeZJ)}ʲ$s,{}x7MS\q\6+fwir]0<At^&6 lbٰ\.Q ۡm g278:8q\VF= @HIv~G4=l*Fm !^ٳ]zYFiP9;hD$YyFVH]mPZc(5yS?oPV5.dYR׵<.ׁ IR c^|8DaHUUl6 MS ðplh ia[6aҪ)46wqZ׿y(shjc~vMr= `Ŗ圦׾|G|5kCk"?fw铝pǎ׎;v͟Յz˗JC8uxU%mb.MSwgCSBz_x=aoE ȋi i!EQ[&xM(4Ӓ0F'n/QMK۴(21*\ӄۻ;=4pL7xj=VkҜݦnh)$_~=Tg m!Iõ;Q`tvM,}!ѠGjyesZ[z#|~{'ofO/.8߱X,X-Vp2q\겢,(m&SI^a-MmeP^ؔWUZ)/ѿj{cǎO׎;-?d揎Bg";mYϻk6Y0M, Q-a1È^><y"NYf i"M4 ~DPaMrrqp]0p]iq{+c>)cns.Yc|?$DQ~0Drh[j \ץ>}7-hp/>|>MRV%yumJC7_0is<'OF!a3 \4+M n ˫ Ҳ &KIډFd/%s$!OS ꌑ{nAPBMEYVi4Mt{[BhƣJ %kq]"hԊ"L$oʺ`8ofu0LcȲ 獝:4MFi.YWAuUI]KK,H0X,qL笖K6Ǐ.0:,͠F,_x> Шe<w ~raY|{h򢤩mp} >7X?u&yBĘ@ hZEHQj%a/+,]?^XSQ4˩˚V̓gk^b|813xUU @k!-k,}MyA4@6`>a rcRşD'??o}뗿6cǎ׎;|{_(_4qߟB7, n8Ų,&1EuV-EސgZԝNW( 9=;e7,1W-b똛قm^eݢDzᔳ°!{FX?1 8<=\O߱'{oٚw޵5߼5t4;vlA .rJ0FABJ-@"D+${8ݧ=<ն}HuWUVz?i;ٮ'<)QZIђ^GAVat6a> }s/N9:>%Y]KݴkAURZ.iSUc? m 5EN6q--#ԏv_mV\խ~>#"~$;Q?E}.f5*B)EYR-iU!SZHr7xM6RJvwww~pQ9&ʲjZth 6{<||NUTEaXUI۴,[H]#MSihۖjjn Lz=,iZ/ϦMҬ`y}zFl!1`yR^)|<(bZ5w`k_k_::x>޽00L 53L}qh&vuuE4X/(tiقqrv2NXE1yQcXý"/SQ!t  n:SwfU:+t ڪ].2?W~/q[;ہV{gHEZܛmQT4FQ JU3`ŻKt!0t va踮M޻d2/^Eל^&˲1 *mu#qeQiR&mI۵J.q a>Z8??< Iruy4 tG.h8By}01=)ȳ C/r1_Ql)Y!d^ Wbk£Gs`(El0{<B+$!0 UPզ,ؖeZD5灮suyӗ/yutLUBе5Mw=1,$Mhꊾ%:!0Ma W %RMv!ӶUEu@;T1]8(J9&)) :m|4MCYM[S7h\]^#zyDCq==f9I/+(}Bh3t]*Fwi]ӱ- 0 4i)4Bu-Ҵ=EQkؔ& ,#"bXж D5:6 SЫp\. nڡױo(Rt7eͺ>5Ru;PC\Ogn $Iӄ|N ckvNkk%qK8WW}b6Ōs,c<1uBòM4mRm.ziOFT C9co3YR-A`6B@Q_^v{|_|uD98lnxe#i۔UmFNՃlttMC)5dTb`6Rw\'WC݀m痼>>e: ?ǃf6sy#.6)詋L)EHk"_04L,B7LыiiֹUT[cZ_S?'yխn!~~g~ 'ewжBhRx@+uQ5 R'<~/~sgVK`+isxxѫ#<.`,)4$χbi I3^8p=?/ 3 }f{@Y&Yu- p\sI]JtuQP C9jIFòY@%KQ׼VV(g|79ukΆM[(&z*GOGRS5EeaQ:w|[O׭nL_>ӟ7i"q S,CфtD? X8!s,#M@A`:6eqpEϫ m(*4$5 [xq0Bг"`ȋDJ@:J)TaaժZ( ڦ{lB<(XVg5縎ýCe6aYi3t2M9.BiNǘh49}OQ뒢*X:g/^pqz1dx4f2aI}7;)Q1M,su! ƣyJZR3CVw{w_?'rխ>!:\:\1:~\ ѥ074 ʶ%3 N a[l/lc‘KYg^^5GO tA6Te!5zZfgg]&:բzE]K;Ҷ (N6$⺢jV뒞<ͩa]}4Mm[ iP%gggXy}rr*ؚ/L8BvEYbJc4FSī5aҫ,Х$B,!!7}e]K$h|L!_Fp+% lۡK%5R(XLFcQi74?uI۶CYY1P\9cΰ(M)]x: 4&kҬGڡzgiHC=}F1m$ Lz9qc&=+N^{{˗Gɹy /_+{{= M#ͳhORqok t?t䃧O h{ 3o\<:,kLi-:<#/r,'ki3'L_PIs'(Y\\%,͹{GloM~kˣ"ѻre] !q階*6hH|׿o}n淺խ~խ?H zuRW'ٟl"obj٣k! uҥkj*;vc1S<# C>C" ?>䭷f{g0(˒m, fp n(f Ցu]ѵ-GGx{wyCLb{gxx)-~%NNxuqJd:y,vrڲzDZL?qr.5L OSk꩛ݐ̧spjiZ4Mg)BtiҸfYx<& CfibHI<{MGrxÇ r蛖8¶, Ķl,1"WUEY@@jڎWxRY/2Bb^80 p\) 8E&kԐS\jj}l6F64 !{1N MG4jcm!5xM-,]fPhm)bȀm *u IS5a8Ʋmvsv~1tTڿ^xɷ>~|LdmF<|4| gkwXTUaHe]</3P}&$mpe+tt9 @Q}|;NpmeqZa:6ق01AiuNO=d6ɰlu((ӲUOeWa2l 4SCFj+nt ˲-0 9&+*$*! <% &/O}wx}vgY& ZՒ2gdiM'h\sڪ5WEW{;/|ճO[n[Pcoo?^YAqm86z-׶pmp+"!bz%O=~;|FZ<}GϾjuIf-\ۧ:!c%g}0I{w|=蘶n}mˊj腆yzm܆ i0M8C\^^n!@1$ el@]X`8!^/iH.X=H980qшxiMk8雼Rrm[F$C59#NDQDUA$z|M /rV5Ys4S u(msif2%]8fww},#c!LF#` ,*Kc&zXkQ8u\k;\-ڥYL8=(ppx.488'szR7 Ðβ,MPoϮkmF4ܞepUUiضmX 뉪SE1`Ðx9EY`6(Pn[,`{kKFшq]Ipm$,Ro$mZ$Ų,& q]i8;;öMDvtMC֔MCqBg }Vi z(䄮Y,HSRTՐM |t]5<<1UYѷ )zrvw9{gYMI+# IKO^f4UCMҔ v= TJ*7MԵ_?ɟѿxvRwF׭n]Yw}ZT3L&mE04$+8j*Zz&L\ΣX$UūS>#>#uD`G3D J5R;;=[95,d2DO'B&>`GxRUζM4i7$IX.TUupq'$!2./ytC[s&hk=vLs8&:sSf:񄺮9==mpm;DQ&>eǝ;w`8(qPV!4$q`(zspzxg'g|Lј89 NQ55Qޝ;|S\^^4Ⱥuy1Ms8BnE)T?7U1sN Ce!؃t6v[o B씯|<{*+0d631m;IruMx˃wy\[:oS"1 &s=6B TөNc6!Q0±`utCKK1t[EA)>c7Hu<]ݽ|>Gi3 7kx3s!ꊫ+looS5OS(P&h:EQ3tBwy1R bS`<gPM[#zEضEeM]iڔER]|%D눲qMIvEquv>dÊ4*@EEmeI"Jt]P\Fa8G]nܬu ܭmip{xiJӶض bp泫 Aer=ʪ!N@ 5F!m"4v8.eYq}*tӶp<<][[HM0LT 6Di(N=`:m:.QHdiC:ZT;8"R|1\P=U ustrUF#<ߣl+Kgk`:jl MӇDz,ȣJQ%ycղ\F^=pgolewYAH!G#,aZqz~JHbΝf),/(n|R]Ї !kr4 ]U8n{'pխv6u[p}VXVtU4`ϥ5֩PGWAu"'Jʦ5 ]gꏘG-^5g'_^;;{ bZr~}m;iF&p!3vqu}iH,x xŝ;nM!Du"/!4&E$ y#fsX hvd0LSvvvڐZ.iNе!t iX8!H]׹w]qvvϟ K# G޻ǃ8440m6suuEӴF#,˺q4MM7c‘ϻY\ P q`4mNNO4 ̧xw)h}4Mo0Qqrr)!hN5(YEIfu]zSPv ȮRkldjuͫ/[\ɓauS(ꪢKlӤg +*ǔ5g蚎Хı, 2qV CVne63FjK{u2" \#YF6k4-w┢,Y%/i# p4f% ֳ==y,1'HS@ii&1uYH]XH<WX]]])~[m^c:;)M0I^"-wۊ>=/9~ƧOQu4VCG^Q\^^^nZ.qldr A0L|&COgEcy_ EYbX2(gG/ v!MY1O?W5Kί(0 ߲ \שNٟϱthP&H9SO߱7wnW ou[խ~{~ydvݾ'+3}{1q4GbU$uN#q,t£{xtp6QӋs|48xp3Bu IDAT=Ϗyve3ViLufS>gxtxb:A uu=}(˱46Oamzm[T6Um[mK]כ^oV?΋/(0x>L& P %Y7]Lm"Y6೅aHY|_Y 4;cCe:K۶CrQ4Cɱm,K^|I6S;;!׿׾5Ͻ;uEY=]IG|X^_../f<~14x@WidžWl$mvvv'1l4@tD - vʆ"Mh,8"ZiM &sbg{ 2rJqR ~ٖkh,Ӡ Tu=T wmWTeE]IBS-,otn"K&q\9gU 45ych*umtyP lF]5,x^HCyA^V;X-8d2JM'\bQ5iFw4UC4uC^+L$ɮmn8. (]cR@4L.ʌ.qm dې~0T"R'24McX $˲X98 ,#жmlmm!m C^fdEi8 U=eQ4Y'TYfPnz.ug SVieYk/_[_nunn[>/?}}&~pg/F<ECYBXAږ䪤ikڦa<{錨os>C5d>ш9c҈,;omw?67M!޻X}u>ϵw꯻5#F3:dd9B"Dȶ ¹ Ć%6I#HGI8(  mh=}Ǫڵ|^ڒ 1I]t]{YEiL. `0sJ]QXe~Ou4]+˒4ejœ'Ox%,3 221 ,ˈp$,q]׹"֐,$I§?ѣG$[diYX8ȲLS7H6b}b!m3J|Y׾Wkyw pqlh{DIBUը̓Ox):e2Q"QTIV:u-Isppl6cB"m c>eZ_NQT%UUjt]GTe,uI-O&޺w. w[B$,PU4MC$::LC%˲qF1I!i*xMUU,{Hp4}۴dyNQ%iѢ̷nL@WDpE/,>>>m 2)prrnh9( ,aٱ&8q0*2&vK ؎t6a>?, \ev0@7$qt6Ŵm֛  1q4US9cU.nDPj4.I i2Z'm\OT.2mס zHHUV=S4MLS(2o, EQ0<Γhbf3eY ->?f[t-eYQ-$ 40Lc*2>~k")XI^mdyNTEd4pBV世isܠ"(js Cc44 YQ7-UUӶ PU JUTuuaY̏O0LmZ^YohiEX.=:S! qJa 1Yex,\lEn[֛-QH Mݢ2YH(lֽ,. i4ka&Y6Պ0T ˶1-AmE nGS?:d:/^IpϿP/_jjҙ \#nR$IjXn4 dYFg^p қ6Ehi-E^6(m`6k$Jbq"YfS,Kmq$a\b[beK% MSُEުp/˲}^syy Fggg|3۪Z <#/ 3#>8xr\ $K3>S^zw} KzjML?uQPUՐd$m KPAe SmG|(JIK4\muB8^X`!$e9uq!uU`96ݎ(uvhD㺭ɳ7, ڦ" `6hGY膁, XQFd!ucaZQM]#2R5&1k F,~d,\џ4=aѲ,,äkʦ[a^s~ c$ZӤd. K:<[W=\, J5QQEt(nWt8cZX HUM) +DAIi"pmt-A@ FX[|GSUʪjkUm}T?g~߮7s3Fp}gq37xsH-n˿nŸRJo,G]K3HhiH uUPdR29ut<N׿b%ž;ۣ#,rO?YF6ܻ}{p8?$ƣeQͫ׸CɆi1 ^zEXEelZqn; 9e)ʋAK2mTWYR`.mvtȸ ]zxJO4mڶ,AOh]CHdE< lÈ7PT'8_.-:="/@6KvᎷ~8@t\I,JXVu=?MefCA㈼MMc8Ft2٫,dIr-e305*Kxos.}]'/XVtr Qt+hej)OlvW}Oş/mf$8\7s3L~4W_](?V]`(6k;(kیF#ڪf$,rZ$EnkΎ{vCnp j7?·O>Ŷ~_O}.՚O'qZ4-R9.g''LC|hvl:6-JNfm XjMd8MӈROWȒBYVzuN @E ]睤^T\c5WWK`)'''~za:YVnwt]zAӴ蚆{yIV}i(ɫׯy[ﳼZCt2NLTMt4uyHLȊeZL'SQC{$ii $n(|d6lA4 )&I<{GkiȪ;wpLQDIJdTuwT]#Y)UUN'4uz#AUuQpvQH%hXe%zȐ"+4zpUQbE*ضM۶iJUtض}k[l$Y1MESY w\u5d0ɲVJX@[Ic,d"@HDTUx4K^znԕgO'$qD\ʨ4u%U)A4 wi0mi)|IӄH˚zm!s4Cq\bXPд U)nv ui:0 tT<Kk`{6,q4Upc٤YNUؖ%`+}eY(M☶m0(שmZ<` dx{]ۉBס&n(*yAs<,1Z}E\yCȲp3?1NDIQl 7ƶHstua5p" }nXn\LEa I'<$IE|6%% #@FUV-iR"cY־(z8yrOuxl6e0@PdN%IC3L4!cL$$ulڶAovMZ(xɣG-痫|9G 3tYETF  u\ ô 0Ȋ"8prt|yUQ2 qw1 q F8pz阦)<@ueTM-,CU -rPd!b&U)Ha}EAnx%͚)ow=-^y?O?!_Bp15()29fZ5摬_߼|w{n?F{nbwRM4EXBd (Lb`Nf|~mZ-(d2e8ۅyeZhܧi*:hʯ:f+o1oo?xC4M!Ib42mF)͎7y a%)m]3|muecZn48Y ILg̦S^Jt]I )!V46TUeYQkC!K2"I-MPd%i8~e}˓AjږuI6ԿOMdtMCmRf]בeMSL8a $nYFixuKM8::q,:MEVTlA-Vu&%1kq Φ]fsz1Ir2DuĴu OsN]ס*"e Q9Mk"cY&&S9e^ IDAT%Rbj* yQ:,IZ7uC^^$IxENYQn+V0}òmF!m%4UWhhhi]i tTeESh!K2Ua:45I1Ֆ˶ ATzɓ/؆[3 q|vP\\ |udEptJ,l;ʺ$ (E 3fS,]Xu*q]0m,ȊdۇdeY r~M$LӔftX1 뚋 (u`Z뽋R%9NheX(LgxD4Te{õvv6\Ų,.//(W‡~zA34tMc6` &f2O}e;|3 xiqt<@mZfcǼ|Ն(;ئ)xsp0#M<`ӠT UYR%a6U^@aެPU )4NZ>mΘߺDiF :MZIMcD  `0Ç,xFdi$7(mlk/lFͧ4M͎"Lױ-Ӷc JAA+tU;up4`y<|vŭdȝfc>#C^?wha6plت˂)iZti;8ܪi}ۉ~Fڷn7s3u37=/v6-Id-x&jS E1e3r}rxoVK$ARTl jGDqJF4s:i0<j UqLkX0dʂvxc`>qsqq(:aXMeضnӧ{A@xKVy1'pmPMSIX0i*QfO7c]w麎x(\]]Ӄr'G~&DVzz2s]!w"I~k6 4NiJ?k"aX{f Xh?db=.I؅;,PeL]GVemzAeY:$suufo}\^^"K,cGGGh2ٔ$I8??GQ?P6[+|}kjB!!Я+6W}Yd2-l׮t4MgY!YVMS|ښG4uMYpٯ|ytttRaޯI6qHم;\4#wH4mK]UX /֕p0UE6U)EEHX7,RW=0 \ޓHyȢj$IAT::ʢܿO}4ͅ*/B)z N9P˲޽{{ }BN:kx{؅?yHQQY]W%Ֆv"{O۶am;nL&cv-2NE^2q`c!P5u9Ut}5S7qmsz'FuA~oݽp4S6;>yO=jEVuT"& (&"<"ɪ USp\q8ˑTEMݐ$)UU:b4LF']Kzfw]%ąX-M( "(M}.9.mryyF#?AW:]Wa_?&4kjuY !~)>j*b00ϙNٻe~MX\,rɧ~JBߵRb*Nb^zO\,1Lw~>YhZd$ޡșΦ<{g_!:\ ?n:%9bqx$),NQHfaI_˲4Ii0P$!Ju]%0"2A45Ǧ3ʲػN¹T ]t,X7dYu]/;i. `a]3(].ځ1 us'uS4\] Gҧ''t]G8(r. YQ0MEUGؖE^lk8,k"ñ Yt?놫+$ lM@amMnxnGGnjFf,ʋ5ՠnZdU:mT]C5f9Q\ai9HUQ*?khixJQ PT8N@YT|k M#LR6p4-0jjڦClDZE0j薁a$YNUGC<ǥJdX,d=`c435YV)x'fKZ"%)''4m'OX,G|lzH],v[/pDhmghRvv}n?o(7=u37G<ߒ~ְΛ(b ې Yi IQ9*pm\8NŋK>}y"J3$ECMʪaFaDGEAGG5ENGTm? 8uʝ~ihM))T]X]GOEQ$(tLEAyfku),4ML4LTEnV,K^_^p\+65svv!]*UY>mU/ʒ$I0FduMCB4tlDBP5"KS>c>]Lׂnܽ{۷ocwܹx{TUIhJZ,mlXdyB6ᖺuOTTU]{k:ap 4C#@W5ʼ*t4Lڮ%M{Zm4)C F4rׄ{Y Fzey8GQ unwٕEd~S%Ik*- 6 ,  qS$qFf#Ě8yAۈYUJдS%I(:!"j#NS%2#2ag)mגQ)--*(d\e)mۀY< :3:4`a!i:m٢2'/ +۵peiZQU5wn3vIx8Gkuv&b;MA ,Xo7TO֙f&!ՆS E״D]T]LihSc{Kӟ_jlnwfn2\7s3C[o۟(vQCSq< ے[kNU8u?(y&)m\g)p]*h˺BY54J&ܿuQV5Yn+qL]T"e1 `f!c\ݣeY&"q4Mc>!n'َj š=N,e x0q]N- =wAu((KU*8F5Q y | ȩ MHEYUya$pp8Deá(ym^SQ\\\8.a\.4dʧnhTL&(ױSV±ʒ ޼z%2^3,%#\CT2YdkBuZDX@v]GȲDԌF#jEY&)h{ױCq3cL#᯿75ܠآKK1Mj 4h4) Y0WT&mppt(D\_ܶݞX%eYS Zu]n.V$ _˚EQU$UY,*$*ED{dYNYuȲ $˶㘪jt$8`~ !7H EUY\.(/ȏ#V5YV8beU%n, 8vƣ!W LC4JIuO1t=f'UE0R9WM+w(HL baE4ut:qk\-0l[wnԝ94t/ Is,gY)zt]C=(+^hJ4dYz_^EƯޣᘬHip|,SQ5'p'>O YE$eE~uf1ֳyE2#wծ3V7fP#5 7 74$ $$Ms@!hn}Lۮ:Uujصܙ1X+ߚ<ַknaa_5 <߀{P $wB35·@k$.:: ʢB+At6-//_k6-&*YSV]׈]|a:pvvi5EZ6Oy۸ӣu]C˕mldYj""q[wYN,a#ճ6p₧O2{j\].,Ee -n8y,nCYf2*\YHaYYkY3?9a:iay$^drdϲ/-,˩麎_f}]!G)KaeM d?4MȒBSȊB網,iN7]0N{]}8븞K-3UQ躆q0*n"ȋ ۱QWQTLo1Apvv&Pi"˴msILUTuHX%\ ]q(K;\f"I''HG2 ,&~AH|QҶ-n(*u]f &eZ1vPU! 7$(} Mд`$$IeY8SLg3\GW,cI8 ㈫+l_,w;,˦,Ųml[,. $`<e"wrvri$i6b_&L#lS'#ѽv|WLm1G;b TTd,(ʔ,$ޓi8&`[6 GUՃkU2Mڶ9tiFeI'A#؆MY"(ܢg LF#IU^%)eۂ,Q% q<~y-+>sg2."ps{g_>4Oi$d)˶0MA#1?:T (zy4uzw{oSW5:e)l!K24fþ'P K3ʢ,U4 cV뵠iJ۶, >#޼:XΘL&}_=}TME$TEJV;~%ȁAp@7u}X"5Mc\'pss+3%eo~w}iw;?>,m RjM}qz=di‚^( a{_|Ml[6..-4I){T{e%z7>Ն8?ɔ,ϩ{Kt:%#-۶nǶwULYx](d(@TMװl離x>GUnn:ALӔi9K2`:0 }FɊ*+Fu!/ ` q=mJ+Qff9uݐx_KeYUh$Ȋx/%`=],VEO6zQt<,zϠޫaZm( ͋t`;*)>D,g\MѰneM{UdFꊪ0M$MTMQȋײMshچȲ], TEEs޼kg˽j[!:p=4tgNS%O0#*ww+o(z\UT4Y:(yb >3)c^TeԪR%h~2L'(v$n-x$Ky"di6ᖼ(X5x~6 o$K]a #mjJڴ-oC}}v=yX~4`)|9G$~LK[DS:l[44:ZF,v\f9!8<إlndEkZJ's&!A#Ҽψ˘Vj80?>'͎D)] 1NQTqd,YnVlw!$(ʂnDmж}An9nKE G4}(;s>cFP%Rܲ>MHt #tY#-$ R!uk`$l6$9vL&Sz gm%ӴP4M ]jJg}q5+bfs^|Ճ)NN 8w\юdVMs0O?䇟}IS֜<: ,嫗Ȓ|>?Fx10" CUE7 +/baKi4mCl*>Uд R')MבPa4M @jQK(TEAUʴ ˗U H($trEyYtMu=heTk[tMGQmiZ ]g6!$l:Y9w]`Y&e45ua:aU }XI%~A3Mh(,afk{=dIfk6q8=;,SV T Uհgik!uQ @&GCvaHvض {$#gEY,̂ #_,z-ﷲm۶{K$InŬm[^x՛7$IBYO> @`Ξ` N< ;wַmj%$jE׵\_]ɧO!\\<=F)LdmO麎aYzg_}gd6*Y,W\^At& QlprDRo@@LSWUn=VYU )0K }B'tR'idiF׉vE핂[ô,\eZC+2I$,Y1zXm%e$:,Ʋ^9'%}ywQTUӃ3r>AU@Qe9 k۶ϩ뚗/_bZbn1-0ifK%߶ FClL#U!RTE!ꚢ* }2# ]G߳*&EQKc<'2MS$ʦt<ϥiZBUny>KJO–{VQm&KRoXUe8(((JRK}J4]{4Iϕ{i۶{ʺu,t\tqEYyX$ iӢ3CD>J`OY@pzr3py%/.)|c̮fW夊yEt(JۣMPtv̍|ߪ_ؽt0#G3 eu"KrqHTUh0b4p].uau},q]x͂Tsz:Ųɋ[NN!Mc MFȒfa`.Ʉ}gqLF1q"ޏw5eQR> ]GEUfE4$QnhkxUUۉE씼Y.VH\XA!5afH nq6EQ0G!IB r=' C4MBVrHyb4DeAVXPHQ%ir1t%U`ܮo[tpӏDҲx蜿;?_X~M^4av*봵Jګ>};tB#˻(Ib<$ , $L;8@)|kJVf,no05WItfxh4|~~;FQdBeF)MS%LSÈnGUeAثO J-ꗃt], DQD$Ht@ܫc`ihY |4]*kr΂ dpq\@BSu..!2,i:.ϟ`0L(M:*eh4ѓ /^E DzڊNÝ`-L8ev_m}WH-mv Сi*e^/ YY1mͱmM9>vZ(mD0q||'5MH99=iZrYs G p,m4(٣ ]2Bq") qJYttMe~|>-؆D=WdC )/^v!j!3TkDd:D74dNg?~nÇy?``)|?1/y)aP$! TUc>?9J4Mg!b`tahȚFS4mC'nR9$ȧu:UUYWk,*rڮFRd&1|˥ sD$trʪ"KR|c(L mC_}=2dHdГEN0EYfӫjY"O3BdMFFʤ.[tdmdyF݂$9I&I3z􄧏Bt.4#ڦi[:qUSGnUJ}GUJW/s~~C>Ra`ԙҶ\ӻUmj~U<4ut@dCEk*ʦ@U:1۟ I}. nCaHfeNQ78Yxh8w}FOQHEUd\/ V~) ]z>''''5.0 k1 ]kجהucYhBUt (v% Cд}8B'I[ocY6E31 lև~]qi/۶>ͮ ںBtUc}G u!R~Inncl1 3tSòMG<~tp8@uF}bi;NNNʊ˗y 9SEˣQ`@nc: ϿzfZLUc4b:6I^`;6#4S*+f*?( .//gx5 3a28gyTE6qD0a&ysyy0>"Ej}ȷnZF 6^߆5퀗/mc*5:.%P%5Ϟsy.aj¢YijX,X.;kvSV%?j^t銚wќ(ի^2%!eU:咢(qH=>>, i@Ud[L%S@B!IR$b$2mI,y- ]TGUw]frP=SxGsCױYMnIxtAG6GLc8AO1eU#s:4ݤaZ!)nZv%Nfq>ӳhEVp]Tag#OJ?S?,UcG3(PTq[]Q wwKoXv∬*Esz|89Lc&]bh: E1./kM}Mfyt~l $r4uMU,ESGQt}GUQ{YYXiO]W C<ϣm[(FQzf\r{ I v]4Sc eZN䐻GɲeYafFſ2M]6Oz 8"/Ȳ>u<#'cƓ)u^KV5MbOy @34OfkqXWˆtt:,\b0N;pfZ}g}Ƌ/(:81?OYQĭ~San> cg3,C#sRGB1ˋ"{ߛv߇tYgeW4 Y/Kvu#z$Un`[h:nw$^d4<0`ywGYhN4 :e(Bu%zd0d;"Vr+pHQec|;OD"fvӦR nq4-$$"iEMFABL4's4dt]yؖz EfFV-1'.lh4dN nhB]qY6EI0DQeီɲ PW u5yJ;4:qI`ؖC4XMY( IDATؖم1u~ðl1C2 ͠z2 8|.ʖU`E9tfE08p)+$Y*mXvKcݎV5rdY&Ԯ}BYXp0mQb+2OFKxX28??zC!3/~??x<(\?yPa776T47T%5i IYS046 А k8!g$yJ)My>G)ٌ`<Ƶ,tE6LL6 LӢ+8*4CM:YbZ?rqiXٌ ՊۻC-]C>+#-aRU(Jڶ w$Iۉx1 tBO1G1p8<<bKESD1l],Y(P$nbڦ!GTUM0 V+~pwwGDt >5t єtP1"(]á(}c@\m6<]_͂m sl[5]yqXz]ȢXຢ_J:f4;Ud4556 <1M(, 4׻#cnnd !+T ua2 tI$Mihz,E?0Ӧ7yZ.E>{C nݬIKXMIvH&+l7Zvm'e)ݖ$DLQ(ʂ7oX4ud"|IQb*`E^b`@1MPU%y2mWa,"L׵LuPFu e)mȠi)Vpc^:p}q=Qg.fg҄=yuϞ=GQ$NG&cʲ@jZqD]7XI~=%TVƴM:Zpj COY[`^S׷Hmј'OmȲI<>klKd-۶fȒLYEJGcW٬Ȓ错?UE ַ MH]Tykۨ#@-r{u]d@\&.n*qd9՚Ap=7`%#fVC1]Iv$8I)T Y϶隖d0m1;nC|?@7łyZT5yW$KATP-USC:I&-rBۆ.ikhFO,dL]4e- oejz qZ2ئAUeIXdъVY D׵}EUֶ#B4ji4Tu0tֲxl]!$Nxm?~am[mrwwGCY۴8OnfC4LGcƓ u u8H2EIVJF]l艳mnaL`nZv&>#V+,Mg6є%Up2?ò]Mon[%o8Ĵlں%+K$BW5 ӄ$f]a:шoXlؗR9 &jp{_?]eڷ//~__0p=|x*e3 h~;5MjED_ ƞD์'K.Zܰ#NSZY:A avqN 00 zMYJ |)ys}\S"CU.IS-?|1.X/h+ 댂Ɍh$JqY mEv(K&Y;LDumO)l‚Yԑ\]^K$ MNf {oc]wy=<|CxC,L @" qA]n 7$Ɗ"R@ H1†39ݧoy-.UێoEwuU},(8p|a(%!2 A[ t0ܬ7iG8'''!Ly3rʲ۷(vhU4Mz N2&)*'|{iR5yQV mQV-J4|=mאFpl&.p S5Y.q\S$a^f)!&=B#Bʢ[ u1mH@}v(8=UU9>>JTn-SiBдCe**oo^y!Ⱥ=3 |M(yMS5xx2ީC^o nl6& mS}/²l>KT0t1~>.Bn6]0Op< MgSھo[η?m>|9_WUU=v7778Cazf6(*o߾TLE"ۺFzJql<#bʢvfX}/^pw{'X5YQfYQ`hD4( hXKkhym!t\hqRAr˺i_L&Sƣ1eP=tJOe]Ih绌D*"ö*+VQ?å7< sapwweYu-1~>4M& #ʲñ-NOp,cڮjYm;Y]3I+"'rPT1,0 Al[V/(Q$mȮK4$ILt4U-UAMQ.Ш~X\뺦z S!- \BRsU.އS,r,ӦkF(*BQPME,Ѕ(_U]G,1}T ӠU%3V(jфp]XMv|Oo|m2Oiq0?3N=4O+۶3P2$ yQȅiQU UՈӔ"/h($q]Ipvv9` vۻ;^xArtt;4n[Pgvm=wTeIYRy2 dF4LƓ1]lEQny5/^7]ޣv= $IQێt ǧ',f3yC񄪮軞 ۲]eٻA%,Kٟ% +._m\g|8%BnLcFA=Wy|?pyyl+=4I%`:Yox5mjBיL$TW|ψkL&2+`lDQe].H'PeSul['3)J2MGh3<ץu;i i#ˇ=#a@&ۺ,KLfTEA$MKI3@uTE\UUʾ.'.C>\Dh*0`P5 $M[crIŴ`N2PT%vpZJQ(PyVB꺦jZC,佧O+p $)Q,z)tu UQ3M&x.]e1xKw.vdYNuV?ZoOǥqQ8`(B/$+/ױӨSEUh |ao6c23!r}}ۋ-$E.s+}C˓s=yl2&\G}]Qd9i1M(цԖtl2n 7!Eg:8q}|lLànjV%7w7ޫ}NB0͙L'02ׅiX锲x\\\p{{;,Ғ%5_7⭴Y$= % ɘ=yhJQH\w `Yq{wK]LS&)Ϟ>ESU%/_%uM@vUy, VծiP xlÐlmWK IA~yGg8G54M|6u]aB7|uc>*uYa:{)T(G\4M% tq*C)U>f O߼yCE #!%j&}quy rrvx4V>)RiR7 e]lx%Ec:a0ϥEs?@6tUgZ\~uӂ(`6خ5cS jM(r\,H8Ip0hێjB5Q Lg30۲h{/$c4_RcNTMc22$8N[/_&GGOlb5١E^nB<6~ytMM[jGy\K_n~g~Qz<.\ߟy\v_ oEƿ]_FٷLC(*h(x|2a13`Dw,KVQn3io ǶcNN8=;d< .ϕmteIUI XI /ncT:UE-G(eҪ" 6\imOaàkUG,ZQ,^i۳3)}בem.iTדGMv Bv+$aZ&)@r{edyNmFYHkTU1L)ַ׿ y&Ɏg3[i;}ƣSjp|r}aQU5mQ5mۣ7M4];O]MygOhuYq{7`4\DYZJ9?w_ƟB;8q8?GZIaYemo)J!Tlijl{`4B4fR}9׿zax3sOO0l<+pL,l6CS5w[8ꊛ4MaY0/?!Tf6 qc6h;jlTUjmt=\o$B4uRN2KENQV M^^qOTm#+ immɫ?9!)y.3~0f]yw]2q\( zPMF#5Mӈ( QPM'4LLJG}4,ycY$ISdΒ$N5LxhF3($O3c&aiZޣ=-yxLQV+"s]la^S%Q k,c t}/3)^@QDENմTMMGK4t]@ףP2zttH\_rs{h4b4quuijT51)S}ZWWQ_/_{a{4$̪k,w#sMa6MP7f4y[6ͮkMR<(J(Ǒe4ͿOi|!igveźjIQ\^^L^^^qw$-Fa]F!ypV3]0ј!/2LC*n.ԴҒkJ!v_x<ޑ ABZ5xX, %P*> x%t4M1Y:`@glו90ȲAueq| dɒ-ɔt|6`_i{`y{e(t= ô48??'%iFg|m5xf IiҒx{wр7ۛl)eR!|x,g98Y\*QEɫW/5E(Eڶgq0g:YƳydȑmGRe14Mfɺ0 o*u[=isF\W]P[⤤,ƁO4vM߷if4Wd\Ƕme%B8x2!lH0)RL詛104P] |O#_{7z C?C}HxrHV\\g Q4[ՏޓLS\]ن/”NFsBҲ̫7&̿+ů?fq?Σ8?0cfiqLco \b,&S1Ʉ(2MzT8fgYC"nk'89O999U,JymZ4SVTSS"_Z!H=٬| >%w%U#ץvPe&KR04 @Cm*fz4"4 dYl69GGr96& ِtGW5 S7]__oݒ(gGN;X<M#IRt]C:MH\1eM%Rô0x/nLӓ!fYEQ֧뺴!\G<9xDޒf3x,wˏ\_pyytX,4uNA;t=Z:4U~ADO09;9>dEIO'KQHHm2NOhh!t 3M upM]k'OpvvےKn)ս ,CQ5MZ!8=:8^Wneurv+ߩ$=UUBE<e , x]5;atmiJ*L ]S4Qiۆl!mQյhHt(`6YL,7!ЃkXsV)|m Dx~JOTu_5M#s}*[M75ttàz4*n&m .Е"+0$UIgaDf{ 65I02c]G؎x!sm9XqGuUFR:Vzk:FT|iF+ EU(EѸdݒg,(tJQHAXm74uB˾1!tIJml:Bt:My}s+ڲr=K4#/2TEǠ qiQ{y^oɿ8p}qz]WME=oKCUqLg1v}NmUS75KDd>>9ݧOyrzJ{g%4E0er kPU4C|A(ZBTUMȬmJuC* TM%H͆$I0MӓNOΦ@U `޶-͆<}hi;Ca9{CKQLTȋrI5iYl˜<.U]s WWLF>qGϳgOqg~«Wo?$b2d_8]?,冮$nۦ}Kk[ʢ,q\֛ DŽᖾ{='e08QRU%]*a c2!~2IۜN<ܞa4Do黖'XvaxJ{CTs͛7;SZm±_Yo$9U]IuceSS|ǓeC׳ݮ5E UQv,v( =E^HHz֕B.5UUvM'KlGUQuڶse^k殊*״]o>>rbR-J$8N$IaywC )j2 " t]p@d v] m A5 !tY yV[4B= y ]liiT(84 sI Յl6q,(\rIuxb>0GL&}|`Z1WPU3=vli9VUU",wYߗ˲[fani ؛咟C?1o޼aP i*-Cy 4Y8&eQe2k&IH$ryӓSƣ|2RTM}#4iOj(q]%I9UF|\] 'GGam(ʒQ 6h-WklS-fCeKPw\_PDlu}(9887$).]5aV .eQ QAQ%IRp=Nqh %$Ò=ǰoOKS xEFea U[6%jWXYW+JUAqQeuQ_*l(뚪mXoרByNQ(ʳgOzli(T5y!9h8s+}x4s"o MojtEdM1x w%㩅nX ކTes999?"rhi¢4C3j?g;ߥy߷p=?J''UͿ^oN7 mm~vs>9J5) RH!((1bTBR/ 6rr\l@DRW;9~ /޹f7E7s1ךc>~?E1l]x|843jj8.J`Yq}wC\]yfSӤ8=cH?*䖲(i~خEVTe {>ޫ1 IDAT|5Ycr-Y@XbWe y "#OrZ J0`b&m3'qD߳Cnhچ ~)^$I6YQuBQ#ĢiD1x}<WWW^> 7-O/h  AU7 ހQ*+Iza46(&cə .#*i 5 a i0h4:ϙ5 dً;$,tƨ7@ׄP6X :tUI۶!l]GkyUG|ӧ,f|ݻwq%)gSΞp5yYswwx2ٳÀm-MSuSdYt]t]e2$X.ӄ9"]=91*7 D؁"+a@<> F '/+ZlCQ4ʢ6i|2Sʪ|%"I IR5^;e6?AS4=4z fE) t,Q&mIXn$(kz=|) ]=oY-O{ؚ 5$Xc)IayHb/Nm6LEX.Yo8E4 HhE7xe]1lBc6 ՚z_ Mp{ٔx>{|& bT`*B$ ]i'?oROӮO?֧H#W_JWMkϕR[8;윋Wiˆ~bfWs:;q|0d:a&+Ib0O#QyQH@$kA_~C UYbS:Ox9O_2H;8u4:ߧk:8"NRTΕe9,K޼zz*ƃ_|9ӳvZ^gjۉ1\<8D(maXAU88qL%t]\\|e $I90ϰzoEY}fںq~k~81 r䜧O1u(!(HDC0@DTiݒf"R'1*kcL u]ۢIJ-dI&/cpwG<8c8Q9e["!: ˲HѶe{>~&ںu .syyijAY(i M,+AI3̓>R+EJBмEhG-_#]+hZs$dYlvضeXt_Ufw]Lۦk8&M$E4$Y1@v&i~ntD'R)xGsh.]ۤ,svsI:*L$ 4,<}$vUdYr'C4]yIu϶$Y{=p~KL'v=cYݞFhAuIvy9:Ȳriq*''eI̗_K}j>z3}? 0_;uMDc4] W֡)kIn#w[8a62M8?9?{=ڦu=e.vEG;ڶkmhh qi>>s$C$4ՠn {7dEaP<'Kg'1MI{F7J3>5-9,nkd d +^O@M Ya kZB0zܕT}ȋ#Q+ɲAI2E!IP5afٌ!|x㺜x=n?R5*i@P'Ip8d<15"+*IaL& tݠ239aE$= .u&(mmVhTUtA,y-rz ?ApU3o~ׯߢ**g1̘M' =X.yɋ^`6w7wȪ6PUtD-. }}H׵ }t]'MS@L1hk`(m~G>Nbi8,9(ma4D1.YLH{ڶm[)S9 ,Rv;ěٖ;e;:˯d0v 8%"l&2ʢB5dYBtyqvÂ7oP*vyzt >է#WOŴ̥f/\T˨0L6GW-BR˺d{5"cO'LtYfd:Y.Ԋ=ä*J0&NȲKY#+J8!NS:~*(ꌶ{1񔯿gD4mp<]hiQv£%+ mעi*qe`;6A4MDzm<0Lt^YpywE є7{[$ 9my.Vt7N1LѠOdILQl6;TUk"ZN<{v$a&^ı8CG CFqjyqǩnk-m]!Iz>nP%Uj#&(r|ߧna7D}..pzrimaZ%II(9ȹ;%,4 Z4Ə$8h2F$XmꪡR 1Z"ĴtMiԲI;}arEؓ 'tf'3Nfs%/2Bf 9i8m-__%3YTsy.{R55(d闷ww CQJnnl {>}u-q}ֻ]QtJ{aqm7+BnI0MkDvIDQ/3@8@/2=<,MNy)~C|CӸ}X"Iev}M]WLȊᆪL2Go8\͖N4BlJfXIsA+ 5Mӣ/Me\껷\}" CdE(+nWȚJB89ډzMnJNYqs,+DY]^a e E^gnH4wCY2o޾!ELcG\}|g|%ooywiq! T}eM_kWt4TT}j>V}~\a{nK* VKV-vaLF,i&na=(FSdD|P^>;0UN IT`-MUIӄ%V[EIeHD#wxBG5uT]GV$ U#NS}XITb/EQ-> NsBfdUezUIYtqTy: rGQ`Ч7$ afC'#Cq,+yՇʋ( cq`0Çq)дc%wH's9qmʲ`~rHtLe8Ctрb7|=:&1N`?6QU$Np\_#RH{Yj"K3dY4ug2iv\ ,6P$0tt^0n #!/ NNNi[,x ޼~n6}~zF۔Ey {eY Ybx@7L&(`Ig!!#di68gO(p@u8<u n'WxI0NQ͚o_}G|IVQ%ϟ?QVbǦKʼ {^o,lk$µ=dE!tz(麠Qyh 0,K<; IȮE 4}L]Ge:'mTRdV'Ss,n oXKXĄyĴ'd +2k/_n@ї'yj4LdUt-,ͤ+T]\:|IQf77w{}9(Zemszzxwl[D[ wE!`)@EGK~c;6-u%"e2yT U>!0 ӄ hL+e #"ך UŕU@R4cdoX+aZ ~m9ݭ[.H-}}Jh0 IYR\g?پӴS}Qe}gW58S89A]7mKHAgӘW7l7 /=g>фhȪB+AZbyףWG?4WyQ,Uj$ӵ}@edyIQ LƯsswDpDOGC_^}tCBVŰ-&19m Pi"ԑ$4( B,gEȘc z,K1QUfM0L$ImpkX><'v[eɓ'E8v{-7lw{W%b|K^ 2ĭac9` . |,(y=o^&,g p@Q!Y( Ҷ 5 BFTU-u]u햪XWy`v 0N 6AہthbB]eAE1Ib veݲX,(W IDATokDONꊻ[6 y.Xi(rʲ܉K4IN9ðe]SAX.DaDAUVeAILU֨J+ADlZtMðM4@7C|T$\C5All~x4K\?ܓ._ר`d2[DuU *ӔeyOC;_~4w>5\U}s?%/7ll6l{KNNN9FefE  XE.?ER0 $@v't(Ja[Y!M2ON"^4[l>g>1ܴPWy!&WwL˂#%YRW%!GV-aB2 8;(F&lw#^7MOĬW+|c~2u\޿ϫWoDP7DQwa;VM'mIǤiFU {y~$I~^0i:!??%]'~ kXeAo[voC l|9gggoɳdLY,K@t-bݒ)mǏWe C}x]5EU<vN%q ZUWȒabWUhv9a$"X#αl|ۦJoЂnTBG >yS5Yi1osFl6#( "{I*U8KYHk;DqGDd,F \C4\ǡ1͈㘦m1-`/MNUHx=#8F6}GF8}D/,0e9eʂ%l!,g3t`<S577bw|fBö,4 bHi,/جWUMj,"˳I:Z,~DEB^Q"l{ ױ"sg]IR9u-U]X;xm;k;FĎbf”U.T` "ۦlQ.؋VQQUAxHx}մ;5wwR+ik|#y;0PUtE5̦TATol 'O{Yߝ)Th'J^V=4tIFCASUlx^~hx0w\4MjEk5HٝIfy8G/tUUyNUh㺌/.?a9?\qswGUht8{/?MEԜ`6?H/$QeUl*v)wkqSNi4K lk8flQdl6B<,4MetMMX Խe+ FC`b:vv#&.ggg;N3Oh (<qH1N05툷!CUm$#b fR{ }}fq\Cq|yFx@U7MW 8;;'o*M]2 U9S8 ߣ( > 쪪Zz; |lЬ"Ld؟ Ƶ-^|96C ''sEAGGDz\ $wEa8^}?Uu}l UUŶlzi$iJؖGt8ME, 0d2.V5c=}@Q5g$K8B5,t6g{ :ZE۶Ǧ(c8QU:PU Idy˗_ &:*(˱_.{\ۡ*+:EZmA$:d޾y?yl6[TYFQd? Y2m$ɤYIuIU]KvN- d:Ƕ-ʲB na>3Y,k4(aRnAȪuڪ%MR2%7 hS7>\3]ɺ]/PGQ\-@),d)it֥B5Hd/7o߲\.qG|xxx@UU\<2 o޲ۅخm[nTibIUmfc\E!pIIR5(hK IޑD1'|8#%w_` ir2=7W_1S層MNO C(:tihSeh^:_/ĹS}?,T?SB1]Fgn.}d2w],gdtk MX|[>t/s]TF>yWT]ǔe\ϡkaۡ("b>@nY\~Q5&5-$^5Œ8IBMBUdǤG;N6];0b6c6Q=<4 ⶼmEVEёhm EQq׶h& o$_4[LPvUY=|H{loNfJ4M'KLCtv,)ײX/dix8bّ%E>ޓ h ?#? y4a(0P$y-ݎt7ʢ!d^oŮ$a&E!(e'1eSf ͆y1MmqHvq(4}VU}pRV* 6 abѶ3lYeY1jX ~^o@xx2%b(ö-ł(!nx&mfCv7ѵ2: $X<?MD %YE 4C od4$K3) իbO-مorqqv'=p5%" jkeQf?WoTO קkgu&;un94_YkxiyNSVh73Mk޼ᎴLq]d^ dYᛡ#sў,\ #VQ.se:4ECTM*+YajDI,+ =.31 p"u5a+ ja=(qq4²Lʦ,NLY44i(-mp {b1_}_}NdM 4LF!߇\ﮘxvypS- 4)OV VQSVpM=g~'r[,ˤ.+$If;] usqy> wkulf`f'3Nϟ Km05vGEX&I]Ki)ϣVPEqWyd).`2y}(!N:dUar^<}A'|7-DZ&_%bqRZ~;+iޛJ]ob3t[UFq 66E6%CFjy+$xǰh,@H/lqwWխ[wț9gs7,qX]Lf/}|iկ4Ͱm)MՈcfkYqzj`&~2IŚ(4uM unv\V(EyǣL4-?{)I%hvZ\al4;mJS7жϩ HTY"/j,cZY8-͖nkTUI6*V6 I#M$*Fx)z!I `2InZ)yQE10:b EBQT^^RU5iw|tK#"^]"`1fޘGN͖,VK,b3NΈi( U.igY5*StݤisV%yȊvt+_SM:%kצ&bF0t7eNN5E04 UI$Ä;Le4S;8. OB(JAC8EQH[(:Ȫn$ruӲɋt]WEvVBU%,,:b%1_~`w<(Y¶MH3A |$)sZl5U7h.IH{)#AV#Mõm42l>wᣏ>(JUDQLY _ehr)ue6 QGўI-f|9EUt4Tm9eU (i T-̦wqB|cLBd4ZT]'MR9Q5 ^xȲLQ _.HMQˊ0XVw$qȨͧOy!!0~PM R%e2%MΎN1Mlb#9_|q2x1^˴iM7,Iqt(~ 0_Yo1MXFfqzrFs$AQdl˦njV^xij#`Y:a>%DVm[t=8V a^m  j,4M<a5{_QUUM=n!~0n6 G#ʲ\ulF1͚_DRTI"RNO zwwOUW wSp0Ķ,(> A7t$ޫP2]PdEt02XwHER۶, eUq]F>yS7"Q5F1eYth,UB]]TU%|Ar) 8uiY2qww' E34e.!bA,i\"B$ھ|㏨cUW5Y.EUUҴXI.ikA<peYnezs¶4rڛX=v=lخ+TR4WDnE łjgA2,M*MӊNjX@%%Ha&)՜"/Q5hۖp XjDN]z&Jc6U]l% bfD~xCFhLUu[7M_ 2: w+^,YVM*F[;E^ f$fX,Y3ӏ}:l[C04j*x9Vo'}W=\zp?_~PʿMkJm>Z&JSQfUU!ܕd^\777DaAo G]eبXvB5 {?C@}s!aQhY>a%ϟd  To?GO`YTR>xV:4+e)giqz7o&eYPW5Q6'|;&"پVt|vfwȮIɄ8\nOYJmJnYk,͠Z(FVe6-4-肚ga4 5^f~'p! *J .IJD=Ƣ7<_ ð F>ٌ|jV+d4C(*\yTU$f7l6|?vʋ00U!Վe,,K à鐗UU!KqL:hkTFYWEfYȚ6b$>'g~w٬VTEE 0⺹a4Q4,K״I]~n+._]0]!2gcNA6m0 BU 3HXqXI^_e2-V @SYH3.7DI_g_|r@$ ]CDM8??0MF$h-rʂb>$iBV䨊lv;ҝLe4Oϸ xDi* IDATGt ahHM'g$a2W.$iBYUn+"rߨTLCgPuUpJɭys{{|ض,Au7?棏h#P5f)¨!O(j~m7r%iև{d0=ώ{P4t]UU}aX%81L0+aWUEJt EU( 2y;fzKXv4$I }diW/^`aps7dIUz>^EQㄶ˲15MwUMSפqLà*UY`j&aEs$g?ӻ5eQ!)2URd1t9:> ,e#e]LI! Y1koBVÐłrś O &{?<~<&O ʶ&e,_R%E!CNW%vzGGbH( A K`YEI-bfәc {nY }\E^{x y!YMcۑض-iP7!akb:P["!Ʋo.=잶wN\f4Mr 3 b&eE) 5U5U3z3^\CdIޫ%tv+rP$u3TYeO^},GgXau]PW N 4ޯJ$#]o~Ⱥljߓ <Y^l,cZsrr&2 Ipl^,HP17teA[:=M8Nw 'x 7o vefGc=Ge&H.^#L"%ӓ#&Rśl2M2H/_%)$s4P)0v٭L CTK]Z!r4m0bZJir4&'Ǽ~t Hl✋)5-I˃3Y,WdEa[Hbq6HO{E01^/?yv?X;f2#۴e0mNhC>)|x}uO9M㸌#anS5u]c60MS(YBQds4xF7 UE3 $$rLU;DqO~)G? Œ$M]s`#O|3~!H\.ISav,[j!5 `u#Le`6u۲\nqLo~bNi3&1Rq||DQ UQmQMYnvTeA ]zGC3x>CQ5UQm7Uaڬ;8f_c\Tg)cG<~`!t\Ui"u04G1eYb&E T=>w,K[0b4^K]UhJ%yKH `'z87qi^?0Gc&(۲,lFQʴ^Z0mxSSI󒲖Ftdyi~4aL C@˶-qPUݸ àAi{,˄aD]4:$g1U]uk[,FRTnl6 `t6*9>iSW^3i(vEB6"{yGUPE H. MS>>*EU^pv:9Ci"04}௮|$t+ArÐbahq YQt:s u iZno &ScqT5qF ެ:Q"T\-pH2ﮮv;DQ$'{E㋬ж!EQDU4xl+Ң@2t$e>Q^7tlW"u5_fPu]( L$B4g<)EQP i4 ӳ˚rN۴eI 3 ?f8b6nR7 iR9#ϧ*+YzѴ-qVڅmwq8gHBIKICV؆IrUduYb0* iQ6EQ-2YФ% O |<ˈӂVQ$MQij)"ʟ$ETmX=\zp?_xz*Uֿ$ha<0DgEm {=ƃ>A6|~^1ȳGg<}t x~ I sQꪦ ,%+2Z4 ;ܫbdiA6xd/oys./ EL0k289E#0-viضMUU\\ I ]Gw vq8IPK(t>j:#.JT]Űg0R5aڮ xGJV73vj`2 i"<k*s MhLpss7dIJo0"4zNL#!JDFјT]Ų t@tlDB"+|FUEʶm0D7M\e[mBVG\Dj)늢i;>nj%@uP$6 "4 A0LEUja"NRfьٌ):.| :6ܡY6AD&,W+:Qվ/(0EiECtBV 6Pe)ܓ.]~8$^dF۶yFfMx8otALw8jt]6ױ1[*B+ ASh% MՄ2+I= hvCUBU4X(BSWUȊJSt? a(rEA W,X,e~eZa[&'novpl"IQچ'UW_>㫯f:>(2, @m9>>FUvMcW-b!+lFCtU(.s $ 0q۶dYFQ,knni%0mPY&MR6-"*AQ$ߧnZʲ"%DaDմ̷+v-}ziд5uݐuNCiFUդih4fZ1Nkmy$ G'"˭3Y6-ʢ$"plin(X:(!?(EwwwUiLmZ /IQ$3޽ndp4Aul* C\a0lF45mӢbح Q N\0*_ *c6iQ&`bNUUA -+,#I䶥al.ϡ*,K:xBVYQxmc? OU֤Yv1x? hۖNY8qׯ_0s|CI6 ݎ.dH@@a\tuOlEs3FYU]!*|9E\8IRYVh[]ޯAAրex\xw35ãcf>s|tD躾 EȲ8h]I,Kh PKE- Ǭoy}KȂY=u]c:Nj|>Ƕmh$s=iHM˗U#FuMdA$)J.oo)8 x-c]HLB'_ow7_'jSSK9u.Ufm֟ w-AaУ Ptk>{˻)Yrl|)enC$lS<9{( Uu0;ig,mTMITIB^UwmSUŜgW\\e`.ހhZ02H'c*PUݧeɎɉ ҂ Ik/YlW8F,Y5w ^~tJJxK?8=:nA$,ö}Pz F}T]e:[Xu1EcYR*a*gc:eQ0Tzv+nЊqްsZӪyMYdHɣGc=7 ECIӌ=VEyTZch[8AU=,K>cZa6ʂӴq)˒v@XI5:Ƕy/^lhN 5U3 M&u #8&/+,KxӚAUUfyU( DZv%/r,GP̅e8>{VFEQhDY޲n1*`!5^Z%KSvfH*Rq^oLK0Ҕ~Ǵ+}ᆾ٧uI&E޵(u|VBUI:@/=<}J%YJ$2DDQur(tq+AUU Ӡ ih)0 $wO)~Xxs)͖h!%wO9>>f}^4 u1< vn0-鲜fG;PMn ˰AY:B]W"[0t<͐ڊ*gī9CT+A^K݉Ůh80- IO "M~닷eM&8.ɤQj4IBϴZ@ uSb[Fμx>n*CjkC>T {R"Z4AGybDi$l,AQ$!B"2>=G-`:e czh<?0]7( ٌ8IȚ0M۲PUiX; kb#a>Q7I8n6ݲZ~}M lrp#M"6˛ -*iژ:e]߰Vծzz_m{dmZ̵4,SGB4BH Q>xz"~<{|)?hOxie8`zȲ4I@(0uM%iˊ(Ic,Q6 oo}9m2 x!cZ,҄ lz]nx0?j:m#0M(ڊ8Iy05xBˆj&L(5i |,+UjF!C>:=7:؞KSHhahWTzf#++겦(JlhRCKqOKk<8? -EUdHٯ+&Vzg(Oߣ[2YސBLv[lKeeQ YQT,ȋEQAA0$q`0$xHmEMdX\G!YZ&if M+B8[w1t*YoENd,kX8M7|گ2ȊnncےV9HPW5r KPV)iQ.Nu$hnT0-vɳi])Eu:*QA #͎aY:͊u\LK\?۶P [[C%mrӛ[4C56du-AR$t]ڲ:m?o~{/\S7_W5&QiJb9=9G(n<{4p~rwM>~h, U s48If\ kߗ5"; EFST E5TIE%|ۥHrn7?.nP IhmDq;a(xgη͇O>z>P&9ec2-h[Bl:{A(n$_p}{C#U>!M&N{e&r֨A%$3[mkA7lN>n_ ,Ǵ-4 [Ef01M,q\0ql41LMոW3,'%o*t|n/rꪥU5*1<^$t]˲y$ItIޛ]k}>6չ)0v8t"8 QlM`b<$cxt+Ʈ*[۝f{]=r#y39g[{#EtLMcݻ/6IN'v[ք%2,B3L//yw 99>,MDF&,J,#wu1OOQoYQpp#>G$qӧO8?c^(-G;<|' UE꙽EQTZ@4%#Z7e4Sd97yG|1Ξ7"1Nx#^~}TM#+@B=GDyx2! 7KlER%6QBU4M8"(&^Y?`np| ʋorW_~q?8S>}:x40LF َaVj)rLC[$L&ȲL$i{rvvNY4 |b< w; aH ִی]aE #oox1Z 鳔ٔ_I٬#ږCIh44 0{Sm>/~FdEw(B(!ROOOyj[:R]eYEm}O*3Ili(bQQ~[U& a wrC>3EQea6"6EYQMuo gBF`45IP!Nzw]J؈+"BFחWȊL۵i8EjKvgcyzzʳOE)8',-$뺭B(:<)rKLrAwmr;S5R>\n$Mdd:RUEQVU[,+2֛Ȓ]i"*Yp8& 7,KǏ?*JZ$Dl?m[L[UUyJ`<Q%˲e?X7/beYEbGhgŤ IHHdyrikAl0شyPr[ 蒢:nnVtR*\]YG!84Y.p\*Xm6 ,C7 |%>{P=~%E^5TyAj)ox)4"3d1"Ku=g C M<< fȲD$at %TȮ4 STEc\־&p &il8ޠ**ш> Ya02E/P!@w}8I.>ٳS8q`>25 "I2DOUI[ãC֛o(1q@UDkò]%y)̘ ` .IQ%Rm0ΰ-[d HPzJu5Mò,1 J[h4fiVl6 D@3* QZ=¶mooq\8 p uPu-Ʉi$I☛[])4IeQ[ȇbTUŶm1;]kR5$qluh834IEDj5ǟ/4EPuu/r[}GKt %ф:״bݤ1.( qfѵ e1 PUd؜_c C0"i?\>}uG(ST 6H>ϊ'4e|jqPAuǦiZ&OStMi[lf4m٤$pM0 {EuPE*am9_,iMl,KV5i&uv"8Mx*(,hZW7,YV\ږ64-QPcTE ț͆81t,I Yb;q8cs2.ki%F!pvz؎h8:N6 .yw8vwyŇ<8σݣj<[4# E;|.MEV1,&׷;<9;enhx[o@Gi c$IPVy4I[u5Ia&2rײcVaj򖲭|x>WHD':9!ggOF`²|CR1/WHm9yj2-`(s (J'|`ptS/-KDl^mPhDttXAMKUK=}B—_z^ C0-Fq.6{{{ Ccw2`<Tu U8JhM7 CG"\dR6[E`YÒDeYsW۶gS|75SRٛθ].w'I099:qtx$Y&mkIJ6iPLq0`l'Ślٛ_I^~%Lt]GEBM?*mUwE zTU%M0cqn%$YBU,g'Rx o/>xSՅ5˒h$~mDQH4ry}I!Mq=,N'˂(Z~H؆1~0M) +{}>|2rtt{A u(Jڶٳ3lf6ae(U̇|ٓ'Hތ $c9.i`E"tg V+y=nt혘tʰ6p8"O1QU*Hj,rIۢc˲ G"[%ILS/7 [fqxkz 4]'SdELtæi[!a8m1cE%6mc{(_nӦin͆لh"}4MGu]l+88麶/(֑%YRx~ix:az4yA,Ix<' C0³m;Oe躂 !#:I ݴPUIQ/Q.}|Zm~Ze'Љ[|۶l6!6=(˲hbA]״E IDATbhZ*$ ۶١ZAa8k&(Fخe[À-Ou:`X(@D4wHW <Y( 6k$sdUc0`&gCbEȋV`Se^uP \h&Uӱ CdIv(SӅ*JI5^\^49c jQ GClĶ-lK2m EjIAdq1uΘfBwu Ҍ:OtP4(È(``X&u#ӆ6HP2dhݎ0_,"6mG۵ISwTe,u؎x2U9CgwCg#;[S,B4tCg2+0i1ՂgP4uڶs|dr3N/ILlM&0x`0@$Yi^e%C[JoT6wMնMVP%Uo!~% ~+!MR p)o~'O>rvfS>֛ؗɌC5 ˶iQYUyl"&&HYI55uӰ^os}~CJxyy)N,r"nkj:뢪~vss%Qr~ sU%ICؿv|xx>;{*dYFb5obq{?{̇|ؼkܿ,Vji۴OX 4I=5躎8,KkW|c.@Mla4EXؒ J(xgghth*"!0Imİ*=t`4)}+$^z݃V!+϶mnIVH41MMPI(b vdݕJb@7 T]e4l=" C س,cZmi*"q=Mp]cX&MעkVm1 ]dZrU|[|&k 3%HҾNSA [D;`!$hIJm;Ȫ&4,LC$i}wvG-RC<ޟ{(Vl6$qo-Lkj !3F!t-uӡh`ig4E<<:S9;}*sڵ-M I0_,Pd??`4ꜗ^z=&aO!M #͂5qk:{{;L1㡩 e1ͨZ:za2af13 xtGGTUEšH,+D˫s4UhY 4%o*6yJ#utel+fئ::"plcb^d1V5UmW#k m3vGjJ8}}o|?/>뇳~d$I~OE\/H~?LxSk]clfldc&]YG5ns~ǜ_^`sr|t4& p0[ŀն-yryPEbE5 Y(jZ@ Q̓gOֻo}0Nx#/_-j5 Tg׿ʻOVC^x:GGHD׶x4mfYI&/ Vl譏]'着U%Eqyy?xK$Eb2K7`ooGVuU4/i:A@T:hIӌlGtM*ꊦIE ^x_xi踖d8Dj[[f<sttجBA5AOQ9Çx~`0`0`:B4Qb!-ʖA? ד6/.ӧ<}zIYXE}vwgؖTOdc F Sv4a6~Lꚶq=sL&xg(Χ`l'q\X.W]\rzvzBZ~+?>,Kʺ noE6'*4M[̿xo?!MsS]dY8TuԉhYaXPU֢yYm3Jwsq 4N'Y*Љa_tN"2$ɐjʬ}Όt|>JnB [=f4M|䋅Smw8[ 8űaZYV(reMUE^cg2$X|ȲNSdbƣlpz`EۼP4{d(4LTuضEޠjq,=zimMH$qwF4Mdǟ}tsr3b["AOu5mE&zCQ$I-MU1x&@Up<Uӷ%݆spp@Qq* Ii8=✲(񔣃T]MBUU4EiP M+e)9qeT0 xjaiNUV"kx$i5E^pUQ,j Ԓ)a/׬Wk Eew0DnZ,!J#ʪj>fS8Uxڼ [.Kd<b{]۱b(bꪦdtm(¶YVSw965- IMȒߙ L0tȷ-VkTHS:I"/T:#6u%(>Sas4hNP fKVdU t2bhl꒛ʲaP5E**&t@7fѾTݗ_C\~N?觋?momW|_oSk6GOf؝L N`-2,<)}14m.2ۙm(|`e}ݦ ,ˢm w*]7 XTagu]K[7de?[ė>EyKtm;Xs >s: *&(Fu]non(MVq]{kQNI耳g|+Ɠ=d@AŒdr-6ښfn-"Lm-m-"cTM!ܬ쓧\^]i-%Áǃضe ;iZT]Y%IȾ a8SqS}=۱e8F~3Ib R`Ig<=''<8<*OQqo U$M5)Me>B%r] *I3d$^}%bȲBݣRP7:& ,F GQ$)=TDsEJvN {qXP5]aKLFM[\.M& @eDiʧPRw*ES $.tG6 VqJ21_,YfNfUEYBZh%$ !k*ш,pma6 c:a~2UӐUqu) ;]**U֡k^sHYW7 ǴD(" U!K;%Ye뛯T~~{˒Y?ׯʯ|'7~*_#? u W FM1mÕc l1,o6]\`:ɔݽ=De!YvC-6*+w|TM|YV#-s"U!3qk\oݯ}o>zk˯7d8%imw7-n E/p-0I{S4(YU)s0 ŠAp02,$E!IbX*/NHZ;,Zc3q]4=CWOFY|9ZnxO(Uݙ/|BdLhef$iJ'a,oYm6Hȼ| I S, ::ncxI,[Y&$>~Lu4MC |> N+jLF#q= (hSZU&M ,YW|1M]86/e$Esʲf:ntmQ6ƪ.)\I-)''mE5 Ki')qb:uRY(s9qsrl6Ŷm0@B sEQX,l-1]u]'2 s 7Ȳ !{{@50L&\+Qy.Պ4M @U%2/"z-KTEET.GGG}Ụ̌G?m\__oWu m>h;M]9+G'LvvǤyH7kEcȯP6;~Q[ qZaY0Ȓ"˨N0_,tFSۃ=,>C,C#>77! ?clq}^KKey{!]LH5]S>WzmS)]EUmQiVл̜8ejvIhڸ@'Q(:$Iux($ץL5M1VuHF lMH-v+[]O(8Md|vMڬXm֔piI״TegiAG:$YfAW &˨H*tZ̉MQ5uբJ08@I󌢪麖pZ tLMa4,s3e)(x0-쩜嚮8ð˲=7ib&0Ki{ݲ-aKWշ , LUOvEWղI"nÐ|le>Z.QnHRf0~kۿsϹ\?-UӕCNK>xe -%e#&ac{Ԫo=C"a0pxp+Lێ ?%6;yu)NӔ$M@ZOGvQt A%de^w҈Cz-Jy۠*hr⳧O WR# 3g 1]b.MQ-XQ%a"#Û~k9֢œ^] H4YJCFt瘦LݕU#f{ociw}{dfdeV]]]Sfl o\,y̅1H $n \$A,53=U=][WeeV.g?w.jn0Jyq8wk:a1y㶀 ˶L`$WWc@ .yHBIʒ8J \^B) c(ʚ8֚bXYUW5m %X!]1 XmiߐtCŲMEREdE?衪*Ϟ=u]1e:@Ӵ-5o\]]4ݒ&UUAUUAEgSLt}WEAQm/?eR%qtǬ4:WW C:i*lJt]NOO6OBm4((})˒rIGn$d<F &)u ˊj"(ϩ(0Z[5G $m{6QV86"֊FY8UF $I}FՒCt;,+4K=dg*XGފ ,i8::PjahސiVu Nj.r^*Πs֫;ڡ,J@iɳ8~(Z:~ߣjhy^0-`iڬV'N߾W%I4MAєUL$ISPdT/.Ҙ2)((8}q_ηg]A"H:./_b<Ux>G)%W%kqxx̗ϞF,FK'7x)?{XK==WW{yY?iڬd"+O|8d&T˘aY,J*cT ٌA:l,EbtdUf !iV珘O'誄iZt#:jԒLe^q0Xy*,yGFel؆H`0L֛|C>| MNdxrbrLvwzhZڔ)ɢZYS_w{ouuB+\|~S?7mVzNϵi Z.aUg|gO)r1oܸsMzCau]ۍa]Uh"+4@eUc Ia@UV|IJYeYXHuMdyx2xw]~o֝~iT՘ ӳ3~?GY|痾׿vq>JO2u (!QPwZ\U ʼ M3{O>X n7n.uݐ%q D0 nk C30 6t8%$xzzωk4M޽_;(+R#.߁ɪڪa䭷2*DiJ4tIn9dT4UɳrAL&11 4Ͷ yc6jxe]'bL4MoC-h&uI҄,Qu =4rEըU CXX&"~imUY%G4@iHÜ՜,KQdY3L ˹5tyALt< d^1q(eQU!O?* ՠij*?͛i!h\~k'drAe.u]J#VqBZ7hB- `m[Țe4fUeNDqm"M]cZa0_̉EPM5(ʆS^^H;7o3 rA!Nu9:<*z%ϟd f%/*Yt-:G@RXLKTâJξ<j|KfizHM`ge\x jIe1} U.*J" #pEQim%FA*o`#ReQe9n e^*f Kx)~3 Os߿[̗f!WFVRI&/kq) F]93ͨ˒eJ ]ߦ,k8xC#X:gcKx4xGA>"DsQ#ãdWg_u#]_ͿxW_ _'SX莌$W4jIN.A؊o GG-6;gˋ =U8,IɤYʤi֪B dI bu4Y-DݜyNUHU)Gɳy5ѳ'?'_|RYWt*/Pz}G|L[''8>< O V YӔ(5M#HhuӐ hvzuE(o~vO4{ܿ{wq,ȲlÐ0dM}wQ&<+rt`.=Ϡ7"C\ ( 0mt(՘/_ fi:ig9r qsuuU;,"˲%s * Q e,K$E"2,DjUb, 0 Q^G[iEk-ZFSVA*i%jLkQLzɠ'/ 4MXL}ߪh&i4!C%Ct]m̼a-j":iMEjKq0Ԭ[uIudI.UU;c*⾭ͨdY.|)bYQ04]XhzmTMϙi"MQ򆰧8|RI"\q:](li|t} UU1ES!#]Us ŧ Պ[{ĥRd4ewOjEz:Ã']놳W.nOwǴ=./ ݃#<4Ǣ`1_:dQ1O򂣣#)锪nLSdK>v:yai|: Q;6IS5PJ8"J̃$"Ev>i +#7OnOY!jR7 >Y7D7mؼqr.Ux̝iD 8 E5޷ʆ,9?cJ1`juY" H=\j|AEqWL&}\gcdi.yUO?e^R5Np/ˬ'S3>yor38:-WEv* u|sVmαRck e)&Q5U9G\:9O8srB%uzX5z~.Ev _/zm)YWߑoyyiaJg@l)˂FXq{8l~LVK{;;|op(T Ӣj*'MbvS^>pMu~>o|[xыAFt;Ec;hFSUKeyex(DQ|?gAa{hlG(FMK $!KSVAvYVXI$<~x28ZE;\~)nv#m'in}MLb:D劢 `\'''(2m{dEAQUZm5@jC۠7Cl6ö-4`&rvF:f)y*ySh(u-tM# Bdc&UUeO=ٗO7d a0_,TkY,LgS$Yu\,SXx|)a4]9E뺾%tnRtY(e<\WX a1,/{Jd햫@xTq=K$-EJb!vB',Hu#)/_b*yY 0m۴PdUce1LdM_ƪFeU2OXeAe!Z"qMɊ8N1, F#OdY(-hIQT"|[x$HMWq{ئE4 ɾ3_ J\%>ꚬ.  ϴG_~?ӗ/ǵx_wRI#!$/-Y}p(N Q,2n I&6@ϯ^#Nxrl4]xؖqFu]syyIU8q$ˤqÇ_X,|q덛M3v4jNQ aH$rim{8= <0}rx/I MW9<<`0hoqj*I]GMG:`s9?s%.'F|En,I1~USi:uYmsHW|KD Nep|t>~CQT\\\CL-$mO8۞*MӘL& 56e:Y$)]1M]G4nU* N9QbL.l6㪪6^m*QZ7 yj;lZ:AE5F&/eu ?4444ElD놦VK4EyX-fJӔȈZfc$ɖֺ)<4Eaei\v,+$YjA8$Ȩ5b9Gdxj`0 CvD,s5P$`D&=Pbx.iQQ b꒲oz>22Y k n hz%[6MCSt] `>_X̨$pL;([GUTeMASTC۾<jkI8@Uf /t:~ `X$E~t[od,H☋W\(JἎ# 21Moݧ%VNnGY+1*$AS ]a8:r\??K+nge*8_-놤(d0 Ӳ$rNfx/(Z(i󌲪gN㏙.؞oͽ0u4yQP5,6p͘* ׷\ضEdYmyKjg^<IU\u7o`fs$^)ʒ T 3N}h_p8ܾ0dPUeOqӧO8=}F8Mego1 !4 IdXeI#IF^Su ,#! nh@udcS̕9YQïꯐe)_Hi\v[{6*I1_Q#4HDZݠn@%._`w`%,&!u٣/1 hiYiئMSȀyhx.q% ))MmѠ #*$U>wxh~)>|#¤o~+Ժa;QxaZLs8FW 4CB"RQlYIgD`&SbEY2YɫsΧS~߶q]݀4  X."+^Dɕۚ}7~{ᨳk|zp1^7t7KHbb.#-q}|.yU2x1UcYf[*)X. 5kqt0hQo0 =qe) CpMj-eE PUòU r'_>{J>ycv#dE&2LSieYbZ&$JxN1L޼yNǥe]3_΅AHj:%8)2ik:]dzrt:.C7\X+m[P'MUU44xGcX\,*$(n'z@ؾθѣ/x iv}nx[,IHD(E |#C*4u=V=Q K]"^'2qe$[e_,`e;.칚Bb=4%<ϣyϚ&a0LNK)e2] !F-CBNx 2'Irʢ`\q\jTavMYgIQtj*vwzbۣ\V930 fEߑ,IxmfyNɚuRE ]bg8?eF ~wU(L5**z.c#)*jEO99{K똦*K{C^`g4[~{7otq,K 74t>a?~|)x#n;BVerlkTE5TU#s8Dt\%鯦iJa9ygƃ/ sT]qzw:׏r1Rݲ%A|,Ut*/rTU EuLK~,rm! `!6M#6uBa)u@Ȝx'd2+tU?賻G7`wwW؞8Dab>qd2A3 G"Af^M#{./!k*aCj& 4s]vwp,|t:#<ǡ~ӏxyvZؖNoH*ptE^p%-3| q4\]q\`{8ضL,K4Iqf"kEܿwp_<@DD嘶aZ- LuM#lk6jFa;lڟn|6<ωEe` / ذ= ərHvVwUuUג[d{3H,u:+32}?穙/7*UY2 R*,&"m2lb,L./IFrr|gsdU$5UsryvNٲ?39k:0]-y)Yk*#UEDْp,Y7kALL$+2VIkaDV] uT{0ṞTV"7F},j ˲ZAmevș\Nz=x"QD0D߭ף,j5UP$9Msǭ~h4" i$ײP]ņO>/>4Tܾo9Ӷ [$( <,"Nڶj9cKER4/ih[>=dEfb xn&Yl{_;_jU=_ \P65"NƇ>8d&+¨4 <ϟ>FTFc{z|\Nn+p]laMih)Bd5 ˶sV,6-yG%~G _ULS>3>z|"J ;| dZɆMж-a \bj1McAXT5 $6--~>U]>=g?۾0QU|vi6=S  i- !=../Y)~3>*c(޿71-(FȲuMS\OV+l"M`3 I7S/ҵPLl~-7xniVqmNFlv%  Fl>[ֿΫO?j:p\Kt%22C)utMCdE! "g6rvza8NeYxKYhuL`62Y:OE,0E[, 6.'`UY5M#^!bh1 thdg,[,Áų4Y7`0tYh*3T^ۮעf#x^*FQפK]Ew=$FI4`Y"ȤЋæi <^ cEȲw/Ǧnv벋;eXE@Fl΅' {Êc,K|=P9^H*4M8<:2MdY\\;]TrBhZlWh+6 łz*T˲ G؎Efخ`o]|Eo燷?WׯjK|mJ[eMP 8; zna$e)_>ry=Ž,he+>~p||᠇8"VTu8@B̪*& yScέbi EH ƛ|ptM](4KdELd\\͐(xxMݹp8$^E<ԮŴ \$]ox!}?ylB<_wxk"_e'SzvA$I)gџhA/bo'<¾ 9-UY@YK`pqPg٬+K%s>#|A0#"^|Ȍ"ZTMC]Ux_3.r߿[_8$1"xA9yST:2iiԟt/^/˲,Tl! a> $wI{OB& IvS$I7(ޑʼn$ bk]$]J D5w]m^%TYg; /"MwJ@ӈ~뺝F@td< v,EHVDmI\t4wú$BTbE.$fe{/XViP7iy$x IDATCi;K]zs&V"$2Ȳ(|Wp~yhf2Ll!sz>^oᲮۨEaZl6[&hFA, `1(N?P$"ER}" eQP%a*eM"6K]Y.$qLQȲt`>wR^q<{yA$Iyߚ&.XaJyub2g}5b< zdyۘurpt>0z-Xm6hI? ;5NzҤ%q] ~ p%d+$U%-qY z ]wMQH+4U7mYW/خCf\\^!i(L+dE=!WuTEó]8tr*R5"i:q!k2Qࢪj>񘴨#/K9:<;Qk[ȊJ $AX @7 " `**,.K|чɯk7~;'Z"#NRU˺+L"-2O{LK\/{77ƽw Q5 C2&TUEg<i`h&`j:uUS%՚>gK4C17ocYu0 Yw Iv8w~m[,+?~.MU7ss<uw/:jX-ئ V,]x45(yAmqx_ηy|(خyItU]@Ud./.8:<0 ^x8{=fmc &y.-B9;;˗e{HȒ½{ ٜ"xȊ%m":=uU:YbNk'<$ZI!K6qBBLyI±L<ѣǜ>9UV4&vAL$Et0 2QU5ٜrO^ j "/È,I8<ecА"4P՚,z@.6--jpzvJg躁a4m+aAz\oZ*nj[zM۶a瘺IQ Zu?Dcj:fC-k,-u9sE+IyvWCYX,+m`0ݰe6:k5W->Զ4u7t8GUdyEjȲJEشj´l34q(UE瘖E۴eP75^>ϲ,E 5], I 7$`2d9a^UYmIY,BorA `}hۖiDd[Q(lmC }ƇXCQa:n((8::E0LZI~1V*xC1[o]^R1"S52t"KI.̗+dUeZQS L8#ꚸ<$2:ATMT8^SH-EU^"I vҪ~ .R+[ ],u(-lZZF!n)Rh41vr1)4-a1q59k>0 8:<&+)mYYn6deʝEAT40Cwmi,˲ӇxijhXCQ֬6V5 `A:lbk7@Rti+'w~?|5pj;׿Vk:Hx0 [1iQW5iPW0*`C{G4)~3 5$kY|j"z(*ENȲB /J݆M#rLS,ˤJn'1YQ2" %y&mPmQ#:*!U Ck (zm;H?k܁'Z^a wٕdf je/uDQVDO M5 EZpeqL&IbCeY:QRU52MbF24}$E7]'+t=ψz!YE!WWW(1_,h|N'eEy!{{#dY mbY&iq9IJmi, O.NnTIBҐoR-].EY,HsGIyFY%mHDt$MYzm >)DQ^/0xo4 Ð0 ږbI\6 [tg[6 j !0I[7GL&bt0ak"ٮe3#<ۢ.*ꪥhzhox񺪘.B]ْ߲1 -:q&1gg/iʂpm rUp8@VDg1Ib04,,D54+%O͊),[4c<q&eS9^(ƝwF4UfK6n3|N-IyMVdTuMՀ4]'mZ$YN @_o0Ҍ(963ι( 6$Ԥ``6n&rP5yURI%M^kUz٦o"7_?{u}5pjϷIIFk$iFbhޭ[.r۰ݮ8=?g2P u ! 2,+>{O?rd8s ~`c;&^a8 MSSV5!(~T̺Lm*8o(LQtc>S4E7hhh)~VDmZg?O?"Y{]ܾe;aiכ<#LytE/R`QX6ϟ'? |AVXho$ hw|m5[d,]k]j)_ën BF}ryyv˳S=\GD(r 鲠* n\,;XxA現Eܿ 7o ɢU280LLBPٶ-sqyIYUڶeZu*)sU,K Kq?ɺt:G34v>!SX,H]3,ױeEllx#&(Bz =|g<õm,idV( LCSTUtMg1<}1z~||`z%/K@&E ݠ:C+%z.IG;??g㺎iB]%8>d9PU5ms]'G7tʢgb 3M}~^˜ijA<:eY,K4 kaZe $8Z_-WIE+kFW4zAYRQ$4MY,@]DQrDtdY4mdI4s|*H5EwHMU<ӲfpiQn _ k ivh E$TLd<#2ggYAf̯Pd7w`"{>ziסK:ep0`6An*rGp:ϑǵqu I;WbI&6mڪynFICVfl7s.gM%Nhm׎oq P-k&M&YܾYUū?ٜ|<_ \竁/& k V% >1wo8_|t30;Ĵe誉E/3exDo B4$rjHQ׸dev00..$)ҴlkO<7^^{C,YA2t*AuEV8y>Gc.N_LJ'7nPK&j$+,KD`^Q9t+,- 5Y>?yZh嚃own%׷thjmwXvWUUe ʬ$(놾msxIq\DM"~Q1 u;'JYg]?mF1UU^- |ͣӄ坯ZTmA@QV[cuIDlT(:Y*^Ah"Koc퐖%_A +yV 9-]o ۱(˂"/L, `CfR,%> pU*-QbY0[A8;DQ@7 ϧ( i afRvrIY݆N yiK2.!]3ðph43$QUL[E0U2O!KEmۻu ~G^nn tBM IRȲ#CL ]Ƿt!e qǏ 2eU00D- C4A5,s|4bBo1-EG} dv1gf H '"q~9aq9EdF>Ebs:lY^-,?{M0͘/8-gdIa8I{Q*R lɻTe4ѿd_AeI(yEqX|[8;arBSV İts9'NBN] MV"eIXQD*4/ӂ& @u%y!N(D60\muEe\x:O1TB A@*yYav/?"OYo]uh[i E.6Ħi3_9< Td,P1.dYL(׵Q$Vшx9/x`7q IQmLDZh^_fȲ,:(HuK9:>2?|C/ BPEmaϧdXiZ乬% MkgG?+j<_ \yR_hPqg8 9O^>'3$i~#~?eZC: oyM0zTU˗/w7iشM%8Kdɏr6wl7y|^OQL&°a xFwdiFfH(]XlYX.qń3fq+EIQJdlUUX,k U<@/Yqq~rfsn(G hj1\H|`0P%8=1ت*e.0t4M^ilV%EQ;DT؍/oGٌj¡ijXNb4T]DZI"O&t *d 2H-Тn(ELG\HiڀDYeIV$i3'q9ܺ}BQ*TUi;uR]$Ķmstt"˨gbͦXxyU#܀:u.'$ a0q~.#i ؎gZv>$X,qYcBa\0]._ͨuy-L&hnXmKYU\\^:neRlr 3r֋%ӫI88C5DUE84@RA4g 8MTMj R4M.*<צj|~zsMz=aݲ^) l6, Dx?&cN`2I9r<߂\0[,|6wtD۶]w1 ]0M0mtfruEȋnvbSdTtk:Ca8c( !ꦡK$ "'M3<JmLf(<;?cX"5-mQ %ag0>ACQULr6@F}Ol<~g6)QssҔib6M]t K Jj fM] fy!uU&ԭBT[KV9T3|F>$ohkZno7u;U O??_ \竁/դGmqEi BKx4uHɄńMQ>ay.{Җ%s`&7oh0Mh%>I4q]P[ S-DSX8E>Oy19xܸ ,QiBi[Ͼ?} zo IDATboohj Kɬq@.r6-6zP4 ˰MpyC{;+wCڛvMYsA[-40Hl zC4=>y1TqM[hoD^ #Yr~~.n`#F&qLlfWS\Wl.TuX, |s>eNԋ}6,z>*a4 vMY 5$mP9UQN1~hϻ.}}ސcn9vtkZ yڨO|SVszxWhZ d"̲ ǶP54Q(Ք'_<%Y #~- 0h$Ӳ B An(Ҝt9N\ a;fGм΍F{(*FJa(EEEYV; u5 {X+JiFYAp0m, MO/.ĀV{HY$Y&du$qZ%dC#ԍ@s7U#zZIPQUzQ$@ Hq&ȜMQ,sUfE% iYff^?j"dנ8N3cEm>b:v!^D&(_I#7bniHMPĖh4gZg%we1 X.Wl6k9i/$~ m3LÇee'a 9n٬֔I&+ܾsH(7G4pԌFaT]onoHɏ$ۄdPk2ŒzCY )DPUU, kFVk~4ud9Y8IpG'Tev)$ID0N&}5͚.;hL޸xԵr9& C,eblO\ur7Mމ˝Y–llٔl zb~1lȶ6 0` HbZv;;a'ܜ:T~9C~b$ܗu~|(jQWWWa'1yH*LJ"DIL3;R.3iLVy}O-iXoB֛pN lih۶)˔eEQ[u2>c,) ˲p- uA@Lg+<~JNz {ѢQ*)XPdTMEu$bME[e|?s=Zd6I*h>+8wL$EBS5\ q UM{~{_htW O|p99;UWuM6]߰q ms2s>E뜧<9?#mJQw,TUTEFdQHU?{a(:7np|t"^ȻhږmiY90 !YH]FjT+HHf/Oxm~7ut <_ȤkؚVhl}Ib!|!1_,xM~wyȋ,%~3Skԍ> ˶IG_G|q.U[a9&/xo./2`VU\D0-se4dc˒z(A??whq퐷^Aj!5.ɋbH۱ z=TEezq3HҔItM3_l6"l^|r+[LF" yu\jrʲꪛb> wE$daUm @r.k6~Y]'Ǽq(QTk*NudIFUL]# C޻|`:oP%0 h`18q,F!4eܽOۆׯKab[aQ6 ia[E&i 4zMëDSNl6a:-.okZQvSj ⨓P)]7PVNjٴ.u->|NǝDc.(j}Z.\.jm zA G LL7zA2ܐdB\.wJzButˢ(*aHж$Y*+TUA4(ܑ;eh0aUY"7S\ۦF5,FE}F=04psJ CHBUUw2MBT[2FS ݠJ\N7Mw./?d:"2MGiɎv8vHðH=4CǬ7m-VrӰty-f(eAQIXhYF^uC\S9g=<^ݎp$ C,ˤ5Y %M$\G\\_Դ`XQ,0`0]"ylm}[vT{{F#$I"X4ִu%iP%EQB[u$㔴 B}a92!:f6"R$E Maz~A4%F#|t M1I&J(K^y 5y4`HEf|cG (+{ bT US)mSf➗~c;K6 &cF1ْY%Jxw p$ Ȳ$5 GG )'_2 98F mZF64˰@4Oq=vp\E՘N/u:;Ujf3K`2u됲lh87Z6i^e)U٨oՕ?g7c3)5sp_SY.՚*0'#N{ 0K"^NȆd^|o%^y9_F&DQDo04/("ϰL USIӄ E&*asdl۠71#zBwgtJ>|_ 4>Ȃ4l7t(Ah<'ܽ@Pu͛'ite\L/Y.W,Iʌp.$a6r5+n*4EZ$Zʦ<\AxB%\fdy!}hvzM&B96P3MHSL}F-j}uau16_z0EH?:WLz=<{f)r1/2m\-;Ke\L/Mi[XWxAӶ)ULhHwtbᯛ0<8:!SBI;%hYFXE'I6jZIfް ЊrttmخXeGc../e MבeAP7 )Q0ft:M:}GA Me[EK樊D+ȪLv턠5E K i$q,Ij\fy=Gha^w|MUIC7lKlϱI˖2. $µ-n\?awfTyB)nPd9 ,}O>CptW -%Ob*=0|#mz(zGY%Ϟq~yc?bbzG2QUnikh|цkxEХ, T39E]6ȓ%0ڰZyiP4 ]ĭ 6T[ljLJG a۶@IPu%vxEQ,MǶX7?<<^%zuӌ0I@F+i TEmn3")Y^~?`P-Ex[ >uP5yh:w [YbӶʠiD"x.jc]yW7ߦijzH5e I ,Iq]^ 䳹0G 4DcY6>;m0ͷ͛X֊bXs|~|bt:E$& Ōx8(Jf,rzz^(K{3.ϧPW{6{)Sez I>m]S1UYar /s./FlO4[/6ڲD8r@Z!s~:g_ a7 e eBHېOB oɗe)O402/ii0,IV|+]86MNs>"=YFS4\CC )Kcr]_L ğ霯5J_/fߗR&Qg0O %gs9 qocBV丢ȑdںXhIH>|gw>jDQz=pDhNeDq e[B`'Ȋ0µ:ZSg9m:a)Wsn^^`]AZwT[!Dm]<|s.K7mZT`22`6hgMP*E)B%zc>C0@You:K/mZPBu]+Ih:[Y +(BdXM?ѷg7Wrh=>{iƏ!ߣ(S,[g4pun=wrO 7o1^P"vL@}E"IRG旗°(z|kܼq) E?`D4]c,KƓ1~bAYe!IXE3(dz>jv:؊bt^$YN5P]8}vƽPp4 &Ӵp:rcQؖE^1N)KN;X̗ܻ{G!ԦG=UAņ5̾АewCFsPVB:qߖ$I;_i(*$j6c,b~90Zwp$Iݲ G,WkEnl0Ο:oy4hB-N0a.hi d` A ꒺n(Y$&6=sDr*+.KqJQ躱l)6(@'aq]õ3]~RŻf;>]DaЧ?W3,M\%-TE}4w0 6!IVPˋ Lƾp&-X"JS4qE^ 0:gpsthmEL"h0NS~_ ;zl6#Qj6/p7`'"p>a~O&..xtvNg =n31 91!u-sɧr10uƓ ~ǰMIVȺՙS$Vd5 <(L~Q$dUvlҪq~v1:] ItȻ TKV&u@|;MɭǼ꫼ 8KV$,bix9 f)#Z dC>|K'ܺ hJt] ihj-+͟>}b/ϧK^۳ɳ2 z.gϞqyy|v%&B0c(V{{"K Ŋ{w^bsslplYU놫K$Yf8!K\]]u 9M4e:nB -2a|9`{A&ܧnq0Uh&M]_4*VF^Ϟp~zNSԼܺ} YqlT7PݽP7 SZj~G>{F߷[or@6H&<9(4,Zd8YJU֘AY4uj9劲*aœn6 @S4-U)z'|sy1po_[ochb"mE^E*u-a&p~yG|GՔ~co0n45r?/Ju:s LӤYnZfs߽lyE#5CooꋯXΎ@oZż^$3 -󂖖'Nbݘ;W^%K Sv UU\^4-v>YVuیl 0 m7V+$R(&KF#%Y$sDRL]5ئCUŗph.e I1Mv/uu6yQp?EyQaxZH GeI]UHKf#h5 m Y8*3 Ķ V%XC Ulr%IFİu]5G -kj42^s(\w0 ۲D!2Mtu$\\r\;ej1hu~'bx_p52Ţ@f4X&e,g=yLQT4G^(UgKe HNeEAd,D6Z~ޥRTT3hgg|Ǽd|#@V%|;@m PdnS|]Ǐ~C~so͛/?iHW<;vHSj\ǣ+ \Adjj U>z*圿xo7 w>KJ6!"a*zfųi*7Wٯ5eoo,Ưjsx~oKoЛ?c8 \]HU07^MɊpx<@H-,M̈~NSO>9pxpȠhoQTUNTeIY(. _S$Yf DUtYFUwB6ܹw~{<=;?s/p <VB!@E>+:}ky,Wkr"˜˯? IHT滗mT( dU!E ,&r>#~rmklⵯ}wXoZFIJ,dY-#LŖcuȿ??po|M^|6~ES6Tmx8f4 \p_duB'|([S|Y}1^/Qd U8=G̮񃀷~^y~O8,Kӻkan_%EV5co2ao|AUɳg==.s CwMԥhjk/߳njxo6<~"-q#|z*9stxHN$Yd 6Ӷi"ϰͮhʄ4jS,<<~utMǶھk..TQiڮx|Who w.n$8Kв,tM#ںΪUU1p]s ya<3 Y,W<}LLevh%HX,MmHudY)p1,KP5+f%y܊".YW ::5~I@vm/X,@:aXa!{^c5Mxk}ϥ.qrE=~p!r`\!2m]RNAT6؊W(Z@eBMbvIQRè0O[ϡOF4$͒VOߏEQ;.IՌ"Neh`h.ʒòldEAdY*Mp):kye& HSxϋrE4Y*W؞"VGz1fC]eh4$Ic^c[&^m؎TYB4!z"8R]jZUڽt]DzmF!nb:P U0[-0ZRU1iR$ m39<>t]8Fl"!m:rl kJ&+(EWuFkhG)к0(p#4ٔ+,CE@j Sñ<N nY6,FXR="-:"`0@342Ml]%J8e?)D$Kg?d<QVgWs8G7Z,_HMTIu&-~fEO|霯K\6]42X-E/j9s-~KSw$ KUUmY<|Vh>(ES;|FYqB]pf!NbEBjZuM,!)22'Ly7q|$6 n:IS%*::6h?4rw7b8<&MebM à, 8N .:BUT~=~u/L( :r֖(9Cף,,BL&M#;M|F]C|Gm嵗_W_AU D!;Ȋb',S%UUE?Ua.˝l[ Y^ăhixg&O|5La-"~)jf 6-Ay`8DU(shsTU0t'ܺuM3iꚋsf8&cO4͗Q&"ܹŕ0\;a~'bB@(L,ٺ"/KZ '|\.0uC 2M\CR$,Ǵ:#|JI&e 7bs'0Nw?;E!di§*dYbo1:oE$ xWen"Q UY1 !eY+ m^Y04Ml g(J$~9' 6 Ϟ>%].Qu`CjHSʪr|ev1e\4tV9A".~(v8v[op8$IXVy olEdyaXUՈㄦprlÖX#lCeUYthAd Clsi9D񆪄$ !I`j2G{xՊub|`H4i "\Yzqq`h{GGE%e]\-[nspp ٌqp %qm2<$0-\DVui׫gw_?霯RmXV{ MʦaŢjj Yeprxdi, N?y'ץ0{>{'TTMpV K%kb6Y%YYQdFzG?O>C7xK{MBQ/Ej ET,55}>y@򍯿/&јElxkwAigmpWxJdK7Ð>)IQW oڿk/B]IBIy@PBU]ɪ* Ȳ06m W'm?/x_䄶h ޴X.di$16w[ִ I2Iu/&yh@o6?C>Eƭ< ض$b$2& p_NT4Mw,\4ɲ neYryql6eeM||xkׯ1 mWuBUvEV)ֺT3ܹCK/͓6 @Q$4F7-nDX4>yGQ1 AO& CVmgs6ax0d<'1aciquubDT, h3@051I/tK `EʪbMWYE&N&ۆ.""h#JO+VN60h~ R'8菆zBUTZD1HdYQf!hwd 6 QSe9m]w8(l"4aq1l$"4M&O,3_ ?OɋRڦf/$hM`0 26uppӧOYVض͓'OD5&I*uOLtױ{;3F!)USt&MN4vt&YM0q,R4<1wr,^WxQUy|!w,͈kTpMY|6i#~_^cg2 +JIBV=9MS4MòlŨHM`s~~nUp]1HVk^zeI۷GWy덯0-f9Y1њn [|EۀύCT&=, p@AZO{LS<ϧΌxBoVv{Azex4bZm.m)rBX%a2O4 vy)>ndw0DM$e42ƫTUUiy-!Q?rE^˫!uSZcOH4gXJbn(*gg[!{{q(ʜ,iʚe+\GL#4(ry];6Lɦ=:9ee%e> -^q`84E3t4b\uu)S7p\[fmOCQӽ};bD+%p]g<qyFV$J]78E20~4`w:ñ=ι{mਂ2a9ԢkB?w}(²mCIߗiEt2kZ EC\q䘳sƓzs/.ЛtĕC8{R8K2+_Oo[e?_6\?e|?zWi ouY_ॽ+{{HrS1eYZ:;\ag2%sI݆9*]G_S=909ovV AZ" D!-S70T)mǴ0UՒ?!5x5vwf4n&vo,nFN(u4q4YV+~3Aȯ~]ҕ99Qڦ2v4pl[* ަ/bw|zsU7{pH P5T]4Wu8N$sZ,G?eo|x_.uP9`*eL^eJkUQp=2k C4M4M'""s=]e2!{]0$I39Z?4B>pRᐦmm>$`<P%d"'N^iN&_iItSn2`qۢGW4,)ӳ n߾bNJѕ+\~6mrbXrce2|<38AgܹsEU2-,`KT`7tb$MesXd׵iZ9(4M߿5or6B0D# %J}C-i2I4<A'0$J"@[֔u(eZMCY5 >SAz.2:mZ˲.۱'r(v% {00ݙbY&izRj6q\Fc)440iD#3<u]'7SIE뺶 9 = lɄ8>>6eme]a&aJY!éW4 u]g ;%Np=]7˒0b;.RUlۖ"+61L$)x|75F6-J[dj34ijƣ1 'kKDU`pU^G%P5u=嶮^-]*(TiNt&C4U%ME3 4M6m'8u};캎 n q}gY$qEgw0vzql2dhQ]u4c3CUTMA `0`8r~~.MRkx~@Vuh7-&!g+ld6Y^2z8B49t}HPy%[8+1K a!ڎbxrې&Yz[jnJ O>r`0|TEe0ptEK۶,K0 MS9_n]{|ԭ`q霨Mxi4Mgr|# B\Ŷl)*Ts\2_,Hxh:Rzx~5P5{Ї6aHzxu1 t9ߣJ3,+۶ݐͳiyj%1Mױ-TuMWEPl;mGzLcZzT $Ͳ#ReYy4]m=΃4 KMӶ>ȓY:秧hCo6y2 \/`ZI`3 `tMsr)e%!.(J8>>LMc|:Yٰ\-A A]eˈB4eMZd ^dċ/\j[Z0uH1-e!󀎪MuX' 'i{2Ɓ-#`41OQ5m7/B{ۤ%IBt]jjN/k$b2(V1á ӧ l|i,dZQL{`N^h4ϟ+Ft8D7]=g|%r IRjb٪|ꍢQ-{_OB%plΗ /:'΂)C/ĵ,,C#/rODc0 Fx44uS#DM*NԜ^\p]%i;+ G#lK6=JSw iߞcEQFR,Vc}>9F7ok|a;_7һȲrIyئYdYG~w=0LxWxxuT](K:EV: hͦeurrs~!uG?==F-;〿oW-_fUK^UM#qƦV+@J%9 Vηq=W8?`w:ƣ1cZfRV%e]Gy.m}{mrxx`0؆4Q*^b۷?74x7ywcXZP,//Mh$ȭLҔ _NԵQlL) Ð`hụ?!'y˯yp0ip7o| I"`eQ !Y4;<~8 \~=mH €|NfXeKtMS<{x`w6❯޾ym;tBJm+z+GeZW?&rvww{zx220 }rycv!m'1ieYq9==meFj6 ʲGqsOl<.UU>Д&:9t*\^Xބ q]C7I^kㄌcہ)p0$ %GRZ9ewzuUV T,1mHn{gT=|B$2|ʲd0jPU 4V:Iɔ7ъi_h2ʔlmr||zfgg{06IڨlDP0)Ȓ"SaZ-]Ҽ Hjͦ*{%_(5`iLd eЩ Щ[9:-ׯ2\lק6?ömʪ#G6Q Jf|W%-xɘIsQh{~499yGgGv<|GPd5GWq*iGL^eVU |o(qZL'D1m6J0MۨB'FK ;YxG[ri/Q DieXh(@-Y10Av&G |_!6KN!.x핯򕛷c;jStC0plIai7ѷ=<<t~Ul_ kS))Z48xguȢvTW4uRމBYTV%^D>џp~~l-n^mTuCghJ]ʌ2 q,πTMf|ݾ}O#U兗ounp P4(&ZqlG^:ئe(NKGfTu_;;hB%,GXbͯpzzJYLӭgxl^ɲ+W)˚i%x[q;yFT]=䥗_^d3%κ{ͦeOʒ Ad0۶ g܇iI:I3n߹oJU0 A0s] - q\J*ov08<<|Ik ޽)e+A#ဃ}anٙa]ۂ6#?~kW;' FtP0mKrgodat$KyϏ h$mx5JS5LBt.//Yt 2.E$r*qoC4?ip[^ lwUQ |ȀhǶ0mYب(=\Rˮt !tQeChHƵ:vz}84WUȭa要m[ iZdYNTR6(4mjTUd2t*KㄯiMgJr`H37My#`d263IU5%MUk:U#56xp~vISg Fg~Heh,s6*`#/i[t:aX1Ϗ1 ]lfȆȲ,)N3ŶE-TlS GG(NݶXlgʢ$N3 $ -b!l0 "=}NEl:W^~r;P7MÐaYF[J=7÷VyRPwP Em# k6HIK)!u[OKǡeQL$4m+FI U0ّC(Xb!ϣEc6aȁRR55cc:mӓgxB7V1U^YޔW^}tӰ)˪3e(ZT<!7a j:MY GT}N2MQ/+l 0QHi*a(ţK}`#eٚ ˱PhhN :9 p[^3}뺤=Vngˊu^0\rDz@11J/Xsqrzvf B&TZ@i+N.6⦦vo7s/@ϗ[)MJi<~xEiDeLtTLf;G# UmjI,S54ӢiEQɕx^3./麎p0`oodB[I9j\Pz4%rm@$j3xmcyQn9h+G|؟#:l6:yE[J ;2HSJ IDATXmk7_7`oOijvblȚ̌붺p8奛/ T"+h{n*ENeNQ4.)+uLˢn[ʦ᳧O.Lx󕛼 c LAIӌA&hyiPfն-a@C;<`:1 (`X&uRW1o2 sqq8[v,zMסhE1?f1qb3╯|[RuӉɵ&t@Gi,kNNN89=%cWݛyrPIiZ8e4uC嘦x:P >'|5~e|-tm]JUU"ǖ\ELi.se|G4U^Y20M(r(VZX{YeɰnJyj*b@q,By-uU \lBdcKO$ B(Au$|$iUR! ZQd 4-`Yv߄Hɯ@U!j+Or>ge[(M-=UQh2ϱl4JVk,W^&=JAoJb}4U2fܒ7Ba͇@G,mYUXnr.Iq>64g4!yUJ٧p]hHQ_U]qvv( 1jxvyhQۖdXh!z MȪE1?`:E -{KN-bK C\Ҭ`>_-UNxBtd O(<۲{znkHm7r@uJnCێ 1۝L}zOAIӔ;6t]' 0q ^ DYb gtiinɳL (Nr@tyE֔UC ]yKl[,IS楛/p~b9!(Q4U% T@UB2 ,F,*3 l$XYF͕Gc򪤪k Cr>iUQ rj:tF̟=;\rq4hq V4UEfZr`Hi%GQjO /O5guU]s1c +sBwBLFQً~oϧvl~6KI/t * Z(i!4ñqeYe.&=8RT!aZhQ .ϙ/hf GCf3 T Ua!*EU \,hd]BWeKkDeɽw{I7p {߇J.I,dň4w}g~|gw?ӻ6 oK; Gc4C'0U4Mjn ,4$ͫO?r@ٔo&oUy)PŶm ݠ$-jT QMXzwryyګ_\~ !MƓe]2xܻw$f[׷M8[ O Y.9'R\[듦T5m\1MS%/E7n CV| FOZRmdfhضDo&ac9VC'<|}Xa乄r}4M'77C=%rƃ7|=.s\ϣ(+5 EN7&3>x2ȀłYV{!n6aXŒϞmSr(e+hF}9,<4&b4Cm3ݝn 3[nQQhM>9(O8z(N蚆B˥ܪmlx?iwm6q\, .H ]K' O"EAnL&Vضl:!HTUUSE?etlKDiul~q\TU!fXmBMS'%3id!z4zK3oȲUR湌kUtʲrFߨB"׍9rM7{;3_%wu-IS'1AtD5m h9e3S gMlBrASpoi8==!U%[a:v-Qa6鄧(z"aHX3ݓ40 0*N pU˖e%; EkׯsReyIs~idڬ4Oql/8,xMSj"e\.׮]!EI) ̇kN0],m?7t?+ SU(**J$Uch*7 K:O>E3L Ӓaqpm$u{jۥh&YY;府]8?>eY>n>0d((4NPel&q&nƫ$O[>+\=-~vl3 7߳i*@tg32'9ϸ9CF5FZ%'g ^,-yvS ແE7e{܊/|pϯ֯]_8+]|~Z<c=`g4,C ˻aMdD4+NҔdxkxuEݶTeI^ȀZ(r hi(0=~ď?|+\zߗfu]zEUגEGUV1(JX(JQ<>>}~8?77@:>_(V*y`KE z?\uãCn|/P5YYS5]ughZ{m^ʖdZiOO>DSj|ь,/YTUtgf c!;)g|g(žo|!y'1m#_Q& 8p4"K\ע"rn~() xkosuV똦ߜDdܐa^o|A''g[_@cy R۵NS/dYAQt`5U4̭tP t$CTV9eY)y*<\@ EQP'12* 9X^HW 9(ql4H q\4K{U3l<î뒦)EQt:(H1QʲbNɲSخ2￲ |t]^gaM~,;4=wS$DR](k / ]n7l7  v^h0 Q]%$")"E̘Ɲ<}NjêUo$ՆI0"py+6M2(E4b{n/mnDiXL!eSsXbruC\Ko&U㇪Pv0X,m IQLS"!wA1TP>aqa:6YUlϭ؛*EY$xF Y eB]KOj]I`@n6<4u^y8:i|Ϲq.d8Xe[4_TUibqEUW<>ak|r|?r,*nT5ym;Ea^Z@ @E1ɜ13+7\ݾihu0 yOd~&l֐`q~Fq0u.˿_k'|_[q+?GyW$cL="OYYY1 #%I;l.†5]C45eQFzm;\˴$)/34I1LVu\7[EQ׽;oM{qxDT eg\m[ӣ*yT`[6|OL6ܼv{wG\v3fU |n˝, ra+uQg9'|ʼUŴL+<|\A45MISܘIHAJmiԵDVUMYPը-8GZ~_,-;wyXHyXضh"K+5M7Ze0O89[G\\"x>/6 0MC7( n6+=ecɄr˧(MƸ[1GcwU7Qu]mpBJ~߶[ae V59a0oMS :ֳKOOHVK<&e0 hEUee)i1d3\tlIk}sqmLs9uNxGU?ct,kB&c¡<;å-( nȘ((Ҙ4J5s- yU9UUa0`<ܬ9:>^/[f*ruL&\,6<}z*090I==C8eg EUa6y1pXxI(.NXWG|_AQB˕w]QZ,,K4hK唵8MX/$v4b.m-c |MbI]hl:uE76Gu4_?ſ7ZR˹n~Mɫfkn݁ -:A0AcY%a(&WBS ZEa[7o0‚WuwEIQV( m#AĴ X),lʺ3}=>3jw883 C Mz@E7wH\V 4E1m U.F*IR>c,57o/ңT܂hiJό`Z&i馄(T ڶAj͟j(ܻ}o0NQ5p3q&)m6b`UPU94Rʣ+ qy?s>x7KQT$iJ-<#N%jj.ӴPXEQ$ HNZ؛ <4 \h+s|~?ԝv$uK!7ﶗ<[o$Iz|Y52ȔWp S0dTixs= IEQfn'Pq)dӴ a\\-QD[:eve)s2YoPM9/>kF\5 uհ>]רDydD̪۟c&UUe٧8E-ѐp4 awrf#j%7kow]ĎzyIL)@D)?sC`y"e"A!j DmU CǰL˔zJmjܾ6#2~!)j.BpnpzIYݳAmAm\E2Jf9JelS!7Smchx2+I.Z8?`ޠ ]r<`vCYʸECp(FΖ*ˊ$M$lGMi"U,eIy`cۈc>}%rM^x>iI0B]`n[)AAʴ ˲M S \Ƕyvtěo'0u޽kʣX膁dEFUWx'uKU5y} EZ. O?@{w}<t]bt%ItǦnm0lA8!04US%CQOO8?mݹ|yFYBi4@Ѥ :2і,dw~aZ:ܴ oo2o0yjijʲ"M `ti2FkDӒg)c0 AQ]r|t̓'OI☼H CnߺhI 0TNDZi`YuE*$qGʇ?{y饇4mihAKu,˦F!p>d IDATShw!B\7D*FMMeخD1zmZ蚆h͚Ϟ' #~B?wŻewpRz7{HNFֶMw˦5"2 I04p6N:I],(Bktd8 9i.;ou#CMUAQ(u#þ!j.rBG=ɲ4:$ʉ!c?ͭTe51yӶNɤ<`/=PpؿF,@$ k./XD_]^Q9c^\../ CFk [n޺ff^R2p|8 >?UU6Irt`o7pQ5a}ESDz(q}h4IrMT^ 8>ӑsEuth,:ګ6 1Mӫ%_ZA] o̹qxtD@JVV6;Xɶ:|>(AdZ\q5bdqqEd؆}M%m/i-]Y.Avŧ|cʦ<}wru{Dۛ y.eUruuE8Tk6Ҷ-]}'hʽC^~%:1o;R# @U58(RJ( rI|s\LE 錴yG??t' f!k;xN.$$²K'h)ꂲ@5PtiDLG.c8ryuſ_?vlok3^xo^0@ T] vn]eMjO>cyq|[ 錬(=0qd+iX(UE˦/wpmeVGlpO#}Ajjel6ul,[GStAR54M,˞&^C+DKoie8Ol-#_!dȬ?h{m}߶4,sW5mЈE4蚊B! ]g8 uh{Wъ@So5KRMfݺyI$^*+c:{{{}ûiiw$I~۵յuu] ;$)4@Aa\t榪0-E#,צiũ G8O&]/ȼ)۶88اnQ%'O1U=n?D1ZaZ坴в, MIL'U59zzv`:ku.. X]]є9KA!\9PWiQV5Ŋ<ڠ [71?agr+5 N2#=ڶ?/!$<2\Wn?v`:6b2"T8/x npm>%hz^^7,K<'}V ߦygUm! C<`jG?s03N,٦UUb&i{>x*[FeӳSՊ&/Ҙp1 lB4_5m %za"GZ:aQ EYV5e]іUS^ m Tm!yZjꆺ0PUѪMKц"2CSM$Bi@ilۑ(z1ف~6:S3v+7EQ tϵq-Ǵ1m PTgbV C K1|HUZu]?M /_k" ]7<{MUWE)8eؖ:xZe,-}ä&4-eszyƓO[ᐃkLS }95.>~G)R9]CI-.%?4Iy:>9뢩zk[9͍X"L4ppҔ5ѐnxo>Yr5>x%ƃ$-P4 ϗʺz@Wn-QIi[ ~%W2yco1t82L:KC .%stE% |,>?zRTC^xo26v=ŲmPTD'&.Q]Y^dD-'Ǩyͻ³e>27_9xF]*؎I#km$EayuEǘɍ[fh)MVK45uhtQG?g"Ib!*4*+i[X.W`=V4 p=h2@%|sV|[u1Lj1-qsn6dYeT9(O57^|NY6X&%;\vxS4R4Ǵzcn b-˲V;>>\.2 ۶Hvh4B ]G‡!çێh*8M!/Y.UIfR檪-=8"ˊ'%ER&ߗ1 m9aҶrKDŦ(<)hQ4I++]Xjh"e=BS-N .m'42hZY%d~exva6h˔pB"M㯅 /PU7s}N ߢ:nENm#Q],44D ǕҪ*=,ٳx'& eQnȓk0˳3$g޾3t0M߶(~;j ZeOY%WTU06pLSKq#ZT$(|nC;;ӱmLͰXE 8GJjQXU 4M'/8zo}1 UH:2oqI)j4cNP[7xdeM^4L'Z$M# f%eVl=ϗQbo(PT<9==ۛ#.r]F4%*ҧ t4I |0&)yS%)Ag\\,>/>zhHU$UNUn4>]WPQ4IUk$jy?gB'h0ttEEf`R)txހ8:>mNG >ASM(< AAnE+T]zⲒ6àn%z]1g8+rQ89-qqqqNx`Q*I J]38]߽?Jkn~9 ׯ5G ~cL0)*E"چ;-*ʜEf.0t@2,΄|vqΓOl}9a6ia}q1UYRB-k)($ir/ཟ{7<FQ߬&i&iUТ;nH\bh/OO_矲7ܾylx4K)d#V烐ţ+㫪tE ~ww'_ Z瘼|[fom;h`DUZTB`;<ͻf_{?a[UYnHҌgϞ֏_|J0 x;Ln(uLT;,,4eZw,BM2Dz 'uI;;rqqN޺&nY'}JeXEyv( @UUVew3 0:amZ.{y)Qi{|g揉ږwno~0jrtæȥ%#sUULɉ-UUȆ,{D[߽͵}B?>4Lʦ.+,۠%u/%=/ Zpq>ḓ7lp`/s(LS\e^KIYek$]׳#- L$"k=#i-K4ME4/}R;~f[Pi2i- Bxx<"+kNNNL6 O<鉙@R7&QZ-ɊPVʲEi`,lP*5u4MEԘ z:Z7M#5uA(Z8c6dILVsX]f)milmqm؛AQep4uϲLݲZ46Ӳ!WG4Mzw]ǰ\F!U,:ݟ ;j"Iޔݽ5A@4B #Ʉxf%SlK!nj jZ"D2,znvAy?6Nh6L DSvqR.t]H򄺬PQPu2K5yYM7ڬZ_g)eQ2 |渞GfdyʛZ-*uݐg83m8s<{3t# >c ܿ庈i:VF"=lOI6$M71b21<) 2 CA]-(K<7| t%9734Sj AmDUS 5 a?x^k_u+?gf۾pm[i6Za ᐁuyGetӖ 21G4MϞvGjA!wFZEU;y]lg'y)rx&sFaH8 &.2HNZӛD:kJOy71 G88TMr"MS4Uvel[QZ4g}̟};Eiܽs;h=yY:TuMSU+2ں{G* ){71(b2px K3q@iʂȸZ4ܺ#I]U=@JPlf1ьBJ5Ͷ#%|{?_|A-J^~|Ea]YnYݻw988eŢ:<MJ26 i1Li4yhuQn}ӳsvl>c[4eh0ٔ f4-u- ~yQJRe!ږ(-$I/$dod8Y)3TUM|xiZl[8bZqy~O9;?}QUI8AǙlp$⢟sq)j#wOg<ߧiD;0tY$)kׯQ Ƴ YQҴ {=ld Ӑ1EQpXҴт*gU9f) '⺁<;dz*kG`GdEIMX\Mܾ}dF#䶲~s#3UEғ"Zf&c8phF0Xm"ʦ0 VnYX$6B(ҷ\EG;{.aQ%P#ȫZmL PF!;lVŲ.A,qgNk4M,|IśM*QEg嬣l]eow1 Vȍ&6|;n0q QW!׮_d xx2 s`r說R>**e^92\F~H[ Rg0]E|e%$C,EI,Ǖ 88ا T ؎eYL%8njKr CJsKI]n7 UZ1 q.j8bC-e*OkIp''>;-3LtGW&,7ew8ATe)BQBt QB(/ Vq1G'r:3ϙfC#-tRN~?7-yeK;vr<9;?, ;os5QyKe UYk"@N@^w[?$!/?z$ I7@SW|'_y]v&]Di[>{9|/ȳMչsxooLp!yQ`8"-s Ϟ>%IgS|'qB2x8p~v[ogx[7xp.`6US $K]m$ZG;NZ.WDQ)HCѺ졟3~λ+,WW^f~&`.eQ``F?:|>4M4L}Ѣ:7Pp;j>䓏><1׮0 8M ClSy. IL6kݿ/0 >6v({H/*Dqg~Bܿﰿeۨ庨J0LLږ̡rqqGonܸi$I"a$c A!M0d8ZmX,8o%MrLI>uWEB"Pȳ4I\[f .{\5(4jq%V9A຾|A0,+(a^cZv](˂({躊aqBYlD0tsz`YV%rtmDV]<ǚXctq/Y{{]3*;@Ux(u]IuI[V >\咆7N,^^oHZV4 !x`*۫ ^sN9\mKQ֫ $dDARe]^蒦l50 O*`#"P EөkE|_Dm:F A(/#!/R82ASUuTYcZ 8Dьjuc&I$a uIo>b1- IDAT2f3MQvKcl1eR erXC%$yA8aiY&JHSIݫst\NvHQi /*xjKQ4t8,#mVHyԵ$J_d< 6uU2O,خP:~HDԊEQdEtM|4a[EQ&iq]gqrq(]7p(%n=/Jdx4a0 C `XWj ]S8ɗPp=G>盆TUjTU.eY$2ӯaU Mlc62ôlh0u.E14oYq숧'g3A\:⌴hg~kpr_ov}m`fȫpr}p$uՊeYu˒22)]Qifqrv>K`0$(*m#1i&AYQP3+$sTlVs=ò,n޸;w؟ 8o7۾܅oq˶,PO3i?s=;#eSTMot<0-ttW k&I"{$H0 q]y^P5&2MHO>#MUɋ/|zPӤ,ouBnl *iK#u#xy и~swnQmAޫ~u]!a8u],𕣣c>#tMGmӴf hضM ֫%!%K?䤧dH; (jA.h[3`7\JtzYmSWLb i |\a:-uUb6ٌ<ϻln׸mۨ6tLb"G(q}u]r) 4IhDp8$YnPuyMUPߓQU;4vrPuYq%!,IKhQ^ |ðQU3...ǽͲo/"n( hku8;:(Amyۼ $erD-mm_4tM힍m2}vتz.av-E2PX#ϖ8Zlb{Ij۲e.ͦ꺁@ME^FwZ:e<;#lPhܸe9ǥr"!/ $g(=sv/ES34]FUmBYAc*lFJa9hɯm陧|nO{muM:tVR@ jP 1@% @@B3ip479t_ZZ w bIB}f}=Ae54 4C d /^yBFMS5hAG*R׊PN4V4T3 B˗Teh44 ,CG!tqp= pKjUU!-r,)^@fޖA|?/|ybߞwn?Lߴ4H(G! x*W](qL-D}6E%Ϟ=ܾ}i<}XQj'[%,m EI, %d\rvvpt:% G^fWwݴmɋߊК !L"OO2yr|rתklaLt]Gm]vuT]\]iS8:ܹuT,A&,I%ٛNµ\FExʯ>Ռz7w & ^b;eQQl6#O)M]G.&1T//.e覎c[1oQRl!F*w[$DIiTpA 45hB)&|tB<ȶ=)3.7{$IڻyL"Cױ]Nd}GJ'l%"K8B?~l6}4fжn]kKdY޼y'IS\y `Zzifk -4MTu9==U7]GhX7 68..BeQX,逽f3d+-iM \^]1Z>l֘AS)G Cx0'9E1H6<=(:J] Faef\*u`γ(ha@4~ltb xA'5M{E`l[P]ߑm9 lsdY&i x1m]0LbV'`4*u;ND|ݴ:&ޏ۟)Atw6]=,Һ 4M`Xu Yٰ_{],S^UBѣ]P Z֛ uUe]RFC+YvT=fPQ0t0 dYJXE!ց%GfY{j c+woq~vɲ,_szvAZbnXL'c4gX2  4 Zo%3JV*z7bo@JGTmWk5B0=Gty\.8;GFo uLf^L ˶8;;WL)hA(֧5&p]?,+8;=4MXo^qyysùKe,˝x;arPbep8Av;U>v\Et]Vn f9&BS.@Hx\]Kaz[8 E3 TMPU \mrBu#icZaU0(ˌbmZK4!sBjI{Նյj2zۖfjuhc^r:M|d:f)hxE_vW}Mm n/m[B]׻6 im\F6J O]\)oޚ*j\YCʪkV5ibiB.r $a]e$ y׹8"-J5&GC AH.זp8Jnp\?r9qT5,s'7:H^Mhaj g=$'KSVIZ6R$uDA@WV8kCJEiigo/Q֖ys}FZx^U::7Z'<]G{ "XԵ4 dӪnnQ`TNY.uUS؛)LU!eK؆A'|._< ]"fz- IҔN1Ll1ɲ:^!D燔s0B{ܺɋ|9|p[wbjf{2 FHْ O=? A&kF4EUcb;TyÊ9ՊO>8(f4X'u=h1t dmb,qI&C 3k1[|k_!=Ҥhk%m2%˶N4F]5tUMd4J|)YXA!eF63p#-h:$=5E^ڛL MյVߨ/e |{Zno8!:*3t ("bG]l* NOjw !4/xj0osr|"hr0mG*\t-rv0zQ8=??*9::d7U˂1E᦮Ȋ*7%u]PڡJx&_?x|kp-1Bfɮ4RɓQꂧ :`hjCT<ʖ$x!9nηs<K BP`ؾʮEUpyNS)+?A}wNn!i"i- @XMZfdEjBu옦EYU̯LFF ij\3<| t|}%x~l]->j"4m7G<' ݖ2,]ѩKg"͚QRBʏ?O>/ΛokoР, (& C>jG5W}Egz|'|Gzߵw#P'ea@HVE(JbQ![gxzͭ[7W~wN&yfxbZ8c0q/Of;Bڶ,91N{!t$ȦITMiyT,5rGtV kVu PJ WϣCdQيȒ 4puAڛNm@MrzFYmE(0Eă^R\ʢ<߇}P;!ȳ$MȋAxMRV9yRfQ,:xδ4U!] M2h$IѭV+ l2zsmےeȚumOp\\\b͆z - ֫ M8|Ie8fd6"[MH=c,l>[:5/?,#NnPiNE^ido%kn0L:.3F˝^g2= sN}{V0 #y5%L|坷NM8 S RY6{u]EQfn8(r B,[Gt>3jҖ%zpSRB4 DJZCy$캮∲H,zW)A]״]i軸U\̖8=XӃ}A`4PREvB;X,&I2 f̖k6IJftMxrxI佊,*bۺiv-`F*d;L' Ϟ=rj%Ap4@I:BE"1 ٶ1BZ_,YVx`BvjpkQ?g_sϗ/='Ԥ?UGiI2O0upd{]S/OvoM5M߶]%mrroєN6Զtx;Sv͈o'[뺸8IG|}DuP]("|8ƶ,dQPƪ.StJ]\`>_Ϙ/ܼ}o~]n޺k۠A h9N$>Nx.Mײ<\-q$ Cr$Y|] &IS%UGuuEHЄnxchB9 #ߧJ~/|Wzq],ݤIkdM4;Єm+Wc../]/nЄ ?/r,<ztQ?W%Ն[|[doo8aJdȲU4;Sסi'1N/iMD\ՉxRvW~Ȇ[Nwc*A ;,[f笓dP"taC~Re)ouw{( q\eCh[/Eq76Z'|t>zwwoV^Mu,ǥ7Q%UY b<}ޔp:kg<ʚCW|vm 4h\^\e oRh&Mh$]2^\P t3㉶eb.nPV >łdy&PW5D̆n!vy!&7 5=pZ+lTAZP"F]}(j~WgAoW$ىS몢J3LY$9G#1DᐪTowTBu,ϹWjڦ8&]4q e7 Ȫס6?rsDilZS71LqlòLPm=Oyꒄ8耶{iɃS+BOcztJRF BeU21-su񊽱ϝ{1F?/٨i?+lGyz5ttȦbtEa9ànZf(6+nܺѭct M6H DaތF Cg>Q%UY=r[Q^:r>*RD2xaf9蚁eNR50)Kt ۤfܴ/?c 4<% IDAT `8Вx^=SI^wm=U э*8fy2M' {٫R `4 N74UMzt.K4D^CWYfl*-BhצkA8!{|mLLD%dI+ͣ0?o_}=_^H|hk?#0uV y`-kẊ8guSꀎQwVhTuO׶]xzft<% Cd/%Vv.l/_[]YuMCݶ|Gȋ7np||LġjABuRe8hjAð9ח|tSp񄣣cрZ6=YWfQ U&,%m[IumiYlkZ 9>x|H#;|_G0hL(69Pi*mAY(ltl\ߣJ`WV<~S.θy&~=F1e]ܬ5n}]Fm冣!3,7`,KM<^ֹ^oX/Wqiı“@x;~_\"G蚃e[Йtft$IJYtk;­V+../u\]]\8::޽{fG#; V l 4uF|\GQɪRQd- zd{Ǥis (*֧,s4òun9!Ds4]z>W(R`ANM^l6w_CyQpt|:4mUf9{>4'iBg.AHuX/Jq<0,8995A(BY>U ͭ]HJ5mS9V:2i[AہDu|h!h6g| C]bTmpoh4"- ?{Fxs4)\_}\d |6*Z8~l:  )ԅAQ&1Ah@(M8TefضESeۘJ`\ԵXxuu;7irn\ۨ`0 $IHTã( nظc,6NO988u{.xOHd9A[ol'/rݷ1-6,V)MkPV߫~FN!41t5uyb p,^`1{5]-ii麆(iچkPhʪwuVɅi!o}9>#/Sk4:FURXVd:2_^]nRbgΌp\ΕӅl+ƾǽ'Ym +d2~kpI#5 M+ _}.d'xW!k4N͆fCEjF':RUd.ayM[1_||Nt4BacZmhF&CZR)O^<ď}Vy护D,_?@ɄtboV4MX40lN2/LVk~_Onqє(XlYAFOփ/Z: cRX$t2pɶr+֛5IӔ&ݷq1EY!l:E4uိ+鴲q:)RWXM kjsv~K>x07{w0une.ub?`2Ch%x0dϮIu."UBؗxMAA)9O`\Q ۼk'Tlj ]5)N_F w YJ/?5dĶ FɈQخr42񶍦~"WWSE,4'\]]9.ܺu=%*f$m^\[)Y4K>xWgvDQGGGY^ c|d>eQ MSn+Erl͊۷nowzh#zyKvX]G]u"ӵRB'h8<>>g^1M{m& m' e蘺Cӌ]k,IJ-BiSUaᐺig?n{6=NKlE:E]b:6]'vw oeYeȶ&Ī3_~7߼xALv2fh>5i٨-~zzF&~@UJW*"8z !7O!4-2O,HK]hk滎L۵F (fs"`H` UUcmim{>htaYb44ö6ɚ|\X%i;AzTE9 !H6 ]fƎn޳K.^]]\.p' mfX`%uigpHȾgѵ?qxO!ma[6P.ݲT |r¤,.^D-LJ{ÐAg___]nj#:fpxK< niVqvR}ökwmTD߫|Ղ+D]2=Fq0l:a`V/qd%I !v-,i7 C:IJ<&}pHUpw_c XuM[Bw^ĵ}7!3F,̗3\[c0kq]!ԇs?yHL#L]#btc^NR<' ]m[|WHyEYCe4;qM:Ӄ=L&1Øp5?)/1-1Gh#ߢ 1ɘٌTvSj!:66BY~Itضi9 (*fhD%KŌA`Sw Sy@ 0Gq绬7)< Ǎꚦaʪb:[baݎ0 b=zHUqxt]h !CNOOɊ1ՖMOSyDjB7@ZTj0İ,dɖ1uw*M:oΜ 4ʪ< {siY"kjDxX,kL4f^>Or0DEdEkc`>TUzI0 I6kFQ]~qo&A7 ˢ*s6mh@רadAkÄrbtbT)hBõ][2\>!DwHMЊU -_A yp5?{ߎ/"sM niD>kZ: DVҵ{>m TxvȲK../HӔ899x<Ʊ%תWInUUlYc&YSVW?s!ܼyh 6c@.r:eMۯ 4M,R?|v7nppiZ ~x2+ vQ.1iUġrEU>/3^x{|7 btCQd΁#ۖbA}gQ/wfKU E rLu }Ov|7yƣ1a4Ĵl55,dSED^l;ʆmu0M qToIhԲkk8uFޤy}ȟ/8}qj G.\]\MKP4|wף#( g:!UUqM,+V1;_ohZPȺVFej  LJSorpp.m}TWJEZW = ThXg|k__*{{F^7 !uR![ zR4Y/k`H N2(LG4GS-z; 7n0/xehK5 S%DGT@m=~\]Il{}io"^DFFF eaH,  Y ĿF5YQveUtEFSgb9)1 Ėb"nwڵ!JpH*r<^ixhZvږF)d7RUX7Lz['Fo3/^\,]3,Baj9SYM֛m^[smR+U^b,3 Ҫa}}688:)YY*MWB` r> l&Y.xK>~B'G#GG][Ei{I]A؋ꖫ)+\ F2FF8CZ6۠)l x%EN۴8;/ψ3lٰ?p|| [t6MSkb L8Iv-SӾp|RUUEմ[p=55y)U!pzM{@Yxd8( ^BaK[dei  Y3q\8`[-Y"MAI+Ω Px92Z-N2"h6fCg]dVݶnsmٝuu̦32998مU o4td>/xA@]Mi1" IDATRfi~H?D4uǖ8R{,$/ !Aɛ՞L)(|9iя"nݼ30;61תmmw\H)aYړ$Ɇsqqd<舽=wl6I2pXqm ( \.֛:7n!e] p<wk:q&ifJ)zÓ/|/?bܹhp0=Ml!"mN*}T8@&{8@b*kWc0^smhA,DR?!4k^% C_v86E(9:9"BfsrJj j2p\ua aI6 %YMgLgK,rz0M (,˩oD!NYBs$z+$uk2]nH6рn;!舆 $MRF AYU\]_c$|a =$+Jr*ptq,Kb#,*0 w8$AZT:?^N|v1dZ2y7m[kT8,+dAl{[F6 FruyEY$iBb#X6|)?{8N}p2X.$ɦYu&IXTmm fA۶!%m۠0Ȳ3c56xrx^3n{prㄪi)ji[&S U{XQf^]]'BpuyFCgsLC 9omc0EAd2A 1֣ J)"'2 8dq@{nfL)iۆrrEh wm,/)rMm7._{Hff$^Q–iVe2#S=}֠۲[HG ,/6U'I3s]0yO9Z1͊yۡ, (. #elA]d %Q4\kl6{ԝ\,ciEkƭdij<_.ߺɸt>zm9I0]Oo.K̶aJ0 ׳5ᵩۺӁ²lƨ 0 LH~?¶^)~c96ipn9KX?c&dKx'ܾm+[,چyIڝw,8h@TueBn\.k^N ,g4㸎aj 4/_Hvzr߬0Td0 |. .!sXVatWS c4zicVRbYU4mPaeYftFN&{cO?gXᚂuk7-7^aa ¨o:O&}mdӶ:X,CݾM$'ί|޻LndiE$ zi||zγ˽80M5Hgm0 I;So2{=}>G8{B{_aXf +)j6ي^ӛdiN2OSSأˡ[Ɣ0i}mYU!74I610q=w{&ՌW|qΛ"p=a) 4u|/ j>t-Qޢ%+8[TKxrqŗz=hk;4&I繄ŖY|ı, [7~@9T*`Ѹfzɗ%ݷ9| ҎahzM]BGJE[Lf<|G7(r1} ۏgi?*o_fK^Oi2hӂ~=lP&eU&ne:_F S7/ekGZұLL*B6L!j\Ss]|OcsPzxM!'''L& y]MCR6*4 @ay̗k~&kyϲ|r(rA|k@SK#V?KZ }l!M34I)2}##av'*` >Mb?#f+\1iAAS3Ot00ڶm 㓥i\ fL^O}%#FC,ۢnZ\[bEM]kfEB0 uJӟ)ϟ.Y^R&m ֫Oc~.'(RHϽ&0wMhs]nx4]sfRK1M}z;]X,(rUOl7 PJC}ߧEle Tg&zo߽ , IҍU4|?7{hsL&W>Aq0L6s @ AH^s r]Eҙ(nlFG#e-[wX,IՊ51hADH[)VGN\gR A]alۡs {|6#BNN9?`4j#mgβlr *n[ڴ9MǏCל7y=$sq ju9aҶ0m,a~c;6q!OS,)qlCU%9~l~x˷Q?’6Y^Ҷ`wc͋,|*r42$4eǟ|>S&L([]F)[,, Fo`uKҌq(eບxEU >Ma >?уǴJOOoo#@"- ? |}ɺv?AgϘN;a]CҏzG#R^7=ٙFE}-ٜs=| |wC|fXҢ 7#FO-ϧmI!LO]謶'Or~0px8cdEH̯w甛710p=ݼFs!E'1Lv|ߑٚWWW,gS͊xxE2]E%U삠VFJWUC#MrBL鲘MY̯i`j0M lǵza⢋^П2e0eNٶmX`iR{{^zMG!Ŷ=F&RyQ`zw^ ZPʠ(R7 !uJS,k$_^$lś mˑDhr / Ee)S4ue lC68f~} GZL{4 ufIf >(S95yVPEٖjOdq0 ziE GBX ɚ}ۺ(,r\[EYZ+MEg8G^~}*`Juq<4K;lةL :eLY_k6imi*>=nߺuEQjEQ7]! q<Ϲmz;՟L&;CrYUa$/xꌼp_1j]{ >?jw[-uX,:U;s%-xG}|{ 0 AD[B5 YQ45qT'OOhےo6oyW1y^i)u]wJmX,<3..ȓ-q>@_+5Aˏl3eU -3HfMn |GnjX☰i9T|:e1S5'$jzP58^aytެh?PETuEoOOF:97o  4EN آ*Pn(Lu^3_qqv.U5m1Mi*M$Ȕgi+F7nU5–i؆BJ 8'^ WΐwEHubﶕrWn9TIJTUmcI*I6ZJtrˇ,^?0c2׻ ,aQ i;ؖCXfa198Dx!LHY/)iF1CژBogY&q]q5t~Ȳi-p-er%TX! H[ ؖ$Os)jpB躨t/ 4lip<h&zAL}F1Pԕޚ+B`Ϭ)٬7ڏ 4@:vD4ꚬG0 aR-MLh@մdIj&[j%1(p2aN=?i~U7_j:UYmmxXR6aUVC/}lQhiB .¶$8]=t Pv&mߙ}-$✳sz(QP~rM'AP+*+./y޺{WWA0*Y{>`lOTUNVudӹ8E2 uR9>W~?s5Ja-4)i3(\k1c IDAT7 rFYfK{=2g1R=_XiÇ|Kn9>>d4"-C:b\pyMmAp0Hji81vZ&{Tc&`6cvt:ejx|#S͒}&7n441ref9rؘqDwE֭q6ۆDn箪*ΐAMl:g,fKF#<ֹP]cH4:%(4uJ)ǵ,q,j̓y^?b0'*EeU㸴mlwS4k&0USeF9>:EIbMs]ߏhEYؖcEC(q)ʂtNx#OmߑДb:EoJ)rD*Ύa;.&Ѐ yTUl@VWx $NuiUy4EVwX0"ϩ!-km -cNG}sʨ&n %QBa eдaU4u`)ms{* hM,lT]Xj%`qzh)ˆ ,aEQ#JnwWJ+>ڭt0 $aZ$Q~\04ϩ*/csy?LWR*?vCs }MPҲ ¯ {+._S劦Bu3 UnfozN<~xkM5&eԍuExXΣr\aCl|A/q-ajĿkZ4>4mtw+|'_Lm0^/UA[L'ϹkR߃7 m0$.lem-t_Y<]mۜP75U%CX5P>P}jW4/;5 %-e>Bʠn5$OSep[mk6_zg >z.&$YJz+6E;MDlaO??Q15?gH6 >ұ$#d[Zp-pfn*fBGzVF \wHv0 (ҜقeRЏ$m M}W\77 /p2Ui1O@5;{>#(n xK3;x|1b~Ec FQD?=om=J&kķ:Da`x%_mypHDOu5VnMZt iudmAsv~Ih4ݻop6UXe9IQachxV&8iTRjwY$6g7U4-!,xx#cry P4p@Z!ld^7%GjyTki᠏l61Y1;G|G4T޾X`OնɊгI7)`Br8rd溑P<#EAj߃6 )]^Ǔ70MZtesuu+.//q]W{d |MS#\ k֫GAHYc9.~z€je :#1LHluofѝmG28hq:/Jjx}B&jlKg5MnZ L]c-`٬7XbND(J`op79UJ\$Xχw(ZS)wh1i (F=u6o4M20jmVU5SwWo0b*m)p=`iL]f6@]!5V-b@~(; iWef e5{{yvDUjjI۴DQD<$IJ]썇x9cHtNCl]^5a` Aaa$kDXxuv|fbi2Ⱥxa&\O}W)yBQ'!5%tz`s| 0ivmZi[.4@c Kzz=8Iͷ(KJB?VY'%*8>؃泟}̓/E}RGe{2$>keI;z^<_IJPmM'GM]i ɘz^Oܼ[zFYd뼬یŃC|a}~ARD>i$M2q^ě7o3rNWZnE )˲jMS7r/r}up?xR&E,ó\=+6+w2-MCklz}(s]֛e<Ꜫs.~;8myqW eֲ571 `53.]ϱ7> ^|wO?`71EjIIdLSҒf)%$Gk+ ОtB=MK?`Y4Pv!|g'2-U7o{P58X^.2T쌺89=%,$!.5rT} _|O> ˱  4lY1&7;vkO/K-4vx>_\]q]~K>0q va 0`Za9b(da9[P! E{ 7YJymyIYp@UVL36eU9!mM[4mK^E|}-=99tQ/ C$,K7kliu^3 ܁5V%uvC9G!{nAA)m..LHM]P5Uh-y^oC^+S,&ڇ+0EkִJ0)x[Ya-jQ(&ZEtgYRP9M- i\^]"UhLflDg}mh% nRJK:m1aB·Zf'Rrpp@E ֶZuaa[ބZZ44u,2">s&À7Ox.dCmZ#|ˤluxn jq4;[97ij9+7ޭjYk\AT:s} !Y)EUZ6wYҴzux/%--d U{yPTe7WEQ LvT%e_l(">NV9iadUMa kۼxWg<}M32"2#~m=}GZsxp)R7 Ug!:&wWyNBhjϞ-똢7h ySW3MSk-m[ EAmYTE? /^`0}ӍA*FSWy9^b;6uYaێ]~/(Z8::u> G# ,LKP 0Uե܇=muwlG%qnX,키J@3??%qd‡!A={okIi=}y`ZCXBBbÖv H v QPU]9TVFdGx{fߌov"j6a/ܯs1}^鲲(r>ܳ/)}H&5'%p=5^*Z #?/>uM|ca8T^ EIA s@hBWH |ܽ$q By SG`'qL Cוt.xWaV+]l6i<bݲn~`Kz|ߤI$\__Ӷ^!gtEAAxlv# IVH7+'y5yE2aUW]ka,o/$SLf1wô4K 4a<BlƲ\Z4.//Y-=v~~w>=»Ky2<ᐸȹcd:@3B Rr{"iMvQ${UJ3٫ ia,^c&[ʼf%6G9 6 :tVR߄1MU )0pxp4bzp[^sMfsfCVtrXhi$iFUT Ơk]&dQ*;mGY.i*E -IBݩJ.S~6NI8JpHyP0 !q"txW~󊦒̧3>|Ѷ,dEDqLI)zn=SlHUų |%,8{pxp@`:I YƵ(4RwiUQi븞Br;V5_䫧O1~ .mж ݴ: rA+td>~x]cc&4 Ys?;<5@\@!5MM&/kN6OMI4AMg3GP0 -]ss|ٖ,/Ȳ5??d:?"N40LvK-۶Y.eռf2,AS U>44 G3L! n[? Ow@b&#C9EUQѐx!A#9X-iq=َ;6b9*#i`̊N &i84 "KY.Jtx2`8Z˕2$ iZ.FPvte8 P&dBc]{IaGUUapppHI.M,K<ϣ ʢF 6ۍvoq "7n[t`$վ+󜢓,Mcntzrr|oJn9?;#L0"ew[-u]@0| "˥zum3f]*C ^m=˪I`n˯6hg_,=peYvh 1R6Ș-g6aV:S05`#Ar7ezh -vp4RqIMeo;m!4|+/2˙uV SOMVSт*Ⱥ0Vkإklpd85? .472!G4ij[Ak*_{K<6a62p27h>WJw=RnbJX,5hDe777A#kմi:e]}Zc|_0?>`2>YVDQh7@G7yYah:VКS9Yg9e*L]' L,޼9WdI9Sʪ)+(}L"6*K?mp"4f'n1|ږAH] o0M2|c97%P IDATZTc+P]7D-SWQ6s8Q]#3n.AV|]ҬrBn <&K.W4W7giΣ'"U[6) ͳiIƛ7<%Z#6 u$p; ;f8*Dnnn,lo޼ꂻa68I|'\__8MlE/& Ʉ8ƴTHTR[q\OmmKe0Z^ B5iѴsPZH!Ni/?Ʉ6H(dުa֢PAV6dFijնe* SZQV("RFweٶЅ<[{uF{nUU{h{F6O@ͧچ+nV0{]nA(+h郷Y]_k^umlJx"3lmQ_"h34IdcXwٮ#^ Fɗ9~=fiGct mp$hj6;`27QA;! ,E.f"KE&kv&qE1v:ZWu0)t4Ϲ?)aG}=NTԭPϴzkۖ*1M)+*2,jR#~WW<tl4en,k4S'RLq9G슖 7 Oy1Yi6y9ew?]8E7fh \S@嬴B!-G5Iu}?5M#- nKnnmGq;n:m:3}YA:u}VFE";rŋDф;᠛)ȷ%uev0VCӰuIx3^.<+˺RCPOy !v_nl#C,#ys?ɏ⫧Fm4uCDh#`Or? 8IR *3:-&Cix9?s*}\ۂVvj/㸓db&PmjtV P(oB.5a@FUּxz"98sRiJtO Qiخ1DU$l윴NMs>)/Aʆk}NNOm&5-P[>p(BżonrvvEQ֌p*|Fe4tA" 8IU Ҕv0 xYj8C)*d[SIP-e&U%.fAajmC)kZ[[ !puZםdTUnWi:d `;6Moӂ 7tj-ic;+iRf*FDzpdE;ʪf2;F1YM9)Ecb&{+=oҕvMÃ3u$ ::A,h7-BzqڶE6j#4%)4  嚺jhiɋVE}4|=?&rw{{ۚL&ضM${x{NQQ JlY {4|%cR56I *s9lGTzx Ð<+Xn #k#lUD4J YwG6 d0 Yo,4!nKxiR%~Žh`j;]5Bp Tʑ,M0[05u8:=fmۄဣC Q9Ihٜя]/_ 0GH%Mrt|LE)=Z .E+sGI-rUMKWlKs1<~/x(y9~PRT5Wؖx:,M888h("ISLM'~H|fq{>p':y]锤YWEI.A!y^q_cjWW%y!ʜ??g5O_x1mUR%1e]x6YQQ ITv^Ǭ%ɔCqy4BwR'CfSqH]g2Qu%on.BH=x'_c U]`aq&`;t88!.X^x`N=n\[[;_$;:w _h[\;դfZAYxIqۢHe*_GmI`ЭJ7$dh)E']]fu$PO hA],V+x q=ݻ}8\U2BSZMl"-(Kdlyixw~x8"Ob,mdQ"4MQ-YRWs,uxe(ܳ mz"N3|tݠ,k hC4fQ_NL]@SR8$E/,KyFeEIU t1J+ %iJ Vk,I0l8ziׯ^ݮLgSfyɘnK]EӫmTeEemTa9IkڦjʼX.)t/ ,+OBg躆'cU$C%Zo F2WRyJ%[7~|{{p0`:q)?/ysZjvGd6.+^ ]anU MfR7B$qļ>b0'ؾ"w'vuUATMK*TmZ|Ysxx`O! Ca{m[, $iZ5q+IW"<>|nr´ ,[8>:&M?1Ͼ~e_]zk#>z}sl<,&jrnڲ%ksrza\^^a!ٌV$ijXA6ܬ<{_}[y#NN<-4g3u a!yqsszh*҆cѴ(e*zR^ .Fx&../x9DZ1 w.a"t,VM˒v|l"Jb 骪* 2lKiΕ%*Il0/^R8,3P-yQ!Q(J¾ohB=FT\)l}mn9fCdifuaEl|FU,W9b阌'#jmI|$p5iw^m[V*׶:N]78ѕ*Mx,FGGGzC0 05Y߮bC2i?ןI?b*G*(&M-0 DomoxC2Eo*(+lQFQ(&-E . s0~Enu=\#"Y훶s45mNHP6|+~')Vл@]kM64`t=4Iɋax^*ssO58s %TNPTVńhh]Ȼa+穀mRv+:Ldݷpsn߼g_s23!Mmũb%ͧxN@hi'YBGힹԽʲ@U- 8MbN'o-Rnw Uem7U*iٌ4M 8::d\XH)}_݃5 !`0T\ qy)}_+h)Mq;xP]eN !mS9Ӷ)v[ķ-#γ_aXcnvCnn%fdk}6Wy!aaihVC>|ϣstݠ툤`>ê%Ļ'O!0y)VZJ$-3Lg0=gD5U-ǿoTU;ئ4lJM ˢD34lYCV5qUr}}v6ib 5677k1oEQd) !VgDtervyA|h˞rH4BuS[N442=T!2Ek[nn8XD5adj' ᐲp3E2kqf3֛5y&TeɃ{wJ_\![S YPeaY4E1goȲwyħ?=|mMImZ Y֤q. ׯ_'1`H qAr|t `^~ZkP>Z\0t$Ǚ $ [LӴ ͍sϗ['f4Ⱥ+zuaoƾA[>4b=yU]Q*T*M5 :ev{AJP2f]__sxxp8dZ^Q ?TUa`&7ʒ.M tm"A|B,iU4r?@ D0 !h:e m޽{{b EERkujG]KCvHIlQP0 QQ))In]bь-^~%Gs»M:>{^b[:~7B2z{]릢!OLwl򜗿sZ׈ Y h)R%Y tu30[쀣|6I/rz?Fu-ɒhidQj?P$IJT>W%M-@Zbʆ{H#3^}[<{ws'D%-WvVۥbd˿՛׌GCq״TضҤ}6[Z]' <,dڡWa5=P qտ??-GQKDMc醮aKR匨|]B/`0\X,ip4mr"3<ôlGV5t+]W2'P,$ $E88?$|>--ɄdD&uP=yU*IJzx d]",âJm'-<$Mh6ix?q(~#Jӊ+)ˌ Qגz IALdbuFt1U#hDъ.y>.eruu8l2n*Aөe %*Mp@ 810Mf/03qG7 0$sp4AiM:rusiizh||2 4à o6g~ *I1|phi(Zli @ (h0ӏ?"[9 >oh}LY % |٬Tu&uUp'%=mDgUͽ{wxڪ3>eZh6͸{mlàe!ᘦ<[.4])>'M+itK ]Vr{}fիל2 $yJlZR6̧SV۲zmQ1,LCg4qp瘫K7+^p0*-[FmhmF! e~0 6/_")9>~ov>w?y|pc"Kme!@V Y<ʒ,Є]eKeTmC0s48'4xM}fR?4-AT)%mZ,ЄPq@2]FDo4 @ˢMʢt_s8}6eU": he9]Mif,TiYaHۚuU%-e&9Qr~qÓ'OiZ?>? yYU%<ʎP ,Xh @hZ\)]ˎD'?mkdY l&s>}vc|hDUI5j d0Qt?-*u4.۶ 4I]@\K^}g9~v8utFt(ٮ\-(Mo8>3 ܀,iel:E{7=YreXTGyw@JC04~0Lj0QJ٬VlV L$M'V": k$G@Ȗ*/)0hte6@md|>c۶eprr>%\]]29<}CNR4TyﴢŴ} }C۶ hO n{O/o۶q'~.uY.yxqZDqh_; 5|S{K?Mups}Ÿmv rb8 WCU?#RYp%w 8iL Xpn7f2Iʆ8)vϦ8m]_Imٝ[{9}m􎰝3+2 Ā0DU$F `H1) `\j($$DNNi;zkof-kaTdNʣ-B/=;(a4@ۦYvlw]i$FA|3>8]/] ꬡ)AE!iCUK.ki[T=/Έ#.__g?1?|;MN$pmne9d|/3/ WdAUѪ[lҔ'CmFIDYNiF]0/Ya5phfW !RkJ4HM[Ҷ,J}-y:K:CM9Oo|Fs q4 0xn,N-;ti8A>N)O>?UUw2 eښ $eW}E(X,{0,lN-mxpE( Apxphl]۝/q1_gX̙MGbuB3l ),5ps;gu{:TikJg<ȱn|<$<|  c aiTuIUYK| `.899!M3^ #Qtv)i%c&O&\^^\\~RJӣc7GbLJ'o?wNHj5D!jM6  _'1߃7sآrwwg{߰jS6CT4}GFLkb:,.M׃,KEy~Ҡ[dvHˏ>7=׷ LjUL9h" \a.yL =>7o%m#]q3R|4 Ȗ}o#*!RleI6#?py~~GCZjJQYVG8+thiiږc!= C#WEk?ӤmmS8:< nd M`ܶ L0yNHiѶj<>'x|>gc8F.eQ5%$ yxOx)1=Ru56gVҌN+$Fʊ{ሶk/, DZ.bp5[>zstG5 d%U[lV-u.)]A^6KZ&bZۥg e`s҄EAh("Gƃ8>z٥%ox%-pzzF4.%m24r'FTUKU_ yjfnu\\!Rã#FZ8=o}~4-Ɍ([ѡE`0 wk^|ɓOWex}a"][ҵr/"wysZQ2(HF#)u=OBxbZ=w=_=qŃ`Slvo8UؽA;2GL$Y^)I4j 8xO0i H\ãH׶>9-2Vᱟ7R3D¦Qru=_m]xó`Xp <|@lښJsxpڬ+T&Z`2prvL8yW|oq|xr[6 9?88gu6p)l6}QeCnb=1qAAc;~p0 (Pأ)s>9gءG0CV7ؾyB^nTG]̎NPX.7ϯkImY%Qm݆Ҩ uULuz)jeZO]qs&ݱ\nX֠a2FceF9uې(OȣTGF}4%-y1^s}+.nO8=")ZA,] yx[ZQu ߮J@cD8Xs{1aflk㤷Tl q$!$ֲ).%ʙ=A#?+2ﯯu\[c 2Ww5hA2hdna[$6/32Œ6ш32N)ޠ;'^['\JcM}V8⸎KV/45GGGOTUVa)`vp g4Qզ`hdWd|缺`4i!r4k(TEAQM^eS7h 8" 2<aKT >S.89:HFEJ 禪k,mZEY, &ϱɳ qOGgtj]Ů88ja2;s [ZctfKe;Ertz`ZmKyf<c;a8-2b|(<.7ދiXe/tm^fT6DDIb14mG~qخ7aeQ1ۊجMjE:G:zKl!-βf9ae)kB%-LgmS.)X^mI899,RwMU,N'oL,óMC(ZtofJFKufCeY Pá(UJѵH|ws~χnGnUʫ R Tq۱Z:c6siڎQ=_Q.rtT)IvRx^>/__|G̎&LJ1u+MUQ -K)DZ),>KB䘣s݂O୷(\H>y2nݙLQ7r z?-h߀x:S.oy9///y}w%g'S8?;B ItF!'A])ȳ  B2̐b{Re)UU39>:bP$Yh;MQU|nmx۶ɊK,ir`5p]o<+l) #iX.{oI, a6Ro{|ϧP*tVe^l)وo}?y@EضPZSi-EQFf+Ъ)ge$QLUV,KI4iWRڿ ۑ5;"𐳓 O%n|81FUaKQ/HӰ\Y.!ylm;l7[8F[P6|qjZX)<0tB 7.RV5mǡ -abfG1h/n7L#^|v%CKk[V2$hay()baQD/Ml1%}X#E1ms IDATVn8g\7ց^4?qӳIȦir¢4M[XR`=s)h{zQ|5Z2(y:>lx˪ަiA)ё9'afL}=n\nz ſF\\\qeYVC]%qe\R75,7k\(I+F2MmF>O𜚺G+ Ʉ\^]qCNrvq:ah$Ya,y踜V0,!EJ(`\& ,d`m2| A I˸^P! |tфɘ]i!{i` 663ޝMd^2&ò\/l]k\%\Ο_+)V,k|-H&ؼJ4]hDQeEfIڲzd604.ϯ/sOpͩbk獨bf uY1:9Cj<tmu$IDq_PU'G3Q֤{.oyiS*ͽf RjյayTy(l"V6h)=/o/>?(n5tF겅j ,}x4FXRҵ~?h2f4JPޏk! }+45;gyn#+ eW( <~?ֿJ n_$cy ]}2)uA@̗+Uh4XCiv,KP6{ j(BXro]خr5''&R ~0,˲|]JOZmRv-w77iA&ރdB;5&H!E_)|7 |5J)FKCW+'u[ouϞf4_gQh}.#d:vM?75S,Q.J㺆Dؿ PMC[M&- }zBJeCTYNUs>zKաP7 `f6ohl€ۛk' mSTiRÈdB]7hq]4M)vH i!톬,Φ]Ӑᘦf[m;> noz] WTٖ580NǠ Fy:ҶmSTRl!u0姃$v ٮLe"Dz ԼlwhLu=$ c#7CP5MH#IiM1Yr}}{l[RU%?ͦ9.wJ#ph_eO5&u}i(-DO)kOgmQJ#-Awi צm2\lsmfᛛ4q8S$ p0Ѐ-Çl6kPA{BrGEQy޾3咶6xk, eɋ>=d6 (:!f Y75yQ m(HӔ P }Ҧ <Ƶn+e'{P+0q=8j) ƛO1eݒ651Nk<1[/yN_f5G+Uh[fcoY%IU! c;DDn&bXy޾@6>J!󒮁m>O#ϢR>ӟL- "#Zli|[5GX>Ӷ 9n:(UeKx/sIyQ|{>uK"wH3NYs!H[ڲ"lm""mw|rϸZyw9mHST'MtpDyc8@#H;h, L?=kh ,Oئl;b؏6e2Oql|gaHۢm;۔ AʄpǏO\՜3߱pv@*~)"*v5dٺ,LLlkn_RK In::eFVWej;Ʋ-ބ-8NݖϞSd)arpx)Z+v*$CnoY.01D45՚zCG$Izl{HEtm| ٦)۰rCU*O?K*$ #Ǭ7k1SbZ2Fk6 MchP`W縞vcU42ۻv gg|9=;Fl{8K^MKf{x )-F5 D( 8{,5qhǺ! |lKSҴ jaـЌ K $S,ijƓ g秤>vߖ0C$&H{N!X.-]}Ѫ2fe"C\Ӷ]/l,i׻;Ah ~KSUmJjf:"kX79=>$yNQ@mw-yGk'YMVFk–6CuhPϨFM-R Jtf>ZR&`42~Bݶ8|u_9Fk p!x8W mlF4{oHiujB%MӁ0tJW7I9gg O:'$]SVioU؁te\RT-iP9uBR%Y4; |5@U m٘]qs}-Ső>{i%iۖĶP=Ӳ%i r{׬[.o\_4X~aiyiRhAOatxxjbXʲ Uo69Smb;~&($&(-|g9<.yzkzlL?$/ yRVmg6XYg3;8ƲQX,hw|Bzlnxg|O~[|S)ҶA Zbmu \{omv5U'H|h¶)󗗜c8mӔ( C[R5%E8OθfI^coW.Y׶M-#FV *m&qAee^m]SG/pP)UgP/iz3Ld]0iX}Pe9Պtcs:Ңi j<Θ4 u_# S!3 #ʪ憛+xS exڲBkƣ!sNum[^xMUDKUer#j[nӏygTe[o8.~)–c~g va4:v5yQpvvOHHga˗/7WtkE^c uɶ;PcY6 R ILuTEڶ$S4mË/ϸq=...89;u%-k Cfi4f!$4iv-ucXE Q}qfcXbI\?(S mceLUM ׈ez.mJF#no٥::I22_ۮFhh,M6-V?VyCuخBhlthVYЬKhb_}wsZ"v$q՚mhϣ ~>Dz$ggg^l6& x>GGGQnjF^2{ƿXz6Mg|pn/uv_pFA{rf6h yiÓ#,OYuNؚ]1eL]ۥe[љO#xFVBk XƠm,|.G-2^A%,VHH vm"JɄ,Mߔf=QtW%l{àlh"aE[M!e$&n5q<"O3$`)9Yóg>76iRo @۶mKghK0;:b<1OTd\ȋ,ѽf4`/rc |fr "?PϣRjiK,i^M[#0ȍp-yuǟ}zx43leI??"#.,sd](fRG\.xmifò|';qzV+V9]SS9|EY8=#JBKGgezޓ,Kɑ_ l7g9lURrtthiv"/إ;5A( MgE٦u7@6(˒ihۆ0 xNoFN)ls}.GG&̺kZҝwsEB2sЪSN" =F˱财S>Rz(ݙAE,ilKkDUUxLGn@v=,3B쳲=c=c)UCf /M@{!BF k2!Mfn8rq!t jEaXG]&tn`׷(4 /3: 5lCg$_y-I+B8&EwFzRB0"#9ljl3f2xHռxۛVqVeY>%U$q|o6@៯b7m[aIB/mqrz{xf=%=,l(q}x8n ҶiUH&Ͳ}Z$L&=e޴y-Jb>jEetME )rͫ[ҢFфQ2R|=YuKQTV[viXCBw"V>|Xvw7il4&!bTP=vhKw4u4!U]ɶ8كsm>^mqd4""ߜ;2j֜qz|Uc5Z?e__z7\HM,]GݚqpYhgk&V}RfgGeYt mv]SPA4!7FJu@%EQ^o5&x%mV="dX $Omi+~;yޞxv.9o.G\h+l,M2J1]eRA5^[Xڢ[%y<| N-na:uRqswL>Iݴ{r%2GUQXAӵBH0 y{^b[6>GG34$6"Oi3 qܽ ۶}3<Xʶm9?;iY+C3Q2&vG?;tdtcɲfKӘG: 8OŤ>qTȊ rY,|gyηg'R5FZ6%DeGVtmcc~{OU9:8aOV&(LY"Ѵ_|?ɏEDό,{ٔqzu˫>7[C5l;$d2a^sL7 Yq||s"Mk>RJ Yf҂K(mpE{t2qoM !m%|͎,HLڰB2 rMgoSA@qDTQGGhYCwK ±5eAw u!?#J<0DJ{^˲4M}{s;="2ʬ ӚĨf1@P?`Sb O*"o活Z =L*IQ-fW}^!:iRuA*8H,۶@  IDAT}ax<$I}mC0д=+,d>9UUg9aaEWbGuY[޾]qv]Y1C1}B]#MS($Ų <fh"eJ|'p(2%U4>zim8P NjѵW؞_@]3h4L؎k\<סcp U.>+vfhc !dcxBېw``~V6mÀsp {o<kd[#ul"IRL}Nre!YoSyAޔ8Rhtl%/(+MVR$Z1HOư* Ikf9R \c8m2 ~Gux<,cu{H /J`pusKz<c<|5eSZ٬6$/V]o,s~\V%D!eYa(mh;( rvu_).ίDG1vhzZ3,,ò-pxlV%c߿yѴ #{t&8ld15s|ۥZ%aa6eUq{{]Ӂ?W˱l6{G}9b~L2iEKشn)LF&d?=a]"R֘lqww+Ewׯ]5\eE#:k)Βm4la- 0(βlmM(]lb:Ө~UE ֘b!%Xfߴp0!MPc%$R*L۠zEu4{7l&}4eSA2|A;93NBO?,- 67<:9= ё$)Ŝm)DcMnFte q|3:UGfPais ͆iG1e|l7ryt}n,,hؖC=cA[#}l6;" B R1,mLMt QLUi|R yDi {Kح7|}4]M6xM4uw|2ѵG'X߭P׶mZuBе9?RVo.9f430r00-&1 jHqPbj@a_òm(hhveY\F1zbl2憫7op-<|,lV}miS%J \( DNl <>Y[]W][ $xؖ+V PKeK{>L`KQ+/kѪ޼kdËe~o|,Pɂ4MF`QAeԭ5M,oϬs{: LaxuuyAST̏F#&1(iZ8aSNUwܞbya `石g@J4A HZN뻸Dݒ,<0Kʲic\bzCf&pw iW86yamky~`A*4ѹrc9ҔQA$UvǤ2u<}l[zٶ񱖮 ]i[ʼf7!I`R `{'<<|4b6҈7oiAT==.BEhP CA 0 Jѵ-oa9g M,0t 鞦j,\ =2uRC3~P R@$˲CجZd9_oCLC#Wo/^y9A>N*L $#+,0~$l|뜱,c\76~^A늺j KZ@j%Z/񏕪IwḆr,kQ@DVp 0X)Jdz@yE-zPmD'r5x-$Baj ( Mh]1<'æph+˒vo0Mc4̧K0d=(r7o0rF#~J%mDz4==M9]Z$6M,[g9GUՇLUUuM<3X-Ϟ?aJ>?guIn23݀th5]((jWÑq,sm(dX8:c٬I!eݒ6@ ,jZaAF TMr, %ςi|FoeZ=8QD^:y}|B-?ֶ\&{b)'g'wEJ -N:b$Xb1k;eaq||xAZ&ӔzM1Jhls\ף,+>vf<n)x:ŲUQ3:9BZP5f]HuK}۴hMr>>:vv-7oS} 6 Ww/?|!sE:/H :!q]vo7maQ$mbFXz'QH0S)//@0[L#v5.2-0r4NQ5:@Rrtvvz aZJ2chE"h:#I ljMK﮿]W\BTƵiح2N i`Ї&UIeemYaHtBnoȦkDzj15 vqȳf%G˅ֱ_gN3B f8> Ϡَ}@^}zð, 0re2M=5k[PBS]MI(w}ȩlx>ppm vslv٬{RL?䄗/_cϽ#,%__~!w5>d>γt EY1m"F1_:_("ڢfKf)oaY|UruyvҰ>iH?G@A3Rm+ Gl8LviˆysuN^s~Fw{Bʖ6I0 q}!ak6TfgK S9 ې~+|x=F Bߡn_|y|>'a-MY:wÖZL=Q~Ѷ-zρizЌbqeI86G^E8i;@X0DLXel Ն e9E~ؽLqf)rЀv; ܥX˜JuGhd[>{ 6JHa2M)0a8Ph;&k`ԖSAya;5` iʁ-i2ц 83郤u8m ,!c 54oTݟ36yEP%=.Ht{[!GA4yqmC )< 6;nnܻwaHQ0 5Rږ4EI<}͛/P$'\L#eR> B .\zE'/C?14(P"lyDшx{}M}d 4QʢJ$C,(|(֙MUUjJ3%tN4uضEd$MdByx4a:ss{~AD1P6 Oߧ,S.^|q~t-~' Cn6Gs\cT5eU^c]v;+\ϣi;V+\GO?טÃ{pp= iX؞MYȦ%ɫ\Xnkv-{dKE؎w _q'%:o00zTuM]W(ڶip] .עhį{όR  drqeVvgcFJn4خP˫~ieU e3FA@-_|y=`: =%Tv[,$ r}۷}o=|@KEDҘm'z#k@E Wڋ61:=1QT N*T[7b%|ciLh]x{/°%<$ aBhE$L&FTlk,=* e< -]~u<\&  [Mxl믾dY1M%t]/x]װPol( MSy " VPUEV Iv-k:? {c]5 "/m98jeJ\U=XM)KT?_GL `.ж~ǵXq}~IM,dġR''!:W1jP:)ʊ}==yJ8tM%=<ףnخ}CfXM$Q3?-#6fWk $"lϧ EګCc; Ih4: f6yQd>5|DoEKTg5/^k~t| {& ?1˴' bADQL$(g4kQޚIeTEcYܻ׼~˫ox~AUC' eU)HC͆,X,iqrzL=ɋ !o:.y)-%UYp0uq`&y!slGsoAxX=9e{{şc ~;|i[x6cKo6F&clGo0nUBݖ=sld/yO(D Af$-]rw2lh~1yZbawyɖ$k奥 NB褢jXL'`{|)..9=^[?>~ Ihx<JzbY#b<1<7@a;&xכJD>"M]UtuG2,˲Cvu.INfxKS7zkf/nz899ư ?H8f:It]n(uX,fL^j_ ! @ޓd)5wo1-I쒄m>Koq{ m-!ā84A!u5 /Y煆&&~*j|gpJ|uЬaXL&S #9xySJ*K-|Q IDATr -뉣Fc2 ?~G,I6wv)_NaY=`{no4%4Lƈzٸ08NoQA&cZDшi g{NU7d4pզFtNmsxaku 0K3.nn//bC+udi14k9MhۖnGQ%1LX2ڶb4dKrOh2{? QKlbJB׫5ɔtB$ɎxbqD'yY>a_WLSQ4nÏ>e2Qx{{ak@ץ,tlӢ(tt\Z" ڪF6-a!Hm4&0q- lS ^lbELSd>Mb)?]]\q.,u4폅y6P>MS%"CTVonF7&k@H 8h]ꎼ-1і&(hoKAԎ4۶-2 d!{Pj-B1MXݡ02+B[o٢(BI~c=󡓴d/xs}l:{ϞqvtAL' dݰO,ϲl'ܰZiT, |6$ww4M"MKޜ_p{wKGeczѓ+ʢǏ太@ރ!fMBO< }ױ)3AHeP9W7ܮn='s?~d<&2ƣׯv->agQcO:ITMCQDQHv=}Mɔvy>X2M7/VXǣoSene-BD !8C'C B!qlǶiꚷ[}aP-0Z20r\*Ѱl cbjCJ}шl"* $ uv]7l[6-կ|y|~ %ϧ?!g(#v>!cLfOH em[?޻ꋯF/핒oy/_8ׯ''?v Q5FU5V(Im6i͵ƶKezt}Y0g!fW\+~ӟPDaHĔ%ɞiMadiJϧq.nؗ9Bdz˶M7l<] f%>e^s~y/8i9=>a;6iss{C6s ( )ɈWI/QIUyFUkiO+@Jj]0>Q??{gx }LB`imꛐ咶ĩ1luǦJnn;x^>>۲ُӇHꆨ~+gPEŸCV t]pzzJ# /ɓ~}NG5~=ln//?O(˂{)Y'؎Cڋ$UB۴xRPA{v=ЃrJ|=ae&e1iv)U0?h)4as}ן}ΌyQDW8KڈnȈXģuY4%c6 <~mqL 7p(6~=W?vn?a ]mKӵ]㸴]KY ^u']Y.mjhI`uGYlh; M<:& esw`A3º}>ES%flDZ|ɧ_#c/L<&>,JϞ='{V׷>8& ӂ,1]Hq /lp4&)s@aY$IִA âk[R{ 󤟱+[4:}BV!@QVخM*˒8ͫ٘H)BR75~>/u }4q\i (ty{y}+W[n;X~ieFeH!h[ΎL)C0J-I7$!=nDqn&gOs}w_G#mR9 uI7G~?@j5ĶLqL0Y.O>nCr|cNh;$yI4yAG, N'Z}ԆY,K{a;|ϢS|/䝧ˊooh˜̸d;>c<nYLgLSDiZu׸6A'q<"OSr\#B)o^-Ŝ|x2!FH7C))^p{waHQWHtdeiJ۶XG1O?nk^_6a} n6-$Avӓ#Bϡ*2&㰜Og w L{D^5\'{pZeUc6=.f)ib&x2W CkPq|l7S±m4U]`Їk:uu('Նחo(|, m(ökp-\,tBe12M<0mnny]+?X0_εn4Ȳ=EYaQ7 hqW)eUҶPU&c<7 "nְxiRI$Pðfih?]uTe/phAYNlu1 _|YϞ޳g$=mQ.[R U /v#@9eYQA2⎪`К^O<igٟ͚3?!uifC,qyN j"2nEGO;q9Um_nk(~/!#y=#",c /QR8c~u}WDQ( ]ol{9Ux^bh(VjmۃL~(XׇB~,43IfL&c֫5{;сM8UCS7ģC)ھv lؘm;؎I4mw :*+($Gz?Y 3襞]MێYlF4UT+H7)mU{cZ6J 7zHvp (xׯ_3>hWoa!$Xoz4!rL hhk۴Bi)smZ"Lh }%<{ɽS,7?'@Ziiݓ3^|faQM^knb?0L|LS9RQUZ^9d۰2 (}^p{} c:SGضh;vfE6 v쐨kg:Aҕ}8w)e &1o/.إ9͆lu#lۥ MScb`#g{ Zh~) @Ǘ~n0teyUL&A@V"$Lgs%w/9OzѽBu垪OSRTSOeF#|?[p4 @*~ڶ`o|SMa &qidnj,%#ن/GO1taa(MSC WH]xc|R0|7C32R6&Mj$^RJ[V%7wI -M3hZ"SWn|^zwORV-|uwñ*X\\6~iLwE2a"RlaafeX1M҇ )qUے I nyE^GmS;wm큲5JIexiq,+avCl,ajrױmq$ ٮ7uX\"/.ج(ן|5csu$]4I,uq]i 4F)\]=A&vL//yrNJc=q8Gdslϛ$MyiiX5q͖Tzce:|ף ,BpQtB?eÞx܂o, )n4MC@/ n$1 @b F~a$), |˶:h~ޚg70z:s8xj2%"055¶˧fm'#8elEUN' 7:%I2|4{]ϥ(Kڮ'oﶸBE1-0 Xm{N __)}qZ3<(X6,7xBE4U\ϧZcN F`[) ӡV Y~3=-1Iy 0Pنoz{"%8&L9lkdsPXt&!sCvǟ5'i'?ɓL\?K s`޲Yoq\mšE{fn#29睉rZo'GYVDa|ԹYUExÀjûw}oHŜ/?b6S iTE ԶSiA42MSʲdjK]n,_!޽(y|}E? kaL&4M-taٮ黎(- rz٣+/ga@F ¢.[oƟ~)ipyuE$c~M$Eq6 !뚲>+]5-Gu!po9^)ΒC,GI7Ng=\"A=Q?+`.]W;h`801Ƣ V, >}c A'{>?GP=,9%WXm.]ɡ8(WQ(Ap 4\ѻ H4[>nQ-Kd^)^MY{ntXxi[gy?žp7,$}Y~Vpd:8Ʉ75]#cՐv۱ ˯Hs| [tuCUi~$\fǦ( -' ]Le͹g iqyi@N0:mY`:Y49lw8 ɏ?,+5}]pd&m7((pvaHUUgO##0\g$eUbp9h"Ys'L4 V8mw LJapqq~ܪ%m`ZZQa&yV`,K&]cx!/l0 p1nwG !,=~atJ.uUG[Hgs,`>ixt|NI" #tBV`ZiiG*L 6K&zI<{~{~c*S߿aLL=~- ?Zp8,[8;#Ey,pm$6O/(x6a7 I` ~f s)IJ-${=,3plǵiʊK../黖[ٲ(QRT´i>/Mg~;k_ՕkKbуLPHPR?]vNFPN/az/Fg95E߳SP dP]ӌ.,cFY16Lzr5y!.qAEY5첌X),aD\H A')ݚϿzC {zק`]sZRT׳9$Av}1hvkV rb2ŋBHickߑ0mQ%^e~Ok$zߓYVжŌtS}qb0,s\Q{lLfDILU5Hٳʒ{5^˗6novKDAH2I{x*iH]<ǶA؂dt:rlQS0=,$NqKc;mUSVm#cկ>k;.//H@գv, 4-"tïQZU%J,Δi۱uf^jH=;ɶi瘄!I5ղ;R:Rda9`M56]i[/,q&( 8mKݎ =/ĵTTEEK^\)ۿPXiߧ lL1l\ӦZ}0!d9|<Aie (=n&08}-uW,qR .OUvtuc@)g0ith':A@UETќa0Pg;ҋM_~^L?:LE&u5q v% R*DhXB۶``ܮ5Jˠzʬok<ߣm{V˝n(bqq(k@VME1頮E0].}ǣGxaݻoo~6>m0s ꦧmpX^5$4M:y:ɶ LUUURI6-y⒡2]:ZFx#gs%e^Z=4MMYz[EEAK&iJ'TuES(0,-7Dl>%snk|?5r= 4etǒw1LxvMUH1 8y|'GKUmMʢw7Î3[p<&)QaWsw`=Y.8̏s{4d(? x>asf B]|jdo-(pmL*EUX\[Sج[fӐ}=+\C*<<< tBT}c mY,C i:Y$8p])SW5}]J)v-EY8Z(Mq}?꓿hģܲdk:'R9l u1c 'v;6 90N †E!sEEe(EY`6階mPBCG<<Qtk$ ۦngKLAǡ+(<-)4 lۣiZMs^\; יZ AsCzj^<30#3!OÁ|~ސ۱۰N'eL^%lxAIn>|` z`0Fz}J>qlR'ArcÎuzϟs&iD2r8dǜ qƢ*Vhp~eY$M+E!0ya t5a_ٖ0o[a0OSa`Z⌙g|/Rae^m@J>z(vû{~'?sN5Vk"L00 AY*q#;d1˵m x|5_f6mNEQhoccI&L,CZq6U~p9/?gQ͍Ԙ u56z8iԴ ]a`;.T/AA/%e߲ܬ隆4>}/5yFQM]W`o,#FJn}[`nlv[&9O?#c %_mz.(7FEG@86i=.irn$-h/~@i:zRU%i~x,j_ea}plG$v["g6Z2u-"&va Պ"?R\]]E0ChdzU~QrKd 2X\Òl>~ϱ,<˨R-o|D۶߽&g M4Z/ɲ+rʹ @GR=I,MvW5cnspxwwض`qu\ ГFJ%U[o蕢.k eU" LCsu a#O^d[7ن%P%hzEqS%1Y<#/ &ؤ9gc$Em/;<ϑ#XA$! z9ł|x_|eyb J<ɄM)`m[ ;0gU)b<myeYj7Kisg-PQU/_\~[ Jd\PK&WWnK-QPe>i4j1KG`IMnGnYǂ~PÀx̘>z51-";ϵα8MMD۵**0z>Dz,bǸ~L,ˆA1#8з1&(5PG6MAb<.WWILQz[jLsfsVKv[A}/RPJJ!„3eͿh~'_7 ןg3{8Ƭ?e4mS5&bɱ81Rgvttt0ȦsZuz6;FcAutmK;N Ӥ:{ʲm,BJIUjbj,G6ffij = &C,蜾ᦝE?,K4r0,mw|kG a` ,x\סmVk]aPV%-VonG<G sl%=o~aɫWf4mǐņ% vy#0:no8 (Gp c%,&)mq<9 8st&ؖ }7o( _g/3HrgxNb-0 ,gq,J0jj>կPCl6׶s!Bxx[HrL)a aepJvیmXLo?~Eۡ[G͛tk[Ym}Em DqHye뉪{yUcײI:% #/k@CO:hR:HCN$4mK|gq1ݻ՚ ]GO.z=.F rO'ǬVk\,y0 eYRiDѷ/$<*ԟL&ep8NN'8ga8;%dY~^=2YX%xK[^=Bv,RZii>ha"Dt}B}Ro I4o?z  ?\*[*s* =sPdU:r!'B ee=A @ב5B8z 1ʟ,|#c%glnpjIc)u"}~Y㠫=p0Ƹ{N!6 i0NYښ -=?gǟ!6 y9yv@ PKu(ǵyq5ʺ0Ldݲ1#Ҧ IS4\z), uGiQL&%%[УCfuШ}caZm>!>UU# ?G /q@2w p`ZiY3:|YA>/O$0htR2X\^!Lv4ٱkk=f fs-pB;c6`ՔZH{P'4Ite^r1S5 :5[O>斲m</_n:޾w<ūWF1C$,x%12104Qjڴ]pm,f6|p架lVH付ϡ*Qijr*ZZnw0( ڦW 3hGuKSUN#/548+ސD \a>  ڶ L86aPU펲(49x M]x=]8ʒe9qo% }AQf!Oc&}6U~p?wg 7ڞ"/ɽp<PDHqئ@ݷ 1[dJjC-l&]Ӱn_S7 ׋KB?@K(ä}X8 mF/4I]p.mrD.M& 񖰒 IDAT|h,Mg(e݌UjIuql> |yAk9hXۇ,kf)/?Dz n>Oy Nb担Ι{,[Ж8Qg,UU]-tM$IH"!+`Zrwwayi>۲tm T]e k,'((ږnnCI_}tŀ4u0HX/7:dxsr^QnR/>zFF5c>jgysl[<EqD1YSQ߿';&G*q'x8(#|!@~e 0MvX?LfS\,(fj'O"Ld`o3&;dRipʎ`[Ð"#ʪÇ7HjO =EmCuϿ|+noc)RmA׵ Ja -4Q%&=D&m0dAz8G9/ ,gÏVcxKWU v\8"L% Qt8Ť,5Hʌq}PUGc"!B";KOۓA)TRdm1,;<#hc4WeȏG$o<;#n:S^@2Mm߱ZmI8[|FtXг>߲,f~iRÚD~C/s> G wG2h )1}%/}I0c~DTuIi 4"xؓgnO;N6ؓ55 ]i۳BU .1Ŵm;9E&5qG|oDQVԺ콆*l*aB4Y\^^b0x{"?X?l1_\\MIN]GThBRyGMtIJmU?t+rK>5=dC01mɔ$^}@Ul7/_>#3 I*^Bf9yZ(]/F>y.xzzj MnjG}MwWAIS-У*r޼~ 9x3t%)F3tt\Ī"t]DZ,:!hz/ִ]ꂪm>޳b9Q<\PTu9M}T8O0 iBf]&*4 ˶B0-,II 5|Q-EYCA6 ۉ+^6\??ԟXoE]濔yv{Q>+۱Ua^Tu45:!&(`Fk0@hEZ8677,W x*˾ tEYq<1 K%S?PXeߘMTxrݞ*XIC4*)+!OB**SDkWtg5o|YZx* THj2I(Ajxިho4uȒkv)˒[㐗/͔L`0 fŲ,m'Yo8gϘq89\^]k^(y7i80Q(ؐg:?L]{b]܋yzxdۯnY-._e15U䴖E&UeViI:yU!dG?߱x^S%; ) ~/Ja} )*lۡkHu\K c! L!kQzcENhE]G.BL4LLSǴ{e麚|i_-ڼ泏?k|B%t]%esH(,qLx&muֿ ]?[[ȟܟOOݮ~Y$;BGU!RCH/nģ:dnڑa[6qNI4YMfO(Kh X<ȒTآPXI5 NOUzswղG ҚCIlw; Pݖ~Gw;v3*IC||Xg |/ŴL=ݎ4M,FSTeI(X4Um4mMʟdXWuE6?v 8l":tB:ˮLH˖$US:P2aJ9Mq,zBu^ᣏ>G?q.ntU(iK=q:E5Ւn]P<ω :TgC*nyXZ? n'V^o8&[^\S5-yQ9Cga8! |~ת$UStT>T]7=$De̗+$fr1g6:v{l7[sq@8vwXW<dq%9&GZ½+,aNҌ4K0LCWL˲BjDK%x`K)T0|]MyhiBS)h#*L&#y%7b }(mru-o"+H35 8:đj.e*$I ,k%*/0zښd]yS9a8&  =Ñ1M1tnk|NtuR5なp\X F>=IxGt,'Mb^}irb6a0 '`Q9B@(AJ‰ EQTϐmɊ@H$>M7)EM4It M2J=Vmk>2{?T}!ۊ)mf-kxB1YkZ25O>;xHӶ{p?3F0afSS2Qᲈ`y^o5`R͉<Ġ$RU8C!m p"сF׶mR3(q]GQ4!sTD0XI(Y6Ӛ @# x2sZ١&y1u4,1MM4&Eҿk:Rx挴6ƔKݞ]w_K+L *}1&pסUYYХ$ePתحE͛=) f3 ĵ\ʺb4Q͞#ǟ`:sMB&IU~VuG/|Gz/氍{qyqi͞jA7zq0aZpL!XŰ4`BU*l3 jx:>0hʆ4N'/>GmYHCKgl6[8:@ꚲ5oeU A'X8t}|頼W)eK[7e41Lo8p>O:nUQNp %m=o^K(k:YM+oov ]?Ӻ* <1BT%M H:4 OO[`#:1$l6S4sA6!'˲ưmUue1ʿxAHt9Ֆ|qsu}$i|K"N'en[I` k,CCcӄgm\j{4hk IDATxDeU`>U~οg=!_4oئ= i/d)ݞ_Y=ql HrFܼd5c#QTjc!(дjkiĉj4/]{oXT p  QY/q@G ,/\4:Ӳ;ƣ 6H2%Vxpv<A]VEM}]Te"GH$Ny044]C3 MGB=5Ữ^o]-C^<>Ɏ0 P q|Dxbl@ E  UYZ!㑻{l 2t›vK$86/clۡm6OR[e?5LӴX7DQmC}#Ȗ {  y[RJ&I[RIɲL}6{G8v& 󉣘)f:pytmy#Ū%~?# Nb d\28lq\gaZl<3Yg&k9=|}idYIcv%)4d;(`S#dG0e]$]T'\S7*:MJ5<˳ 4pbnUe>iIv/^c|1'KT.UmvM.V̧S[Wc8xu{K$,s>sf(x5Ñlu۴{ ڜ]GYV4mh4:7DS7^|q0mkGeÓ A'Wp@UU,VpGӴH/3n2Q>0ޒ"+,njs& v]~ bJڕ^$)QtBs=%34LK242m7zuUy}Z/c ",b:$1ӑ  uղmtmZ8l:e<Sq` |qKE`4m3E*,öX"4{{UE+;̾9jږ(V2I]t^P7j6-B=D)p(y[ρH:eYQ%uS38iD1EY.U^xJ@6rqt$I¼E3}s&b6m]-qFc%iYolw;2c{\}|Е0M4;C9,Vs)"+C65Ipuy}K|a 磊EvLu]I<==a\W=XL!a]r9a1yde)1 09Eyve).ڶ XsuoĔU8+QxΪoo?׷o|&|ךr&yW͔K|ʦ6,LC-^HI'i*5elMPA>,Ceol0cDzZeNmچ͆録8tRNTUEtlxmzخLbq9 7)xe*\G`!yM*ذt:p:)cۘBNw-eѱZ-l M'#_k M | צk|mv|L˥C^S Z k~߆w]GI<"=4j<t?b< 9DGzn6i{¶,^<6 RJ9}{lp:Ejl ~WxZoH4\TP? gst\r Pۧ<ͨML'!eQU qr8$EmTUpxw)q,\V2?$ ~~qyPr x8R%eP5apu}qlhe)ta `q ԵjuM)i%шtBE& E+*onǼ}Cu+]{uԶt<Mx+1AcѶ qPuo:?<>gpyy%_a&]Wt F#, <mل)U^{:1i^![ybԔUvRH e9TuC[m) 6]mr:E1iҏA5Zh44- 8Kc)ͧħtԃmlq..9<׌K,ͤ(kFb6iKpK(ԩ,MX^\Qː;nݯB{pN.|]W0q@%Bl6N멼-ݤ MY Ҷ:Y0vZi9eU 4y||<^l˦ikEutM8Lkvq0Mұ)eɋ.ZJ^?׷ ÿQe+8iZM3bGVdiACK tz)fjMnjj,UEӛ9 l˶.^6B$h,xzx@"X]`&qJ quS_,QSd9vqv.+DU%E/^ ŒqpVyT[숩N3>mC,KOT?{/H/Ŵ,V $1G& Պavu\ʲmQ-?X׼~FiUWSхKa=J#锼wH뚎<\`@ 9MKxLsU#)2Et`z_sLw}ZyDWG%lIgަ,+,~V(Va bؚV.% 4r෋AJ:"~ss5lRɺMJ΍m~u" %}Z՜vkjcx:*e="lV.qu}HXDX2htSW,jc|4;q[ "GSl]Ú qr7$'V*EMl)y>fM؞AӴ^)OR~kj&eT qnfc{׀IMSuJ]BU'!BM-_e3~okt014Q e/km~Gf)U@ WgGt3 F6Gl,5lUP"u2SeapWaZ*jJ,3u)qg;uYJ$ATEqi'3 C4McR%WWWEG)9EG&DC%jt[g y9}ƫW`6NHr<Ga5] UNʫΐQ$YZXR8$öCEd=iVKV"eIHN!f0-$”.~80uڦmB3ndyt:a<떑fi 9oBWB*pVZȭv]Ki$Q!9twonP߾fu9zA0.2M1"<F^[L6-mmb9.a0b1Q9Q S-i^pd$yAUnF[sxs$i% 0 =%jp/U&eM#)V`IomGt}{oxmu͟\K׻$KHӊNiIӢ&E1׶ C4MKgjsH])-AxTEPV~Sm$QB%3&A@׵ĩjڶk;5*$axh]ʈUPFzv<#NPE*,J\ۥ1FYop,(Be<Ϙj!-]tU+pOuLgX.u-ia&a[erq<JRڮaF05S֦', hak7Wy^_\_zP~8͛<ӟK v9v5E E0I!cl˄z>B てiqϟ?gXp|>i69Daj$c3qBCIJ:Mui2=uk(I eb#o, c~ a)Y.qD?4r,_xAS׶CGIA\K۞˒wo V7+ʦeO;d")Mf^; G1^d\0u{ 1V}.[K^st~pq2=;J("Fߝ:u]_[J Ɖ8Z)evc>( mKilpyZгI!6YG.8ıEkj`=XuμT .i|^KGuN.{Î' OhQuXz5 J" H%n蓘;>W s|И )` P]zu. UP;˺Xa&C٦pыjQZ9Rx|أG[⌮7?z`LRl<|kxMei}OرMNLq݆KTuM0AC|N'ӉjEf*=[FA _prlY#סJ/a>nl6&Opz)%q{0b>4iJ"IB7Mcwͥ:eY2 cpq_$Ue"blf & C,$^/㱤 Fڮe&{#$2׶r sfz^OcQf+8 |k4NPBTҚIlm\kWƻFbd }d#9fTK[OEJ/\i bAkP ? q-׾esky W9mg 6(˂lMy?Kq$ #:s<̵&YYWs:ǡ$ (jA_? x+N垴(fŠXքÈXOhyZ\t<(5B9t}woQK1yiȲgϞF)uYҍCߣVLٝNAje$ic[Ϟs4Z-wwCe]ח+Z!Gxg ԕ7r~=xlŒv#8pʒ<(@vXXQP'Rk; h(HSsXj`]UUR致|(>(Lv>4=UA\@gMχ3A{αzu"Ɩy> |`; = n㐏;Kq׼%>5l'ܿ2ORK24\(+ _f/?xz~WnM6dǖ5kxoi? G}ז'y|I׶gޱJy*^5o(4J(mFk9E;@֜Zg|yPLDT4PD-1S#iJ*EDhLJ uU>>͌ۈld76 GDZy"?Qi{fۮҚ+88&1O&&TUuiSWWÀe;'/ jM[e]v~O$ahJM6M{ ~)Y]>}opt 0Q!$Mf.;²p73oֵ8'nMBꦹeL`״<>lTZ_εtnll6b, x8Gڎbg& z,Ŷno i; 9C=cqmlC7 iʁɍʒo2NPkX28Ʋ,x0`׳ #4 -:t;Ʊ縎ϒ)9<CgR Yy}oVC:QR$Y-ݿvlQXt2V+Ni+\Cݝgf1xǼ{à=q[ڶ&c/ 1MPf8~0E:\םr:N&KYnx65gԇ\o35C`ƹ;χ>? p6Q׶B FO7Hba{<}.i78Jψv-[r\G>o+KCquْ  V l5-F7uQzOާv0?<=϶Cuӊ;>vRp996GE=_Qc!vj 9pW8r$A|'z,xqgj:Dخ'V_L\kࠤIJS?=PB Y}bY 6˂/2W3gyX`=ffbWPo*Edv5G*Ӂ3,XVٌ5mYXeAFkʦZleݞ9W,Ō|FDa<5$7>5,eSWyFDN'@YTb?8׆ЛD(\Ӊv;ŕl6_/ၶmM`g},c , ȲO>)1kǴM摛'x84Iɲ2q Z_}_&$)W鹐~Ij䧜q^QDXPGv5qaqex s|\>j)k}4RZ8Ce]-zm[Qw|uSZi+4n_qlzd@3Km bͰ<:RץA.3=G%(s#l5'!]_4i7Dc s&Dq8 ~tH)p/A$fs\ס8]1v9wo_^--TuMYgc' ~; x iqY]ϻ8Gל?Hyr}R푃"=ƛRhBH%,}f Tc63]gXTyEAw>Zg9ṫ||7`)BH7EM wq,-x6c)z~ U/۫kR#4%y/;<֊*+:U6t߼ z?mrq AoUǟ5d??P-])7#X3߷|j-k#p<8ưOkGXw i>;gaM%w ja;/\ ?]_w^wB 46gc9Rj <"Hzm۾Lk8Gj~ݏak3-DA9ܮVq`8E1~a],'8Eqif݀R`peˏlh$MY.\ݬn/n!}zURbdbS؏,\] ǁ cI?b!GC8á(ȫoRf-0x>af0K2$Ee,+4CE B5~h+`C?4?)lkjyk7Rڲ@iA٦5c? "J'@۩~{z}.~WF~_Zgg?zءl nD^ʄ q}dٶeY㈍ b$ I^e3 s9N`$EIKYO'.4Pv4 eiR⷏;a`ZREc;~R9Eapʲe)y|&L"@ q!k ]ے)Wkڦ4 DZ)՚bngo9lwb$ ,u qͩt{ʺdX 0(˒6B6>ki0d;$IcʢFkzCg\Vym#܂ih$NȲ( QBz/$qLv(-GAvfONjt:>e!| Mc,Ǣi{l[yy=qѫH┪ڎQK򢤭+f\q:뚛kC4#/1hTqMk%@Pz10m!0 ˂17V!J)vEY6e<<k:l,2EJ(ј$C:clٖ /I&I!CC3a6c$ZBI,,EFmD(ȲKqlUM8"}%M[SO#2Ӝ~`h;N}cI eY0i 1wwwy%777HUUzm[1bM[Ӎ-i,mǯHÀ/*$̡zNEan3YaŴu)ʂH&=FJI#\ϛn=bILi]Xw-AU ǁkqP|)GS{$d%Mض{`e<,ITwYŊ/by\>}JR6n|ٓ'yi-%m9H$ aixk:{ <ql>cJHz^0nɤJ1E)NM7oTu"[G(%(&9+Xmʼ/p;·S$yAlEu|~Re\>.E3C\eoRɲDEXM?BcwbJ`[6ikA`I qij=> m~Y|{. )ضy.PI9_rS|ɯE Rk~~GHV]Qq<SZqI .7r[]qB)0ZcS(]q$R - 1K-Qh8a-lKQ=R($C(M`0]k& ڳ,{*E h}h}ԥ >YP&l]&og_yXh@^%i׳p$[eqssCUWza`h'Ʈpq,Hwn^so GvҴ5r{ c^/ N׶yYהHYc(D?4㡴FX mzȶaG,1 PYNS/ !au&i0Ko[g٣eb1zRJ_ s6ع;MLJ8.MRT5JiZh0XqȒϳVDwh )ՂW/_SWs\}f1 at ڼyah2'q]ۛj:y5ϟ>T8^[޾qwh\405=vP7Oo{aȸbqi !F@2wq-Y!C̿ is{T(Y5 }O48=eNf3t:]$mC?^!ƾ\"7irX =ZZIBS9?,;=5l<2yQ (ip4K(Ķz/^P-ӑZe<.#2ᢁL'N"&z, ,u]\,8{~ "q($cCؓ zA]~`ȑI޾{ŋg\]}ֶ u]rF)pҚQF+x##tRt=Eqduq8Jf)Q%Z*(HSg9|F$G{exUS%﷏9}5!b)n$ p=D F_C2ϟEQl&ɭ{-imۚr<rhc[ \ŗB/ =wl>|<xxkH04br--EG^|-;4k`ŭe? [jOQW\pEvRƕu黁rF84웂[qM&1r<ﶴFHVLEcr0Q$hRAOy؎(}!!0n]SyD ⿀ E}ĸG6KMRQ -ɋ9\f(Z1}\ϧn$ɏ#yQ?苟Y(P"ض-o߾% %yUIGPRIDAV8@amAYh[Xh,l00 Q*8!emqb&MشQed$rS- 8A_ȩύ1m W+v[qlCvlq4i7hYi2 xz枬-IQ ”5 p8y0[.Pz~x:j7 p]8h6ĶW3B "tI҄'Uɷ~gɮfc>JӢPJA`I=ṟ,YR}= tj+MǿQO_5{o=O>?uItA;x(Jm!iᑻaJXa@fYME}PZ`issm#E&ܟM4ӱ}z1B)F(5s,K(*+27a!"q|эGQ(c?Lҫ9IrkyUV4MF&q 3["auFڏ Mn[o4ѹmPڠ56MHEz !\+wWO[?l jjί %1W҃QEXavWKA'~b2EQ&1v papH$TswO?8>ې$9C#us@$ZGY4D2OR^<NUdiʢyW#y#,a'W$@Gi;UI[;a#DiY sfw0,@߷,WKʲ"/ TQ8M,l[#E#>KRfߚ$M9h38GqyvDI1SxWIyQ5irxswy>:4JFjWU5 Cr͓" x$s.f̾R= DDO4"]Qka6C6M>fI躁IG0JQj0is, n[4;}D,/G oPIMkp[9<|V'SAs -g ͉Y|:+YBSUll~ 6ӵgH:;O,O @R9+ ʀn(?NjiUB9^,R57 Dyޱܼv7IDu>zDe ͷCW[.p'~X{g]_ wE)0!Y`a*%"@V%8YdPxs@fĻ"^&A BF;DEG>']' )cO7SF|g蜣= "3<ސ~i4$Io-EQJLR"\7$B0xy Cv\\^ yD&8Q{ g w6zIuUG)Ҍg}FU<}=,tmCU|ﳽߑp6 uafXE5X/]ϙ}'9"B"idtu]%eY1=];lMUi ]ד(ŤGuIh4-mP/kTsuyI$|'񾔽$eEQt݀1!3m[s-הYIlGs<6|4]eD[D eU{ωw f39|YZGi= 2TQ,5Ws^x0d" HapVS5}mw~g֑)EG@,ptxʢi1GOկ}矁Q%43=!WAFӖ$f\H1 'n'3`|EYl @$iЄLpdeq8зeMWtݑg>knJ?|2Sm,_-nS1^vO3wP8 Bp k`<68$I*HU*Jl4~H,i~yR;<:#"'X'm=ݤchMZ3I&8kFuh=ޖ ):.f. ƾi[Yoι89$m{>pR141L#U3Dny}sΠE@N|ahM5KTHc4nG&C?YB^0i0Rl<kr3Qy: I*#\`Ák, $L$+=!b..ʇOvwܽ~M"c& cf4ia=sJb8N8?g$Iva?룬Qxeozdɋ(p2 ;94tǾCI9m16cY% bmZa9P6xP,fXky>G\GƘw&$ytN84 Da%qb+廒7K|xxb[xHIRFoJcCiKrBu.u[bɓKv4oy7q^&18K$_ TӴ I^z0_1`͞37jſ*l2|goѶɺ'__ XX|%ioYf"N@GfB& *UdY%}7 H!c6oN>.!{B!8@H탌flba _|!Id$r ?T_J J1ֹ@]&R<L'ͫW躎.1f-T%M /ѳ+g8L?\8 %84Goؐ):nޑ>cb>^"*M6NjIbQ¦Z<Gӧ]?H P QK*)P IDATge\^^5 !%7>X,~Y7EaJPq]qisvv.°3{{nwkɓ JHbo{G0MM 9O=^Tu3,Gq>ʦe0alIyFZ@$a]nH۴18q2&6DUU5)5nwi\'/dy-}ӡ XQʚgWWy90Z-(0d (Y9cwdhxC;r|{ZG:nӁuI CdHf1R)()iv{onX-Y_$K)|lFpK 9h`hƐ[wx55XGQe?ҳy}Yp]~f\^8(\%$euBH7M'I}G$2vGDR;qZRdiNRI1iH9Ā3+L`m R=H^(0i,]bA-M3ʪDf;6krQ1DWO}ˢZГ2f6 g5f9=mT@I?iv(dIʢ(zd"9%ȳáHC s\d䅣 =Jgg$  =0P8 q(Ku\ =vgIR1"{;!a˜E%w<{mJS%HarbqH1~y %sdJ@g{5d*i4MK]U,5i`?"i,A>""I_?~#b]!D9((?uO\p9'3xbqz#`b' Pe]x!{#JcDqח^@Y$X 5Y9Ag򉁺ط ?.?FWGFpuE|n;; ?w? x?; u_CgNYnO#q2MH=tkJH@OcŖ~(zC0>^s 5*{!^x?]8!\̟p?L(S2yLRg?cef9qH8?_ܬ1û~Hݶm)cpaӊFfOEGfx8l{.//~Oyg2;WW(^CimW-4v")%(pyq0L~ڣ|^m@OYfEg)0ؑfo6-.D?fYGfk֓( !ܼHW|txTAmdo!V^xz^oD\ 桔Y%g0FTI~{ AvQ6O4 r.$^;DQdsseKR{w.EN 'x&<)ߝ&)ߜr3|z,64Bti~cY ]>V?Ғ#kӊb;ktmJl̜ʳ JHaph$M(-~tJ,Nsפ' NHw;ENL/|/~z;͹<a8a[_>?yNI!"DZbć 켗XnmY9?[svvF=NeIQx}ѵG>ù5,r4m~m6Y1IcG{;L1Nzs^] Ղjž鸻{O[1 ]??)+<~Dߏ4Mіi)MM=yՆm>`( < diq3 /YL$+*e5]ӱ\/ƓAvvRYdYFUW!2N}?\sV!bZU;nA:S혆)*4KH~E웅88gpU̎$ȋ*cz@o=c?eIXdiƋgp5*o9; $4Ǟ:,n"S9RDu׷H؜]Poȳr~Ǐ|,Vk%Er`\J 5#FEQCve7i1,+5\UAa33 *Kֿe`{Jd2:?R"D8!*/h*MmcqUZ@3ن;Z ABf$%$;4#I y1nh)/_9|5Bp(%K]/uM(5udԚgv=J*GHҔj*ğ ;caC߁|?O2|?Asdx&iW%O>fⴶmZ<#IR- DK;}䄧3 !ޠe832G7v)~s+H5iF#RaDJ"EЦ mpBa<’ N춷xoI;GF௼![|Y]|W"^6Ӑgmۓ O92 1]IXERູYKe1oy[:]G_T鱧{F泿;|)fdqnۛIruzļp0P߯Uł!Cmw{x}%_]jv8́%~45}plлbIUܼﹼieMdYg>~ja`F 3ib:.U>R$c8CפAfn쁺Ce^?4t"9TK 0Wn=plHU>Jb $g0q]%-UQbek5O(nO?˜OSӵx~~x_=b^ʼnS?(+bF5Ax^9L~;m=zuvI^fFD&t}OuKViCSiB)a<}=֗k?92fz:cip I^ dnau$Fr4PpáAИj_˿r}Yp6Ziw?+֔a&6_E?"0K+GQF[8 C`LQb&"D,' X7pdiB`' Q1 /8v{6ggV%ht D(!^ om' ~P*1-y1ÁQXTUŠ5cQ994!igb a1Z2)țDR)J (@*.JT0 Iao"ˠ[xhDJMsjIOlw{U\!&&$oMж-eYRU_{=zd  eUý} ǟc:vDI8/vvxdgg8Lq:Ya=}$KaN A@5#ӌ(iQQC~tJHMMz$),dI]@͟P mp}D(" v7qsY]\zѳ,mFYjArl n.N_o}I!*t uH ^d"&0H/:%g *i]O2crY:mpRL'' :7$><1z,E4R: iج7vx}r^牙D)OB0ށpNC NE]DŽݱa4AB^PW5m۰0e #5gWMP,ǞQВ#!r..ۻ[vO*Tr$<:_6~@5Je꺘%y<Ҷ=P*[{DZ9c^Q%icߣ#IX.tmG8s3,+Aljw8h(I>[59z>e])5Y"dw{7 ӄ7W0o E /GWYFX9[ǩVӐ*+%77\FeO>aGWα\.(ʒr'kcu·ʨ2n/.9ݡO>~rftMXgQi%]OQF{M~C%"5z$'ɊӢAzǩ*1cUJkq ),ieEY2)8 I*($IR2MmR䂶86ʪ`Y3M;vB޸DྩK"a @NpYYf ~&eզ|"CviRV5`#PYٓP%웉y ]*QHf$ LCpFv/Cj^UeC7]QevZLckmPT#i$DDcC,eGbv㉇D ơgҖ %2KfFQ8:R@]Uss}M&_\ UaaІ^8kOE%1OKo8ki$X\=B& kdt*M?S"g]r'rkʪfFFmb'# fEwv|lj3BT  lf&e/& xHp &d4;L{|[xlPBP-H2!J޻`^Bx @xg[ W;i;G| 4:b0C;D(fIH'p.z/J8:ZGaϿ^l@t@t%4%BWvw~|CB{6k1>(@)g9) 0M}dҖvo9oSŨ쭋׬wq*51ޖ [!-U!*g>ۙs'xqf6F՚(oסN8c,HtuTbx IUWKi"M&馞בz8inYG~q >n1HXR%OPB,q*ֵtMKYԼiz"Q^~K@";a}~F麎a 3\H1{v$zax IDAT& Sdk!֤JU*ZIƉ2/I(92O{IJXDQƨr!8e-oܑjMBIp R8k;p')]2EYrlXc{e1g9k1fbXR_s{UEdH)ʒa<-f0zmV# cL&R3Z.buQ0<*OA%ZIk GdJ3?.κL濟+3ƅKHl? E0g{8}rùuq3O?{FQ$JqyqrPi6JMr2+X,H*$R=oqd\ TF?EV|Tb3(ˊEzaҚ=Lm0>e>ݾsp4mfqxt rDŽm65Ն|tS^1!`fR7$3ʲ"I!9׎!ήR"Bf3bǷOx[\Ŋ4%M2x#`WI2j`*So\'KL}\7^.QJsxt@vq.G0!`2ͤpjGCElb]?n[.(LrI2; GY~ 򊃅1QͦN -z]ؼ:ߑܡ74 ^sz2(S%Eab1hp" Rkfh6g74\![T?DM7Qzj_vx'6Ox?NX[ze["d^cv۟V+^Y޿gty?f)։eɭP"@뺩uYW&w,{^l5x-)3ԻNi=ͦ ͚m D;e2tH, K,o6X bJB& EspttdA^Ϻbp-C@sEr dh$@D0j,]վ`b⎵zrY&cmR[aeNQC й1 [$LIyh#T>ų]Zc%neY`E'W4FZ~K&o>-51xVRasXɢ!EdyzI9AA H|ǭ͙^~9W8?puٲ^RNjaFE$$m,h/Jez1]?$=o 1\|dz#E-#<.;Lk(Axضkk>'-"*iQ5 Ɔ<˨dZL Mn:A t{vC(<dq z"dH\/ tqwagsD^j!/2,cyu04,#CףH ҆a0S.r 9mw5q@7tXkPpS*TgLEZ7sε>Nj7ڠie^o'TGBEoJ Whd]Mi|h-$Nq^MLUe(2fYc7s!`⺞bo& [Ybve{*ڢ$1 1YƆH_<5A%g4޾("D |牙2zUd6GBDcqʵNc%c1)/` x/o.N,ѾK{fդ I9cXW$d>XQj F~1xNŒ i[ -)g-JϷ$!@2,!x?q \ߚ@ljDIE1{JaQih6}6(MWcclŭ# eӵ]Gv̪I5nɄ4YQP)ӯwHD%~px1;qr''%$ՆL_yY`3<_+?wB׶l65npya:pPU>i1)-ãC,꒾Yv z!3ldyʧ\-QyzXVKz'CYt2ٚ>Yw#9Ze!֥îmF(Kf Qm!]Pd9#r((sl')D"zqCOj *ڦO c@nsM6 \Sc }, 6uMT FkcZ &U9~d:ezC,/%GxN:-ޭ7kDV5˫+f)EYR?beOk08̀<+)fSiZt!p>śD5P&F^ٟ K/#u}ԟZ7?Ig ֖CUlO?#E[c9sԴ?=E[-M&]5)/!/vyeZJal u!hcxD(cL*-(a/+Z2*ˣPOvMD'rK|{ }&+#Z5-E~)l$Ip:7cu6Yɪ=ek }v?c$ڲlj\~omDz@%{˞& {Oكjɟ'EvŸBlg!>2.[Cgg5;R(I2Z`*E QQQP611S2L /g|Ott]dY>g;]UMְZu89:LrrE%'Qfo 3VAg}WO(pI|(QiB%IpP' XB Da 63nmoi`$I1k5FeC»ǀ T !F]^ꖕRÍy[ 5!xkahFaf6]]~е{.۷PVp턇C (Z ^eYl>{f2~ؙl'J@AP(*R|HlB6˪4Y!Hm¢@Dhۖc-]rzvuM];>fa{t S. x;t*Y]K !`6yQ͚OhX i| Fh4^ucpC 2)߻lu@>͘KDqrxB[N?8˦I.Y1 6, C҆9..1PUC"/1ZΠ<[MN7 -X7+=Pt |Y򋙒1S?'zSMͧI\WtX|?6ߣ Mg*w>\2+fr!~}oa\S؈:n3^/%1~Yjc d|{O j~;jchC]o=*GBVLgS| 8(#mݠvΣ${s; R5 m[u]Qۉ3`z6'Q0wF1>E ˒fo/ɲ-eY١EX$3ݾ]A.9!1h/LNI$QXc6' %E5}G7q[ށA#s gbDVѱ2y $uHCF&1@ vlЎO]{d5 Pho"N*t9J7GmնW+m3~WA=3JS]ȨpJM( Carze ZҢu'0@r'E)I:||ɳ[|<[8i MݠI,%pr|Y./f ON8?;mlfq>Ykf.? I#M[ӸHn r[Ҷ=Ur,s!=yz7`=g);9{<P͆oA4LS\\,m}Y,VPwu"eYF%)'ӗ2۩шOR88X`i.Y@gմpb^ӷ Zku2ݶԛM"Qd%nQ"c/*ȃGU*l( "\dUK; ]DȬE [7PV%yQ9J\pNiJ}y9][syyIYMMd]23i9 ^1gYQktA% _W&3gͱy񦾩pM} k\D >f+ktUUG&{^ƙj(]]:+ܛQ(?Ⅻ 4QA1)9:<&/rOψElq1 }D[R'eekލ+ :Khۀē'iS-Ch<1.@2 d iWOJ\I!b=JDzmuSO?$BT1Yk58yJh.;mɳI4J؛ '\*],f# yagSsl6 mӑgE&I !`Gdp_ҺڕCciPl[Y^!q@;dBC }$Fp@J]/0bk=v E!\&+"$ꙵXk%ơ5DRPߣ!3A ԕŃ=K}CCD%ŪZ9g\QTՀRԔetZQSRVyQ잟zShл\+CfE׷@mKf,CHYUbKKeSsUVqhła $Rl(W_{ trXbl6~'*}}( f:X*mA9'#'ui#j wڌcC55sKSkeJ;-#I#0gd.q~ (y>iy3tj3+3T ޑe6M$Y QG0JQBu* v[ Ir\.)GtyEָS,4,dW+$\躯pM9ӯ. |!M( Yn#YASRh:ӑ IDAT+B|cNF&!}s+qcٓ}a ^2Hz1Fidt{e#ңrmU4me~j+'{w`ʶd/`5zʯԟ$D">L(I@! 4I~L3pBMr0 'qsI|a?]+8hњ|NQ&{r4fa .i$-v}vJ:*2|T6#ҵȇGӒZ".0 *CU~i&\Ic~5(|TfwWTT[eϨ'~GLi62@Bvx=E*1n(1c0FeL?J"m[lnpn"&UD(ȬEGiێj2a6%zq\d)JBMO# Z֜s~yU7uIcINw4Y)2Zc2= +J1o<%#Wi ޣm#2.8Fb嫝 >ySYEW_;>U?c0TCbtu( nݾÇ?l~.OCT:8d3M/х`Ru;&6Y)>U|FPaZ%Yy' ,e4mCwLg3l% x |A5g*/S(Bv<|tY5cc7,gS7]r ZP5fhS*ԗg''u^\4ứo\7uSyc#^\e߳J-#YfSLUѴ-O x Iz&at+}˵B==Va$색͘ڤ3WmL[oόEbLfvmx8x+10+ c{NNN m2 =s$ ޹m]]%NJS^ JyJ1msZ~/ݷߏaG[^]mS hLQPK.IrDžَ}YiDvH@,Hd2R"5D4*rW;@=!xd챼kZYbKJA${;2I[cȵUe+"0Of;(BZ4]E5  6_M2(d0 mz@8<Dh x 1P%!}$(aO KJ>l ;q~2Z`^c3K8EV Q1hm~8f^mSLCyk 0vG&Xga!:'o]=拦.ia댕sn]V\D4_͋3E,"/Uw\ym\Uoڗ EpXHV䘪@l2Zm n7 #Kڦyd2ǚflvٜ>! u œWOG(t X$b"238>]eǏ|x}8:. w`LQhxKAsP<_vN)f4G}D$IjW#L;ةQƠB)':5@ 'ZCÐrb1Hb~UQ;[񴏔COrZ}mZثI}@Z{; dA ~/-jR"QN+51GzM^I2 IHmpc8 zNa2TZmCew`q"n z6oflyenҴMsN(F /z~J4mJr^>k7{z+0?6R,ӯ ܺm󓋋zs(6xŦ9ɴ8%m'}?.%nQzjh}߾t2EݛX4MCYSMSl6`NY2~6cSX!O@Ȕ6,,# S%yaQ< *>%_.̿b5 f[ϥ_\7uS7Aݶu},Jsl;GYUiAB$7&9ȋ7dkm515 ~ߞ:;t=q ]3V8=+Β>&V(6hI\'{薨B_H!#dNg jǵ'f1Ǭv'4VCmRhcqq#AЙN3xA h=:f#8鵈XzM86gdAOKcJjgl>![*ߐ)Ixc1kR]霴N?&NdAf Q>%Z[4.2yCȉm{D,y1aH;1#?46|f|N{Y\x|y5%䢂YT1}~H[,+=W5tӉU%uͽn|髯w^m?_W?!ômO/ʲ7֍g7&48Y;>mZ G0.p.\&)օ,K9y2#3ln"nduRoZ6rily^3b4tCWUPYFo7ob51gmu8uwd9uS7uSibbxU՗ȍ+n߹CݵKhaHycI{IR-c1c\r(,|lB.BHR%tT Re>N~?Nc$žEXq0LC~.wΑcrBԞ ;j h!$o%[1%t8:1izJ?dIƉ-jd H2f*,( CMTP^-o HOBS7xߒJ2FqxxHum6#9OV92-ښl%QDnBnZ$FL>TrXVruxM51dVϦ#L-)h,}bЖ^Zħ/oDžR(Vk0Re^E@,H[Z5$&l2ÓmAKT G(JR[/==lU#+$@$L#(xqDb2 ( UA5-4/R_vzW6?D<Q#dh#dT$≊MWt~]eQ׽+>sS#%'P?ՙmaQ_A껟gΖ|S_sf8^rnˏe{.rxx"eQ\mIt]ÇENUM yHw_[t>7F{cPM}G YRZxe'~zO~mYv nMM%h3t:}T (kt Ն`]3=YiMLQ qXc_7CH6Uqٲ6 |fR?*Ix&Ȼ^+z$f-ZpI`2F{; egXTZg4`g g2Q[)e.籕J>`}߃zsS(Ԃi&F =bOBW&(Gns L9L'syW躎l݀!$s=i듒8ؿFSI̭R*2(D7~~pq@|M:QC!cX写ݯ)oW1 r/jLP[,<ݹm0>:j JBn,eDǞN ` :fcoCE b$IF-&^>m+cCIuHc{S_v˖&Ũ2d(kWGe"֚*W"]p6;:_6Y9MлKcڷ"ײs|9s-T[=_ؙvy4?%m@]7جm[U8&jDb,PkRz\VLZaRj֊z'!B!m8sCZc mn,b;JրJ=U놶 *'ق'օ47ڀ,1bH~xK$1wGN鄣$7Dz5#n17 mh6K)Pa@}uzLz'*1D0ʠ$FN|.AʂΒ)N X]%bTa)nAHT cm"Dw,Fٍ/5fԽ#ǷmHIÉ (Kos:}po\uOs{p }lVdαyd&Ei0KZjkqyju`^[܃${5k\@Gn\7 q;Ѵ L.*E{-W,g ֣ uKQiC]ּYT=0~M ຩgA{>G7tF5\^'HdZNR(R׌P6YX<ω1Fo 12ƨ$>r,ZqR]KݔNއ^$[26:".Yޱ}Fi|&J0:Y!B I8)+C|Q$zN'LBV*9Kqg^hjO׳J2 B5z$@cQ MWYl6yNN}2əO+ ft}GSI5亸> l=@C&:]\#Bi '⣷RlVLPx 1Z -1|1sr!^n a :hGC%G0J<Fhֵ"jJ9ב<{f(EL,Ilq(b ]u]z!e 3m/AF(Y YeOYj^=n_l}ZWS~dO}yu }N@I׽6.0AԣtUt>aYj DvlN/ι\!*sE[* _2>ix7^4N"zS1pM{^ѽ&ÃCSLJ( yY$zD@+M DO)ٛ& HJ ,$iJ F  +.elsy=1HdX4M>.˜+{$}'ݹk]WK+Gc4EiF ɤK%g%aQɌ!5$Yh|60Y[hgw :.bL2z5ޣ!*>3oY3).nUbQnm) լꊢ"DnHRqN,̭T.EFE&GKG~`3#LPZF*SRtU4 'Smgs_gܟdtA\Mb1(a24*eYi!m !FёsK0%U3esZZ͛i@ KQ2๽nwa9Goqo`*!˲` J{7{ӗx/V~5?~_ux<:Dg'.6BY9)_/fvټȴ!;__&ԡȋcaھod5[!{ss7릞Y7nJ/dٝ,xaY21ql'e|(nާ랔/CA$S h88s궥i$-#fK>F/!O$ 1-$2Rۇ8ʸ liwQ^wnMȦ2Y}[kxOVEAlqۿnO^.hZ'v3W/Gϧ5gqozl5%zM/8)W\Ɯlʫڱ@Aa!tbzѠ#^u(SQ̩°ZOfJώ}`mWTr ʸk-KH$"/z޸[Tu'N=)B+Y&*N]ƐitozWunn=7-ٯx~ Ý?j"d֢m a$TRϒV*$Vف| lR1&fKk(~͙Ofe7-b"DA>{-x˂gku 6/f{k{#n4ABģ$0[>Q9`ϳE1;oܺo^]9wzG0y)Cߊ CĿ]SKo_@ddJk=:uLyPTϾcJHO2<+uS7uS"%*ެy^baٴ̉!}S7&Tw[v ,Cޛ۶Y7Ƙj>=6t-ɲ-[ l*c"Jo E_"JRc I(]) &8V$KnߞfwsǘkOsu%[|Ug9%_ 5R3 IDAT-cr 65S3' 65%ˬsqS~ ƓQ.!<%d= 5.a0HB4R_ŏD> ,_Rhӆoq$x2f:b"3<قvvݏ"ohzag!Kq|v/y6G<7ܬLlC Kg T$҄'^-\~"kiGj[4*8q>EUqEL'tONyá1xg9-m fqcןz[  ]r"zWkL.oxFp99vu &tc\wiqB;\95`]KjVmKL!@$#S)?%wT!;s NLJi7bhA9]T, )P!L쫞5FيW^^eEOgI.KP"b= _|?ȇ@hBjRt78WV>Q8'x3j-Dן[cm60#]U~=Kّεl1x9I늢Ӷ1E Ѭ>Z zM}rt *g ;{93vGnZH=iL?MqxK5&RvMАB@v%ϯ\ oth4bͫBa x;ݰ%hBw./|Q9~DTkLaݾkPXHƓl7*j'SH[s-O`\|vQώ'|Wv盖B6؏9H)̾*E߽ y02<`kB yrZhՂ0`wp 0=`Ë,0F,UQƶ`XUa?*xޱ}g(Vb`gom!f;kQBL`6LS"Z)mQD0#.H6qVޞZ D214JN:|i#|cňָkfLl贡( POelxvلF'D > z)LU]ŝ ڏz\vemhKaGdе {;!|]~-ʕfǟzëW8:G=X'ܻ{pLSڮ#ļT{~{Ç~ R'In;胅+:x5|y**u"MRb|ؒH;f10 i7by`O yc/]smCJq ,ѶpV{1m`b b6}>~_0uE.|5)cb9v@BBHB=Hu7B@D%TAy*\b{9[U} qHИA)"s8UGf*t_ u:m"adلTM('-`=sr0hb؇NGzR%]>rtn(1\yK1ΌhΘ )`ne1އn[׺GJ89=KylۆZ|ױZ8;=kׯ`@"R@PMĔH E S4 >汾󹳫9 a"*5x"LchIØ u~FRJ}{S AR.%&_z?9KF|rpHMIbD\Y0@#YڎbvHCӮ'+n7GRyi{aŝ??*|wMltwzRֱ^ic~ Ƞb]&\1DXk.P;}[Y4&bU1 9*w!l-*L0W+(,#lGr1Z!qS1zFg+Z O4Kb4!5`F5[7('s6 Hm\G!l=w_Yu~wwUELAh vygwn D|U7<7`Njp 0=YG5cͥ"EBTܧ\jXsT.F6RGa *YXFEܖZUShL=bͫrPX5-w^a܍!+ 1-{>ثIΒ@mvSŊG2yncWo0S6BhSAl~@j[0|LJ4Ϟ1m(KNsb HܟMʲd2PU5رR΋6Eڦֽx]ͺ]vw@jn7?Ez.Ot5[-6[ 1m_0[nXNaÄuP|Bmh&Adg֢D{"{?^hM 1ܧIϩڙB6(;gs<ֶmZ*Fc0+F\1:xrȋo%*n=qWj޾"Oj>xsD"DHĀu=jؓg-xZI>!jNz-`:*ńp&U]&MGW/)xG44* o܁zRQb_QN Cm>,)j΋\ ƹݕ6ɒ>\^#a"UE]W$%hhvʄvl/27yS22E,LTYXn1E0Fcb\ӴKb(=WEAk$. @%& }'Ne)q]Pr\wpL_qmb&(UnqQݨ)\li+(E11@G}D|`d-6ESl j t $%'˨/˭G2].IQsPT|:Q| :1gԚ=y11fλ(~$5w]@2}.Iz&%XU[|h<@u8'㒫2ȇ%r4<=&7DM3zgמm XR/O]f78#CHd-D8 >k0;@[/ 'k6`†[r|6%G(ƆU\㑐 MC׶(\Ot磟r5Z 1N`qE1 A-֕PlUʄۿݦMM?x#T!UD+aOjOω~[WS5[4B5ڬ,I1a 8Wm/t7LL4|N(Z2|,9@GWQ3? 1EV۳ ԎFCq1"vDt%٠+LG/d4:JZn(OŰ8tPԔK:dMz4?yvM2wTE|ncO| 0k@  Uۅ&E}1czkǧ."KoϝL1'3c:;e4@,9mZpn`:- nήcR ?TU\VZǴyl̾)EB3xajٓ{;=~)(V(wBs 6s;v|h~'^~EVX"yt( ")Kݗf qzrfDH ^蠥( ld2EPc|]aKK ]T1e79]&KW>)쏔3Rţsް.W0j;bpe5ٌdY^to^$X;m,/[e~͝􎄹ys?Fj1pr gҖɵ'iVSR8X-F˦cb֑EzqQe/˷߿`E%+[gbI$I'z~c<}}65"ׁ,1gnWvI=BM cC$#9//DrJS_ckI!Hx^s{ )ҭ֜*WhY]1[Q"#gakHMGdm[B*Vg-(XsSJty_$ZCQԌ&1w<ԓkY״]{搧xWذXXeZQO\9O~ߟ\m>4M~xQrf)fK#x0.]4^˸Z! { _+tE]쯵DXnx2-- ժV!Nqs)9ӎO=p4j9gNO8N&zVCi4y+Dd c̺[4`8WbuH1"6_#FuMYpv¶mβZ '>l )ppxwNUc_O?O泃utyJWZ~y$M NhD^wxTXҏ#k쐽v1듾:?}d$Dm0" P G㛖m!*T繻\R]Cj:aJRh[k6"3j@a!Reӌ|l׈1"9ώLzsY/7.J xG嗾)6|c \ xppvan^1[[c$ ٩+v`4\19gW``9zDX;+hFa,v)JRI!D1$ĕPxPU[%.ĺMP.x*6*9J-"ZZ|LiN'L =m>׶tbPќL\ DELŘ;^b Lf|Hɂl`$`*@xIel'B] }ƈa_!z"X01Gt{O++ܹ{1-ĺih}l5t+Wy[OOF/7o)l plCs޾s ܽs[U\~bj.z )گ$`b\޲_dwϱ#]&nr9p(9̹$/5Nx6Juy'j"Ǝ,UPѶj40`WeM~r`]N[(ƈ-ǵJdCG))"yJFӏm"#y0TBTNK>O\Wej Nj{qP6%D,uƀ Jk|؉t߰Z9z&6t|q_BlrWN5K}, UHD$QQ6NC"M)˜6ԇ~b+U]3N1fB1aDhMmQ%n:b4c4R쌲r|h\ŀ+ ٔjT]]ܽsK/m[4)/|EnLj^c[s/Ok4$LŚ1PU{f Պ} 8cH1Re^d3 z4-:#6` ͺSz* 1yX'_! 9L@#$ #XgQ^5$c Z8AHʡb Gbg (!Sy7:#j?v%aXuV`HٶWkMa0`m k >JQTb]|Аb;_ۍPr!gtP6$gqŔrX+m$W¶~On7W帨sk;͉[:]n?^l(ƍk40{_qj, 1,)e΢$"TEq)f63׈RX,wMC~zO`gygKzRӓRt]_+^gH?@>6uՀ?C֘ҔRLJ=v#B6~;;QACDh]J2mӟyߠlz k '[aRSK&;iAQZМc̚%dz^cS.uTœv] )Vft7Q^z/:#$Q:1Wmp-A!NYw[|z|k111e>p5HYEk{}Q+9|e?;X CԎE&Yw3Sܒ!C">}1Pj5RՂrMle(cl^JΫɯ6_ilF~FL%6`H?2O^?jN/&nbzi^9v@ck+CPs˘yV͚kBeIQ8A]EAOWGQ_?[=c))ՄveYQa ׮]ƍmxs%Mi6 k!&65bLvx4] )2ΩST6٠%F5-roVgB"{?AC*RL9rA{ewqwnfڍhWy5is>DA-b,#$5djH%0`c@ QT 1!!)`D3A%/v磁N#Rp>&E  qGfCa,,jQ11MRQ6!%!vETI!k4ʞH/|0}@pu[cpe )ỮG*%!\x ր: fypsQ~"ԳɄhD4uqkPQ5F Uᐑ("ED̝U;u]/mǽ{O?Kjy^9x^;x 4WtB5)J[t:ݛU8(Kv)BfU`h-чX.!|LEc~ېR̛I(\D۴F.f,۳ц>l9^G: ~aqz[~qg Y.dͮ7XKd׀;Սm MG5`( ]A+{B0t~==Aţ,l(mCYW"8,".zb A#;m$PZ,99-q&}k0bE$WBXcBEu:Y+֓1k ON٥|#rkɣ;eO]UTUͺ]Ҷ-j$.06BIhbuW~/O\*?8ZjH8z>ť5Q|#Rl[ڮS` wzd6qhlz Ejz}[cFE15nӢެZ'P͛;SdBG lC\En?~E1N=(ʣ^E̙cț')ɓ e9+9| xG xx7?wMs־w+.۬HWCJU>;$-1lUBDI$hS7N _cnpgI]W5ι~tI)b!!X 3JC1WEEl9c1BZt>)յoXd>B$RJF㚜Xm֜rmxMbbZr<# v IWLI$k3)7'l EUQUeegzdHew_ne 0XvFƀ狤~b+{}:s]w7BwzF!%%N*)FRH+13d#)WÀ<Ck6GLǕ&֫!y gZPE~AW!6]zs /j 7n_PZh3x!QyC=QBHΤ.nL27(B&8{]`JrTd]1pk;]/; 1xD^%q)݌><,0k ٦>&&1:&E.j\M?)FV[[ٮ[;♺fpڌ65!s:>OGIڨl8+`u`]в,fRKKaC),AEXo5 k>ޢגjwX1e Q!F%ΗeYb$ y[y#w}͹ bz@P۬08K e a)HƘ~Q%ŜyF Cҭ Dz@ -p 0¨3h20Qh}qy'<$` n2xЙ;:/tN*Ĕ5c]E7t1K#544ЛqdC 1 ]r? 캎m90zx4b4cDbh7JHT Z,=\RֈY9˝{PXKBqFWfʢRX~fosw/';ɢkc)gM*4*톣;ѕOxS6HizdvM[9ZY|5E5W?L۸tYc&Z)|D5P*"zXeCBd+ l"B,hLQG £HwUwjFY`lm&oXFwj#(FzR87΀1Bo pF0"J+$H&U|o=`o0`cEFc1O֋S5|F2mb ]E9auggE:&JQ6-))E0F(B&&BNzd.u}y˻qHtN"SJh:a<\h\Y"} $ƓXy.TU3w k 4D%P%P% fWN[R}lO}!|?`GP*j YA6Ҙu=z{ڙ/eJcÛQso&=4Ɯ.Ug~ZFSIoT2L u>D@$_ӄ;EI Z1b bWTex2A}dYc, 9eY6}n%1cBPwtmz!-GcMU1},p[k PuŊǁ62wA ] `R2EXMە3`o k$=u|L m+njJ>w\D/v^v= ha}PkL.C$C:&vCk["ϢG`च-%TS<"dEQQRj}42LHlقJTtxR(zA6Cdv@ QJ0a揔"A@^S%B =f&˘M(bgR)\yh<&F(P`, CtɆRz[[,Es~#eUK#QU`SΛ9yO>t~οS]+?"s=Ze򫔔wLgohbTCd;HNuMi0ưTrr[θr RH$mWKα\7(! >L /rfS۶y :PDoSmB 7=c2Vx6rqR2ͷh\r&R'F1-4ndݜuayn@hlg?/]P.Õó FGwxӿCPPTAjwB .x+999'DY#)GKZ[X.CCH RkC[,'7us8<>vK#Y wf(mJS#V,l}ى_iϚphr{V[vvο5>cUUNzvњr\3#QEI=R'Kq}{`lun$$5RB(ROčk:\aD<߼d20 hl8#kR*)c&-f)eQHJm >x6L3ʪ9Ʉx̹smmZ4MM \/Y̡rϲsptMVN9`2%u߰\s13}E2@gZil, 4'S^ؿl'O? y/CBcf{wE{G9b=oEhPa\=Jz\1d9YÃڤ h1f2 ; Y!YXW}kywqѮ\G[,dYff$8A&1MӲ\-ȋ EY0ٚ|zݠd@FOI&\(Bq8}(+?B'Oy}OD|%L5(oߠL|u6_u{<ˏ\LV]v_.zfTUQ{]fd֍r2 e9Jvvfd&gq'@U˂s )xRBwq4D 3%Ȳ 6']ή 2Xr,w0 R+)⫹֞x[1mLxEr8 kjōA !R [A0 3W1JbxUsJ+IsBrLtl#Ggb Eт@E>L ހBbO/"w(QJ`#i0<=K1u# ޣtFVT{傢4윻,zcl#Qw-40G .HuwIZqt|LլjD Ⴅ ),p>'tLڇJ0a]>BG'jھKַ ߕjtKby.] )GHhi dOqTlh}7wy6?ϟ>{щƭGd| ^t[גUY4X{OgMwKH/Pdc6rϤ䫧#l]ufJ.ӞlB *cL  ׈on#Z PGA$/J9Q̶eE>:*Q8M Ag<:T+1h舸,r`r. M!a׍v01T{dyAQEЂcCtYQJ-U1tf̱ut:e4Xkf-tXwkY"&;;(SP 5TmӖv1L.O?Se=Wo?QW"קߴZ[[W|֭ٿU]O1'oUfmR>~f!_ );_JjQ>1[V%"hqTU')&s0\+DV%zF__*9gDCfr:E[8q=>Jc|Mg :,Ҕ [6w $InQ*$yN,V'ء3쩠.8BT(^"z ``$6F$ݦ˦;p"N; -x"3 6 lQ#Ce(#u2EdZ ,f"]gfAoW IE 3h}Dڒ9wqt5Q:Wmc.Y5bW~'8{掟\u^ξo>tOW]{>̷~9(_6'c>Oڧ#W_{hZFnk-C5ZU'i ~ì,)q7 GC!# \=esS@Uj 8=)\d͐Lx6awJ>zPi4eW-ݥۺI(+IGR\\L|PõB[= : ]*{;^<>WSYKs>8p#|ܜxE6󌇁~14yN0G:m8JH=]0EVNVG}?K\?Um5k+{׎O1GQw#G ֆ=o6οm~7M/[Z|,1ǫ>8~S.?-xI;}ox7z9BƘyM(pj}6s{s:x.x eXb uGXY.v{&#>շx17\/Ȩ0RJUu]$ɋG*$yN[yv(E0D;pԈ'(ITtWS 89NN{f[DR#C!)0F#aAf248;Ĵ ۚrTBO- 4TeQNK~mIp&Fw}](!^k}S 7y6moQMǜ!uDP,}?|yފ3[D{ٿ?Z/>GIr{X |+g[S.ݹOjTCS/i%([b6OR7E xs{ޤ5&?XR3AOƄ"\Iш׏ݚۧ!od"1:u{gJk&Ig-\Iܲr}7j[SB艶EH,wQUܿ7Jb4qu}2De2ۖ0D{@ITD4!0`yxE$7h yH%q+cpB\d8F}~ã^n{ӯW7g|pUׯlěQG׷lU6Կ$|}pw$ϻO+5?Lt>zK,+>Er\QJnWZLPM=\FbC F0_|F5b"lM9]b:Ȼg;! _]ODfv-S4|$$\I2M_DG1/k]7\p?#I(EwtwnM3UOVxӴe[1R.1ECg{b! Bk ѵ)BbH==t ~>FD v1!<#A~%4D0:B`) :&I򬥂+I[V}0SBa%: ]KD@`we=9ٮXO(&zQ޷ G+8\(8q~!Fe l :WuTJNs~ÿ<_ﻺ~,Z_Q֯bY)Q/[B2;9=Y!/K<1: n"5II2 my0^[j޶&o(`Zl z.\D!CnbU ̐g0tj.wۺI+I[vR Ͳ#]K9ԈDof"ɔlu?9du_=뚭kܽi!@It(pe(`;9~:{f3׹{b߱>a\~O|X_2C|y.rptDJ2`LgXkYbT,ZK YPD"ZHdB1U8xza&cE+OȊkfiar B.)Z9A7xt]w-Jjg{6:z(殨x.fiQ"MŵqXC9u>cD1Ŀ h)Lg3.]{_cT䅺z8^$yy'eU7<2S%(<q!΅ Hۆ:DKRebc-J#x4Dwj39|uhe1CHP!D/$ ^*$%O~Ur͕,!0ϘLǜ?l iBaׇ?y5ly/2*NfbxJEjM)7-'݊Ipq| g> eQRf%1<˂"\8j[]$yzOU֯B5e5bgwݽ=PH2py"+s:)t[cBKu=JIWQNeGok2=/%I+Iܚ(.B^E|>m[d@9,4 sN};݂q_>{ X"^D +| -1sUE2ϨSfS^Wp˟>?iVFӓ_v6T@2LPZv mzqBHpG(5DAm'KZ$[2MQ(XV8% DFx9" I$B*$%ZwмR"i rի1 ZW%_]i-'#ƈB !{J!b,( &-F))W&#Jo%I@o}돶C"T"(Cd&@o[qɂ;"1/+J|DPD,T{QB!B s&)DHpJ]87{+ITp%IrK46LELŅX2ƜR)BoBp-O劫Z9x<8#"J Ic p Xk S%$m0UI>QkOs8zc=f`%#jHmޙ]bbn8g ^P754<" Bd(D6l͚A>MZRdYFUUbΔyr`/ W)ITp%I[.zd9^SWO(l跒B 1Q޳{Ve9x\0Fl)bHp#”JAD]Y6]$I~Og{b6onFܠЛUUA lߣuõc!O$)P)ol8 !`!shˊtt۱>I+IgmUֻTbLj~ L>D‡M,MCi D8=Z"d B HB_$YpC9R v"[7z-I>~?ŷ/sE!#9*ScM1rz8@JǻL'.qIH1=#쩥Yh dnǺ$I $Iw~m~YX1EGD$ FquMYuM#^( @j"PWmI)0Z'^1`mDƌcքX55Z [z{nr%Ix#OѽZI޶q !p#z("[QRc+܍*ALu&x㊲$2^f3v$۱&I8+IgKqw?;1iX3_͉>{,!!ںChT!f>0_KJI[dmPSZ &)&hږ4kKvz_$(DqeC;Tzt%۳]8Y.Num1g lBvt$u>1sr2i{ {nϊ$Ib $I5*hg#1' DJvTEɹC|4(=jp44n$ B-d0P&g]DDk>8j\=?}$G@ <.U; tQl&.~(3w aSa`Ai蝣;QEUkK!oW?]$E'IKW$Ϛ] |{8;[14rCDkIWkPe2|g1|,d$aȱRl9Q ;ɘjNԌru>_L*I?Rk]+c6ٶ:9^ז/5bb\ѭV)p/#Ȱ#DDn~ Ĉ)&j,r6BVΉ($iW$B*$yU|k뺷z)/f.YB)ھAC{ sobD%Q*s` d2I90C8GgTLLE>c;/,`O&=_s?tm[;bEtNQTqNqBȩF#tQɘ*wպf^Һ5dˏ8|m]$InٕG/{s\fag"~p9]@H^v+/°2D=K1gxo]1Fڦ%s8ZUtm!$yJW$Z16rdd3jbi[ h ! 7f*Fx"-&&7H=ĽOgc w(1zf=XL&oD/om^$IC6C IDAT7ϼ={p)?_[ڶEH<Ϝr1 D|l1FT|n|/PL[$I^&I$);LuK!1;¦!1a11|6J))[T ]\4mz{vɪ]ۖH\L.+ݟ$yػ?1~lg2 ?uxt>==%x4e")1B(OaH0fr ~H) !4 Z,knN,p%IXw}o흒N* g}ߐ#\xR k=rb JsM^H))/0_ާ8zM!á.2J}[&IݧHy_=JMc7[8{G =2? n$nz6CڥfE!5kW$ U:JYk](59C"'8l 7;B#&E L#a4s9.^}DjIQj؁G%b+I^BOGogb}`pxTÆ}$SͩqEAYYkĴYs/0ITp%IHԧEy*Dtn(b Ӕ)@,3dp^PV%m v͢YVEt(r}RzX=OLy%dR6pwB)tBI,5KRh1F!,F^|m|I@+IgE*)s:3D$mW3M^T R|ڮ`#%BJR yYR&Xq.0wEQXx䳟j:O*Lr/G?_5HG~q25#mF=R6hm@ qSn !bYk1,αMعɛޏmzIФ+I/XQQ鴪bM/DMrvPZu9PEh b3Kc{c@=,O֣k'ҿJI$I`˅9yOY*bĒi-%18(t]Miwf9C.t_r~IpO }Cnƕh! BD @bT(Q^a<ݯ+It•$vG}^k)bl xbM\ tzQ9[3 5=ظZ-w}.[I|AlBx\|r<.R(B)QJB+)ZJ(Zj2MˑUJ $I`E>.Ek!cbz, ( ޽}>m>Wxl$J۠"V&PQ H QFiz!q*qDqbpbc;qm=vΜ}~s&9~xz>Si=Qkrn\,էR4b䃻~2ro*uexV*޹jaJ8L7S.=zh¥zӶӷܹs'H]a-)/o(PsB,i(TR(9l 6_`'mMd"k/7mW3 @gJTJ)@L%`V6w\JNj&\J7-Nߜ7ǴdRN䜉1R_l B!kkߞ_|zwJ_l9<8+B΅R_[8>qǬ1&4Ey[q,*ԿD.ԛo[#wV88\^pzwK{9.c b Ղz5EW _Oi,W?c>K93c^6yq* s.]N|S;&E.ԛRss-oz;_`}Xj{yo98!RQs8ltm-oO;>מ)$/R)bBCrΗ,qJXy[\~Lփ2,CqRi¥zSE8U>3nR:&hZb)RJ]1, ]S+Ĕrzş ؔR_L|0Miރ?ޔ "VaĭwZ~vzXmRMЄK)jwS; ˊ!L&)2LjMB[鏧ޟz-o&ke3O?P Tb=U Mgq_~aZ7K)tRM9yqw߹w[̲k6%S!5f'S1rpW&>¾QJ5TS>.I͚qJΗ]U>EjE"w@( [l=0;&AORo&bIj88B#-)V 8FJ,+xIƑD[_RZ/_{M~j:̵ vnSJEٙ ɫn(p){BB#qsHX/.д XJIT*mX$2-)uOFL}_TJer.)4xk0 F#3L- Gh0b8Y;C*JInsU'ggH&,ɩ⽥dKg 5cE?H;[mн( S`|1JNdXPk֌9JW%~}1)z¥zh_铧/ ]pfG+FӖ]ߓ*T1 \vs]0 )}-ԿV_}89L.c0J58 aLR͖aἡT9ֿqRh¥zh/nܺ\ cv M- DNyT~VRɷ3s;\w-sJEѵ-!*P0 3bLc^l6H%;Rfzh7W_gXs#!X4EB;\2S~<)ojsŦ#AAe-W-sJԌL3YW,C)P4RJ=44SmhL/C, j[+7݂\2Ԃhvqe>w JӻwpB+BίlUATcIDI ZoYfeJFM}B*K.C6O=D+k]0LS)̇X:g۟vK)7MbKaX!D_=x78Lusx?ʥ@)0m۬+?{R4RJ=wq&M:ƉFlppI 0Pk&Dɂs&4,B,cPJG:f8k DN )er[bDȮ wyQJ=R، ?a|@CL==T2; sT#H#FvK)wP) rl>j\*9W7i"t݂g}Ǣz< R꡴Y{Z "qK\Qe.apaa"i+2~ ?^QJ]-sMwۮX9s#`5S%tI5R4RJ=ID.g%1#}{smK))N 5`Zi&P#iSFu= 1?"%4 C؞ĥ&R1 Rꡬڕ86$PKaJ)X;Z1K˜"uiپׯRMŐO^Rϝ/5W 9er.|%#ׂɉ@ fؘU?RR~}N}DbҧC0ɹ`1L ˗0C)k짽wSzs#HHJ\8݌MR_Uf)mL(5<}мD-A1BӮˑ5p(ԫe8W?0N :RR1  Ӑ ՕcQJ=4RJ=izrF)9`D($j k F1Wrԣc"b%J U*U*T(Ygu%)xgiA(Zåzꚵ:F`Ic% gɵV0A-aRٝqQ*i1(+궈Ě&ay#+~v( _Cvay~2.R걡'\J[8׊q ]`z81⿸5\JG3S<|Z2J}VU|OI*,ุ\GoѮ@J7 R˵q]Ph5 eF;ߵ }!QJ=RFB "se%2#gwωcT˰cK^eR i¥zݮk 8/TIT bZI)qIԣEXrHgS|ʃu\϶h9X0b\F.җ 巉qL -5Gu1rpń5=;@I=7}wJ)I`/yR3Ux @Rv/jrbj]uРR4RJ!8Բ>鷙8 "ROy/|aC %!mSy*Rue PIa䲏\B&QLbaw1sk]]PE*ސ&\J7t~&~nXR1Xƈ#牜#B, CB/:CzO\)KWIC.%cJ2"^չ 51s~vBLbmY:X)4RJsn|aK l%g)R3d8ʔƀ[,Vۻ߉w,J)1fj)΃+̜lr2ihZr$Vw,JG&\J7 7ѢP'X3'Wr%wrʌqZ/ RX;zEMSn3js2Xb ]dT"WJ=N4RJ!3Nƅς8;w2rɈ12w-4S"HI$;z )lR F V)c=bk-Rm3 z\Gss߱(mp)аOϻl2`5ą3cjLCxr#ADlҕR`1x*ʽ[ Z*S}dswb_?K֨*K.}}w[3s rbIWS&X-|z7=r#a:M O!Ƒk2_3` ML .J1S]lRG.}1~8w7;YZa0 =9ˢBə|SD)SZiC@^YR ,M謷Z$bb.ZΫK,вj!\!-I)up)ȲUK4M/O2Ϯ!6 1N@׮hBK)2'~WoR{We ԂJD` P*dl (e{LFL&!A5i¥C)<`ADb\62ϫBjޓse' ,O7Nq)5ѧ2\aF-T,[ P檮Ja)Jv8 p)K.}}G;X3tRF(X!@׶bb11|oҵK)؀R(D*\2)D ˏL.c-.4`,Ɓ8'%}J)u_A(duW넹*岮!Sk[K"łE7pո|jq)ThL6| 5r8h[)6`p)K.Ho[a&\| sT*" FS}V1uw/׿߭/'J exRMǚ^V”Uc-Zj13ӔRԣ)n_uFkMtXbJκ<CʙRF|hI11N4roz\q}: ;F[8w}L1.i 퉦`OJTv"B~Đ+d2^hmR4RJ1u(EPJ3pᜣdn ~9X8ߜmU,p)[cm"QJ̉)P:1sC\T<\Wa`~7RRYiݝ?y SJR0b#Xk:`TSfj.1n6L)x_7j"Hʕ:_.R,(䔉1TEC8RJTp)K.ڱՒuDvΦFMR Ru|m;ӵu D-U2Fms!B.`',h|̰eˉRjehyL 6,!L1QkԊ1sc )W)҄!5|_qJ҄K)ѻxK֭Y(R/J%S5WdǘB!u 9|K)5wXސm@I` @tc 59i¥/MRuլ:[3 H2rԄqsbc$ SԂX F@mX:)E϶mO) u}fx4H9De}/[)҄K)YMlF|R"M-y1@qx!=qXkqa҄({];~n)ď=߹R? ,F<s UR&MRQruaקwK1Hg=κK%LL F=$.7qS>a~=J?3NrpB7-$ƮOaUJ=4RJnWj)fcK 9Ua$k01^e>&k+Rg}GgRJYb}SvpѶEc88 OЄ.V UORK.k6SYWܸ$MR29sZ<19ZWRJfT.W/g:)vKu#5V}??{JG&\J{蟿aZӾgf8a}p ͎tQRJf>z7K3'KbiPsJץ RꞚ7FI5dz6 )s Œm0"T` YBJr~B붔Rm)cupHL)͵ھקzt}/@)8?ىY5xf_0 [ 1*4ab 6i8]ߌ?d/F*&m\N U[߾\)V>vR.=RJݳ8O[w`JV5,n'iDXqTr)swZ J)=uB Uu/SJ.=4pZ9\8.v|w+ Fi@X.wjCU cQJ 8lצz4i¥zUwWV0Mw8{c*J6бr@͖>,W܏J}Xy7}݇UKףzti¥9qXLT ba<`|a4wإ|;z՛ˮs? C)Q|RԣK.=S RESPX

RESPX

--- Mock [HTTPX](https://www.python-httpx.org/) with awesome request patterns and response side effects. [![tests](https://img.shields.io/github/actions/workflow/status/lundberg/respx/test.yml?branch=master&label=tests&logo=github&logoColor=white&style=flat-square)](https://github.com/lundberg/respx/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/github/lundberg/respx?logo=codecov&logoColor=white&style=flat-square)](https://codecov.io/gh/lundberg/respx) [![PyPi Version](https://img.shields.io/pypi/v/respx?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/respx/) [![Python Versions](https://img.shields.io/pypi/pyversions/respx?logo=python&logoColor=white&style=flat-square)](https://pypi.org/project/respx/) ## QuickStart RESPX is a simple, *yet powerful*, utility for mocking out the [HTTPX](https://www.python-httpx.org/), *and [HTTP Core](https://www.encode.io/httpcore/)*, libraries. Start by [patching](guide.md#mock-httpx) `HTTPX`, using `respx.mock`, then add request [routes](guide.md#routing-requests) to mock [responses](guide.md#mocking-responses). ``` python import httpx import respx from httpx import Response @respx.mock def test_example(): my_route = respx.get("https://foo.bar/").mock(return_value=Response(204)) response = httpx.get("https://foo.bar/") assert my_route.called assert response.status_code == 204 ``` > Read the [User Guide](guide.md) for a complete walk-through. ### pytest + httpx For a neater `pytest` experience, RESPX includes a `respx_mock` *fixture* for easy `HTTPX` mocking, along with an optional `respx` *marker* to fine-tune the mock [settings](api.md#configuration). ``` python import httpx import pytest def test_default(respx_mock): respx_mock.get("https://foo.bar/").mock(return_value=httpx.Response(204)) response = httpx.get("https://foo.bar/") assert response.status_code == 204 @pytest.mark.respx(base_url="https://foo.bar") def test_with_marker(respx_mock): respx_mock.get("/baz/").mock(return_value=httpx.Response(204)) response = httpx.get("https://foo.bar/baz/") assert response.status_code == 204 ``` ## Installation Install with pip: ``` console $ pip install respx ``` Requires Python 3.7+ and HTTPX 0.21+. See [Changelog](https://github.com/lundberg/respx/blob/master/CHANGELOG.md) for older HTTPX compatibility. respx-0.21.1/docs/migrate.md000066400000000000000000000074501460110214700156400ustar00rootroot00000000000000# Migrate from requests ## responses Here's a few examples on how to migrate your code *from* the `responses` library *to* `respx`. ### Patching the Client #### Decorator ``` python @responses.activate def test_foo(): ... ``` ``` python @respx.mock def test_foo(): ... ``` > See [Router Settings](guide.md#router-settings) for more details. #### Context Manager ``` python def test_foo(): with responses.RequestsMock() as rsps: ... ``` ``` python def test_foo(): with respx.mock() as respx_mock: ... ``` > See [Router Settings](guide.md#router-settings) for more details. #### unittest setUp ``` python def setUp(self): self.responses = responses.RequestsMock() self.responses.start() self.addCleanup(self.responses.stop) ``` ``` python def setUp(self): self.respx_mock = respx.mock() self.respx_mock.start() self.addCleanup(self.respx_mock.stop) ``` > See [unittest examples](examples.md#reuse-setup-teardown) for more details. ### Mock a Response ``` python responses.add( responses.GET, "https://example.org/", json={"foo": "bar"}, status=200, ) ``` ``` python respx.get("https://example.org/").respond(200, json={"foo": "bar"}) ``` > See [Routing Requests](guide.md#routing-requests) and [Mocking Responses](guide.md#mocking-responses) for more details. ### Mock an Exception ``` python responses.add( responses.GET, "https://example.org/", body=Exception("..."), ) ``` ``` python respx.get("https://example.org/").mock(side_effect=ConnectError) ``` > See [Exception Side Effect](guide.md#exceptions) for more details. ### Subsequent Responses ``` python responses.add(responses.GET, "https://example.org/", status=200) responses.add(responses.GET, "https://example.org/", status=500) ``` ``` python respx.get("https://example.org/").mock( side_effect=[Response(200), Response(500)] ) ``` > See [Iterable Side Effect](guide.md#iterable) for more details. ### Callbacks ``` python def my_callback(request): headers = {"Content-Type": "application/json"} body = {"foo": "bar"} return (200, headers, json.dumps(resp_body)) responses.add_callback( responses.GET, "http://example.org/", callback=my_callback, ) ``` ``` python def my_side_effect(request, route): return Response(200, json={"foo": "bar"}) respx.get("https://example.org/").mock(side_effect=my_side_effect) ``` > See [Mock with a Side Effect](guide.md#mock-with-a-side-effect) for more details. ### History and Assertions ### History ``` python responses.calls[0].request responses.calls[0].response ``` ``` python respx.calls[0].request respx.calls[0].response request, response = respx.calls[0] respx.calls.last.response ``` > See [Call History](guide.md#call-history) for more details. #### Call Count ``` python responses.assert_call_count("http://example.org/", 1) ``` ``` python route = respx.get("https://example.org/") assert route.call_count == 1 ``` > See [Call History](guide.md#call-history) for more details. #### All Called ``` python with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: ... ``` ``` python with respx.mock(assert_all_called=False) as respx_mock: ... ``` > See [Assert all Called](guide.md#assert-all-called) for more details. ### Modify Mocked Response ``` python responses.add(responses.GET, "http://example.org/", json={"data": 1}) responses.replace(responses.GET, "http://example.org/", json={"data": 2}) ``` ``` python respx.get("https://example.org/").respond(json={"data": 1}) respx.get("https://example.org/").respond(json={"data": 2}) ``` ### Pass Through Requests ``` python responses.add_passthru("https://example.org/") ``` ``` python respx.route(url="https://example.org/").pass_through() ``` > See [Pass Through](guide.md#pass-through) for more details. ## requests-mock *todo ... contribution welcome* ;-) respx-0.21.1/docs/mocking.md000066400000000000000000000017351460110214700156370ustar00rootroot00000000000000# Mock HTTPX RESPX is a mock router, [capturing](guide.md#mock-httpx) requests sent by `HTTPX`, [mocking](guide.md#mocking-responses) their responses. Inspired by the flexible query API of the [Django](https://www.djangoproject.com/) ORM, requests are filtered and matched against routes and their request [patterns](api.md#patterns) and [lookups](api.md#lookups). Request [patterns](api.md#patterns) are *bits* of the request, like `host` `method` `path` etc, with given [lookup](api.md#lookups) values, combined using *bitwise* [operators](api.md#operators) to form a `Route`, i.e. `respx.route(path__regex=...)` A captured request, [matching](guide.md#routing-requests) a `Route`, resolves to a [mocked](guide.md#mock-a-response) `httpx.Response`, or triggers a given [side effect](guide.md#mock-with-a-side-effect). To skip mocking a specific request, a route can be marked to [pass through](guide.md#pass-through). > Read the [User Guide](guide.md) for a complete walk-through. respx-0.21.1/docs/stylesheets/000077500000000000000000000000001460110214700162345ustar00rootroot00000000000000respx-0.21.1/docs/stylesheets/slate.css000066400000000000000000000004031460110214700200530ustar00rootroot00000000000000[data-md-color-scheme="slate"] { --md-hue: 245; --md-typeset-a-color: #9772d7; --md-default-bg-color: hsla(var(--md-hue),15%,11%,1); --md-footer-bg-color: hsla(var(--md-hue),15%,5%,0.87); --md-footer-bg-color--dark: hsla(var(--md-hue),15%,1%,1); } respx-0.21.1/docs/upgrade.md000066400000000000000000000045771460110214700156460ustar00rootroot00000000000000# Upgrade Guide As of RESPX version `0.15.0`, the API has changed, but kept with **deprecation** warnings, later to be **broken** for backward compatibility in `0.16.0`. The biggest change involved *separating* request pattern *arguments* from response details. This brings the RESPX request matching API closer to the `HTTPX` client API, and the response mocking aligned with the python `Mock` API. ## Responses Response details are now mocked separatelty: ``` python # Previously respx.post("https://some.url/", status_code=200, content={"x": 1}) # Now respx.post("https://some.url/").mock(return_value=Response(200, json={"x": 1})) respx.post("https://some.url/").respond(200, json={"x": 1}) respx.post("https://some.url/") % dict(json={"x": 1}) ``` The `.add` API has changed to `.route`: ``` python # Previously respx.add("POST", "https://some.url/", content="foobar") # Now respx.route(method="POST", url="https://some.url/").respond(content="foobar") ``` ## Callbacks Callbacks and simulated errors are now *side effects*: ``` python # Previously respx.post("https://some.url/", content=callback) respx.post("https://some.url/", content=Exception()) respx.add(callback) # Now respx.post("https://some.url/").mock(side_effect=callback) respx.post("https://some.url/").mock(side_effect=Exception) respx.route().mock(side_effect=callback) ``` ## Stacking Repeating a mocked response, for stacking, is now solved with *side effects*: ``` python # Previously respx.post("https://some.url/", status_code=404) respx.post("https://some.url/", status_code=200) # Now respx.post("https://some.url/").mock( side_effect=[ Response(404), Response(200), ], ) ``` > **Note:** Repeating a route in `0.15.0+` replaces any existing route with same pattern. ## Aliasing Aliases changed to *named routes*: ``` python # Previously respx.post("https://example.org/", alias="example") assert respx.aliases["example"].called # Now respx.post("https://example.org/", name="example") assert respx.routes["example"].called ``` ## History Call history *renamed*: ``` python # Previously assert respx.stats.call_count == 1 # Now assert respx.calls.call_count == 1 ``` ## MockTransport The `respx.MockTransport` should no longer be used as a mock router, use `respx.mock(...)`. ``` python # Previously my_mock = respx.MockTransport(assert_all_called=False) # Now my_mock = respx.mock(assert_all_called=False) ``` respx-0.21.1/docs/versions/000077500000000000000000000000001460110214700155305ustar00rootroot00000000000000respx-0.21.1/docs/versions/0.14.0/000077500000000000000000000000001460110214700162505ustar00rootroot00000000000000respx-0.21.1/docs/versions/0.14.0/api.md000066400000000000000000000267241460110214700173560ustar00rootroot00000000000000!!! attention "Warning" This is the documentation of the older version `0.14.0`. See [latest](../../../) for current release. # Developer Interface - Version 0.14.0 ## Mocking Responses ### HTTP Method API For regular and simple use, use the HTTP method shorthands. See [Request API](#request-api) for parameters. > ::: respx.get > respx.options(...) > respx.head(...) > respx.post(...) > respx.put(...) > respx.patch(...) > respx.delete(...) ### Request API For full control, use the core `add` method. > ::: respx.add > :docstring: > > **Parameters:** > > * **method** - *str | callable | RequestPattern* > Request HTTP method, or [Request callback](#request-callback), to match. > * **url** - *(optional) str | pattern | tuple (httpcore) | httpx.URL* > Request exact URL, or [URL pattern](#url-pattern), to match. > * **params** - *(optional) str | list | dict* > Request URL params to merge with url. > * **status_code** - *(optional) int - default: `200`* > Response status code to mock. > * **headers** - *(optional) dict* > Response headers to mock. > * **content_type** - *(optional) str* > Response Content-Type header value to mock. > * **content** - *(optional) bytes | str | list | dict | callable | exception - default `b""`* > Response content to mock. - *See [Response Content](#response-content).* > * **text** - *(optional) str* > Response *text* content to mock, with automatic content type header. > * **html** - *(optional) str* > Response *html* content to mock, with automatic content type header. > * **json** - *(optional) str | list | dict* > Response *json* content to mock, with automatic content type header. > * **pass_through** - *(optional) bool - default `False`* > Mark matched request to pass-through to real server, *e.g. don't mock*. > * **alias** - *(optional) str* > Name this request pattern. - *See [Call Statistics](#call-statistics).* --- ## Matching Requests ### Exact URL To match and mock a request by an exact URL, pass the `url` parameter as a *string*. ``` python respx.get("https://foo.bar/", status_code=204) ``` ### URL pattern Instead of matching an [exact URL](#exact-url), you can pass a *compiled regex* to match the request URL. ``` python import httpx import re import respx @respx.mock def test_something(): url_pattern = re.compile(r"^https://foo.bar/\w+/$") respx.get(url_pattern, content="Baz") response = httpx.get("https://foo.bar/baz/") assert response.text == "Baz" ``` !!! tip Named groups in the regex pattern will be passed as `kwargs` to the response content [callback](#content-callback), if used. ### Base URL When adding a lot of request patterns sharing the same domain/prefix, you can configure RESPX with a `base_url` to use as the base when matching URLs. Like `url`, the `base_url` can also be passed as a *compiled regex*, with optional named groups. ``` python import httpx import respx @respx.mock(base_url="https://foo.bar") async def test_something(respx_mock): async with httpx.AsyncClient(base_url="https://foo.bar") as client: request = respx_mock.get("/baz/", content="Baz") response = await client.get("/baz/") assert response.text == "Baz" ``` ### Request callback For full control of what request to **match** and what response to **mock**, pass a *callback* function as the `add(method, ...)` parameter. The callback's response argument will be pre-populated with any additional response parameters. ``` python import httpx import respx def match_and_mock(request, response): """ Return `None` to not match the request. Return the `response` to match and mock this request. Return the `request` for pass-through behaviour. """ if request.method != "POST": return None if "X-Auth-Token" not in request.headers: response.status_code = 401 else: response.content = "OK" return response @respx.mock def test_something(): custom_request = respx.add(match_and_mock, status_code=201) respx.get("https://foo.bar/baz/") response = httpx.get("https://foo.bar/baz/") assert response.status_code == 200 assert not custom_request.called response = httpx.post("https://foo.bar/baz/") assert response.status_code == 401 assert custom_request.called response = httpx.post("https://foo.bar/baz/", headers={"X-Auth-Token": "x"}) assert response.status_code == 201 assert custom_request.call_count == 2 ``` ### Repeated patterns If you mock several responses with the same *request pattern*, they will be matched in order, and popped til the last one. ``` python import httpx import respx @respx.mock def test_something(): respx.get("https://foo.bar/baz/123/", status_code=404) respx.get("https://foo.bar/baz/123/", content={"id": 123}) respx.post("https://foo.bar/baz/", status_code=201) response = httpx.get("https://foo.bar/baz/123/") assert response.status_code == 404 # First match response = httpx.post("https://foo.bar/baz/") assert response.status_code == 201 response = httpx.get("https://foo.bar/baz/123/") assert response.status_code == 200 # Second match assert response.json() == {"id": 123} ``` ### Manipulating Existing Patterns Clearing all existing patterns: ``` python import respx @respx.mock def test_something(): respx.get("https://foo.bar/baz", status_code=404) respx.clear() # no patterns will be matched after this call ``` Removing and optionally re-using an existing pattern by alias: ``` python import respx @respx.mock def test_something(): respx.get("https://foo.bar/", status_code=404, alias="index") request_pattern = respx.pop("index") respx.get(request_pattern.url, status_code=200) ``` --- ## Response Content ### JSON content To mock a response with json content, pass a `list` or a `dict`. The `Content-Type` header will automatically be set to `application/json`. ``` python import httpx import respx @respx.mock def test_something(): respx.get("https://foo.bar/baz/123/", content={"id": 123}) response = httpx.get("https://foo.bar/baz/123/") assert response.json() == {"id": 123} ``` ### Content callback If you need dynamic response content, pass a *callback* function. When used together with a [URL pattern](#url-pattern), named groups will be passed as `kwargs`. ``` python import httpx import re import respx def some_content(request, slug=None): """ Return bytes, str, list or a dict. """ return {"slug": slug} @respx.mock def test_something(): url_pattern = r"^https://foo.bar/(?P\w+)/$") respx.get(url_pattern, content=some_content) response = httpx.get("https://foo.bar/apa/") assert response.json() == {"slug": "apa"} ``` ### Request Error To simulate a failing request, *like a connection error*, pass an `Exception` instance. This is useful when you need to test proper `HTTPX` error handling in your app. ``` python import httpx import httpcore import respx @respx.mock def test_something(): respx.get("https://foo.bar/", content=httpcore.ConnectTimeout()) response = httpx.get("https://foo.bar/") # Will raise ``` --- ## Built-in Assertions RESPX has the following built-in assertion checks: > * **assert_all_mocked** > Asserts that all captured `HTTPX` requests are mocked. Defaults to `True`. > * **assert_all_called** > Asserts that all mocked request patterns were called. Defaults to `True`. Configure checks by using the `respx.mock` decorator / context manager *with* parentheses. ``` python @respx.mock(assert_all_called=False) def test_something(respx_mock): respx_mock.get("https://some.url/") # OK respx_mock.get("https://foo.bar/") response = httpx.get("https://foo.bar/") assert response.status_code == 200 assert respx_mock.calls.call_count == 1 ``` ``` python with respx.mock(assert_all_mocked=False) as respx_mock: response = httpx.get("https://foo.bar/") # OK assert response.status_code == 200 assert respx_mock.calls.call_count == 1 ``` !!! attention "Without Parentheses" When using the *global* scope `@respx.mock` decorator / context manager, `assert_all_called` is **disabled**. --- ## Call History The `respx` API includes a `.calls` object, containing captured (`request`, `response`) named tuples and MagicMock's *bells and whistles*, i.e. `call_count`, `assert_called` etc. ### Retrieving mocked calls A matched and mocked `Call` can be retrieved from call history, by either unpacking... ``` python request, response = respx.calls.last request, response = respx.calls[-2] # by call order ``` ...or by accessing `request` or `response` directly... ``` python last_response = respx.calls.last.response assert respx.calls.last.request.call_count == 1 assert respx.calls.last.response.status_code == 200 ``` !!! attention "Deprecation Warning" As of version `0.14.0`, statistics via `respx.stats` is deprecated, in favour of `respx.calls`. ### Request Pattern calls Each mocked response *request pattern* has its own `.calls`, along with `.called` and `.call_count ` stats shortcuts. Example using locally added request pattern: ``` python import httpx import respx @respx.mock def test_something(): request = respx.post("https://foo.bar/baz/", status_code=201) httpx.post("https://foo.bar/baz/") assert request.called assert request.call_count == 1 assert request.calls.last.response.status_code == 201 request.calls.assert_called_once() ``` Example using globally aliased request pattern: ``` python import httpx import respx # Added somewhere outside the test respx.get("https://foo.bar/", alias="index") @respx.mock def test_something(): httpx.get("https://foo.bar/") assert respx.aliases["index"].called assert respx.aliases["index"].call_count == 1 last_index_response = respx.aliases["index"].calls.last.response ``` ### Reset stats To reset stats during a test case, *without stop mocking*, use `respx.reset()`. ``` python import httpx import respx @respx.mock def test_something(): respx.post("https://foo.bar/baz/") httpx.post("https://foo.bar/baz/") assert respx.calls.call_count == 1 request.calls.assert_called_once() respx.reset() assert len(respx.calls) == 0 assert respx.calls.call_count == 0 respx.calls.assert_not_called() ``` ### Examples Here's a handful example usages of the call stats API. ``` python import httpx import respx @respx.mock def test_something(): # Mock some calls respx.get("https://foo.bar/", alias="index") baz_request = respx.post("https://foo.bar/baz/", status_code=201) # Make some calls httpx.get("https://foo.bar/") httpx.post("https://foo.bar/baz/") # Assert mocked assert respx.aliases["index"].called assert respx.aliases["index"].call_count == 1 assert baz_request.called assert baz_request.call_count == 1 baz_request.calls.assert_called_once() # Global stats increased assert respx.calls.call_count == 2 # Assert responses assert respx.aliases["index"].calls.last.response.status_code == 200 assert respx.calls.last.response is baz_request.calls.last.response assert respx.calls.last.response.status_code == 201 # Reset respx.reset() assert len(respx.calls) == 0 assert respx.calls.call_count == 0 respx.calls.assert_not_called() ``` respx-0.21.1/docs/versions/0.14.0/mocking.md000066400000000000000000000147431460110214700202320ustar00rootroot00000000000000!!! attention "Warning" This is the documentation of the older version `0.14.0`. See [latest](../../../) for current release. # Mock HTTPX - Version 0.14.0 To mock out `HTTPX` *and/or* `HTTP Core`, use the `respx.mock` decorator / context manager. Optionally configure [built-in assertion](api.md#built-in-assertions) checks and [base URL](api.md#base-url) with `respx.mock(...)`. ## Using the Decorator ``` python import httpx import respx @respx.mock def test_something(): request = respx.get("https://foo.bar/", content="foobar") response = httpx.get("https://foo.bar/") assert request.called assert response.status_code == 200 assert response.text == "foobar" ``` ## Using the Context Manager ``` python import httpx import respx with respx.mock: request = respx.get("https://foo.bar/", content="foobar") response = httpx.get("https://foo.bar/") assert request.called assert response.status_code == 200 assert response.text == "foobar" ``` !!! note "NOTE" You can also start and stop mocking `HTTPX` manually, by calling `respx.start()` and `respx.stop()`. ## Using the mock Transports The built-in transports are the base of all mocking and patching in RESPX. *In fact*, `respx.mock` is an actual instance of `MockTransport`. ### MockTransport ``` python import httpx import respx mock_transport = respx.MockTransport() request = mock_transport.get("https://foo.bar/", content="foobar") with mock_transport: response = httpx.get("https://foo.bar/") assert request.called assert response.status_code == 200 assert response.text == "foobar" ``` ### SyncMockTransport If you don't *need* to patch the original `HTTPX`/`HTTP Core` transports, then use the `SyncMockTransport` or [`AsyncMockTransport`](#asyncmocktransport) directly, by passing the `transport` *arg* when instantiating your `HTTPX` client, or alike. ``` python import httpx import respx mock_transport = respx.SyncMockTransport() request = mock_transport.get("https://foo.bar/", content="foobar") with httpx.Client(transport=mock_transport) as client: response = client.get("https://foo.bar/") assert request.called assert response.status_code == 200 assert response.text == "foobar" ``` ### AsyncMockTransport ``` python import httpx import respx mock_transport = respx.AsyncMockTransport() request = mock_transport.get("https://foo.bar/", content="foobar") async with httpx.AsyncClient(transport=mock_transport) as client: response = await client.get("https://foo.bar/") assert request.called assert response.status_code == 200 assert response.text == "foobar" ``` !!! note "NOTE" The mock transports takes the same configuration arguments as the decorator / context manager. ## Global Setup & Teardown ### pytest ``` python # conftest.py import pytest import respx @pytest.fixture def mocked_api(): with respx.mock(base_url="https://foo.bar") as respx_mock: respx_mock.get("/users/", content=[], alias="list_users") ... yield respx_mock ``` ``` python # test_api.py import httpx def test_list_users(mocked_api): response = httpx.get("https://foo.bar/users/") request = mocked_api["list_users"] assert request.called assert response.json() == [] ``` !!! tip Use a **session** scoped fixture `@pytest.fixture(scope="session")` when your fixture contains **multiple** endpoints that not necessary gets called by a single test case, or [disable](api.md#built-in-assertions) the built-in `assert_all_called` check. ### unittest ``` python # testcases.py class MockedAPIMixin: def setUp(self): self.mocked_api = respx.mock(base_url="https://foo.bar") self.mocked_api.get("/users/", content=[], alias="list_users") ... self.mocked_api.start() def tearDown(self): self.mocked_api.stop() ``` ``` python # test_api.py import unittest import httpx from .testcases import MockedAPIMixin class MyTestCase(MockedAPIMixin, unittest.TestCase): def test_list_users(self): response = httpx.get("https://foo.bar/users/") request = self.mocked_api["list_users"] assert request.called assert response.json() == [] ``` !!! tip Use `setUpClass` and `tearDownClass` when you mock **multiple** endpoints that not necessary gets called by a single test method, or [disable](api.md#built-in-assertions) the built-in `assert_all_called` check. ## Async Support You can use `respx.mock` in both **sync** and **async** contexts to mock out `HTTPX` responses. ### pytest ``` python @respx.mock @pytest.mark.asyncio async def test_something(): async with httpx.AsyncClient() as client: request = respx.get("https://foo.bar/", content="foobar") response = await client.get("https://foo.bar/") assert request.called assert response.text == "foobar" ``` ``` python @pytest.mark.asyncio async def test_something(): async with respx.mock: async with httpx.AsyncClient() as client: request = respx.get("https://foo.bar/", content="foobar") response = await client.get("https://foo.bar/") assert request.called assert response.text == "foobar" ``` **Session Scoped Fixtures** If a session scoped RESPX fixture is used in an async context, you also need to broaden the `pytest-asyncio` [event_loop](https://github.com/pytest-dev/pytest-asyncio#event_loop) fixture. You can use the `session_event_loop` utility for this. ``` python # conftest.py import pytest import respx from respx.fixtures import session_event_loop as event_loop # noqa: F401 @pytest.fixture(scope="session") async def mocked_api(event_loop): # noqa: F811 async with respx.mock(base_url="https://foo.bar") as respx_mock: ... yield respx_mock ``` ### unittest ``` python import asynctest class MyTestCase(asynctest.TestCase): @respx.mock async def test_something(self): async with httpx.AsyncClient() as client: request = respx.get("https://foo.bar/", content="foobar") response = await client.get("https://foo.bar/") assert request.called assert response.text == "foobar" async def test_something(self): async with respx.mock: async with httpx.AsyncClient() as client: request = respx.get("https://foo.bar/", content="foobar") response = await client.get("https://foo.bar/") assert request.called assert response.text == "foobar" ``` respx-0.21.1/flake.lock000066400000000000000000000045621460110214700146730ustar00rootroot00000000000000{ "nodes": { "flakeUtils": { "inputs": { "systems": "systems" }, "locked": { "lastModified": 1710146030, "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "owner": "numtide", "repo": "flake-utils", "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1710777701, "narHash": "sha256-hMyIBLJY2VjsM/dOmXta5XdyxcuQoKUkm4M/K0c0xlo=", "owner": "nixos", "repo": "nixpkgs", "rev": "f78a4dcd452449992e526fd88a60a2d45e0ae969", "type": "github" }, "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" } }, "nixpkgs22": { "locked": { "lastModified": 1669833724, "narHash": "sha256-/HEZNyGbnQecrgJnfE8d0WC5c1xuPSD2LUpB6YXlg4c=", "owner": "nixos", "repo": "nixpkgs", "rev": "4d2b37a84fad1091b9de401eb450aae66f1a741e", "type": "github" }, "original": { "owner": "nixos", "ref": "22.11", "repo": "nixpkgs", "type": "github" } }, "nixpkgsUnstable": { "locked": { "lastModified": 1710734606, "narHash": "sha256-rFJl+WXfksu2NkWJWKGd5Km17ZGEjFg9hOQNwstsoU8=", "owner": "nixos", "repo": "nixpkgs", "rev": "79bb4155141a5e68f2bdee2bf6af35b1d27d3a1d", "type": "github" }, "original": { "owner": "nixos", "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "flakeUtils": "flakeUtils", "nixpkgs": "nixpkgs", "nixpkgs22": "nixpkgs22", "nixpkgsUnstable": "nixpkgsUnstable" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default", "type": "github" } } }, "root": "root", "version": 7 } respx-0.21.1/flake.nix000066400000000000000000000026061460110214700145360ustar00rootroot00000000000000{ inputs = { nixpkgs.url = "github:nixos/nixpkgs"; nixpkgs22.url = "github:nixos/nixpkgs/22.11"; nixpkgsUnstable.url = "github:nixos/nixpkgs/nixpkgs-unstable"; flakeUtils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, nixpkgs22, nixpkgsUnstable, flakeUtils }: flakeUtils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; pkgs22 = nixpkgs22.legacyPackages.${system}; pkgsUnstable = nixpkgsUnstable.legacyPackages.${system}; in { packages = flakeUtils.lib.flattenTree { python312 = pkgs.python312; python311 = pkgs.python311; python310 = pkgs.python310; python39 = pkgs.python39; python38 = pkgs22.python38; python37 = pkgs22.python37; go-task = pkgsUnstable.go-task; }; devShell = pkgs.mkShell { buildInputs = with self.packages.${system}; [ python312 python311 python310 python39 python38 python37 go-task ]; shellHook = '' [[ ! -d .venv ]] && \ echo "Creating virtualenv ..." && \ ${pkgs.python310}/bin/python -m \ venv --copies --upgrade-deps .venv > /dev/null source .venv/bin/activate ''; }; } ); } respx-0.21.1/mkdocs.yaml000066400000000000000000000022221460110214700150720ustar00rootroot00000000000000site_name: RESPX site_description: A utility for mocking out the Python HTTPX library. site_url: https://lundberg.github.io/respx/ theme: name: "material" icon: logo: "material/school" palette: - scheme: default media: "(prefers-color-scheme: light)" primary: "deep purple" accent: "deep purple" toggle: icon: material/weather-night name: Switch to dark mode - scheme: slate media: "(prefers-color-scheme: dark)" primary: "deep purple" accent: "deep purple" toggle: icon: material/weather-sunny name: Switch to light mode extra_css: - stylesheets/slate.css repo_name: lundberg/respx repo_url: https://github.com/lundberg/respx edit_uri: "" nav: - Introduction: "index.md" - User Guide: "guide.md" - API Reference: "api.md" - Test Case Examples: "examples.md" - Migrate from requests: "migrate.md" - Upgrading: "upgrade.md" - Older Versions: - 0.14.0: - Mock HTTPX: "versions/0.14.0/mocking.md" - Developer Interface: "versions/0.14.0/api.md" markdown_extensions: - admonition - codehilite: css_class: highlight - mkautodoc respx-0.21.1/noxfile.py000066400000000000000000000016671460110214700147600ustar00rootroot00000000000000import nox nox.options.stop_on_first_error = True nox.options.reuse_existing_virtualenvs = True nox.options.keywords = "test + mypy" @nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]) def test(session): deps = ["pytest", "pytest-asyncio", "pytest-cov", "trio", "starlette", "flask"] session.install("--upgrade", *deps) session.install("-e", ".") if any(option in session.posargs for option in ("-k", "-x")): session.posargs.append("--no-cov") session.run("pytest", *session.posargs) @nox.session(python="3.7") def mypy(session): session.install("--upgrade", "mypy") session.install("-e", ".") session.run("mypy") @nox.session(python="3.10") def docs(session): deps = ["mkdocs", "mkdocs-material", "mkautodoc>=0.1.0"] session.install("--upgrade", *deps) session.install("-e", ".") args = session.posargs if session.posargs else ["build"] session.run("mkdocs", *args) respx-0.21.1/respx/000077500000000000000000000000001460110214700140715ustar00rootroot00000000000000respx-0.21.1/respx/__init__.py000066400000000000000000000014431460110214700162040ustar00rootroot00000000000000from .__version__ import __version__ from .handlers import ASGIHandler, WSGIHandler from .models import MockResponse, Route from .router import MockRouter, Router from .utils import SetCookie from .api import ( # isort:skip mock, routes, calls, start, stop, clear, reset, pop, route, add, request, get, post, put, patch, delete, head, options, ) __all__ = [ "__version__", "MockResponse", "MockRouter", "ASGIHandler", "WSGIHandler", "Router", "Route", "SetCookie", "mock", "routes", "calls", "start", "stop", "clear", "reset", "pop", "route", "add", "request", "get", "post", "put", "patch", "delete", "head", "options", ] respx-0.21.1/respx/__version__.py000066400000000000000000000000271460110214700167230ustar00rootroot00000000000000__version__ = "0.21.1" respx-0.21.1/respx/api.py000066400000000000000000000050321460110214700152140ustar00rootroot00000000000000from typing import Any, Optional, Union, overload from .models import CallList, Route from .patterns import Pattern from .router import MockRouter from .types import DefaultType, URLPatternTypes mock = MockRouter(assert_all_called=False) routes = mock.routes calls: CallList = mock.calls def start() -> None: global mock mock.start() def stop(clear: bool = True, reset: bool = True) -> None: global mock mock.stop(clear=clear, reset=reset) def clear() -> None: global mock mock.clear() def reset() -> None: global mock mock.reset() @overload def pop(name: str) -> Route: ... # pragma: nocover @overload def pop(name: str, default: DefaultType) -> Union[Route, DefaultType]: ... # pragma: nocover def pop(name, default=...): global mock return mock.pop(name, default=default) def route(*patterns: Pattern, name: Optional[str] = None, **lookups: Any) -> Route: global mock return mock.route(*patterns, name=name, **lookups) def add(route: Route, *, name: Optional[str] = None) -> Route: global mock return mock.add(route, name=name) def request( method: str, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: global mock return mock.request(method, url, name=name, **lookups) def get( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.get(url, name=name, **lookups) def post( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.post(url, name=name, **lookups) def put( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.put(url, name=name, **lookups) def patch( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.patch(url, name=name, **lookups) def delete( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.delete(url, name=name, **lookups) def head( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.head(url, name=name, **lookups) def options( url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any ) -> Route: global mock return mock.options(url, name=name, **lookups) respx-0.21.1/respx/fixtures.py000066400000000000000000000004141460110214700163130ustar00rootroot00000000000000try: import pytest except ImportError: # pragma: nocover pass else: import asyncio @pytest.fixture(scope="session") def session_event_loop(): loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() respx-0.21.1/respx/handlers.py000066400000000000000000000024431460110214700162460ustar00rootroot00000000000000from typing import Any, Callable import httpx class TransportHandler: def __init__(self, transport: httpx.BaseTransport) -> None: self.transport = transport def __call__(self, request: httpx.Request) -> httpx.Response: if not isinstance( request.stream, httpx.SyncByteStream, ): # pragma: nocover raise RuntimeError("Attempted to route an async request to a sync app.") return self.transport.handle_request(request) class AsyncTransportHandler: def __init__(self, transport: httpx.AsyncBaseTransport) -> None: self.transport = transport async def __call__(self, request: httpx.Request) -> httpx.Response: if not isinstance( request.stream, httpx.AsyncByteStream, ): # pragma: nocover raise RuntimeError("Attempted to route a sync request to an async app.") return await self.transport.handle_async_request(request) class WSGIHandler(TransportHandler): def __init__(self, app: Callable, **kwargs: Any) -> None: super().__init__(httpx.WSGITransport(app=app, **kwargs)) class ASGIHandler(AsyncTransportHandler): def __init__(self, app: Callable, **kwargs: Any) -> None: super().__init__(httpx.ASGITransport(app=app, **kwargs)) respx-0.21.1/respx/mocks.py000066400000000000000000000245471460110214700155730ustar00rootroot00000000000000import inspect from abc import ABC from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar, Dict, List, Type from unittest import mock import httpcore import httpx from respx.patterns import parse_url from .models import AllMockedAssertionError, PassThrough from .transports import TryTransport if TYPE_CHECKING: from .router import Router # pragma: nocover __all__ = ["Mocker", "HTTPCoreMocker"] class Mocker(ABC): _patches: ClassVar[List[mock._patch]] name: ClassVar[str] routers: ClassVar[List["Router"]] targets: ClassVar[List[str]] target_methods: ClassVar[List[str]] # Automatically register all the subclasses in this dict __registry: ClassVar[Dict[str, Type["Mocker"]]] = {} registry = MappingProxyType(__registry) def __init_subclass__(cls) -> None: if not getattr(cls, "name", None) or ABC in cls.__bases__: return if cls.name in cls.__registry: raise TypeError( "Subclasses of Mocker must define a unique name. " f"{cls.name!r} is already defined as {cls.__registry[cls.name]!r}" ) cls.routers = [] cls._patches = [] cls.__registry[cls.name] = cls @classmethod def register(cls, router: "Router") -> None: cls.routers.append(router) @classmethod def unregister(cls, router: "Router") -> bool: if router in cls.routers: cls.routers.remove(router) return True return False @classmethod def add_targets(cls, *targets: str) -> None: targets = tuple(filter(lambda t: t not in cls.targets, targets)) if targets: cls.targets.extend(targets) cls.restart() @classmethod def remove_targets(cls, *targets: str) -> None: targets = tuple(filter(lambda t: t in cls.targets, targets)) if targets: for target in targets: cls.targets.remove(target) cls.restart() @classmethod def start(cls) -> None: # Ensure we only patch once! if cls._patches: return # Start patching target transports for target in cls.targets: for method in cls.target_methods: try: spec = f"{target}.{method}" patch = mock.patch(spec, spec=True, new_callable=cls.mock) patch.start() cls._patches.append(patch) except AttributeError: pass @classmethod def stop(cls, force: bool = False) -> None: # Ensure we don't stop patching when registered transports exists if cls.routers and not force: return # Stop patching HTTPX while cls._patches: patch = cls._patches.pop() patch.stop() @classmethod def restart(cls) -> None: # Only stop and start if started if cls._patches: # pragma: nocover cls.stop(force=True) cls.start() @classmethod def handler(cls, httpx_request): httpx_response = None assertion_error = None for router in cls.routers: try: httpx_response = router.handler(httpx_request) except AllMockedAssertionError as error: assertion_error = error continue else: break if assertion_error and not httpx_response: raise assertion_error return httpx_response @classmethod async def async_handler(cls, httpx_request): httpx_response = None assertion_error = None for router in cls.routers: try: httpx_response = await router.async_handler(httpx_request) except AllMockedAssertionError as error: assertion_error = error continue else: break if assertion_error and not httpx_response: raise assertion_error return httpx_response @classmethod def mock(cls, spec): raise NotImplementedError() # pragma: nocover class HTTPXMocker(Mocker): name = "httpx" targets = [ "httpx._client.Client", "httpx._client.AsyncClient", ] target_methods = ["_transport_for_url"] @classmethod def mock(cls, spec): def _transport_for_url(self, *args, **kwargs): handler = ( cls.async_handler if inspect.iscoroutinefunction(self.request) else cls.handler ) mock_transport = httpx.MockTransport(handler) pass_through_transport = spec(self, *args, **kwargs) transport = TryTransport([mock_transport, pass_through_transport]) return transport return _transport_for_url class AbstractRequestMocker(Mocker): @classmethod def mock(cls, spec): if spec.__name__ not in cls.target_methods: # Prevent mocking mock return spec argspec = inspect.getfullargspec(spec) def mock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) request = cls.to_httpx_request(**kwargs) request, kwargs = cls.prepare_sync_request(request, **kwargs) response = cls._send_sync_request( request, target_spec=spec, instance=self, **kwargs ) return response async def amock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) request = cls.to_httpx_request(**kwargs) request, kwargs = await cls.prepare_async_request(request, **kwargs) response = await cls._send_async_request( request, target_spec=spec, instance=self, **kwargs ) return response return amock if inspect.iscoroutinefunction(spec) else mock @classmethod def _merge_args_and_kwargs(cls, argspec, args, kwargs): arg_names = argspec.args[1:] # Omit self new_kwargs = ( dict(zip(arg_names[-len(argspec.defaults) :], argspec.defaults)) if argspec.defaults else dict() ) new_kwargs.update(zip(arg_names, args)) new_kwargs.update(kwargs) return new_kwargs @classmethod def _send_sync_request(cls, httpx_request, *, target_spec, instance, **kwargs): try: httpx_response = cls.handler(httpx_request) except PassThrough: response = target_spec(instance, **kwargs) else: response = cls.from_sync_httpx_response(httpx_response, instance, **kwargs) return response @classmethod async def _send_async_request( cls, httpx_request, *, target_spec, instance, **kwargs ): try: httpx_response = await cls.async_handler(httpx_request) except PassThrough: response = await target_spec(instance, **kwargs) else: response = await cls.from_async_httpx_response( httpx_response, instance, **kwargs ) return response @classmethod def prepare_sync_request(cls, httpx_request, **kwargs): """ Sync pre-read request body """ httpx_request.read() return httpx_request, kwargs @classmethod async def prepare_async_request(cls, httpx_request, **kwargs): """ Async pre-read request body """ await httpx_request.aread() return httpx_request, kwargs @classmethod def to_httpx_request(cls, **kwargs): raise NotImplementedError() # pragma: nocover @classmethod def from_sync_httpx_response(cls, httpx_response, target, **kwargs): raise NotImplementedError() # pragma: nocover @classmethod async def from_async_httpx_response(cls, httpx_response, target, **kwargs): raise NotImplementedError() # pragma: nocover class HTTPCoreMocker(AbstractRequestMocker): name = "httpcore" targets = [ "httpcore._sync.connection.HTTPConnection", "httpcore._sync.connection_pool.ConnectionPool", "httpcore._sync.http_proxy.HTTPProxy", "httpcore._async.connection.AsyncHTTPConnection", "httpcore._async.connection_pool.AsyncConnectionPool", "httpcore._async.http_proxy.AsyncHTTPProxy", ] target_methods = ["handle_request", "handle_async_request"] @classmethod def prepare_sync_request(cls, httpx_request, **kwargs): """ Sync pre-read request body, and update transport request arg. """ httpx_request, kwargs = super().prepare_sync_request(httpx_request, **kwargs) kwargs["request"].stream = httpx_request.stream return httpx_request, kwargs @classmethod async def prepare_async_request(cls, httpx_request, **kwargs): """ Async pre-read request body, and update transport request arg. """ httpx_request, kwargs = await super().prepare_async_request( httpx_request, **kwargs ) kwargs["request"].stream = httpx_request.stream return httpx_request, kwargs @classmethod def to_httpx_request(cls, **kwargs): """ Create a `HTTPX` request from transport request arg. """ request = kwargs["request"] raw_url = ( request.url.scheme, request.url.host, request.url.port, request.url.target, ) return httpx.Request( request.method, parse_url(raw_url), headers=request.headers, stream=request.stream, extensions=request.extensions, ) @classmethod def from_sync_httpx_response(cls, httpx_response, target, **kwargs): """ Create a `httpcore` response from a `HTTPX` response. """ return httpcore.Response( status=httpx_response.status_code, headers=httpx_response.headers.raw, content=httpx_response.stream, extensions=httpx_response.extensions, ) @classmethod async def from_async_httpx_response(cls, httpx_response, target, **kwargs): """ Create a `httpcore` response from a `HTTPX` response. """ return cls.from_sync_httpx_response(httpx_response, target, **kwargs) DEFAULT_MOCKER: str = HTTPCoreMocker.name respx-0.21.1/respx/models.py000066400000000000000000000406711460110214700157360ustar00rootroot00000000000000import inspect from typing import ( Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Type, Union, ) from unittest import mock from warnings import warn import httpx from respx.utils import SetCookie from .patterns import M, Pattern from .types import ( CallableSideEffect, Content, CookieTypes, HeaderTypes, ResolvedResponseTypes, RouteResultTypes, SideEffectListTypes, SideEffectTypes, ) def clone_response(response: httpx.Response, request: httpx.Request) -> httpx.Response: """ Clones a httpx Response for given request. """ response = httpx.Response( response.status_code, headers=response.headers, stream=response.stream, request=request, extensions=dict(response.extensions), ) return response class Call(NamedTuple): request: httpx.Request optional_response: Optional[httpx.Response] @property def response(self) -> httpx.Response: if self.optional_response is None: raise ValueError(f"{self!r} has no response") return self.optional_response @property def has_response(self) -> bool: return self.optional_response is not None class CallList(list, mock.NonCallableMock): def __init__(self, *args: Sequence[Call], name: Any = "respx") -> None: super().__init__(*args) mock.NonCallableMock.__init__(self, name=name) @property def called(self) -> bool: # type: ignore[override] return bool(self) @property def call_count(self) -> int: # type: ignore[override] return len(self) @property def last(self) -> Call: return self[-1] def record( self, request: httpx.Request, response: Optional[httpx.Response] ) -> Call: call = Call(request=request, optional_response=response) self.append(call) return call class MockResponse(httpx.Response): def __init__( self, status_code: Optional[int] = None, *, content: Optional[Content] = None, content_type: Optional[str] = None, http_version: Optional[str] = None, cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None, **kwargs: Any, ) -> None: if not isinstance(content, (str, bytes)) and ( callable(content) or isinstance(content, (dict, Exception)) ): raise TypeError( f"MockResponse content can only be str, bytes or byte stream" f"got {content!r}. Please use json=... or side effects." ) if content is not None: kwargs["content"] = content if http_version: kwargs["extensions"] = kwargs.get("extensions", {}) kwargs["extensions"]["http_version"] = http_version.encode("ascii") super().__init__(status_code or 200, **kwargs) if content_type: self.headers["Content-Type"] = content_type if cookies: if isinstance(cookies, dict): cookies = tuple(cookies.items()) self.headers = httpx.Headers( ( *self.headers.multi_items(), *( cookie if isinstance(cookie, SetCookie) else SetCookie(*cookie) for cookie in cookies ), ) ) class Route: def __init__( self, *patterns: Pattern, **lookups: Any, ) -> None: self._pattern = M(*patterns, **lookups) self._return_value: Optional[httpx.Response] = None self._side_effect: Optional[SideEffectTypes] = None self._pass_through: bool = False self._name: Optional[str] = None self._snapshots: List[Tuple] = [] self.calls = CallList(name=self) self.snapshot() def __eq__(self, other: object) -> bool: if not isinstance(other, Route): return False # pragma: nocover return self.pattern == other.pattern def __repr__(self): # pragma: nocover name = f"name={self._name!r} " if self._name else "" return f"" def __call__(self, side_effect: CallableSideEffect) -> CallableSideEffect: self.side_effect = side_effect return side_effect def __mod__(self, response: Union[int, Dict[str, Any], httpx.Response]) -> "Route": if isinstance(response, int): self.return_value = httpx.Response(status_code=response) elif isinstance(response, dict): response.setdefault("status_code", 200) self.return_value = httpx.Response(**response) elif isinstance(response, httpx.Response): self.return_value = response else: raise TypeError( f"Route can only % with int, dict or Response, got {response!r}" ) return self @property def name(self) -> Optional[str]: return self._name @name.setter def name(self, name: str) -> None: raise NotImplementedError("Can't set name on route.") @property def pattern(self) -> Pattern: return self._pattern @pattern.setter def pattern(self, pattern: Pattern) -> None: raise NotImplementedError("Can't change route pattern.") @property def return_value(self) -> Optional[httpx.Response]: return self._return_value @return_value.setter def return_value(self, return_value: Optional[httpx.Response]) -> None: if return_value is not None and not isinstance(return_value, httpx.Response): raise TypeError(f"{return_value!r} is not an instance of httpx.Response") self.pass_through(False) self._return_value = return_value @property def side_effect( self, ) -> Optional[Union[SideEffectTypes, Sequence[SideEffectListTypes]]]: return self._side_effect @side_effect.setter def side_effect( self, side_effect: Optional[Union[SideEffectTypes, Sequence[SideEffectListTypes]]], ) -> None: self.pass_through(False) if not side_effect: self._side_effect = None elif isinstance(side_effect, (Iterator, Sequence)): self._side_effect = iter(side_effect) else: self._side_effect = side_effect def snapshot(self) -> None: # Clone iterator-type side effect to not get pre-exhausted when rolled back side_effect = self._side_effect if isinstance(side_effect, Iterator): side_effects = tuple(side_effect) self._side_effect = iter(side_effects) side_effect = iter(side_effects) self._snapshots.append( ( self._pattern, self._name, self._return_value, side_effect, self._pass_through, CallList(self.calls, name=self), ), ) def rollback(self) -> None: if not self._snapshots: return snapshot = self._snapshots.pop() pattern, name, return_value, side_effect, pass_through, calls = snapshot self._pattern = pattern self._name = name self._return_value = return_value self._side_effect = side_effect self.pass_through(pass_through) self.calls[:] = calls def reset(self) -> None: self.calls.clear() def mock( self, return_value: Optional[httpx.Response] = None, *, side_effect: Optional[ Union[SideEffectTypes, Sequence[SideEffectListTypes]] ] = None, ) -> "Route": self.return_value = return_value self.side_effect = side_effect return self def respond( self, status_code: int = 200, *, headers: Optional[HeaderTypes] = None, cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None, content: Optional[Content] = None, text: Optional[str] = None, html: Optional[str] = None, json: Optional[Union[str, List, Dict]] = None, stream: Optional[Union[httpx.SyncByteStream, httpx.AsyncByteStream]] = None, content_type: Optional[str] = None, http_version: Optional[str] = None, **kwargs: Any, ) -> "Route": response = MockResponse( status_code, headers=headers, cookies=cookies, content=content, text=text, html=html, json=json, stream=stream, content_type=content_type, http_version=http_version, **kwargs, ) return self.mock(return_value=response) def pass_through(self, value: bool = True) -> "Route": self._pass_through = value return self @property def is_pass_through(self) -> bool: return self._pass_through @property def called(self) -> bool: return self.calls.called @property def call_count(self) -> int: return self.calls.call_count def _next_side_effect( self, ) -> Union[CallableSideEffect, Exception, Type[Exception], httpx.Response]: assert self._side_effect is not None effect: Union[CallableSideEffect, Exception, Type[Exception], httpx.Response] if isinstance(self._side_effect, Iterator): effect = next(self._side_effect) else: effect = self._side_effect return effect def _call_side_effect( self, effect: CallableSideEffect, request: httpx.Request, **kwargs: Any ) -> RouteResultTypes: # Add route kwarg if the side effect wants it argspec = inspect.getfullargspec(effect) if "route" in kwargs: warn(f"Matched context contains reserved word `route`: {self.pattern!r}") if "route" in argspec.args: kwargs["route"] = self try: # Call side effect result: RouteResultTypes = effect(request, **kwargs) except Exception as error: raise SideEffectError(self, origin=error) from error # Validate result if ( result and not inspect.isawaitable(result) and not isinstance(result, (httpx.Response, httpx.Request)) ): raise TypeError( f"Side effects must return; either a `httpx.Response`," f"a `httpx.Request` for pass-through, " f"or `None` for a non-match. Got {result!r}" ) return result def _resolve_side_effect( self, request: httpx.Request, **kwargs: Any ) -> RouteResultTypes: effect = self._next_side_effect() # Handle Exception `instance` side effect if isinstance(effect, Exception): raise SideEffectError(self, origin=effect) # Handle Exception `type` side effect elif isinstance(effect, type) and issubclass(effect, Exception): raise SideEffectError( self, origin=( effect("Mock Error", request=request) if issubclass(effect, httpx.RequestError) else effect() ), ) # Handle `Callable` side effect elif callable(effect): result = self._call_side_effect(effect, request, **kwargs) return result # Resolved effect is a mocked response return effect def resolve(self, request: httpx.Request, **kwargs: Any) -> RouteResultTypes: result: RouteResultTypes = None if self._side_effect: result = self._resolve_side_effect(request, **kwargs) if result is None: return None # Side effect resolved as a non-matching route elif self._return_value: result = self._return_value else: # Auto mock a new response result = httpx.Response(200, request=request) if isinstance(result, httpx.Response) and not result._request: # Clone reused Response for immutability result = clone_response(result, request) return result def match(self, request: httpx.Request) -> RouteResultTypes: """ Matches and resolves request with given patterns and optional side effect. Returns None for a non-matching route, mocked response for a match, or input request for pass-through. """ context: Dict[str, Any] = {} if self._pattern: match = self._pattern.match(request) if not match: return None context = match.context if self._pass_through: return request result = self.resolve(request, **context) return result class RouteList: _routes: List[Route] _names: Dict[str, Route] def __init__(self, routes: Optional["RouteList"] = None) -> None: if routes is None: self._routes = [] self._names = {} else: self._routes = list(routes._routes) self._names = dict(routes._names) def __repr__(self) -> str: return repr(self._routes) # pragma: nocover def __iter__(self) -> Iterator[Route]: return iter(self._routes) def __bool__(self) -> bool: return bool(self._routes) def __len__(self) -> int: return len(self._routes) def __contains__(self, name: str) -> bool: return name in self._names def __getitem__(self, key: Union[int, str]) -> Route: if isinstance(key, int): return self._routes[key] else: return self._names[key] def __setitem__(self, i: slice, routes: "RouteList") -> None: """ Re-set all routes to given routes. """ if (i.start, i.stop, i.step) != (None, None, None): raise TypeError("Can't slice assign routes") self._routes = list(routes._routes) self._names = dict(routes._names) def clear(self) -> None: self._routes.clear() self._names.clear() def add(self, route: Route, name: Optional[str] = None) -> Route: # Find route with same name existing_route = self._names.pop(name or "", None) if route in self._routes: if existing_route and existing_route != route: # Re-use existing route with same name, and drop any with same pattern index = self._routes.index(route) same_pattern_route = self._routes.pop(index) if same_pattern_route.name: del self._names[same_pattern_route.name] same_pattern_route._name = None elif not existing_route: # Re-use existing route with same pattern index = self._routes.index(route) existing_route = self._routes[index] if existing_route.name: del self._names[existing_route.name] existing_route._name = None if existing_route: # Update existing route's pattern and mock existing_route._pattern = route._pattern existing_route.return_value = route.return_value existing_route.side_effect = route.side_effect existing_route.pass_through(route.is_pass_through) route = existing_route else: # Add new route self._routes.append(route) if name: route._name = name self._names[name] = route return route def pop(self, name, default=...): """ Removes a route by name and returns it. Raises KeyError when `default` not provided and name is not found. """ try: route = self._names.pop(name) self._routes.remove(route) return route except KeyError as ex: if default is ...: raise ex return default class AllMockedAssertionError(AssertionError): pass class SideEffectError(Exception): def __init__(self, route: Route, origin: Exception) -> None: self.route = route self.origin = origin class PassThrough(Exception): def __init__(self, message: str, *, request: httpx.Request, origin: Route) -> None: super().__init__(message) self.request = request self.origin = origin class ResolvedRoute: def __init__(self): self.route: Optional[Route] = None self.response: Optional[ResolvedResponseTypes] = None respx-0.21.1/respx/patterns.py000066400000000000000000000526421460110214700163140ustar00rootroot00000000000000import io import json as jsonlib import operator import pathlib import re from abc import ABC from enum import Enum from functools import reduce from http.cookies import SimpleCookie from types import MappingProxyType from typing import ( Any, Callable, ClassVar, Dict, List, Mapping, Optional, Pattern as RegexPattern, Sequence, Set, Tuple, Type, Union, ) from unittest.mock import ANY from urllib.parse import urljoin import httpx from respx.utils import MultiItems, decode_data from .types import ( URL as RawURL, CookieTypes, FileTypes, HeaderTypes, QueryParamTypes, RequestFiles, URLPatternTypes, ) class Lookup(Enum): EQUAL = "eq" REGEX = "regex" STARTS_WITH = "startswith" CONTAINS = "contains" IN = "in" class Match: def __init__(self, matches: bool, **context: Any) -> None: self.matches = matches self.context = context def __bool__(self): return bool(self.matches) def __invert__(self): self.matches = not self.matches return self def __repr__(self): # pragma: nocover return f"" class Pattern(ABC): key: ClassVar[str] lookups: ClassVar[Tuple[Lookup, ...]] = (Lookup.EQUAL,) lookup: Lookup base: Optional["Pattern"] value: Any # Automatically register all the subclasses in this dict __registry: ClassVar[Dict[str, Type["Pattern"]]] = {} registry = MappingProxyType(__registry) def __init_subclass__(cls) -> None: if not getattr(cls, "key", None) or ABC in cls.__bases__: return if cls.key in cls.__registry: raise TypeError( "Subclasses of Pattern must define a unique key. " f"{cls.key!r} is already defined in {cls.__registry[cls.key]!r}" ) cls.__registry[cls.key] = cls def __init__(self, value: Any, lookup: Optional[Lookup] = None) -> None: if lookup and lookup not in self.lookups: raise NotImplementedError( f"{self.key!r} pattern does not support {lookup.value!r} lookup" ) self.lookup = lookup or self.lookups[0] self.base = None self.value = self.clean(value) def __iter__(self): yield self def __bool__(self): return True def __and__(self, other: "Pattern") -> "Pattern": if not bool(other): return self elif not bool(self): return other return _And((self, other)) def __or__(self, other: "Pattern") -> "Pattern": if not bool(other): return self elif not bool(self): return other return _Or((self, other)) def __invert__(self): if not bool(self): return self return _Invert(self) def __repr__(self): # pragma: nocover return f"<{self.__class__.__name__} {self.lookup.value} {repr(self.value)}>" def __hash__(self): return hash((self.__class__, self.lookup, self.value)) def __eq__(self, other: object) -> bool: return hash(self) == hash(other) def clean(self, value: Any) -> Any: """ Clean and return pattern value. """ return value def parse(self, request: httpx.Request) -> Any: # pragma: nocover """ Parse and return request value to match with pattern value. """ raise NotImplementedError() def strip_base(self, value: Any) -> Any: # pragma: nocover return value def match(self, request: httpx.Request) -> Match: try: value = self.parse(request) except Exception: return Match(False) # Match and strip base if self.base: base_match = self.base._match(value) if not base_match: return base_match value = self.strip_base(value) return self._match(value) def _match(self, value: Any) -> Match: lookup_method = getattr(self, f"_{self.lookup.value}") return lookup_method(value) def _eq(self, value: Any) -> Match: return Match(value == self.value) def _regex(self, value: str) -> Match: match = self.value.search(value) if match is None: return Match(False) return Match(True, **match.groupdict()) def _startswith(self, value: str) -> Match: return Match(value.startswith(self.value)) def _contains(self, value: Any) -> Match: # pragma: nocover raise NotImplementedError() def _in(self, value: Any) -> Match: return Match(value in self.value) class Noop(Pattern): def __init__(self) -> None: super().__init__(None) def __repr__(self): return f"<{self.__class__.__name__}>" def __bool__(self) -> bool: # Treat this pattern as non-existent, e.g. when filtering or conditioning return False def match(self, request: httpx.Request) -> Match: # If this pattern is part of a combined pattern, always be truthy, i.e. noop return Match(True) class PathPattern(Pattern): path: Optional[str] def __init__( self, value: Any, lookup: Optional[Lookup] = None, *, path: Optional[str] = None ) -> None: self.path = path super().__init__(value, lookup) class _And(Pattern): value: Tuple[Pattern, Pattern] def __repr__(self): # pragma: nocover a, b = self.value return f"{repr(a)} AND {repr(b)}" def __iter__(self): a, b = self.value yield from a yield from b def match(self, request: httpx.Request) -> Match: a, b = self.value a_match = a.match(request) if not a_match: return a_match b_match = b.match(request) if not b_match: return b_match return Match(True, **{**a_match.context, **b_match.context}) class _Or(Pattern): value: Tuple[Pattern, Pattern] def __repr__(self): # pragma: nocover a, b = self.value return f"{repr(a)} OR {repr(b)}" def __iter__(self): a, b = self.value yield from a yield from b def match(self, request: httpx.Request) -> Match: a, b = self.value match = a.match(request) if not match: match = b.match(request) return match class _Invert(Pattern): value: Pattern def __repr__(self): # pragma: nocover return f"NOT {repr(self.value)}" def __iter__(self): yield from self.value def match(self, request: httpx.Request) -> Match: return ~self.value.match(request) class Method(Pattern): key = "method" lookups = (Lookup.EQUAL, Lookup.IN) value: Union[str, Sequence[str]] def clean(self, value: Union[str, Sequence[str]]) -> Union[str, Sequence[str]]: if isinstance(value, str): value = value.upper() else: assert isinstance(value, Sequence) value = tuple(v.upper() for v in value) return value def parse(self, request: httpx.Request) -> str: return request.method class MultiItemsMixin: lookup: Lookup value: Any def _multi_items( self, value: Any, *, parse_any: bool = False ) -> Tuple[Tuple[str, Tuple[Any, ...]], ...]: return tuple( ( key, tuple( ANY if parse_any and v == str(ANY) else v for v in value.get_list(key) ), ) for key in sorted(value.keys()) ) def __hash__(self): return hash((self.__class__, self.lookup, self._multi_items(self.value))) def _eq(self, value: Any) -> Match: value_items = self._multi_items(self.value, parse_any=True) request_items = self._multi_items(value) return Match(value_items == request_items) def _contains(self, value: Any) -> Match: if len(self.value.multi_items()) > len(value.multi_items()): return Match(False) value_items = self._multi_items(self.value, parse_any=True) request_items = self._multi_items(value) for item in value_items: if item not in request_items: return Match(False) return Match(True) class Headers(MultiItemsMixin, Pattern): key = "headers" lookups = (Lookup.CONTAINS, Lookup.EQUAL) value: httpx.Headers def clean(self, value: HeaderTypes) -> httpx.Headers: return httpx.Headers(value) def parse(self, request: httpx.Request) -> httpx.Headers: return request.headers class Cookies(Pattern): key = "cookies" lookups = (Lookup.CONTAINS, Lookup.EQUAL) value: Set[Tuple[str, str]] def __hash__(self): return hash((self.__class__, self.lookup, tuple(sorted(self.value)))) def clean(self, value: CookieTypes) -> Set[Tuple[str, str]]: if isinstance(value, dict): return set(value.items()) return set(value) def parse(self, request: httpx.Request) -> Set[Tuple[str, str]]: headers = request.headers cookie_header = headers.get("cookie") if not cookie_header: return set() cookies: SimpleCookie = SimpleCookie() cookies.load(rawdata=cookie_header) return {(cookie.key, cookie.value) for cookie in cookies.values()} def _contains(self, value: Set[Tuple[str, str]]) -> Match: return Match(bool(self.value & value)) class Scheme(Pattern): key = "scheme" lookups = (Lookup.EQUAL, Lookup.IN) value: Union[str, Sequence[str]] def clean(self, value: Union[str, Sequence[str]]) -> Union[str, Sequence[str]]: if isinstance(value, str): value = value.lower() else: assert isinstance(value, Sequence) value = tuple(v.lower() for v in value) return value def parse(self, request: httpx.Request) -> str: return request.url.scheme class Host(Pattern): key = "host" lookups = (Lookup.EQUAL, Lookup.REGEX, Lookup.IN) value: Union[str, RegexPattern[str], Sequence[str]] def clean( self, value: Union[str, RegexPattern[str]] ) -> Union[str, RegexPattern[str]]: if self.lookup is Lookup.REGEX and isinstance(value, str): value = re.compile(value) return value def parse(self, request: httpx.Request) -> str: return request.url.host class Port(Pattern): key = "port" lookups = (Lookup.EQUAL, Lookup.IN) value: Optional[int] def parse(self, request: httpx.Request) -> Optional[int]: scheme = request.url.scheme port = request.url.port scheme_port = get_scheme_port(scheme) return port or scheme_port class Path(Pattern): key = "path" lookups = (Lookup.EQUAL, Lookup.REGEX, Lookup.STARTS_WITH, Lookup.IN) value: Union[str, Sequence[str], RegexPattern[str]] def clean( self, value: Union[str, RegexPattern[str]] ) -> Union[str, RegexPattern[str]]: if self.lookup in (Lookup.EQUAL, Lookup.STARTS_WITH) and isinstance(value, str): # Percent encode path, i.e. revert parsed path by httpx.URL. # Borrowed from HTTPX's "private" quote and percent_encode utilities. path = "".join( char if char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/" else "".join(f"%{byte:02x}" for byte in char.encode("utf-8")).upper() for char in value ) path = urljoin("/", path) # Ensure leading slash value = httpx.URL(path).path elif self.lookup is Lookup.REGEX and isinstance(value, str): value = re.compile(value) return value def parse(self, request: httpx.Request) -> str: return request.url.path def strip_base(self, value: str) -> str: if self.base: value = value[len(self.base.value) :] value = "/" + value if not value.startswith("/") else value return value class Params(MultiItemsMixin, Pattern): key = "params" lookups = (Lookup.CONTAINS, Lookup.EQUAL) value: httpx.QueryParams def clean(self, value: QueryParamTypes) -> httpx.QueryParams: return httpx.QueryParams(value) def parse(self, request: httpx.Request) -> httpx.QueryParams: query = request.url.query return httpx.QueryParams(query) class URL(Pattern): key = "url" lookups = ( Lookup.EQUAL, Lookup.REGEX, Lookup.STARTS_WITH, ) value: Union[str, RegexPattern[str]] def clean(self, value: URLPatternTypes) -> Union[str, RegexPattern[str]]: url: Union[str, RegexPattern[str]] if self.lookup is Lookup.EQUAL and isinstance(value, (str, tuple, httpx.URL)): _url = parse_url(value) _url = self._ensure_path(_url) url = str(_url) elif self.lookup is Lookup.REGEX and isinstance(value, str): url = re.compile(value) elif isinstance(value, (str, RegexPattern)): url = value else: raise ValueError(f"Invalid url: {value!r}") return url def parse(self, request: httpx.Request) -> str: url = request.url url = self._ensure_path(url) return str(url) def _ensure_path(self, url: httpx.URL) -> httpx.URL: if not url._uri_reference.path: url = url.copy_with(path="/") return url class ContentMixin: def parse(self, request: httpx.Request) -> Any: content = request.read() return content class Content(ContentMixin, Pattern): lookups = (Lookup.EQUAL, Lookup.CONTAINS) key = "content" value: bytes def clean(self, value: Union[bytes, str]) -> bytes: if isinstance(value, str): return value.encode() return value def _contains(self, value: Union[bytes, str]) -> Match: return Match(self.value in value) class JSON(ContentMixin, PathPattern): lookups = (Lookup.EQUAL,) key = "json" value: str def clean(self, value: Union[str, List, Dict]) -> str: return self.hash(value) def parse(self, request: httpx.Request) -> str: content = super().parse(request) json = jsonlib.loads(content.decode("utf-8")) if self.path: value = json for bit in self.path.split("__"): key = int(bit) if bit.isdigit() else bit try: value = value[key] except KeyError as e: raise KeyError(f"{self.path!r} not in {json!r}") from e except IndexError as e: raise IndexError(f"{self.path!r} not in {json!r}") from e else: value = json return self.hash(value) def hash(self, value: Union[str, List, Dict]) -> str: return jsonlib.dumps(value, sort_keys=True) class Data(MultiItemsMixin, Pattern): lookups = (Lookup.EQUAL, Lookup.CONTAINS) key = "data" value: MultiItems def clean(self, value: Dict) -> MultiItems: return MultiItems( (key, "" if value is None else str(value)) for key, value in value.items() ) def parse(self, request: httpx.Request) -> Any: data, _ = decode_data(request) return data class Files(MultiItemsMixin, Pattern): lookups = (Lookup.CONTAINS, Lookup.EQUAL) key = "files" value: MultiItems def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, Any]: # Mimic httpx `FileField` to normalize `files` kwarg to shortest tuple style if isinstance(value, tuple): filename, fileobj = value[:2] else: try: filename = pathlib.Path(str(getattr(value, "name"))).name # noqa: B009 except AttributeError: filename = ANY fileobj = value # Normalize file-like objects and strings to bytes to allow equality check if isinstance(fileobj, io.BytesIO): fileobj = fileobj.read() elif isinstance(fileobj, str): fileobj = fileobj.encode() return filename, fileobj def clean(self, value: RequestFiles) -> MultiItems: if isinstance(value, Mapping): value = list(value.items()) files = MultiItems( (name, self._normalize_file_value(file_value)) for name, file_value in value ) return files def parse(self, request: httpx.Request) -> Any: _, files = decode_data(request) return files def M(*patterns: Pattern, **lookups: Any) -> Pattern: extras = None for pattern__lookup, value in lookups.items(): # Handle url pattern if pattern__lookup == "url": extras = parse_url_patterns(value) continue # Parse pattern key and lookup pattern_key, __, rest = pattern__lookup.partition("__") path, __, lookup_name = rest.rpartition("__") if pattern_key not in Pattern.registry: raise KeyError(f"{pattern_key!r} is not a valid Pattern") # Get pattern class P = Pattern.registry[pattern_key] pattern: Union[Pattern, PathPattern] if issubclass(P, PathPattern): # Make path supported pattern, i.e. JSON try: lookup = Lookup(lookup_name) if lookup_name else None except ValueError: lookup = None path = rest pattern = P(value, lookup=lookup, path=path) else: # Make regular pattern lookup = Lookup(lookup_name) if lookup_name else None pattern = P(value, lookup=lookup) # Skip patterns with no value, exept when using equal lookup if not pattern.value and pattern.lookup is not Lookup.EQUAL: continue patterns += (pattern,) # Combine and merge patterns combined_pattern = combine(patterns) if extras: combined_pattern = merge_patterns(combined_pattern, **extras) return combined_pattern def get_scheme_port(scheme: Optional[str]) -> Optional[int]: return {"http": 80, "https": 443}.get(scheme or "") def combine(patterns: Sequence[Pattern], op: Callable = operator.and_) -> Pattern: patterns = tuple(filter(None, patterns)) if not patterns: return Noop() return reduce(op, patterns) def parse_url(value: Union[httpx.URL, str, RawURL]) -> httpx.URL: url: Union[httpx.URL, str] if isinstance(value, tuple): # Handle "raw" httpcore urls. Borrowed from HTTPX prior to #2241 raw_scheme, raw_host, port, raw_path = value scheme = raw_scheme.decode("ascii") host = raw_host.decode("ascii") if host and ":" in host and host[0] != "[": # it's an IPv6 address, so it should be enclosed in "[" and "]" # ref: https://tools.ietf.org/html/rfc2732#section-2 # ref: https://tools.ietf.org/html/rfc3986#section-3.2.2 host = f"[{host}]" port_str = "" if port is None else f":{port}" path = raw_path.decode("ascii") url = f"{scheme}://{host}{port_str}{path}" else: url = value return httpx.URL(url) def parse_url_patterns( url: Optional[URLPatternTypes], exact: bool = True ) -> Dict[str, Pattern]: bases: Dict[str, Pattern] = {} if not url or url == "all": return bases if isinstance(url, RegexPattern): return {"url": URL(url, lookup=Lookup.REGEX)} url = parse_url(url) scheme_port = get_scheme_port(url.scheme) if url.scheme and url.scheme != "all": bases[Scheme.key] = Scheme(url.scheme) if url.host: # NOTE: Host regex patterns borrowed from HTTPX source to support proxy format if url.host.startswith("*."): domain = re.escape(url.host[2:]) regex = re.compile(f"^.+\\.{domain}$") bases[Host.key] = Host(regex, lookup=Lookup.REGEX) elif url.host.startswith("*"): domain = re.escape(url.host[1:]) regex = re.compile(f"^(.+\\.)?{domain}$") bases[Host.key] = Host(regex, lookup=Lookup.REGEX) else: bases[Host.key] = Host(url.host) if url.port and url.port != scheme_port: bases[Port.key] = Port(url.port) if url._uri_reference.path: # URL.path always returns "/" lookup = Lookup.EQUAL if exact else Lookup.STARTS_WITH bases[Path.key] = Path(url.path, lookup=lookup) if url.query: lookup = Lookup.EQUAL if exact else Lookup.CONTAINS bases[Params.key] = Params(url.query, lookup=lookup) return bases def merge_patterns(pattern: Pattern, **bases: Pattern) -> Pattern: if not bases: return pattern # Flatten pattern patterns: List[Pattern] = list(filter(None, iter(pattern))) if patterns: if "host" in (_pattern.key for _pattern in patterns): # Pattern is "absolute", skip merging bases = {} else: # Traverse pattern and set related base for _pattern in patterns: base = bases.pop(_pattern.key, None) # Skip "exact" base + don't overwrite existing base if _pattern.base or base and base.lookup is Lookup.EQUAL: continue _pattern.base = base if bases: # Combine left over base patterns with pattern base_pattern = combine(list(bases.values())) if pattern and base_pattern: pattern = base_pattern & pattern else: pattern = base_pattern return pattern respx-0.21.1/respx/plugin.py000066400000000000000000000012461460110214700157440ustar00rootroot00000000000000from typing import cast import pytest import respx from .router import MockRouter def pytest_configure(config): config.addinivalue_line( "markers", "respx(assert_all_called=False, assert_all_mocked=False, base_url=...): " "configure the respx_mock fixture. " "See https://lundberg.github.io/respx/api.html#configuration", ) @pytest.fixture() def respx_mock(request): respx_marker = request.node.get_closest_marker("respx") mock_router: MockRouter = ( respx.mock if respx_marker is None else cast(MockRouter, respx.mock(**respx_marker.kwargs)) ) with mock_router: yield mock_router respx-0.21.1/respx/py.typed000066400000000000000000000000001460110214700155560ustar00rootroot00000000000000respx-0.21.1/respx/router.py000066400000000000000000000356761460110214700160040ustar00rootroot00000000000000import inspect from contextlib import contextmanager from functools import partial, update_wrapper, wraps from types import TracebackType from typing import ( Any, Callable, Dict, Generator, List, NewType, Optional, Tuple, Type, Union, cast, overload, ) import httpx from .mocks import Mocker from .models import ( AllMockedAssertionError, CallList, PassThrough, ResolvedRoute, Route, RouteList, SideEffectError, ) from .patterns import Pattern, merge_patterns, parse_url_patterns from .types import DefaultType, ResolvedResponseTypes, RouteResultTypes, URLPatternTypes Default = NewType("Default", object) DEFAULT = Default(...) class Router: def __init__( self, *, assert_all_called: bool = True, assert_all_mocked: bool = True, base_url: Optional[str] = None, ) -> None: self._assert_all_called = assert_all_called self._assert_all_mocked = assert_all_mocked self._bases = parse_url_patterns(base_url, exact=False) self.routes = RouteList() self.calls = CallList() self._snapshots: List[Tuple] = [] self.snapshot() def clear(self) -> None: """ Clears all routes. May be rolled back to snapshot state. """ self.routes.clear() def snapshot(self) -> None: """ Snapshots current routes and calls state. """ # Snapshot current routes and calls routes = RouteList(self.routes) calls = CallList(self.calls) self._snapshots.append((routes, calls)) # Snapshot each route state for route in routes: route.snapshot() def rollback(self) -> None: """ Rollbacks routes, and optionally calls, to snapshot state. """ if not self._snapshots: return # Revert added routes and calls to last snapshot routes, calls = self._snapshots.pop() self.routes[:] = routes self.calls[:] = calls # Revert each route state to last snapshot for route in self.routes: route.rollback() def reset(self) -> None: """ Resets call stats. """ self.calls.clear() for route in self.routes: route.reset() def assert_all_called(self) -> None: not_called_routes = [route for route in self.routes if not route.called] assert not_called_routes == [], "RESPX: some routes were not called!" def __getitem__(self, name: str) -> Route: return self.routes[name] @overload def pop(self, name: str) -> Route: ... # pragma: nocover @overload def pop(self, name: str, default: DefaultType) -> Union[Route, DefaultType]: ... # pragma: nocover def pop(self, name, default=...): """ Removes a route by name and returns it. Raises KeyError when `default` not provided and name is not found. """ try: return self.routes.pop(name) except KeyError as ex: if default is ...: raise ex return default def route( self, *patterns: Pattern, name: Optional[str] = None, **lookups: Any ) -> Route: route = Route(*patterns, **lookups) return self.add(route, name=name) def add(self, route: Route, *, name: Optional[str] = None) -> Route: """ Adds a route with optionally given name, replacing any existing route with same name or pattern. """ if not isinstance(route, Route): raise ValueError( f"Invalid route {route!r}, please use respx.route(...).mock(...)" ) route._pattern = merge_patterns(route.pattern, **self._bases) route = self.routes.add(route, name=name) return route def request( self, method: str, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: if lookups: # Validate that lookups doesn't contain method or url pattern_keys = {p.split("__", 1)[0] for p in lookups.keys()} if "method" in pattern_keys: raise TypeError("Got multiple values for pattern 'method'") elif url and "url" in pattern_keys: raise TypeError("Got multiple values for pattern 'url'") return self.route(method=method, url=url, name=name, **lookups) def get( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="GET", url=url, name=name, **lookups) def post( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="POST", url=url, name=name, **lookups) def put( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="PUT", url=url, name=name, **lookups) def patch( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="PATCH", url=url, name=name, **lookups) def delete( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="DELETE", url=url, name=name, **lookups) def head( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="HEAD", url=url, name=name, **lookups) def options( self, url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any, ) -> Route: return self.request(method="OPTIONS", url=url, name=name, **lookups) def record( self, request: httpx.Request, *, response: Optional[httpx.Response] = None, route: Optional[Route] = None, ) -> None: call = self.calls.record(request, response) if route: route.calls.append(call) @contextmanager def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, None]: resolved = ResolvedRoute() try: yield resolved if resolved.route is None: # Assert we always get a route match, if check is enabled if self._assert_all_mocked: raise AllMockedAssertionError(f"RESPX: {request!r} not mocked!") # Auto mock a successful empty response resolved.response = httpx.Response(200) elif resolved.response == request: # Pass-through request raise PassThrough( f"Request marked to pass through: {request!r}", request=request, origin=resolved.route, ) else: # Mocked response assert isinstance(resolved.response, httpx.Response) except SideEffectError as error: self.record(request, response=None, route=error.route) raise error.origin from error except PassThrough: self.record(request, response=None, route=resolved.route) raise else: self.record(request, response=resolved.response, route=resolved.route) def resolve(self, request: httpx.Request) -> ResolvedRoute: with self.resolver(request) as resolved: for route in self.routes: prospect = route.match(request) if prospect is not None: resolved.route = route resolved.response = cast(ResolvedResponseTypes, prospect) break if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream): resolved.response.read() # Pre-read stream return resolved async def aresolve(self, request: httpx.Request) -> ResolvedRoute: with self.resolver(request) as resolved: for route in self.routes: prospect: RouteResultTypes = route.match(request) # Await async side effect and wrap any exception if inspect.isawaitable(prospect): try: prospect = await prospect except Exception as error: raise SideEffectError(route, origin=error) from error if prospect is not None: resolved.route = route resolved.response = cast(ResolvedResponseTypes, prospect) break if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream): await resolved.response.aread() # Pre-read stream return resolved def handler(self, request: httpx.Request) -> httpx.Response: resolved = self.resolve(request) assert isinstance(resolved.response, httpx.Response) return resolved.response async def async_handler(self, request: httpx.Request) -> httpx.Response: resolved = await self.aresolve(request) assert isinstance(resolved.response, httpx.Response) return resolved.response class MockRouter(Router): def __init__( self, *, assert_all_called: bool = True, assert_all_mocked: bool = True, base_url: Optional[str] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> None: super().__init__( assert_all_called=assert_all_called, assert_all_mocked=assert_all_mocked, base_url=base_url, ) self.Mocker: Optional[Type[Mocker]] = None self._using = using @overload def __call__( self, func: None = None, *, assert_all_called: Optional[bool] = None, assert_all_mocked: Optional[bool] = None, base_url: Optional[str] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> "MockRouter": ... # pragma: nocover @overload def __call__( self, func: Callable = ..., *, assert_all_called: Optional[bool] = None, assert_all_mocked: Optional[bool] = None, base_url: Optional[str] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> Callable: ... # pragma: nocover def __call__( self, func: Optional[Callable] = None, *, assert_all_called: Optional[bool] = None, assert_all_mocked: Optional[bool] = None, base_url: Optional[str] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> Union["MockRouter", Callable]: """ Decorator or Context Manager. Use decorator/manager with parentheses for local state, or without parentheses for global state, i.e. shared patterns added outside of scope. """ if func is None: # Parentheses used, branch out to new nested instance. # - Only stage when using local ctx `with respx.mock(...) as respx_mock:` # - First stage when using local decorator `@respx.mock(...)` # FYI, global ctx `with respx.mock:` hits __enter__ directly settings: Dict[str, Any] = { "base_url": base_url, "using": using, } if assert_all_called is not None: settings["assert_all_called"] = assert_all_called if assert_all_mocked is not None: settings["assert_all_mocked"] = assert_all_mocked respx_mock = self.__class__(**settings) return respx_mock # Determine if decorated function needs a `respx_mock` instance is_async = inspect.iscoroutinefunction(func) argspec = inspect.getfullargspec(func) needs_mock_reference = "respx_mock" in argspec.args if needs_mock_reference: func = partial(func, respx_mock=self) # Async Decorator async def _async_decorator(*args, **kwargs): assert func is not None async with self: return await func(*args, **kwargs) # Sync Decorator def _sync_decorator(*args, **kwargs): assert func is not None with self: return func(*args, **kwargs) if needs_mock_reference: async_decorator = wraps(func)(_async_decorator) sync_decorator = wraps(func)(_sync_decorator) else: async_decorator = update_wrapper(_async_decorator, func) sync_decorator = update_wrapper(_sync_decorator, func) # Dispatch async/sync decorator, depending on decorated function. # - Only stage when using global decorator `@respx.mock` # - Second stage when using local decorator `@respx.mock(...)` return async_decorator if is_async else sync_decorator def __enter__(self) -> "MockRouter": self.start() return self def __exit__( self, exc_type: Optional[Type[BaseException]] = None, exc_value: Optional[BaseException] = None, traceback: Optional[TracebackType] = None, ) -> None: self.stop(quiet=bool(exc_type is not None)) async def __aenter__(self) -> "MockRouter": return self.__enter__() async def __aexit__(self, *args: Any) -> None: self.__exit__(*args) @property def using(self) -> Optional[str]: from respx.mocks import DEFAULT_MOCKER if self._using is None: using = None elif self._using is DEFAULT: using = DEFAULT_MOCKER elif isinstance(self._using, str): using = self._using else: raise ValueError(f"Invalid Router `using` kwarg: {self._using!r}") return using def start(self) -> None: """ Register transport, snapshot router and start patching. """ self.snapshot() self.Mocker = Mocker.registry.get(self.using or "") if self.Mocker: self.Mocker.register(self) self.Mocker.start() def stop(self, clear: bool = True, reset: bool = True, quiet: bool = False) -> None: """ Unregister transport and rollback router. Stop patching when no registered transports left. """ unregistered = self.Mocker.unregister(self) if self.Mocker else True try: if unregistered and not quiet and self._assert_all_called: self.assert_all_called() finally: if clear: self.rollback() if reset: self.reset() if self.Mocker: self.Mocker.stop() respx-0.21.1/respx/transports.py000066400000000000000000000052741460110214700166720ustar00rootroot00000000000000from types import TracebackType from typing import ( TYPE_CHECKING, Any, Callable, Coroutine, List, Optional, Type, Union, cast, ) from warnings import warn import httpx from httpx import AsyncBaseTransport, BaseTransport from .models import PassThrough if TYPE_CHECKING: from .router import Router # pragma: nocover RequestHandler = Callable[[httpx.Request], httpx.Response] AsyncRequestHandler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]] class MockTransport(httpx.MockTransport): _router: Optional["Router"] def __init__( self, *, handler: Optional[RequestHandler] = None, async_handler: Optional[AsyncRequestHandler] = None, router: Optional["Router"] = None, ): if router: super().__init__(router.handler) self._router = router elif handler: super().__init__(handler) self._router = None elif async_handler: super().__init__(async_handler) self._router = None else: raise RuntimeError( "Missing a MockTransport required handler or router argument" ) warn( "MockTransport is deprecated. " "Please use `httpx.MockTransport(respx_router.handler)`.", category=DeprecationWarning, ) def __exit__( self, exc_type: Optional[Type[BaseException]] = None, exc_value: Optional[BaseException] = None, traceback: Optional[TracebackType] = None, ) -> None: if not exc_type and self._router and self._router._assert_all_called: self._router.assert_all_called() async def __aexit__(self, *args: Any) -> None: self.__exit__(*args) class TryTransport(BaseTransport, AsyncBaseTransport): def __init__( self, transports: List[Union[BaseTransport, AsyncBaseTransport]] ) -> None: self.transports = transports def handle_request(self, request: httpx.Request) -> httpx.Response: for transport in self.transports: try: transport = cast(BaseTransport, transport) return transport.handle_request(request) except PassThrough: continue raise RuntimeError() # pragma: nocover async def handle_async_request(self, request: httpx.Request) -> httpx.Response: for transport in self.transports: try: transport = cast(AsyncBaseTransport, transport) return await transport.handle_async_request(request) except PassThrough: continue raise RuntimeError() # pragma: nocover respx-0.21.1/respx/types.py000066400000000000000000000034331460110214700156120ustar00rootroot00000000000000from typing import ( IO, Any, AsyncIterable, Awaitable, Callable, Dict, Iterable, Iterator, List, Mapping, Optional, Pattern, Sequence, Tuple, Type, TypeVar, Union, ) import httpx URL = Tuple[ bytes, # scheme bytes, # host Optional[int], # port bytes, # path ] Headers = List[Tuple[bytes, bytes]] Content = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] HeaderTypes = Union[ httpx.Headers, Dict[str, str], Dict[bytes, bytes], Sequence[Tuple[str, str]], Sequence[Tuple[bytes, bytes]], ] CookieTypes = Union[Dict[str, str], Sequence[Tuple[str, str]]] DefaultType = TypeVar("DefaultType", bound=Any) URLPatternTypes = Union[str, Pattern[str], URL, httpx.URL] QueryParamTypes = Union[ bytes, str, List[Tuple[str, Any]], Dict[str, Any], Tuple[Tuple[str, Any], ...] ] ResolvedResponseTypes = Optional[Union[httpx.Request, httpx.Response]] RouteResultTypes = Union[ResolvedResponseTypes, Awaitable[ResolvedResponseTypes]] CallableSideEffect = Callable[..., RouteResultTypes] SideEffectListTypes = Union[httpx.Response, Exception, Type[Exception]] SideEffectTypes = Union[ CallableSideEffect, Exception, Type[Exception], Iterator[SideEffectListTypes], ] # Borrowed from HTTPX's "private" types. FileContent = Union[IO[bytes], bytes, str] FileTypes = Union[ # file (or bytes) FileContent, # (filename, file (or bytes)) Tuple[Optional[str], FileContent], # (filename, file (or bytes), content_type) Tuple[Optional[str], FileContent, Optional[str]], # (filename, file (or bytes), content_type, headers) Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], ] RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] respx-0.21.1/respx/utils.py000066400000000000000000000102571460110214700156100ustar00rootroot00000000000000import email from datetime import datetime from email.message import Message from typing import ( Any, Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union, cast, ) from urllib.parse import parse_qsl try: from typing import Literal # type: ignore[attr-defined] except ImportError: # pragma: no cover from typing_extensions import Literal import httpx class MultiItems(dict): def get_list(self, key: str) -> List[Any]: try: return [self[key]] except KeyError: # pragma: no cover return [] def multi_items(self) -> List[Tuple[str, Any]]: return list(self.items()) def _parse_multipart_form_data( content: bytes, *, content_type: str, encoding: str ) -> Tuple[MultiItems, MultiItems]: form_data = b"\r\n".join( ( b"MIME-Version: 1.0", b"Content-Type: " + content_type.encode(encoding), b"\r\n" + content, ) ) data = MultiItems() files = MultiItems() for payload in email.message_from_bytes(form_data).get_payload(): payload = cast(Message, payload) name = payload.get_param("name", header="Content-Disposition") filename = payload.get_filename() content_type = payload.get_content_type() value = payload.get_payload(decode=True) assert isinstance(value, bytes) if content_type.startswith("text/") and filename is None: # Text field data[name] = value.decode(payload.get_content_charset() or "utf-8") else: # File field files[name] = filename, value return data, files def _parse_urlencoded_data(content: bytes, *, encoding: str) -> MultiItems: return MultiItems( (key, value) for key, value in parse_qsl(content.decode(encoding), keep_blank_values=True) ) def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]: content = request.read() content_type = request.headers.get("Content-Type", "") if content_type.startswith("multipart/form-data"): data, files = _parse_multipart_form_data( content, content_type=content_type, encoding=request.headers.encoding, ) else: data = _parse_urlencoded_data( content, encoding=request.headers.encoding, ) files = MultiItems() return data, files Self = TypeVar("Self", bound="SetCookie") class SetCookie( NamedTuple( "SetCookie", [ ("header_name", Literal["Set-Cookie"]), ("header_value", str), ], ) ): def __new__( cls: Type[Self], name: str, value: str, *, path: Optional[str] = None, domain: Optional[str] = None, expires: Optional[Union[str, datetime]] = None, max_age: Optional[int] = None, http_only: bool = False, same_site: Optional[Literal["Strict", "Lax", "None"]] = None, secure: bool = False, partitioned: bool = False, ) -> Self: """ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#syntax """ attrs: Dict[str, Union[str, bool]] = {name: value} if path is not None: attrs["Path"] = path if domain is not None: attrs["Domain"] = domain if expires is not None: if isinstance(expires, datetime): # pragma: no branch expires = expires.strftime("%a, %d %b %Y %H:%M:%S GMT") attrs["Expires"] = expires if max_age is not None: attrs["Max-Age"] = str(max_age) if http_only: attrs["HttpOnly"] = True if same_site is not None: attrs["SameSite"] = same_site if same_site == "None": # pragma: no branch secure = True if secure: attrs["Secure"] = True if partitioned: attrs["Partitioned"] = True string = "; ".join( _name if _value is True else f"{_name}={_value}" for _name, _value in attrs.items() ) self = super().__new__(cls, "Set-Cookie", string) return self respx-0.21.1/setup.cfg000066400000000000000000000022151460110214700145510ustar00rootroot00000000000000[bdist_wheel] universal = 1 [flake8] max-line-length = 88 ignore = B024,C408,E203,W503 exclude = .git show-source = true [isort] line_length = 88 known_first_party = respx default_section = THIRDPARTY multi_line_output = 3 combine_as_imports = true include_trailing_comma = true force_grid_wrap = 0 [tool:pytest] addopts = -p no:respx --cov=respx --cov=tests --cov-report=term-missing --cov-report=xml --cov-fail-under 100 -rxXs asyncio_mode = auto [coverage:run] source = respx,tests branch = True [coverage:report] skip_covered = True show_missing = True [mypy] python_version = 3.7 files = respx,tests pretty = True no_implicit_reexport = True no_implicit_optional = True strict_equality = True strict_optional = True check_untyped_defs = True disallow_incomplete_defs = True ignore_missing_imports = False warn_unused_configs = True warn_redundant_casts = True warn_unused_ignores = True warn_unreachable = True show_error_codes = True [mypy-pytest.*] ignore_missing_imports = True [mypy-trio.*] ignore_missing_imports = True [mypy-flask.*] ignore_missing_imports = True [mypy-starlette.*] ignore_missing_imports = True respx-0.21.1/setup.py000066400000000000000000000032721460110214700144460ustar00rootroot00000000000000#!/usr/bin/env python from pathlib import Path from setuptools import setup exec(Path("respx", "__version__.py").read_text()) # Load __version__ into locals setup( name="respx", version=locals()["__version__"], license="BSD-3-Clause", author="Jonas Lundberg", author_email="jonas@5monkeys.se", url="https://lundberg.github.io/respx/", keywords=["httpx", "httpcore", "mock", "responses", "requests", "async", "http"], description="A utility for mocking out the Python HTTPX and HTTP Core libraries.", long_description=Path("README.md").read_text("utf-8"), long_description_content_type="text/markdown", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ], project_urls={ "GitHub": "https://github.com/lundberg/respx", "Changelog": "https://github.com/lundberg/respx/blob/master/CHANGELOG.md", "Issues": "https://github.com/lundberg/respx/issues", }, packages=["respx"], package_data={"respx": ["py.typed"]}, entry_points={"pytest11": ["respx = respx.plugin"]}, include_package_data=True, zip_safe=False, python_requires=">=3.7", install_requires=["httpx>=0.21.0"], ) respx-0.21.1/tests/000077500000000000000000000000001460110214700140725ustar00rootroot00000000000000respx-0.21.1/tests/__init__.py000066400000000000000000000000001460110214700161710ustar00rootroot00000000000000respx-0.21.1/tests/conftest.py000066400000000000000000000020211460110214700162640ustar00rootroot00000000000000import httpx import pytest import respx from respx.fixtures import session_event_loop as event_loop # noqa: F401 pytest_plugins = ["pytester"] @pytest.fixture() async def client(): async with httpx.AsyncClient() as client: yield client @pytest.fixture() async def my_mock(): async with respx.mock( base_url="https://httpx.mock", using="httpcore" ) as respx_mock: respx_mock.get("/", name="index").respond(404) yield respx_mock @pytest.fixture(scope="session") async def mocked_foo(event_loop): # noqa: F811 async with respx.mock( base_url="https://foo.api/api/", using="httpcore" ) as respx_mock: respx_mock.get("/", name="index").respond(202) respx_mock.get("/bar/", name="bar") yield respx_mock @pytest.fixture(scope="session") async def mocked_ham(event_loop): # noqa: F811 async with respx.mock(base_url="https://ham.api", using="httpcore") as respx_mock: respx_mock.get("/", name="index").respond(200) yield respx_mock respx-0.21.1/tests/test_api.py000066400000000000000000000511721460110214700162620ustar00rootroot00000000000000import asyncio import json as jsonlib import re import socket from unittest import mock import httpcore import httpx import pytest import respx from respx.models import Route from respx.patterns import M from respx.router import MockRouter async def test_http_methods(client): async with respx.mock: url = "https://foo.bar" route = respx.get(url, path="/") % 404 respx.post(url, path="/").respond(200) respx.post(url, path="/").respond(201) respx.put(url, path="/").respond(202) respx.patch(url, path="/").respond(500) respx.delete(url, path="/").respond(204) respx.head(url, path="/").respond(405) respx.options(url, path="/").respond(status_code=501) respx.request("GET", url, path="/baz/").respond(status_code=204) url += "/" response = httpx.get(url) assert response.status_code == 404 response = await client.get(url) assert response.status_code == 404 response = httpx.get(url + "baz/") assert response.status_code == 204 response = await client.get(url + "baz/") assert response.status_code == 204 response = httpx.post(url) assert response.status_code == 201 response = await client.post(url) assert response.status_code == 201 response = httpx.put(url) assert response.status_code == 202 response = await client.put(url) assert response.status_code == 202 response = httpx.patch(url) assert response.status_code == 500 response = await client.patch(url) assert response.status_code == 500 response = httpx.delete(url) assert response.status_code == 204 response = await client.delete(url) assert response.status_code == 204 response = httpx.head(url) assert response.status_code == 405 response = await client.head(url) assert response.status_code == 405 response = httpx.options(url) assert response.status_code == 501 response = await client.options(url) assert response.status_code == 501 assert route.called is True assert respx.calls.call_count == 8 * 2 @pytest.mark.parametrize( ("url", "pattern"), [ ("https://foo.bar", "https://foo.bar"), ("https://foo.bar/baz/", None), ("https://foo.bar/baz/", ""), ("https://foo.bar/baz/", "https://foo.bar/baz/"), ("https://foo.bar/baz/", re.compile(r"^https://foo.bar/\w+/$")), ("https://foo.bar/baz/", (b"https", b"foo.bar", None, b"/baz/")), ("https://foo.bar:443/baz/", (b"https", b"foo.bar", 443, b"/baz/")), ("https://foo.bar/%08", "https://foo.bar/%08"), ], ) async def test_url_match(client, url, pattern): async with MockRouter(assert_all_mocked=False) as respx_mock: request = respx_mock.get(pattern) % dict(content="baz") response = await client.get(url) assert request.called is True assert response.status_code == 200 assert response.text == "baz" async def test_invalid_url_pattern(): async with MockRouter() as respx_mock: with pytest.raises(TypeError): respx_mock.get(["invalid"]) # type: ignore[arg-type] async def test_repeated_pattern(client): async with MockRouter() as respx_mock: url = "https://foo/bar/baz/" route = respx_mock.post(url) route.side_effect = [ httpx.Response(201), httpx.Response(409), ] response1 = await client.post(url, json={}) response2 = await client.post(url, json={}) with pytest.raises(RuntimeError): await client.post(url, json={}) assert response1.status_code == 201 assert response2.status_code == 409 assert respx_mock.calls.call_count == 2 assert route.called is True assert route.call_count == 2 statuses = [call.response.status_code for call in route.calls] assert statuses == [201, 409] async def test_status_code(client): async with MockRouter() as respx_mock: url = "https://foo.bar/" request = respx_mock.get(url) % 404 response = await client.get(url) assert request.called is True assert response.status_code == 404 @pytest.mark.parametrize( ("headers", "content_type", "expected"), [ ({"X-Foo": "bar"}, None, {"X-Foo": "bar"}), ( {"Content-Type": "foo/bar", "X-Foo": "bar"}, None, {"Content-Type": "foo/bar", "X-Foo": "bar"}, ), ( {"Content-Type": "foo/bar", "X-Foo": "bar"}, "ham/spam", {"Content-Type": "ham/spam", "X-Foo": "bar"}, ), ], ) async def test_headers(client, headers, content_type, expected): async with MockRouter() as respx_mock: url = "https://foo.bar/" request = respx_mock.get(url).respond( headers=headers, content_type=content_type ) response = await client.get(url) assert request.called is True assert response.headers == httpx.Headers(expected) @pytest.mark.parametrize( ("content", "expected"), [ (b"eldr\xc3\xa4v", "eldräv"), ("äpple", "äpple"), ("Gehäusegröße", "Gehäusegröße"), ], ) async def test_text_encoding(client, content, expected): async with MockRouter() as respx_mock: url = "https://foo.bar/" request = respx_mock.post(url) % dict(content=content) response = await client.post(url) assert request.called is True assert response.text == expected @pytest.mark.parametrize( ("key", "value", "expected_content_type"), [ ("content", b"foobar", None), ("content", "foobar", None), ("json", ["foo", "bar"], "application/json"), ("json", {"foo": "bar"}, "application/json"), ("text", "foobar", "text/plain; charset=utf-8"), ("html", "foobar", "text/html; charset=utf-8"), ], ) async def test_content_variants(client, key, value, expected_content_type): async with MockRouter() as respx_mock: url = "https://foo.bar/" request = respx_mock.get(url) % {key: value} async_response = await client.get(url) assert request.called is True assert async_response.headers.get("Content-Type") == expected_content_type assert async_response.content is not None respx_mock.reset() sync_response = httpx.get(url) assert request.called is True assert sync_response.headers.get("Content-Type") == expected_content_type assert sync_response.content is not None @pytest.mark.parametrize( ("content", "headers", "expected_headers"), [ ( {"foo": "bar"}, {"X-Foo": "bar"}, { "Content-Type": "application/json", "Content-Length": "14", "X-Foo": "bar", }, ), ( ["foo", "bar"], {"Content-Type": "application/json; charset=utf-8", "X-Foo": "bar"}, { "Content-Type": "application/json; charset=utf-8", "Content-Length": "14", "X-Foo": "bar", }, ), ], ) async def test_json_content(client, content, headers, expected_headers): async with MockRouter() as respx_mock: url = "https://foo.bar/" request = respx_mock.get(url) % dict(json=content, headers=headers) async_response = await client.get(url) assert request.called is True assert async_response.headers == httpx.Headers(expected_headers) assert async_response.json() == content respx_mock.reset() sync_response = httpx.get(url) assert request.called is True assert sync_response.headers == httpx.Headers(expected_headers) assert sync_response.json() == content def test_json_post_body(): post_url = "https://example.org/" get_url = "https://something.else/" with respx.mock: post_route = respx.post(post_url, json={"foo": "bar"}) % 201 get_route = respx.get(get_url) % 204 post_response = httpx.post(post_url, json={"foo": "bar"}) assert post_response.status_code == 201 assert post_route.called get_response = httpx.get(get_url) assert get_response.status_code == 204 assert get_route.called def test_data_post_body(): with respx.mock: url = "https://foo.bar/" route = respx.post(url, data={"foo": "bar"}) % 201 response = httpx.post(url, data={"foo": "bar"}, files={"file": b"..."}) assert response.status_code == 201 assert route.called def test_files_post_body(): with respx.mock: url = "https://foo.bar/" file = ("file", ("filename.txt", b"...", "text/plain", {"X-Foo": "bar"})) route = respx.post(url, files={"file": mock.ANY}) % 201 response = httpx.post(url, files=[file]) assert response.status_code == 201 assert route.called async def test_raising_content(client): async with MockRouter() as respx_mock: url = "https://foo.bar/" request = respx_mock.get(url) request.side_effect = httpx.ConnectTimeout("X-P", request=None) with pytest.raises(httpx.ConnectTimeout): await client.get(url) assert request.called is True _request, _response = request.calls[-1] assert _request is not None assert _response is None # Test httpx exception class get instantiated route = respx_mock.get(url).mock(side_effect=httpx.ConnectError) with pytest.raises(httpx.ConnectError): await client.get(url) assert route.call_count == 2 assert route.calls.last.request is not None assert route.calls.last.has_response is False with pytest.raises(ValueError, match="has no response"): assert route.calls.last.response async def test_callable_content(client): async with MockRouter() as respx_mock: url_pattern = re.compile(r"https://foo.bar/(?P\w+)/") def content_callback(request, slug): content = jsonlib.loads(request.content) return respx.MockResponse(content=f"hello {slug}{content['x']}") request = respx_mock.post(url_pattern) request.side_effect = content_callback async_response = await client.post("https://foo.bar/world/", json={"x": "."}) assert request.called is True assert async_response.status_code == 200 assert async_response.text == "hello world." assert request.calls[-1][0].content == b'{"x": "."}' respx_mock.reset() sync_response = httpx.post("https://foo.bar/jonas/", json={"x": "!"}) assert request.called is True assert sync_response.status_code == 200 assert sync_response.text == "hello jonas!" assert request.calls[-1][0].content == b'{"x": "!"}' async def test_request_callback(client): def callback(request, name): if request.url.host == "foo.bar" and request.content == b'{"foo": "bar"}': return respx.MockResponse( 202, headers={"X-Foo": "bar"}, text=f"hello {name}", http_version="HTTP/2", ) return httpx.Response(404) async with MockRouter(assert_all_called=False) as respx_mock: request = respx_mock.post(host="foo.bar", path__regex=r"/(?P\w+)/") request.side_effect = callback response = await client.post("https://foo.bar/lundberg/") assert response.status_code == 404 response = await client.post("https://foo.bar/lundberg/", json={"foo": "bar"}) assert request.called is True assert not request.is_pass_through assert response.status_code == 202 assert response.http_version == "HTTP/2" assert response.headers == httpx.Headers( { "Content-Type": "text/plain; charset=utf-8", "Content-Length": "14", "X-Foo": "bar", } ) assert response.text == "hello lundberg" respx_mock.get("https://ham.spam/").mock( side_effect=lambda req: "invalid" # type: ignore[arg-type] ) def _callback(request): raise httpcore.NetworkError() respx_mock.get("https://egg.plant").mock(side_effect=_callback) with pytest.raises(TypeError): await client.get("https://ham.spam/") with pytest.raises(httpx.NetworkError): await client.get("https://egg.plant/") @pytest.mark.parametrize( ("using", "route", "expected"), [ ("httpcore", Route(url="https://example.org/").pass_through(), True), ("httpx", Route(url="https://example.org/").pass_through(), True), ("httpcore", Route().mock(side_effect=lambda request: request), False), ("httpcore", Route().pass_through(), True), ], ) async def test_pass_through(client, using, route, expected): async with MockRouter(using=using) as respx_mock: request = respx_mock.add(route) with mock.patch( "anyio.connect_tcp", side_effect=ConnectionRefusedError("test request blocked"), ) as open_connection: with pytest.raises(httpx.NetworkError): await client.get("https://example.org/") assert open_connection.called is True assert request.called is True assert request.is_pass_through is expected with MockRouter(using=using) as respx_mock: request = respx_mock.add(route) with mock.patch( "socket.create_connection", side_effect=socket.error("test request blocked") ) as connect: with pytest.raises(httpx.NetworkError): httpx.get("https://example.org/") assert connect.called is True assert request.called is True assert request.is_pass_through is expected @respx.mock async def test_parallel_requests(client): def content(request, page): return httpx.Response(200, text=page) url_pattern = re.compile(r"https://foo/(?P\w+)/$") respx.get(url_pattern).mock(side_effect=content) responses = await asyncio.gather( client.get("https://foo/one/"), client.get("https://foo/two/") ) response_one, response_two = responses assert response_one.text == "one" assert response_two.text == "two" assert respx.calls.call_count == 2 @pytest.mark.parametrize( ("method_str", "client_method_attr"), [ ("DELETE", "delete"), ("delete", "delete"), ("GET", "get"), ("get", "get"), ("HEAD", "head"), ("head", "head"), ("OPTIONS", "options"), ("options", "options"), ("PATCH", "patch"), ("patch", "patch"), ("POST", "post"), ("post", "post"), ("PUT", "put"), ("put", "put"), ], ) async def test_method_case(client, method_str, client_method_attr): url = "https://example.org/" content = {"spam": "lots", "ham": "no, only spam"} async with MockRouter() as respx_mock: request = respx_mock.route(method=method_str, url=url) % dict(json=content) response = await getattr(client, client_method_attr)(url) assert request.called is True assert response.json() == content def test_pop(): with respx.mock: request = respx.get("https://foo.bar/", name="foobar") popped = respx.pop("foobar") assert popped is request with pytest.raises(KeyError): respx.pop("foobar") assert respx.pop("foobar", "custom default") == "custom default" @respx.mock @pytest.mark.parametrize( ("url", "params", "call_url", "call_params"), [ ("https://foo/", "foo=bar", "https://foo/", "foo=bar"), ("https://foo/", b"foo=bar", "https://foo/", b"foo=bar"), ("https://foo/", [("foo", "bar")], "https://foo/", [("foo", "bar")]), ("https://foo/", {"foo": "bar"}, "https://foo/", {"foo": "bar"}), ("https://foo/", (("foo", "bar"),), "https://foo/", (("foo", "bar"),)), ("https://foo?foo=bar", "baz=qux", "https://foo?foo=bar", "baz=qux"), ("https://foo?foo=bar", "baz=qux", "https://foo?foo=bar&baz=qux", None), (re.compile(r"https://foo/(\w+)/"), "foo=bar", "https://foo/bar/", "foo=bar"), (httpx.URL("https://foo/"), "foo=bar", "https://foo/", "foo=bar"), ( httpx.URL("https://foo?foo=bar"), "baz=qux", "https://foo?foo=bar&baz=qux", None, ), ], ) async def test_params_match(client, url, params, call_url, call_params): respx.get(url, params=params) % dict(content="spam spam") response = await client.get(call_url, params=call_params) assert response.text == "spam spam" @pytest.mark.parametrize( ("base", "url"), [ (None, "https://foo.bar/baz/"), ("", "https://foo.bar/baz/"), ("https://foo.bar", "baz/"), ("https://foo.bar/", "baz/"), ("https://foo.bar/", "/baz/"), ("https://foo.bar/baz/", None), ("https://foo.bar/", re.compile(r"/(\w+)/")), ], ) async def test_build_url_base(client, base, url): with respx.mock(base_url=base) as respx_mock: respx_mock.get(url) % dict(content="spam spam") response = await client.get("https://foo.bar/baz/") assert response.text == "spam spam" def test_add(): with respx.mock: route = Route(method="GET", url="https://foo.bar/") respx.add(route, name="foobar") response = httpx.get("https://foo.bar/") assert response.status_code == 200 assert respx.routes["foobar"].called with pytest.raises(TypeError): respx.add(route, status_code=418) # type: ignore[call-arg] with pytest.raises(ValueError, match="Invalid route"): respx.add("GET") # type: ignore[arg-type] with pytest.raises(NotImplementedError): route.name = "spam" with pytest.raises(NotImplementedError): route.pattern &= M(params={"foo": "bar"}) def test_respond(): with respx.mock: route = respx.get("https://foo.bar/").respond( content="lundberg", content_type="text/xml", http_version="HTTP/2", ) response = httpx.get("https://foo.bar/") assert response.status_code == 200 assert response.headers.get("Content-Type") == "text/xml" assert response.http_version == "HTTP/2" with pytest.raises(TypeError, match="content can only be"): route.respond(content={}) with pytest.raises(TypeError, match="content can only be"): route.respond(content=Exception()) # type: ignore[arg-type] def test_can_respond_with_cookies(): with respx.mock: route = respx.get("https://foo.bar/").respond( json={}, headers={"X-Foo": "bar"}, cookies={"foo": "bar", "ham": "spam"} ) response = httpx.get("https://foo.bar/") assert len(response.headers) == 5 assert response.headers["X-Foo"] == "bar", "mocked header is missing" assert len(response.cookies) == 2 assert response.cookies["foo"] == "bar" assert response.cookies["ham"] == "spam" route.respond(cookies=[("egg", "yolk")]) response = httpx.get("https://foo.bar/") assert len(response.cookies) == 1 assert response.cookies["egg"] == "yolk" route.respond( cookies=[respx.SetCookie("foo", "bar", path="/", same_site="Lax")] ) response = httpx.get("https://foo.bar/") assert len(response.cookies) == 1 assert response.cookies["foo"] == "bar" def test_can_mock_response_with_set_cookie_headers(): request = httpx.Request("GET", "https://example.com/") response = httpx.Response( 200, headers=[ respx.SetCookie("foo", value="bar"), respx.SetCookie("ham", value="spam"), ], request=request, ) assert len(response.cookies) == 2 assert response.cookies["foo"] == "bar" assert response.cookies["ham"] == "spam" @pytest.mark.parametrize( "kwargs", [ {"content": b"foobar"}, {"content": "foobar"}, {"json": {"foo": "bar"}}, {"json": [{"foo": "bar", "ham": "spam"}, {"zoo": "apa", "egg": "yolk"}]}, {"data": {"animal": "Räv", "name": "Röda Räven"}}, ], ) async def test_async_post_content(kwargs): async with respx.mock: respx.post("https://foo.bar/", **kwargs) % 201 async with httpx.AsyncClient() as client: response = await client.post("https://foo.bar/", **kwargs) assert response.status_code == 201 respx-0.21.1/tests/test_mock.py000066400000000000000000000606751460110214700164520ustar00rootroot00000000000000from contextlib import ExitStack as does_not_raise import httpcore import httpx import pytest import respx from respx import ASGIHandler, WSGIHandler from respx.mocks import Mocker from respx.models import AllMockedAssertionError from respx.router import MockRouter @respx.mock async def test_decorating_test(client): assert respx.calls.call_count == 0 respx.calls.assert_not_called() request = respx.route(url="https://foo.bar/", name="home").respond(202) response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 1 assert respx.routes["home"].call_count == 1 respx.calls.assert_called_once() respx.routes["home"].calls.assert_called_once() async def test_mock_request_fixture(client, my_mock): assert respx.calls.call_count == 0 assert my_mock.calls.call_count == 0 response = await client.get("https://httpx.mock/") request = my_mock.routes["index"] assert request.called is True assert response.is_error assert response.status_code == 404 assert respx.calls.call_count == 0 assert my_mock.calls.call_count == 1 async def test_mock_single_session_fixture(client, mocked_foo): current_foo_call_count = mocked_foo.calls.call_count response = await client.get("https://foo.api/api/bar/") request = mocked_foo.routes["bar"] assert request.called is True assert response.status_code == 200 assert mocked_foo.calls.call_count == current_foo_call_count + 1 async def test_mock_multiple_session_fixtures(client, mocked_foo, mocked_ham): current_foo_call_count = mocked_foo.calls.call_count current_ham_call_count = mocked_ham.calls.call_count response = await client.get("https://foo.api/api/") request = mocked_foo.routes["index"] assert request.called is True assert response.status_code == 202 response = await client.get("https://ham.api/") request = mocked_foo.routes["index"] assert request.called is True assert response.status_code == 200 assert mocked_foo.calls.call_count == current_foo_call_count + 1 assert mocked_ham.calls.call_count == current_ham_call_count + 1 def test_global_sync_decorator(): @respx.mock def test(): assert respx.calls.call_count == 0 request = respx.get("https://foo.bar/") % httpx.Response(202) response = httpx.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 1 assert respx.calls.call_count == 0 test() assert respx.calls.call_count == 0 async def test_global_async_decorator(client): @respx.mock async def test(): assert respx.calls.call_count == 0 request = respx.get("https://foo.bar/") % httpx.Response(202) response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 1 assert respx.calls.call_count == 0 await test() assert respx.calls.call_count == 0 @pytest.mark.parametrize("using", ["httpcore", "httpx"]) def test_local_sync_decorator(using): @respx.mock(using=using) def test(respx_mock): assert respx.calls.call_count == 0 request = respx_mock.get("https://foo.bar/") % 202 response = httpx.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 0 assert respx_mock.calls.call_count == 1 with pytest.raises(AllMockedAssertionError): httpx.post("https://foo.bar/") assert respx.calls.call_count == 0 test() assert respx.calls.call_count == 0 @pytest.mark.parametrize("using", ["httpcore", "httpx"]) async def test_local_async_decorator(client, using): @respx.mock(using=using) async def test(respx_mock): assert respx.calls.call_count == 0 stream = httpx.ByteStream(b"foobar") request = respx_mock.get("https://foo.bar/").mock( return_value=httpx.Response(202, stream=stream) ) response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 assert response.content == b"foobar" assert respx.calls.call_count == 0 assert respx_mock.calls.call_count == 1 with pytest.raises(AllMockedAssertionError): httpx.post("https://foo.bar/") assert respx.calls.call_count == 0 await test() assert respx.calls.call_count == 0 def test_local_decorator_with_reference(): router = respx.mock() @router def test(respx_mock): assert respx_mock is router test() def test_local_decorator_without_reference(): router = respx.mock() route = router.get("https://foo.bar/") % 202 @router def test(): assert respx.calls.call_count == 0 response = httpx.get("https://foo.bar/") assert route.called is True assert response.status_code == 202 assert respx.calls.call_count == 0 assert router.calls.call_count == 1 assert router.calls.call_count == 0 assert respx.calls.call_count == 0 test() assert respx.calls.call_count == 0 async def test_global_contextmanager(client): with respx.mock: assert respx.calls.call_count == 0 request = respx.get("https://foo/bar/") % 202 response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 1 async with respx.mock: assert respx.calls.call_count == 0 request = respx.get("https://foo/bar/") % 202 response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 1 assert respx.calls.call_count == 0 async def test_local_contextmanager(client): with respx.mock() as respx_mock: assert respx_mock.calls.call_count == 0 request = respx_mock.get("https://foo/bar/") % 202 response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 0 assert respx_mock.calls.call_count == 1 async with respx.mock() as respx_mock: assert respx_mock.calls.call_count == 0 request = respx_mock.get("https://foo/bar/") % 202 response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 assert respx.calls.call_count == 0 assert respx_mock.calls.call_count == 1 assert respx.calls.call_count == 0 async def test_nested_local_contextmanager(client): with respx.mock() as respx_mock_1: get_request = respx_mock_1.get("https://foo/bar/") % 202 with respx.mock() as respx_mock_2: post_request = respx_mock_2.post("https://foo/bar/") % 201 assert len(respx_mock_1.routes) == 1 assert len(respx_mock_2.routes) == 1 response = await client.get("https://foo/bar/") assert get_request.called is True assert response.status_code == 202 assert respx.calls.call_count == 0 assert respx_mock_1.calls.call_count == 1 assert respx_mock_2.calls.call_count == 0 response = await client.post("https://foo/bar/") assert post_request.called is True assert response.status_code == 201 assert respx.calls.call_count == 0 assert respx_mock_1.calls.call_count == 1 assert respx_mock_2.calls.call_count == 1 assert len(respx.routes) == 0 async def test_nested_global_contextmanager(client): with respx.mock: get_request = respx.get("https://foo/bar/") % 202 assert len(respx.routes) == 1 with respx.mock: post_request = respx.post("https://foo/bar/") % 201 assert len(respx.routes) == 2 response = await client.get("https://foo/bar/") assert get_request.called is True assert response.status_code == 202 assert respx.calls.call_count == 1 response = await client.post("https://foo/bar/") assert post_request.called is True assert response.status_code == 201 assert respx.calls.call_count == 2 assert len(respx.routes) == 1 assert len(respx.routes) == 0 async def test_configured_decorator(client): @respx.mock(assert_all_called=False, assert_all_mocked=False) async def test(respx_mock): assert respx_mock.calls.call_count == 0 request = respx_mock.get("https://foo.bar/") response = await client.get("https://some.thing/") assert response.status_code == 200 assert response.headers == httpx.Headers() assert response.text == "" assert request.called is False assert respx.calls.call_count == 0 assert respx_mock.calls.call_count == 1 _request, _response = respx_mock.calls.last assert _request is not None assert _response is not None assert respx_mock.calls.last.request is _request assert respx_mock.calls.last.response is _response assert _request.url == "https://some.thing/" await test() assert respx.calls.call_count == 0 @respx.mock(base_url="https://foo.bar") async def test_configured_decorator_with_fixture(respx_mock, client): respx_mock.get("/") response = await client.get("https://foo.bar/") assert response.status_code == 200 async def test_configured_router_reuse(client): router = respx.mock() route = router.get("https://foo/bar/") % 404 assert len(router.routes) == 1 assert router.calls.call_count == 0 with router: route.return_value = httpx.Response(202) response = await client.get("https://foo/bar/") assert route.called == True # noqa: E712 assert response.status_code == 202 assert router.calls.call_count == 1 assert respx.calls.call_count == 0 assert len(router.routes) == 1 assert route.called == False # noqa: E712 assert router.calls.call_count == 0 async with router: assert router.calls.call_count == 0 response = await client.get("https://foo/bar/") assert route.called == True # noqa: E712 assert response.status_code == 404 assert router.calls.call_count == 1 assert respx.calls.call_count == 0 assert len(router.routes) == 1 assert route.called == False # noqa: E712 assert router.calls.call_count == 0 assert respx.calls.call_count == 0 async def test_router_return_type_misuse(): router = respx.mock(assert_all_called=False) route = router.get("https://hot.dog/") with pytest.raises(TypeError): route.return_value = "not-a-httpx-response" # type: ignore[assignment] @respx.mock(base_url="https://ham.spam/") async def test_nested_base_url(respx_mock): request = respx_mock.patch("/egg/") % dict(content="yolk") async with respx.mock(base_url="https://foo.bar/api/") as foobar_mock: request1 = foobar_mock.get("/baz/") % dict(content="baz") request2 = foobar_mock.post(path__regex=r"(?P\w+)/?$") % dict(text="slug") request3 = foobar_mock.route() % dict(content="ok") request4 = foobar_mock.patch("http://localhost/egg/") % 204 async with httpx.AsyncClient(base_url="https://foo.bar/api") as client: response = await client.get("/baz/") assert request1.called is True assert response.text == "baz" response = await client.post("/apa/") assert request2.called is True assert response.text == "slug" response = await client.put("/") assert request3.called is True assert response.text == "ok" response = await client.patch("http://localhost/egg/") assert request4.called is True assert response.status_code == 204 response = await client.patch("https://ham.spam/egg/") assert request.called is True assert response.text == "yolk" def test_leakage(mocked_foo, mocked_ham): # NOTE: Including session fixtures, since they are pre-registered routers assert len(respx.routes) == 0 assert len(respx.calls) == 0 assert len(Mocker.registry["httpcore"].routers) == 2 async def test_start_stop(client): url = "https://start.stop/" request = respx.get(url) % 202 try: respx.start() response = await client.get(url) assert request.called == True # noqa: E712 assert response.status_code == 202 assert response.text == "" assert respx.calls.call_count == 1 respx.stop(clear=False, reset=False) assert len(respx.routes) == 1 assert respx.calls.call_count == 1 assert request.called == True # noqa: E712 respx.reset() assert len(respx.routes) == 1 assert respx.calls.call_count == 0 assert request.called == False # noqa: E712 respx.clear() assert len(respx.routes) == 0 finally: # pragma: nocover respx.stop() # Cleanup global state on error, to not affect other tests @pytest.mark.parametrize( ("assert_all_called", "do_post", "raises"), [ ( True, False, pytest.raises(AssertionError, match="some routes were not called"), ), (True, True, does_not_raise()), (False, True, does_not_raise()), (False, False, does_not_raise()), ], ) async def test_assert_all_called(client, assert_all_called, do_post, raises): with raises: async with MockRouter(assert_all_called=assert_all_called) as respx_mock: request1 = respx_mock.get("https://foo.bar/1/") % 404 request2 = respx_mock.post("https://foo.bar/") % 201 await client.get("https://foo.bar/1/") if do_post: await client.post("https://foo.bar/") assert request1.called is True assert request2.called is do_post @pytest.mark.parametrize( ("assert_all_mocked", "raises"), [(True, pytest.raises(AllMockedAssertionError)), (False, does_not_raise())], ) async def test_assert_all_mocked(client, assert_all_mocked, raises): with raises: with MockRouter(assert_all_mocked=assert_all_mocked) as respx_mock: response = httpx.get("https://foo.bar/") assert respx_mock.calls.call_count == 1 assert response.status_code == 200 with raises: async with MockRouter(assert_all_mocked=assert_all_mocked) as respx_mock: response = await client.get("https://foo.bar/") assert respx_mock.calls.call_count == 1 assert response.status_code == 200 assert respx_mock.calls.call_count == 0 def test_add_remove_targets(): from respx.mocks import HTTPCoreMocker target = "httpcore._sync.connection.HTTPConnection" assert HTTPCoreMocker.targets.count(target) == 1 HTTPCoreMocker.add_targets(target) assert HTTPCoreMocker.targets.count(target) == 1 pre_add_count = len(HTTPCoreMocker.targets) try: HTTPCoreMocker.add_targets( "httpx._transports.asgi.ASGITransport", "httpx._transports.wsgi.WSGITransport", ) assert len(HTTPCoreMocker.targets) == pre_add_count + 2 HTTPCoreMocker.remove_targets("foobar") assert len(HTTPCoreMocker.targets) == pre_add_count + 2 finally: HTTPCoreMocker.remove_targets( "httpx._transports.asgi.ASGITransport", "httpx._transports.wsgi.WSGITransport", ) assert len(HTTPCoreMocker.targets) == pre_add_count async def test_proxies(): with respx.mock: respx.get("https://foo.bar/") % dict(json={"foo": "bar"}) with httpx.Client(proxies={"https://": "https://1.1.1.1:1"}) as client: response = client.get("https://foo.bar/") assert response.json() == {"foo": "bar"} async with respx.mock: respx.get("https://foo.bar/") % dict(json={"foo": "bar"}) async with httpx.AsyncClient( proxies={"https://": "https://1.1.1.1:1"} ) as client: response = await client.get("https://foo.bar/") assert response.json() == {"foo": "bar"} async def test_uds(): async with respx.mock: uds = httpx.AsyncHTTPTransport(uds="/tmp/foobar.sock") async with httpx.AsyncClient(transport=uds) as client: request = respx.get("https://foo.bar/") % 202 response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 async def test_mock_using_none(): @respx.mock(using=None) async def test(respx_mock): respx_mock.get("https://example.org/") % 204 transport = httpx.MockTransport(respx_mock.handler) async with httpx.AsyncClient(transport=transport) as client: response = await client.get("https://example.org/") assert response.status_code == 204 await test() async def test_router_using__none(): router = respx.MockRouter(using=None) router.get("https://example.org/") % 204 @router async def test(): transport = httpx.MockTransport(router.handler) async with httpx.AsyncClient(transport=transport) as client: response = await client.get("https://example.org/") assert response.status_code == 204 await test() def test_router_using__invalid(): with pytest.raises(ValueError, match="using"): respx.MockRouter(using=123).using # type: ignore[arg-type] def test_mocker_subclass(): with pytest.raises(TypeError, match="unique name"): class Foobar(Mocker): name = "httpcore" class Hamspam(Mocker): pass assert not hasattr(Hamspam, "routers") def test_sync_httpx_mocker(): class TestTransport(httpx.BaseTransport): def handle_request(self, *args, **kwargs): raise RuntimeError("would pass through") client = httpx.Client(transport=TestTransport()) @respx.mock(using="httpx") def test(respx_mock): mock_route = respx_mock.get("https://example.org/") % 204 pass_route = respx_mock.get(host="pass-through").pass_through() with client: response = client.get("https://example.org/") assert response.status_code == 204 assert mock_route.call_count == 1 with pytest.raises(RuntimeError, match="would pass through"): client.get("https://pass-through/") assert pass_route.call_count == 1 with pytest.raises(AllMockedAssertionError): client.get("https://not-mocked/") with respx.mock(using="httpx"): # extra registered router test() async def test_async_httpx_mocker(): class TestTransport(httpx.AsyncBaseTransport): async def handle_async_request(self, *args, **kwargs): raise RuntimeError("would pass through") client = httpx.AsyncClient(transport=TestTransport()) @respx.mock @respx.mock(using="httpx") async def test(respx_mock): respx.get(host="foo.bar") async def streaming_side_effect(request): async def content(): yield b'{"foo"' yield b':"bar"}' return httpx.Response(204, content=content()) mock_route = respx_mock.get("https://example.org/") mock_route.side_effect = streaming_side_effect pass_route = respx_mock.get(host="pass-through").pass_through() async with client: response = await client.get("https://example.org/") assert response.status_code == 204 assert response.json() == {"foo": "bar"} assert mock_route.call_count == 1 with pytest.raises(RuntimeError, match="would pass through"): await client.get("https://pass-through/") assert pass_route.call_count == 1 with pytest.raises(AllMockedAssertionError): await client.get("https://not-mocked/") async with respx.mock(using="httpx"): # extra registered router await test() @pytest.mark.parametrize("using", ["httpcore", "httpx"]) async def test_async_side_effect(client, using): async def effect(request, slug): assert request.extensions.get("timeout", {}).get("read") == 44.0 return httpx.Response(204, text=slug) async with respx.mock(using=using) as respx_mock: mock_route = respx_mock.get( "https://example.org/", path__regex=r"/(?P\w+)/" ).mock(side_effect=effect) response = await client.get("https://example.org/hello/", timeout=44.0) assert response.status_code == 204 assert response.text == "hello" assert mock_route.called @pytest.mark.parametrize("using", ["httpcore", "httpx"]) async def test_async_side_effect__exception(client, using): async def effect(request): raise httpx.ConnectTimeout("X-P", request=request) async with respx.mock(using=using) as respx_mock: mock_route = respx_mock.get("https://example.org/").mock(side_effect=effect) with pytest.raises(httpx.ConnectTimeout): await client.get("https://example.org/") assert mock_route.called @pytest.mark.parametrize("using", ["httpcore", "httpx"]) async def test_async_app_route(client, using): from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route async def baz(request): return JSONResponse({"ham": "spam"}) app = Starlette(routes=[Route("/baz/", baz)]) async with respx.mock(using=using, base_url="https://foo.bar/") as respx_mock: app_route = respx_mock.route().mock(side_effect=ASGIHandler(app)) response = await client.get("https://foo.bar/baz/") assert response.json() == {"ham": "spam"} assert app_route.called async with respx.mock: respx.route(host="foo.bar").mock(side_effect=ASGIHandler(app)) response = await client.get("https://foo.bar/baz/") assert response.json() == {"ham": "spam"} @pytest.mark.parametrize("using", ["httpcore", "httpx"]) def test_sync_app_route(using): from flask import Flask app = Flask("foobar") @app.route("/baz/") def baz(): return {"ham": "spam"} with respx.mock(using=using, base_url="https://foo.bar/") as respx_mock: app_route = respx_mock.route().mock(side_effect=WSGIHandler(app)) response = httpx.get("https://foo.bar/baz/") assert response.json() == {"ham": "spam"} assert app_route.called with respx.mock: respx.route(host="foo.bar").mock(side_effect=WSGIHandler(app)) response = httpx.get("https://foo.bar/baz/") assert response.json() == {"ham": "spam"} @pytest.mark.parametrize( ("url", "port"), [ ("https://foo.bar/", None), ("https://foo.bar:443/", 443), ], ) async def test_httpcore_request(url, port): async with MockRouter(using="httpcore") as router: router.get(url) % dict(text="foobar") request = httpcore.Request( b"GET", httpcore.URL(scheme=b"https", host=b"foo.bar", port=port, target=b"/"), ) with httpcore.ConnectionPool() as http: response = http.handle_request(request) body = response.read() assert body == b"foobar" async with httpcore.AsyncConnectionPool() as http: response = await http.handle_async_request(request) body = await response.aread() assert body == b"foobar" async def test_route_rollback(): respx_mock = respx.mock() def example(request, route): route.mock(return_value=httpx.Response(404)) return httpx.Response(202) route = respx_mock.delete("https://example.org/foobar/") route.side_effect = example with respx_mock: async with httpx.AsyncClient(base_url="https://example.org/") as client: response = await client.delete("/foobar/") assert response.status_code == 202 response = await client.delete("/foobar/") assert response.status_code == 404 with respx_mock: async with httpx.AsyncClient(base_url="https://example.org/") as client: response = await client.delete("/foobar/") assert response.status_code == 202 respx-0.21.1/tests/test_patterns.py000066400000000000000000000505271460110214700173540ustar00rootroot00000000000000import io import re from unittest.mock import ANY import httpx import pytest from respx.patterns import ( JSON, URL, Content, Cookies, Data, Files, Headers, Host, Lookup, M, Method, Noop, Params, Path, Pattern, Port, Scheme, merge_patterns, parse_url_patterns, ) def test_bitwise_and(): pattern = Method("GET") & Host("foo.bar") request = httpx.Request("GET", "https://foo.bar/") match = pattern.match(request) assert match assert bool(match) is True assert not ~match @pytest.mark.parametrize( ("method", "url", "expected"), [ ("GET", "https://foo.bar/", True), ("GET", "https://foo.bar/baz/", False), ("POST", "https://foo.bar/", True), ("POST", "https://ham.spam/", True), ("PATCH", "https://foo.bar/", True), ("PUT", "https://foo.bar/", False), ], ) def test_bitwise_operators(method, url, expected): pattern = ( (Method("GET") | Method("post") | Method("Patch")) & URL("https://foo.bar/") ) | (Method("POST") & ~URL("https://foo.bar/")) request = httpx.Request(method, url) assert bool(pattern.match(request)) is expected assert bool(~pattern.match(request)) is not expected def test_match_context(): request = httpx.Request("GET", "https://foo.bar/baz/?ham=spam") pattern = ( URL(r"https?://foo.bar/(?P\w+)/", Lookup.REGEX) & URL(r"https://(?P[^/]+)/baz/", Lookup.REGEX) & Params({"ham": "spam"}) ) match = pattern.match(request) assert bool(match) assert match.context == {"host": "foo.bar", "slug": "baz"} def test_noop_pattern(): assert bool(Noop()) is False assert bool(Noop().match(httpx.Request("GET", "https://example.org"))) is True assert list(filter(None, [Noop()])) == [] assert repr(Noop()) == "" assert isinstance(~Noop(), Noop) assert Method("GET") & Noop() == Method("GET") assert Noop() & Method("GET") == Method("GET") assert Method("GET") | Noop() == Method("GET") assert Noop() | Method("GET") == Method("GET") @pytest.mark.parametrize( ("kwargs", "url", "expected"), [ ({"params__eq": {}}, "https://foo.bar/", True), ({"params__eq": {}}, "https://foo.bar/?x=y", False), ({"params__contains": {}}, "https://foo.bar/?x=y", True), ], ) def test_m_pattern(kwargs, url, expected): request = httpx.Request("GET", url) assert bool(M(host="foo.bar", **kwargs).match(request)) is expected @pytest.mark.parametrize( ("lookup", "value", "expected"), [ (Lookup.EQUAL, "GET", True), (Lookup.EQUAL, "get", True), (Lookup.EQUAL, "POST", False), (Lookup.IN, ["get", "POST"], True), (Lookup.IN, ["POST", "PUT"], False), ], ) def test_method_pattern(lookup, value, expected): request = httpx.Request("GET", "https://foo.bar/") assert bool(Method(value, lookup=lookup).match(request)) is expected @pytest.mark.parametrize( ("lookup", "headers", "request_headers", "expected"), [ (Lookup.CONTAINS, {"X-Foo": "bar"}, {"x-foo": "bar"}, True), (Lookup.CONTAINS, {"content-type": "text/plain"}, "", False), ], ) def test_headers_pattern(lookup, headers, request_headers, expected): request = httpx.Request( "GET", "http://foo.bar/", headers=request_headers, json={"foo": "bar"} ) assert bool(Headers(headers, lookup=lookup).match(request)) is expected def test_headers_pattern_hash(): assert Headers({"X-Foo": "bar"}) == Headers({"x-foo": "bar"}) @pytest.mark.parametrize( ("lookup", "cookies", "request_cookies", "expected"), [ (Lookup.CONTAINS, {"foo": "bar"}, {"ham": "spam", "foo": "bar"}, True), (Lookup.CONTAINS, {"foo": "bar"}, {"ham": "spam"}, False), (Lookup.EQUAL, {"foo": "bar"}, {"foo": "bar"}, True), (Lookup.EQUAL, [("foo", "bar")], {"foo": "bar"}, True), (Lookup.EQUAL, {}, {}, True), (Lookup.EQUAL, {}, None, True), (Lookup.EQUAL, {"foo": "bar"}, {"ham": "spam"}, False), ], ) def test_cookies_pattern(lookup, cookies, request_cookies, expected): request = httpx.Request( "GET", "http://foo.bar/", cookies=request_cookies, json={"foo": "bar"} ) assert bool(Cookies(cookies, lookup=lookup).match(request)) is expected def test_cookies_pattern__hash(): assert Cookies({"x": "1", "y": "2"}) == Cookies({"y": "2", "x": "1"}) @pytest.mark.parametrize( ("lookup", "scheme", "expected"), [ (Lookup.EQUAL, "https", True), (Lookup.EQUAL, "HTTPS", True), (Lookup.EQUAL, "http", False), (Lookup.IN, ["http", "HTTPS"], True), ], ) def test_scheme_pattern(lookup, scheme, expected): request = httpx.Request("GET", "https://foo.bar/") assert bool(Scheme(scheme, lookup=lookup).match(request)) is expected @pytest.mark.parametrize( ("lookup", "host", "expected"), [ (Lookup.EQUAL, "foo.bar", True), (Lookup.EQUAL, "ham.spam", False), (Lookup.REGEX, r".+\.bar", True), ], ) def test_host_pattern(lookup, host, expected): request = httpx.Request("GET", "https://foo.bar/") assert bool(Host(host, lookup=lookup).match(request)) is expected @pytest.mark.parametrize( ("lookup", "port", "url", "expected"), [ (Lookup.EQUAL, 443, "https://foo.bar/", True), (Lookup.EQUAL, 80, "https://foo.bar/", False), (Lookup.EQUAL, 80, "http://foo.bar/", True), (Lookup.EQUAL, 8080, "https://foo.bar:8080/baz/", True), (Lookup.EQUAL, 8080, "https://foo.bar/baz/", False), (Lookup.EQUAL, 22, "//foo.bar:22/baz/", True), (Lookup.EQUAL, None, "//foo.bar/", True), (Lookup.IN, [80, 443], "http://foo.bar/", True), (Lookup.IN, [80, 443], "https://foo.bar/", True), (Lookup.IN, [80, 443], "https://foo.bar:8080/", False), ], ) def test_port_pattern(lookup, port, url, expected): request = httpx.Request("GET", url) assert bool(Port(port, lookup=lookup).match(request)) is expected def test_path_pattern(): request = httpx.Request("GET", "https://foo.bar") assert Path("/").match(request) request = httpx.Request("GET", "https://foo.bar/baz/") assert Path("/baz/").match(request) assert not Path("/ham/").match(request) request = httpx.Request("GET", "https://foo.bar/baz/?ham=spam") assert Path("/baz/").match(request) assert not Path("/ham/").match(request) match = Path(r"/(?P\w+)/", Lookup.REGEX).match(request) assert bool(match) is True assert match.context == {"slug": "baz"} match = Path(re.compile(r"^/ham/"), Lookup.REGEX).match(request) assert bool(match) is False request = httpx.Request("GET", "https://foo.bar/baz/") assert Path(["/egg/", "/baz/"], lookup=Lookup.IN).match(request) path = Path("/bar/") assert path.strip_base("/foo/bar/") == "/foo/bar/" path.base = Path("/foo/") assert path.strip_base("/foo/bar/") == "/bar/" @pytest.mark.parametrize( ("lookup", "params", "url", "expected"), [ (Lookup.CONTAINS, "", "https://foo.bar/", True), (Lookup.CONTAINS, "x=1", "https://foo.bar/?x=1", True), (Lookup.CONTAINS, "x=", "https://foo.bar/?x=1", False), # False by httpx #2354 (Lookup.CONTAINS, "x=", "https://foo.bar/?x=", True), (Lookup.CONTAINS, "y=2", "https://foo.bar/?x=1", False), (Lookup.CONTAINS, [("x", "1")], "https://foo.bar/?x=1", True), (Lookup.CONTAINS, {"x": "1"}, "https://foo.bar/?x=1", True), (Lookup.CONTAINS, {"x": "2"}, "https://foo.bar/?x=1", False), (Lookup.CONTAINS, {"x": ANY}, "https://foo.bar/?x=1&y=2", True), (Lookup.CONTAINS, {"y": ANY}, "https://foo.bar/?x=1", False), (Lookup.CONTAINS, [("x", ANY), ("x", "2")], "https://foo.bar/?x=1&x=2", True), (Lookup.CONTAINS, [("x", ANY), ("x", "2")], "https://foo.bar/?x=2&x=3", False), (Lookup.CONTAINS, "x=1&y=2", "https://foo.bar/?x=1", False), (Lookup.EQUAL, "", "https://foo.bar/", True), (Lookup.EQUAL, "x", "https://foo.bar/?x", True), (Lookup.EQUAL, "x=", "https://foo.bar/?x=", True), (Lookup.EQUAL, "x=1", "https://foo.bar/?x=1", True), (Lookup.EQUAL, "y=2", "https://foo.bar/?x=1", False), (Lookup.EQUAL, {"x": ANY}, "https://foo.bar/?x=1", True), (Lookup.EQUAL, {"y": ANY}, "https://foo.bar/?x=1", False), (Lookup.EQUAL, {}, "https://foo.bar/?x=1", False), (Lookup.EQUAL, {}, "https://foo.bar/", True), (Lookup.EQUAL, "x=1&y=2", "https://foo.bar/?x=1", False), (Lookup.EQUAL, "y=2&x=1", "https://foo.bar/?x=1&y=2", True), (Lookup.EQUAL, "y=3&x=2&x=1", "https://foo.bar/?x=1&x=2&y=3", False), # ordered (Lookup.EQUAL, "y=3&x=1&x=2", "https://foo.bar/?x=1&x=2&y=3", True), # ordered (Lookup.CONTAINS, "x=2&x=1", "https://foo.bar/?x=1&x=2&y=3", False), # ordered (Lookup.CONTAINS, "x=1&x=2", "https://foo.bar/?x=1&x=2&x=3", False), # ordered (Lookup.CONTAINS, "x=1&x=2", "https://foo.bar/?x=1&x=2&y=3", True), # ordered ], ) def test_params_pattern(lookup, params, url, expected): request = httpx.Request("GET", url) assert bool(Params(params, lookup=lookup).match(request)) is expected def test_params_pattern_hash(): assert Params("x=1&y=2") == Params("y=2&x=1") @pytest.mark.parametrize( ("lookup", "value", "context", "url", "expected"), [ (Lookup.REGEX, r"https?://a.b/(?P\w+)/", {"c": "c"}, "http://a.b/c/", True), (Lookup.REGEX, re.compile(r"^https://a.b/.+$"), {}, "https://a.b/c/", True), (Lookup.REGEX, r"https://a.b/c/", {}, "https://x.y/c/", False), (Lookup.EQUAL, "https://a.b/c/", {}, "https://a.b/c/", True), (Lookup.EQUAL, "https://a.b/x/", {}, "https://a.b/c/", False), (Lookup.EQUAL, "https://a.b?x=y", {}, "https://a.b/?x=y", True), (Lookup.EQUAL, "https://a.b/?x=y", {}, "https://a.b?x=y", True), (Lookup.STARTS_WITH, "https://a.b/b", {}, "https://a.b/baz/", True), (Lookup.STARTS_WITH, "http://a.b/baz/", {}, "https://a.b/baz/", False), ( Lookup.EQUAL, (b"https", b"FE80::1", None, b""), {}, "https://[FE80::1]", True, ), ], ) def test_url_pattern(lookup, value, context, url, expected): request = httpx.Request("GET", url) match = URL(value, lookup=lookup).match(request) assert bool(match) is expected assert match.context == context def test_url_pattern_invalid(): with pytest.raises(ValueError, match="Invalid"): URL(["invalid"]) def test_url_pattern_hash(): p = Host("foo.bar") & Path("/baz/") assert M(url="//foo.bar/baz/") == p p = Scheme("https") & Host("foo.bar") & Path("/baz/") assert M(url="https://foo.bar/baz/") == p @pytest.mark.parametrize( ("lookup", "content", "expected"), [ (Lookup.EQUAL, b"foobar", True), (Lookup.EQUAL, "foobar", True), (Lookup.CONTAINS, b"bar", True), (Lookup.CONTAINS, "bar", True), (Lookup.CONTAINS, "baz", False), ], ) def test_content_pattern(lookup, content, expected): request = httpx.Request("POST", "https://foo.bar/", content=b"foobar") match = Content(content, lookup=lookup).match(request) assert bool(match) is expected @pytest.mark.parametrize( ("lookup", "data", "request_data", "expected"), [ ( Lookup.EQUAL, {"foo": "bar", "ham": "spam"}, None, True, ), ( Lookup.EQUAL, {"foo": "bar", "ham": "spam"}, {"ham": "spam", "foo": "bar"}, True, ), ( Lookup.EQUAL, {"uni": "äpple", "mixed": "Gehäusegröße"}, None, True, ), ( Lookup.EQUAL, {"blank_value": ""}, None, True, ), ( Lookup.EQUAL, {"none_value": None}, None, True, ), ( Lookup.EQUAL, {"non_str": 123}, None, True, ), ( Lookup.EQUAL, {"x": "a"}, {"x": "b"}, False, ), ( Lookup.EQUAL, {"foo": "bar"}, {"foo": "bar", "ham": "spam"}, False, ), ( Lookup.CONTAINS, {"foo": "bar"}, {"foo": "bar", "ham": "spam"}, True, ), ], ) def test_data_pattern(lookup, data, request_data, expected): request_with_data = httpx.Request( "POST", "https://foo.bar/", data=request_data or data, ) request_with_data_and_files = httpx.Request( "POST", "https://foo.bar/", data=request_data or data, files={"upload-file": ("report.xls", b"<...>", "application/vnd.ms-excel")}, ) match = Data(data, lookup=lookup).match(request_with_data) assert bool(match) is expected match = Data(data, lookup=lookup).match(request_with_data_and_files) assert bool(match) is expected @pytest.mark.parametrize( ("lookup", "files", "request_files", "expected"), [ ( Lookup.EQUAL, [("file_1", b"foo..."), ("file_2", b"bar...")], None, True, ), ( Lookup.EQUAL, {"file_1": b"foo...", "file_2": b"bar..."}, None, True, ), ( Lookup.EQUAL, {"file_1": ANY}, {"file_1": b"foobar..."}, True, ), ( Lookup.EQUAL, { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"bar..."), }, None, True, ), ( Lookup.EQUAL, {"file_1": ("filename_1.txt", ANY)}, {"file_1": ("filename_1.txt", b"...")}, True, ), ( Lookup.EQUAL, {"upload": b"foo..."}, {"upload": b"bar..."}, # Wrong file data False, ), ( Lookup.EQUAL, { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"bar..."), }, { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"ham..."), # Wrong file data }, False, ), ( Lookup.EQUAL, {"file_1": ("filename.png", io.BytesIO(b"some..image..data"), "image/png")}, None, True, ), ( Lookup.EQUAL, {"file_1": ("filename.png", "some..image..data", "image/png")}, # str data {"file_1": ("filename.png", io.BytesIO(b"some..image..data"), "image/png")}, True, ), ( Lookup.CONTAINS, { "file_1": ("filename_1.txt", b"foo..."), }, { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"bar..."), }, True, ), ( Lookup.CONTAINS, { "file_1": ("filename_1.txt", ANY), }, { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"bar..."), }, True, ), ( Lookup.CONTAINS, [("file_1", ANY)], { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"bar..."), }, True, ), ( Lookup.CONTAINS, {"file_1": "foo..."}, # str data { "file_1": ("filename_1.txt", io.BytesIO(b"foo...")), "file_2": ("filename_2.txt", io.BytesIO(b"bar...")), }, True, ), ( Lookup.CONTAINS, [("file_1", b"ham...")], { "file_1": ("filename_1.txt", b"foo..."), "file_2": ("filename_2.txt", b"bar..."), }, False, ), ], ) def test_files_pattern(lookup, files, request_files, expected): request = httpx.Request( "POST", "https://foo.bar/", files=request_files or files, ) match = Files(files, lookup=lookup).match(request) assert bool(match) is expected @pytest.mark.parametrize( ("lookup", "value", "json", "expected"), [ ( Lookup.EQUAL, {"foo": "bar", "ham": "spam"}, {"ham": "spam", "foo": "bar"}, True, ), ( Lookup.EQUAL, {"foo": "bar", "ham": "spam"}, {"egg": "yolk", "foo": "bar"}, False, ), ( Lookup.EQUAL, [{"ham": "spam", "egg": "yolk"}, {"zoo": "apa", "foo": "bar"}], [{"egg": "yolk", "ham": "spam"}, {"foo": "bar", "zoo": "apa"}], True, ), ( Lookup.EQUAL, [{"ham": "spam"}, {"foo": "bar"}], [{"foo": "bar"}, {"ham": "spam"}], False, ), (Lookup.EQUAL, "json-string", "json-string", True), ], ) def test_json_pattern(lookup, value, json, expected): request = httpx.Request("POST", "https://foo.bar/", json=json) match = JSON(value, lookup=lookup).match(request) assert bool(match) is expected @pytest.mark.parametrize( ("json", "path", "value", "expected"), [ ({"foo": {"bar": "baz"}}, "foo__bar", "baz", True), ({"x": {"z": 2, "y": 1}}, "x", {"y": 1, "z": 2}, True), ({"ham": [{"spam": "spam"}, {"egg": "yolk"}]}, "ham__1__egg", "yolk", True), ([{"name": "jonas"}], "0__name", "jonas", True), ({"pk": 123}, "pk", 123, True), ({"foo": {"bar": "baz"}}, "foo__ham", "spam", False), ([{"name": "lundberg"}], "1__name", "lundberg", False), ], ) def test_json_pattern_path(json, path, value, expected): request = httpx.Request("POST", "https://foo.bar/", json=json) pattern = M(**{f"json__{path}": value}) match = pattern.match(request) assert bool(match) is expected def test_invalid_pattern(): with pytest.raises(KeyError, match="is not a valid Pattern"): M(foo="baz") with pytest.raises(NotImplementedError, match="pattern does not support"): Scheme("http", Lookup.REGEX) with pytest.raises(ValueError, match="is not a valid Lookup"): M(scheme__baz="zoo") def test_iter_pattern(): pattern = M( Method("GET") & Path("/baz/") | ~Params("x=y"), url="https://foo.bar:88/" ) patterns = list(iter(pattern)) assert len(patterns) == 6 assert set(patterns) == { Method("GET"), Scheme("https"), Host("foo.bar"), Port(88), Path("/baz/"), Params("x=y"), } def test_parse_url_patterns(): patterns = parse_url_patterns("https://foo.bar:443/ham/spam/?egg=yolk") assert patterns == { "scheme": Scheme("https"), "host": Host("foo.bar"), "path": Path("/ham/spam/"), "params": Params({"egg": "yolk"}, Lookup.EQUAL), } patterns = parse_url_patterns("https://foo.bar:1337/ham/spam/?egg=yolk") assert patterns == { "scheme": Scheme("https"), "host": Host("foo.bar"), "port": Port(1337), "path": Path("/ham/spam/"), "params": Params({"egg": "yolk"}, Lookup.EQUAL), } patterns = parse_url_patterns("https://foo.bar/ham/spam/?egg=yolk", exact=False) assert patterns == { "scheme": Scheme("https"), "host": Host("foo.bar"), "path": Path("/ham/spam/", Lookup.STARTS_WITH), "params": Params({"egg": "yolk"}, Lookup.CONTAINS), } patterns = parse_url_patterns("all://*.foo.bar") assert len(patterns) == 1 assert "host" in patterns assert patterns["host"].lookup is Lookup.REGEX patterns = parse_url_patterns("all") assert len(patterns) == 0 def test_merge_patterns(): pattern = Method("GET") & Path("/spam/") base = Path("/ham/", Lookup.STARTS_WITH) merged_pattern = merge_patterns(pattern, path=base) assert any(tuple(p.base == base for p in iter(merged_pattern))) def test_unique_pattern_key(): with pytest.raises(TypeError, match="unique key"): class Foobar(Pattern): key = "url" respx-0.21.1/tests/test_plugin.py000066400000000000000000000024501460110214700170020ustar00rootroot00000000000000def test_respx_mock_fixture(testdir): testdir.makepyfile( """ import httpx import pytest @pytest.fixture def some_fixture(): yield "foobar" def test_plain_fixture(respx_mock): route = respx_mock.get("https://foo.bar/") % 204 response = httpx.get("https://foo.bar/") assert response.status_code == 204 @pytest.mark.respx(base_url="https://foo.bar", assert_all_mocked=False) def test_marked_fixture(respx_mock): route = respx_mock.get("/") % 204 response = httpx.get("https://foo.bar/") assert response.status_code == 204 response = httpx.get("https://example.org/") assert response.status_code == 200 def test_with_extra_fixture(respx_mock, some_fixture): import respx assert isinstance(respx_mock, respx.Router) assert some_fixture == "foobar" @pytest.mark.respx(assert_all_mocked=False) def test_marked_with_extra_fixture(respx_mock, some_fixture): import respx assert isinstance(respx_mock, respx.Router) assert some_fixture == "foobar" """ ) result = testdir.runpytest("-p", "respx") result.assert_outcomes(passed=4) respx-0.21.1/tests/test_remote.py000066400000000000000000000025611460110214700170020ustar00rootroot00000000000000import os import pytest import respx pytestmark = pytest.mark.skipif( os.environ.get("PASS_THROUGH") is None, reason="Remote pass-through disabled" ) @pytest.mark.parametrize( ("using", "client_lib", "call_count"), [ ("httpcore", "httpx", 2), # TODO: AsyncConnectionPool + AsyncHTTPConnection ("httpx", "httpx", 1), ], ) def test_remote_pass_through(using, client_lib, call_count): # pragma: nocover with respx.mock(using=using) as respx_mock: # Mock pass-through calls url = "https://httpbin.org/post" route = respx_mock.post(url, json__foo="bar").pass_through() # Make external pass-through call client = __import__(client_lib) response = client.post(url, json={"foo": "bar"}) # Assert response is correct library model assert isinstance(response, client.Response) assert response.status_code == 200 assert response.content is not None assert len(response.content) > 0 assert "Content-Length" in response.headers assert int(response.headers["Content-Length"]) > 0 assert response.json()["json"] == {"foo": "bar"} assert respx_mock.calls.last.request.url == url assert respx_mock.calls.last.has_response is False assert route.call_count == call_count assert respx_mock.calls.call_count == call_count respx-0.21.1/tests/test_router.py000066400000000000000000000412521460110214700170270ustar00rootroot00000000000000import warnings import httpcore import httpx import pytest from respx import Route, Router from respx.models import AllMockedAssertionError, PassThrough, RouteList from respx.patterns import Host, M, Method async def test_empty_router(): router = Router() request = httpx.Request("GET", "https://example.org/") with pytest.raises(AllMockedAssertionError): router.resolve(request) with pytest.raises(AllMockedAssertionError): await router.aresolve(request) async def test_empty_router__auto_mocked(): router = Router(assert_all_mocked=False) request = httpx.Request("GET", "https://example.org/") resolved = router.resolve(request) assert resolved.route is None assert isinstance(resolved.response, httpx.Response) assert resolved.response.status_code == 200 resolved = await router.aresolve(request) assert resolved.route is None assert isinstance(resolved.response, httpx.Response) assert resolved.response.status_code == 200 @pytest.mark.parametrize( ("args", "kwargs", "expected"), [ ((Method("GET"), Host("foo.bar")), dict(), True), (tuple(), dict(method="GET", host="foo.bar"), True), ((Method("GET"),), dict(port=443, url__regex=r"/baz/$"), True), ((Method("POST"),), dict(host="foo.bar"), False), ((~Method("GET"),), dict(), False), ((~M(url__regex=r"/baz/$"),), dict(), False), (tuple(), dict(headers={"host": "foo.bar"}), True), (tuple(), dict(headers={"Content-Type": "text/plain"}), False), (tuple(), dict(headers={"cookie": "foo=bar"}), False), (tuple(), dict(cookies={"ham": "spam"}), True), ], ) def test_resolve(args, kwargs, expected): router = Router(assert_all_mocked=False) route = router.route(*args, **kwargs).respond(status_code=201) request = httpx.Request( "GET", "https://foo.bar/baz/", cookies={"foo": "bar", "ham": "spam"} ) resolved = router.resolve(request) assert bool(resolved.route is route) is expected assert isinstance(resolved.response, httpx.Response) if expected: assert bool(resolved.response.status_code == 201) is expected else: assert resolved.response.status_code == 200 # auto mocked def test_pass_through(): router = Router(assert_all_mocked=False) route = router.get("https://foo.bar/", path="/baz/").pass_through() request = httpx.Request("GET", "https://foo.bar/baz/") with pytest.raises(PassThrough) as exc_info: router.resolve(request) assert exc_info.value.origin is route assert exc_info.value.origin.is_pass_through route.pass_through(False) resolved = router.resolve(request) assert resolved.route is not None assert resolved.route is route assert not resolved.route.is_pass_through assert resolved.response is not None @pytest.mark.parametrize( ("url", "lookups", "expected"), [ ("https://foo.bar/api/baz/", {"url": "/baz/"}, True), ("https://foo.bar/api/baz/", {"path__regex": r"^/(?P\w+)/$"}, True), ("http://foo.bar/api/baz/", {"url": "/baz/"}, False), ("https://ham.spam/api/baz/", {"url": "/baz/"}, False), ("https://foo.bar/baz/", {"url": "/baz/"}, False), ("https://foo.bar/api/hej:svejs", {"url": "/hej:svejs"}, True), ], ) def test_base_url(url, lookups, expected): router = Router(base_url="https://foo.bar/api/", assert_all_mocked=False) route = router.get(**lookups).respond(201) request = httpx.Request("GET", url) resolved = router.resolve(request) assert bool(resolved.route is route) is expected assert isinstance(resolved.response, httpx.Response) if expected: assert bool(resolved.response.status_code == 201) is expected else: assert resolved.response.status_code == 200 # auto mocked @pytest.mark.parametrize( ("lookups", "url", "expected"), [ ({"url": "//foo.bar/baz/"}, "https://foo.bar/baz/", True), ({"url": "all"}, "https://foo.bar/baz/", True), ({"url": "all://"}, "https://foo.bar/baz/", True), ({"url": "https://*foo.bar"}, "https://foo.bar/baz/", True), ({"url": "https://*foo.bar"}, "https://baz.foo.bar/", True), ({"url": "https://*.foo.bar"}, "https://foo.bar/baz/", False), ({"url": "https://*.foo.bar"}, "https://baz.foo.bar/", True), ({"url__eq": "https://foo.bar/baz/"}, "https://foo.bar/baz/", True), ({"url__eq": "https://foo.bar/baz/"}, "http://foo.bar/baz/", False), ({"url__eq": "https://foo.bar"}, "https://foo.bar/", True), ({"url__eq": "https://foo.bar/"}, "https://foo.bar", True), ( {"url": "https://foo.bar/", "path__regex": r"/(?P\w+)/"}, "https://foo.bar/baz/", True, ), ], ) def test_url_pattern_lookup(lookups, url, expected): router = Router(assert_all_mocked=False) route = router.get(**lookups) % 418 request = httpx.Request("GET", url) response = router.handler(request) assert bool(response.status_code == 418) is expected assert route.called is expected def test_mod_response(): router = Router() route1a = router.get("https://foo.bar/baz/") % 409 route1b = router.get("https://foo.bar/baz/") % 404 route2 = router.get("https://foo.bar") % dict(status_code=201) route3 = router.post("https://fox.zoo/") % httpx.Response(401, json={"error": "x"}) request = httpx.Request("GET", "https://foo.bar/baz/") resolved = router.resolve(request) assert isinstance(resolved.response, httpx.Response) assert resolved.response.status_code == 404 assert resolved.route is route1b assert route1a is route1b request = httpx.Request("GET", "https://foo.bar/") resolved = router.resolve(request) assert isinstance(resolved.response, httpx.Response) assert resolved.response.status_code == 201 assert resolved.route is route2 request = httpx.Request("POST", "https://fox.zoo/") resolved = router.resolve(request) assert isinstance(resolved.response, httpx.Response) assert resolved.response.status_code == 401 assert resolved.response.json() == {"error": "x"} assert resolved.route is route3 with pytest.raises(TypeError, match="Route can only"): router.route() % [] # type: ignore[operator] async def test_async_side_effect(): router = Router() async def effect(request): return httpx.Response(204) router.get("https://foo.bar/").mock(side_effect=effect) request = httpx.Request("GET", "https://foo.bar/") response = await router.async_handler(request) assert response.status_code == 204 def test_side_effect_no_match(): router = Router() def no_match(request): request.respx_was_here = True return None router.get(url__startswith="https://foo.bar/").mock(side_effect=no_match) router.get(url__eq="https://foo.bar/baz/").mock(return_value=httpx.Response(204)) request = httpx.Request("GET", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 204 assert response.request.respx_was_here is True # type: ignore[attr-defined] def test_side_effect_with_route_kwarg(): router = Router() def foobar(request, route, slug): response = httpx.Response(201, json={"id": route.call_count + 1, "slug": slug}) if route.call_count > 0: route.mock(return_value=httpx.Response(501)) return response router.post(path__regex=r"/(?P\w+)/").mock(side_effect=foobar) request = httpx.Request("POST", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 201 assert response.json() == {"id": 1, "slug": "baz"} response = router.handler(request) assert response.status_code == 201 assert response.json() == {"id": 2, "slug": "baz"} response = router.handler(request) assert response.status_code == 501 def test_side_effect_with_reserved_route_kwarg(): router = Router() def foobar(request, route): assert isinstance(route, Route) return httpx.Response(202) router.get(path__regex=r"/(?P\w+)/").mock(side_effect=foobar) with warnings.catch_warnings(record=True) as w: request = httpx.Request("GET", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 202 assert len(w) == 1 def test_side_effect_list(): router = Router() route = router.get("https://foo.bar/").mock( return_value=httpx.Response(409), side_effect=[httpx.Response(404), httpcore.NetworkError, httpx.Response(201)], ) request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 404 assert response.request == request request = httpx.Request("GET", "https://foo.bar") with pytest.raises(httpcore.NetworkError): router.handler(request) request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 201 assert response.request == request request = httpx.Request("GET", "https://foo.bar") with pytest.raises(StopIteration): router.handler(request) route.side_effect = None request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 409 assert response.request == request def test_side_effect_exception(): router = Router() router.get("https://foo.bar/").mock(side_effect=httpx.ConnectError) router.get("https://ham.spam/").mock(side_effect=httpcore.NetworkError) router.get("https://egg.plant/").mock(side_effect=httpcore.NetworkError()) request = httpx.Request("GET", "https://foo.bar") with pytest.raises(httpx.ConnectError) as e: router.handler(request) assert e.value.request == request request = httpx.Request("GET", "https://ham.spam") with pytest.raises(httpcore.NetworkError): router.handler(request) request = httpx.Request("GET", "https://egg.plant") with pytest.raises(httpcore.NetworkError): router.handler(request) def test_side_effect_decorator(): router = Router() @router.route(host="ham.spam", path__regex=r"/(?P\w+)/") def foobar(request, slug): return httpx.Response(200, json={"slug": slug}) @router.post("https://example.org/") def example(request): return httpx.Response(201, json={"message": "OK"}) request = httpx.Request("GET", "https://ham.spam/egg/") response = router.handler(request) assert response.status_code == 200 assert response.json() == {"slug": "egg"} request = httpx.Request("POST", "https://example.org/") response = router.handler(request) assert response.status_code == 201 assert response.json() == {"message": "OK"} def test_rollback(): router = Router() route = router.get("https://foo.bar/") % 404 pattern = route.pattern assert route.name is None router.snapshot() # 1. get 404 route.return_value = httpx.Response(200) router.post("https://foo.bar/").mock( side_effect=[httpx.Response(400), httpx.Response(201)] ) router.snapshot() # 2. get 200, post _route = router.get("https://foo.bar/", name="foobar") _route = router.get("https://foo.bar/baz/", name="foobar") assert _route is route assert route.name == "foobar" assert route.pattern != pattern route.return_value = httpx.Response(418) request = httpx.Request("GET", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 418 request = httpx.Request("POST", "https://foo.bar") response = router.handler(request) assert response.status_code == 400 assert len(router.routes) == 2 assert router.calls.call_count == 2 assert route.call_count == 1 assert route.return_value.status_code == 418 router.snapshot() # 3. directly rollback, should be identical router.rollback() assert len(router.routes) == 2 assert router.calls.call_count == 2 assert route.call_count == 1 assert route.return_value.status_code == 418 router.patch("https://foo.bar/") assert len(router.routes) == 3 route.rollback() # get 200 assert router.calls.call_count == 2 assert route.call_count == 0 assert route.return_value.status_code == 200 request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 200 router.rollback() # 2. get 404, post request = httpx.Request("POST", "https://foo.bar") response = router.handler(request) assert response.status_code == 400 assert len(router.routes) == 2 router.rollback() # 1. get 404 assert len(router.routes) == 1 assert router.calls.call_count == 0 assert route.return_value == None # noqa: E711 router.rollback() # Empty initial state assert len(router.routes) == 0 assert route.return_value == None # noqa: E711 # Idempotent route.rollback() router.rollback() assert len(router.routes) == 0 assert route.name is None assert route.pattern == pattern assert route.return_value is None def test_multiple_pattern_values_type_error(): router = Router() with pytest.raises(TypeError, match="Got multiple values for pattern 'method'"): router.post(method__in=("PUT", "PATCH")) with pytest.raises(TypeError, match="Got multiple values for pattern 'url'"): router.get("https://foo.bar", url__regex=r"https://example.org$") def test_routelist__add(): routes = RouteList() foobar = Route(method="PUT") routes.add(foobar, name="foobar") assert routes assert list(routes) == [foobar] assert routes["foobar"] == foobar assert routes["foobar"] is routes[0] hamspam = Route(method="POST") routes.add(hamspam, name="hamspam") assert list(routes) == [foobar, hamspam] assert routes["hamspam"] == hamspam def test_routelist__pop(): routes = RouteList() foobar = Route(method="GET") hamspam = Route(method="POST") routes.add(foobar, name="foobar") routes.add(hamspam, name="hamspam") assert list(routes) == [foobar, hamspam] _foobar = routes.pop("foobar") assert _foobar == foobar assert list(routes) == [hamspam] default = Route() route = routes.pop("egg", default) assert route is default assert list(routes) == [hamspam] with pytest.raises(KeyError): routes.pop("egg") def test_routelist__replaces_same_name_and_pattern(): routes = RouteList() foobar1 = Route(method="GET") routes.add(foobar1, name="foobar") assert list(routes) == [foobar1] foobar2 = Route(method="GET") routes.add(foobar2, name="foobar") assert list(routes) == [foobar2] assert routes[0] is foobar1 def test_routelist__replaces_same_name_diff_pattern(): routes = RouteList() foobar1 = Route(method="GET") routes.add(foobar1, name="foobar") assert list(routes) == [foobar1] foobar2 = Route(method="POST") routes.add(foobar2, name="foobar") assert list(routes) == [foobar2] assert routes[0] is foobar1 def test_routelist__replaces_same_pattern_no_name(): routes = RouteList() foobar1 = Route(method="GET") routes.add(foobar1) assert list(routes) == [foobar1] foobar2 = Route(method="GET") routes.add(foobar2, name="foobar") assert list(routes) == [foobar2] assert routes[0] is foobar1 def test_routelist__replaces_same_pattern_diff_name(): routes = RouteList() foobar1 = Route(method="GET") routes.add(foobar1, name="name") assert list(routes) == [foobar1] foobar2 = Route(method="GET") routes.add(foobar2, name="foobar") assert list(routes) == [foobar2] assert routes[0] is foobar1 def test_routelist__replaces_same_name_other_pattern_no_name(): routes = RouteList() foobar1 = Route(method="GET") routes.add(foobar1, name="foobar") assert list(routes) == [foobar1] hamspam = Route(method="POST") routes.add(hamspam) foobar2 = Route(method="POST") routes.add(foobar2, name="foobar") assert list(routes) == [foobar2] assert routes[0] is foobar1 def test_routelist__replaces_same_name_other_pattern_other_name(): routes = RouteList() foobar1 = Route(method="GET") hamspam = Route(method="POST") routes.add(foobar1, name="foobar") routes.add(hamspam, name="hamspam") assert list(routes) == [foobar1, hamspam] foobar2 = Route(method="POST") routes.add(foobar2, name="foobar") assert list(routes) == [foobar2] assert routes["foobar"] is foobar1 def test_routelist__unable_to_slice_assign(): routes = RouteList() with pytest.raises(TypeError, match="slice assign"): routes[0:1] = routes respx-0.21.1/tests/test_stats.py000066400000000000000000000066241460110214700166510ustar00rootroot00000000000000import re import httpx import pytest import respx from respx.router import MockRouter async def test_named_route(): async with MockRouter(assert_all_called=False) as respx_mock: request = respx_mock.get("https://foo.bar/", name="foobar") assert "foobar" not in respx.routes assert "foobar" in respx_mock.routes assert respx_mock.routes["foobar"] is request assert respx_mock["foobar"] is request @respx.mock async def backend_test(): url = "https://foo.bar/1/" respx.get(re.compile("https://some.thing")) respx.delete("https://some.thing") foobar1 = respx.get(url, name="get_foobar") % dict(status_code=202, text="get") foobar2 = respx.delete(url, name="del_foobar") % dict(text="del") assert foobar1.called == False # noqa: E712 assert foobar1.call_count == len(foobar1.calls) assert foobar1.call_count == 0 with pytest.raises(IndexError): foobar1.calls.last assert respx.calls.call_count == len(respx.calls) assert respx.calls.call_count == 0 with pytest.raises(AssertionError, match="Expected 'respx' to have been called"): respx.calls.assert_called_once() with pytest.raises(AssertionError, match="Expected ' None: expires = datetime.fromtimestamp(0, tz=timezone.utc) cookie = SetCookie( "foo", value="bar", path="/", domain=".example.com", expires=expires, max_age=44, http_only=True, same_site="None", partitioned=True, ) assert cookie == ( "Set-Cookie", ( "foo=bar; " "Path=/; " "Domain=.example.com; " "Expires=Thu, 01 Jan 1970 00:00:00 GMT; " "Max-Age=44; " "HttpOnly; " "SameSite=None; " "Secure; " "Partitioned" ), )