pax_global_header00006660000000000000000000000064147277051120014520gustar00rootroot0000000000000052 comment=4d1083ed570e9385d8fa499375525bc9508919df pook-2.1.3/000077500000000000000000000000001472770511200124735ustar00rootroot00000000000000pook-2.1.3/.editorconfig000066400000000000000000000003561472770511200151540ustar00rootroot00000000000000root = true [*.{py,rst,txt}] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true end_of_line = LF [*.yml] indent_style = space indent_size = 2 end_of_line = LF [Makefile] indent_style = tab pook-2.1.3/.github/000077500000000000000000000000001472770511200140335ustar00rootroot00000000000000pook-2.1.3/.github/pull_request_template.md000066400000000000000000000004121472770511200207710ustar00rootroot00000000000000## Description ## PR Checklist - [ ] I've added tests for any code changes - [ ] I've documented any new features pook-2.1.3/.github/workflows/000077500000000000000000000000001472770511200160705ustar00rootroot00000000000000pook-2.1.3/.github/workflows/ci_cd.yml000066400000000000000000000044731472770511200176640ustar00rootroot00000000000000name: "CI/CD" on: pull_request: push: branches: - master concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: lint: name: lint runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' - name: Install hatch run: pipx install hatch - name: Lint run: hatch run lint docs: name: build docs runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' - name: Install hatch run: pipx install hatch - name: Build docs run: hatch run docs:build --fail-on-warning build: name: build runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' cache: 'pip' - name: Install hatch run: pipx install hatch - name: Install twine run: pipx install twine - name: Build distribution run: hatch build - name: Check build run: twine check --strict dist/* test: name: test on ${{ matrix.py }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: py: - 'pypy3.10' - '3.13' - '3.12' - '3.11' - '3.10' - '3.9' steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.py }} cache: pip - name: Install hatch run: pipx install hatch - name: Run test suite run: hatch run test.${{ !startsWith(matrix.py, 'pypy') && 'py' || '' }}${{ matrix.py }}:test pook-2.1.3/.gitignore000066400000000000000000000020451472770511200144640ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *.pyc *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject .DS_Store pook-2.1.3/.pre-commit-config.yaml000066400000000000000000000015701472770511200167570ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: check-executables-have-shebangs - id: check-json - id: check-case-conflict - id: check-toml - id: check-merge-conflict - id: check-xml - id: check-yaml - id: end-of-file-fixer - id: check-symlinks - id: mixed-line-ending - id: fix-encoding-pragma args: - --remove - id: check-docstring-first - id: requirements-txt-fixer - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.9 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: local hooks: - id: types name: types entry: hatch run types language: system pass_filenames: false pook-2.1.3/.readthedocs.yaml000066400000000000000000000003251472770511200157220ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.12" commands: - pip install hatch - hatch run docs:build - mkdir -p $READTHEDOCS_OUTPUT - cp -r docs/_build/html $READTHEDOCS_OUTPUT pook-2.1.3/CODE_OF_CONDUCT.md000066400000000000000000000044671472770511200153050ustar00rootroot00000000000000# Contributor Code of Conduct As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery * Personal attacks * Trolling or insulting/derogatory comments * Public or private harassment * Publishing other's private information, such as physical or electronic addresses, without explicit permission * Other unethical or unprofessional conduct Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting a project maintainer at pook@sarayourfriend.pictures. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available at https://www.contributor-covenant.org/version/1/3/0/code-of-conduct.html [homepage]: https://www.contributor-covenant.org pook-2.1.3/CONTRIBUTING.md000066400000000000000000000031441472770511200147260ustar00rootroot00000000000000# Pook development **All contributors are obliged to follow Pook's [Code of Conduct](./CODE_OF_CONDUCT.md)**. ## Getting started First, clone the repository: ```bash git clone git@github.com:h2non/pook.git ``` Pook uses [`hatch`](https://hatch.pypa.io/) for script and environment management. Run the `ci` script to set up a development virtual environment and verify your local setup: ```bash hatch run ci ``` Running the `ci` script will: - Cause Hatch to set up a development virtual environment - Install pre-commit hooks - Run the pre-commit hooks, including type checks - Run the unit tests Now you are ready to contribute to Pook! If there is a specific issue you'd like to work on, leave a comment expressing your interest and any questions you have for maintainers regarding implementation details. When contributing code, please add unit tests for all changes and documentation for any non-internal changes. ## Testing Pook supports all actively supported CPython versions, as well as the latest Pypy version. To run Pook's test suite for each supported interpreter, run the following: ```bash hatch run test:test ``` Note that each interpreter requires a new virtual environment, and hatch will automatically handle creating these. To run tests only for a specific version, affix the version designation to the environment name (the left side of the `:`): ```bash hatch run test.pypy3.10:test ``` ## Documentation Generate the documentation site by running: ```bash hatch run docs:build ``` ## Building for distribution Use Hatch's build tools to create the Pook distribution: ```bash hatch build -c ``` pook-2.1.3/History.rst000066400000000000000000000251421472770511200146720ustar00rootroot00000000000000History ======= v2.1.3 / 2024-12-16 ------------------------- * Ensure aiohttp headers can be matched from both the session and request in the same matcher v2.1.2 / 2024-11-21 ------------------------- * Return the correct type of ``headers`` object for standard library urllib by @sarayourfriend in https://github.com/h2non/pook/pull/154. * Support ``Sequence[tuple[str, str]]`` header input with aiohttp by @sarayourfriend in https://github.com/h2non/pook/pull/154. * Fix network filters when multiple filters are active by @rsmeral in https://github.com/h2non/pook/pull/155. * Fix aiohttp matching not working with session base URL or headers by @sarayourfriend in https://github.com/h2non/pook/pull/157. * Add support for Python 3.13 by @sarayourfriend in https://github.com/h2non/pook/pull/149. v2.1.1 / 2024-10-15 ------------------------- * Flush mocks when `pook.activate` used as a wrapper by @shift0965 in https://github.com/h2non/pook/pull/145. * This prevents mocks from leaking between test cases and should fix some potentially confusing edge case bugs. v2.1.0 / 2024-10-08 ------------------------- * Drop support for Python 3.8 (it is EOL 2024-10-07) v2.0.1 / 2024-10-08 ------------------------- * Improve aiohttp JSONMatcher support by @KyleJamesWalker in https://github.com/h2non/pook/pull/139 v2.0.0 / 2024-07-01 ------------------------- See https://github.com/h2non/pook/issues/128 for a summary of the breaking changes and how to update your code if you are affected. * **Breaking change**: Remove ``Response::body``'s ``binary`` parameter and enforce a keyword argument for ``chunked``. * The ``binary`` parameter is no longer needed since responses are now always byte-encoded in all cases (see below). * A keyword argument is now enforced for ``chunked`` to ensure unnamed arguments meant for the removed ``binary`` parameter cannot be confused as ``chunked`` arguments. * Only return byte-encoded response bodies, matching the bahviour of all supported client libraries. * This is possible because for all supported client libraries, pook mocks the actual response sent to the client library over the wire, which will, in all cases, be bytes. Client libraries that support reading response content as a string or other formats will continue to work as expected, because they'll always be handling bytes from pook. * This causes no change to application code for users of pook, except for users of the standard library ``urllib``, for which this also fixed a bug where non-bytes bodies were returned by pook in certain cases. This is impossible in real application code. If you rely on pook to mock ``urllib`` responses and have code that handles non-bytes response bodies, that code can be safely deleted (provided the only reason it was there was pook in the first place). * **Breaking change**: Remove ``Mock::body``'s ``binary`` parameter. * This parameter was already unused due to a bug in the code (it was not passed through to the ``BodyMatcher``), so this will not cause any changes to tests that relied on it: it didn't do anything to begin with. * The breaking change is simply the removal of the unused parameter, which should be deleted from tests using pook. * Pook's code has also been updated to remove all cases where non-bytes objects were being handled. Instead, the body of any interecepted request will always be stored as bytes, and only decoded when necessary for individual downstream matchers (JSON, XML). * Correct documentation strings for ``XMLMatcher`` and ``JSONMatcher`` to no longer suggest they can handle regex matchers. * These classes never implemented regex matching. * Add a pytest fixture to the package. v1.4.3 / 2024-02-23 ------------------- * Fix httpx incorrectly named method on interceptor subclass by @sarayourfriend in https://github.com/h2non/pook/pull/126 v1.4.2 / 2024-02-15 ------------------- * fix #122: httpx streaming via `iter_raw` raises `httpx.StreamConsumed` by @petarmaric in https://github.com/h2non/pook/pull/123 v1.4.1 / 2024-02-12 ------------------- * Fix `Mock` constructor params/url order mishandling (#111) * Optionally match empty values in query parameter presence matcher (#113) * Fix httpx network mode (#116) v1.4.0 / 2023-12-29 ------------------- * Add support for httpx (#90) * Enable mocket integration tests for Python >= 3.11 (#103) v1.3.0 / 2023-12-25 ------------------- This release modernizes Pook build and development environments. * Drop support for EOL'd Python versions (in other words, 3.6 and 3.7) * Use pyproject.toml * Use ruff to lint files * Use pre-commit to add pre-commit hooks * Use hatch to manage test, development, and build environments * Fix the test configuration to actually run the example tests * Fix the documentation build * Fix support for asynchronous functions in the activate decorator (this was a direct result of re-enabling the example tests and finding lots of little issues) * Remove all mention of the unsupported pycurl library * Clean up tests that can use pytest parametrize to do so (and get better debugging information during tests runs as a result) * Use pytest-pook to clean up a bunch of unnecessary test fixtures * Fix deprecation warning for invalid string escape sequences caused by untagged regex strings v1.2.1 / 2023-12-23 ------------------- * Fix usage of regex values in header matchers (#97) * Fix urllib SSL handling (#98) v1.2.0 / 2023-12-17 ------------------- * feat(api): add support for binary bodies (#88) * fix(urllib3): don't put non-strings into HTTP header dict (#87) * refactor: drop Python 3.5 support (#92). Note: Python 3.5 had been supported for some time. The change here only makes the documentation accurately reflect that 3.5 is not supported. v1.1.0 / 2023-01-01 ------------------- * chore(version): bump minor v1.1.0 * Switch to Python >= 3.5 and fix latest aiohttp compatability (#83) * fix: remove print call (#81) v1.0.2 / 2021-09-10 ------------------- * fix(urllib3): interceptor is never really disabled (#68) * Closes #75 Re consider @fluent decorator (#76) * fix(#69): use match keyword in pytest.raises * fix(History): invalid rst syntax v1.0.1 / 2020-03-24 ------------------- * fix(aiohttp): compatible with non aiohttp projects (#67) * feat(History): add release changes v1.0.0 / 2020-03-18 ------------------- * fix(aiohttp): use latest version, allow Python 3.5+ for async http client v0.2.8 / 2019-10-31 ------------------- * fix collections import warning (#61) v0.2.7 / 2019-10-21 ------------------- * fix collections import warning (#61) v0.2.6 / 2019-02-01 ------------------- * Add mock.reply(new_response=True) to reset response definition object v0.2.5 / 2017-10-19 ------------------- * refactor(setup): remove extra install dependency * Fix py27 compatibility (#49) * Add activate_async decorator (#48) * fix typo in pook.mock.Mock.ismatched.__doc__ (#47) * fix README example (#46) v0.2.4 / 2017-10-03 ------------------- * fix(#45): regex URL issue * fix(travis): allow failures in pypy * feat(docs): add sponsor banner * refactor(History): normalize style v0.2.3 / 2017-04-28 ------------------- * feat(docs): add supported version for aiohttp * Merge branch 'master' of https://github.com/h2non/pook * fix(api): export missing symbol "disable_network" * Update README.rst (#43) v0.2.2 / 2017-04-03 ------------------- * refactor(compare): disable maxDiff length limit while comparing values v0.2.1 / 2017-03-25 ------------------- * fix(engine): enable new mock engine on register if needed * fix(engine): remove activate argument before instantiating the Mock v0.2.0 / 2017-03-18 ------------------- * refactor(engine): do not activate engine on mock declaration if not explicitly requested. This introduces a behavioral library change: you must explicitly use ``pook.on()`` to enable `pook` mock engine. v0.1.14 / 2017-03-17 -------------------- * feat(docs): list supported HTTP client versions * fix(#41): disable mocks after decorator call invokation * feat(examples): add mock context manager example file * feat(#40): support context manager definitions * feat(#39): improve unmatched request output * feat(docs): add mocket example file * feat(#33): add mocket examples and documentation v0.1.13 / 2017-01-29 -------------------- * fix(api): `mock.calls` property should be an `int`. v0.1.12 / 2017-01-28 -------------------- * feat(#33): proxy mock definitions into mock.Request * refactor(api): `pook.unmatched_requests()` now returns a `list` instead of a lazy `tuple`. v0.1.11 / 2017-01-14 -------------------- * refactor(query) * fix(#37): fix URL comparison * fix(#38): proper mock engine interface validation. v0.1.10 / 2017-01-13 -------------------- * fix(#37): decode byte bodies * feat(setup.py): add author email v0.1.9 / 2017-01-06 ------------------- * fix(Makefile): remove proper egg file * feat(package): add wheel package distribution support * feat(docs): add documentation links v0.1.8 / 2016-12-24 ------------------- * fix(assertion): extract regex pattern only when required * feat(examples): add regular expression example v0.1.7 / 2016-12-18 ------------------- * feat(#33): add support for user defined custom mock engine v0.1.6 / 2016-12-14 ------------------- * fix(setup.py): force utf-8 encoding * feat(setup.py): add encoding header * feat(api): add debug mode * refactor(docs): minor enhancements * refactor(tests): update URL matcher test cases * refactor(docs): add note about HTTP clients and update features list * fix(setup.py): remove encoding param * fix(tests): use strict equality assertion 0.1.5 / 2016-12-12 ------------------ * fix(matchers): fix matching issue in URL. * refactor(assertion): regex expression based matching must be explicitly enabled. * feat(tests): add initial matchers tests. 0.1.4 / 2016-12-08 ------------------ * refactor(README): minor changes * fix(setup.py): lint error * fix(#32): use explicit encoding while reading files in setup.py 0.1.3 / 2016-12-08 ------------------ * fix(core): several bug fixes. * feat(core): add pending features and major refactors. * feat(matchers): use ``unittest.TestCase`` matching engine by default. 0.1.2 / 2016-12-01 ------------------ * fix(matchers): runtime missing variable. 0.1.1 / 2016-12-01 ------------------ * fix: Python 2 dictionary iteration syntax. * feat(docs): add more examples. * fix(matchers): better regular expression comparison support. 0.1.0 / 2016-11-30 ------------------ * First version (still beta) 0.1.0-rc.1 / 2016-11-27 ----------------------- * First release candidate version (still beta) pook-2.1.3/LICENSE000066400000000000000000000020651472770511200135030ustar00rootroot00000000000000MIT License Copyright (c) 2016-2020 Tomás Aparicio Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pook-2.1.3/README.rst000066400000000000000000000201501472770511200141600ustar00rootroot00000000000000pook |PyPI| |Coverage Status| |Documentation Status| |Stability| |Quality| |Versions| ===================================================================================== Versatile, expressive and hackable utility library for HTTP traffic mocking and expectations made easy in `Python`_. Heavily inspired by `gock`_. To get started, read the `documentation`_, `how it works`_, `FAQ`_ or `examples`_. Features -------- - Simple, expressive and fluent API. - Provides both Pythonic and chainable DSL API styles. - Full-featured HTTP response definitions and expectations. - Matches any HTTP protocol primitive (URL, method, query params, headers, body...). - Full regular expressions capable mock expectations matching. - Supports most popular HTTP clients via interceptor adapters. - Configurable volatile, persistent or TTL limited mocks. - Works with unittest and pytest. - First-class JSON & XML support matching and responses. - Supports JSON Schema body matching. - Works in both runtime and testing environments. - Can be used as decorator and/or via context managers. - Supports real networking mode with optional traffic filtering. - Map/filter mocks easily for generic or custom mock expectations. - Custom user-defined mock matcher functions. - Simulated raised error exceptions. - Network delay simulation (only available for ``aiohttp``). - Pluggable and hackable API. - Customizable HTTP traffic mock interceptor engine. - Supports third-party mocking engines, such as `mocket`_. - Fits good for painless test doubles. - Does not support WebSocket traffic mocking. - Works with +3.9 (including PyPy). - Dependency-less: just 3 small dependencies for JSONSchema, XML tree comparison, and URL parsing. Supported HTTP clients ---------------------- ``pook`` can work with multiple mock engines, however it provides a built-in one by default, which currently supports traffic mocking in the following HTTP clients: - ✔ `urllib3`_ v1+ - ✔ `requests`_ v2+ - ✔ `aiohttp`_ v3+ - ✔ `urllib`_ / `http.client`_ - ✔ `httpx`_ More HTTP clients can be supported progressively. **Note**: only recent HTTP client package versions were tested. Installation ------------ Using ``pip`` package manager (requires pip 1.8+): .. code:: bash pip install --upgrade pook Or install the latest sources from GitHub: .. code:: bash pip install -e git+git://github.com/h2non/pook.git#egg=pook Getting started --------------- See ReadTheDocs documentation: |Documentation Status| API --- See `annotated API reference`_ documention. Examples -------- See `examples`_ documentation for full featured code and use case examples. Basic mocking: .. code:: python import pook import requests @pook.on def test_my_api(): mock = pook.get('http://twitter.com/api/1/foobar', reply=404, response_json={'error': 'not found'}) resp = requests.get('http://twitter.com/api/1/foobar') assert resp.status_code == 404 assert resp.json() == {"error": "not found"} assert mock.calls == 1 Using the chainable API DSL: .. code:: python import pook import requests @pook.on def test_my_api(): mock = (pook.get('http://twitter.com/api/1/foobar') .reply(404) .json({'error': 'not found'})) resp = requests.get('http://twitter.com/api/1/foobar') assert resp.json() == {"error": "not found"} assert mock.calls == 1 Using the decorator: .. code:: python import pook import requests @pook.get('http://httpbin.org/status/500', reply=204) @pook.get('http://httpbin.org/status/400', reply=200) def fetch(url): return requests.get(url) res = fetch('http://httpbin.org/status/400') print('#1 status:', res.status_code) res = fetch('http://httpbin.org/status/500') print('#2 status:', res.status_code) Simple ``unittest`` integration: .. code:: python import pook import unittest import requests class TestUnitTestEngine(unittest.TestCase): @pook.on def test_request(self): pook.get('server.com/foo').reply(204) res = requests.get('http://server.com/foo') self.assertEqual(res.status_code, 204) def test_request_with_context_manager(self): with pook.use(): pook.get('server.com/bar', reply=204) res = requests.get('http://server.com/bar') self.assertEqual(res.status_code, 204) Using the context manager for isolated HTTP traffic interception blocks: .. code:: python import pook import requests # Enable HTTP traffic interceptor with pook.use(): pook.get('http://httpbin.org/status/500', reply=204) res = requests.get('http://httpbin.org/status/500') print('#1 status:', res.status_code) # Interception-free HTTP traffic res = requests.get('http://httpbin.org/status/200') print('#2 status:', res.status_code) Example using `mocket`_ Python library as underlying mock engine: .. code:: python import pook import requests from mocket.plugins.pook_mock_engine import MocketEngine # Use mocket library as underlying mock engine pook.set_mock_engine(MocketEngine) # Explicitly enable pook HTTP mocking (optional) pook.on() # Target server URL to mock out url = 'http://twitter.com/api/1/foobar' # Define your mock mock = pook.get(url, reply=404, times=2, headers={'content-type': 'application/json'}, response_json={'error': 'foo'}) # Run first HTTP request requests.get(url) assert mock.calls == 1 # Run second HTTP request res = requests.get(url) assert mock.calls == 2 # Assert response data assert res.status_code == 404 assert res.json() == {'error': 'foo'} # Explicitly disable pook (optional) pook.off() Example using Hy language (Lisp dialect for Python): .. code:: hy (import [pook]) (import [requests]) (defn request [url &optional [status 404]] (doto (.mock pook url) (.reply status)) (let [res (.get requests url)] (. res status_code))) (defn run [] (with [(.use pook)] (print "Status:" (request "http://server.com/foo" :status 204)))) ;; Run test program (defmain [&args] (run)) Contributing ------------ See `contributing <./CONTRIBUTING.md>`_ for how to contribute to Pook. License ------- MIT - Tomas Aparicio .. _Go: https://golang.org .. _Python: http://python.org .. _gock: https://github.com/h2non/gock .. _annotated API reference: http://pook.readthedocs.io/en/latest/api.html .. _examples: http://pook.readthedocs.io/en/latest/examples.html .. _aiohttp: https://github.com/KeepSafe/aiohttp .. _httpx: https://www.python-httpx.org/ .. _requests: http://docs.python-requests.org/en/master/ .. _urllib3: https://github.com/shazow/urllib3 .. _urllib: https://docs.python.org/3/library/urllib.html .. _http.client: https://docs.python.org/3/library/http.client.html .. _documentation: http://pook.readthedocs.io/en/latest/ .. _FAQ: http://pook.readthedocs.io/en/latest/faq.html .. _how it works: http://pook.readthedocs.io/en/latest/how_it_works.html .. _mocket: https://github.com/mindflayer/python-mocket .. |PyPI| image:: https://img.shields.io/pypi/v/pook.svg?maxAge=2592000?style=flat-square :target: https://pypi.python.org/pypi/pook .. |Coverage Status| image:: https://coveralls.io/repos/github/h2non/pook/badge.svg?branch=master :target: https://coveralls.io/github/h2non/pook?branch=master .. |Documentation Status| image:: https://readthedocs.org/projects/pook/badge/?version=latest :target: https://pook.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. |Quality| image:: https://codeclimate.com/github/h2non/pook/badges/gpa.svg :target: https://codeclimate.com/github/h2non/pook :alt: Code Climate .. |Stability| image:: https://img.shields.io/pypi/status/pook.svg :target: https://pypi.python.org/pypi/pook :alt: Stability .. |Versions| image:: https://img.shields.io/pypi/pyversions/pook.svg :target: https://pypi.python.org/pypi/pook :alt: Python Versions pook-2.1.3/docs/000077500000000000000000000000001472770511200134235ustar00rootroot00000000000000pook-2.1.3/docs/.gitignore000066400000000000000000000000071472770511200154100ustar00rootroot00000000000000source pook-2.1.3/docs/api.rst000066400000000000000000000007111472770511200147250ustar00rootroot00000000000000.. _api: API Documentation ================= Core API -------- .. automodule:: pook :members: :member-order: bysource :undoc-members: :show-inheritance: Matchers API ------------ .. automodule:: pook.matchers :members: :undoc-members: :show-inheritance: Interceptors API ---------------- .. automodule:: pook.interceptors :members: :undoc-members: :show-inheritance: :exclude-members: activate, disable pook-2.1.3/docs/conf.py000066400000000000000000000236641472770511200147350ustar00rootroot00000000000000#!/usr/bin/env python3 # # pook documentation build configuration file, created by # sphinx-quickstart on Tue Oct 4 18:59:54 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath("..")) import pook # noqa # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx.ext.autosummary", "sphinx.ext.napoleon", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "pook" copyright = "2016, Tomas Aparicio" author = "Tomas Aparicio" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = pook.__version__ # The full version, including alpha/beta/rc tags. release = pook.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # # today = '' # # Else, today_fmt is used as the format for a strftime call. # # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The reST default role (used for this markup: `text`) to use for all # documents. # # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. # " v documentation" by default. # # html_title = 'pook v0.1.0' # A shorter title for the navigation bar. Default is the same as html_title. # # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # # html_logo = None # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or # 32x32 pixels large. # # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # # html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # # html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # # html_additional_pages = {} # If false, no module index is generated. # # html_domain_indices = True # If false, no index is generated. # # html_use_index = True # If true, the index is split into individual pages for each letter. # # html_split_index = False # If true, links to the reST sources are added to the pages. # # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' # # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. # # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = "pookdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "pook.tex", "pook Documentation", "Tomas Aparicio", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # # latex_use_parts = False # If true, show page references after internal links. # # latex_show_pagerefs = False # If true, show URL addresses after external links. # # latex_show_urls = False # Documents to append as an appendix to all manuals. # # latex_appendices = [] # It false, will not define \strong, \code, itleref, \crossref ... but only # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added # packages. # # latex_keep_old_macro_names = True # If false, no module index is generated. # # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "pook", "pook Documentation", [author], 1)] # If true, show URL addresses after external links. # # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "pook", "pook Documentation", author, "pook", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # # texinfo_appendices = [] # If false, no module index is generated. # # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # # texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. # intersphinx_mapping = {"http://docs.python.org/": None} pook-2.1.3/docs/examples.rst000066400000000000000000000060461472770511200160010ustar00rootroot00000000000000Examples ======== Basic mocking example using requests ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/requests_client.py Chainable API DSL ^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/chainable_api.py Context manager for isolated mocking ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/context_manager.py Single mock context manager definition for isolated mocking ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/mock_context_manager.py Declaring mocks as decorators ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/decorators.py Activating the mock engine via decorator within the function context ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/decorator_activate.py Activating the mock engine via decorator within an async coroutine function context ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/decorator_activate_async.py Featured JSON body matching ^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/json_matching.py JSONSchema based body matching ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/json_schema.py Request Query Params matching ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. literalinclude:: ../examples/query_params_matching.py Enable real networking mode ^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/network_mode.py Persistent mock ^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/persistent_mock.py Time TTL limited mock ^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/time_ttl_mock.py Regular expression matching ^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/regex.py ``unittest`` integration ^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/unittest_example.py ``py.test`` integration ^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/pytest_example.py Simulated error exception on mock matching ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/simulated_error.py Using ``urllib3`` as HTTP client ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/urllib3_client.py Using ``urllib3`` to return a chunked response ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/urllib3_chunked_response.py Asynchronous HTTP request using ``aiohttp`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/aiohttp_client.py Using ``http.client`` standard Python package as HTTP client ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/http_client_native.py Example using `mocket`_ Python library as underlying mock engine ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/mocket_example.py Hy programming language example ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../examples/basic.hy .. _mocket: https://github.com/mindflayer/python-mocket pook-2.1.3/docs/faq.rst000066400000000000000000000040701472770511200147250ustar00rootroot00000000000000FAQ === How does it work? ----------------- Please, read `how it works`_ section. What HTTP clients are supported? -------------------------------- Please, see `supported HTTP clients`_ section. .. _supported HTTP clients: index.html#supported-http-clients .. _how it works: how_it_works.html Does ``pook`` mock out all the outgoing HTTP traffic from my app? ----------------------------------------------------------------- Yes, that's the default behaviour: any outgoing HTTP traffic across the supported HTTP clients will be intercepted by ``pook``. In case that an outgoing request does not match any mock expectation, an exception error will be raised, telling you no mock was matched in order to review or fix your code accordingly. You can change this behaviour and don't raise any exception if no mock definition can be matched. You can change this enabling the real networking mode via :func:`pook.enable_network`. Can I use ``pook`` in a non-testing environment? ------------------------------------------------ Absolutely. ``pook`` is testing environment agnostic. You simply have to take care of the side effects of mocking HTTP traffic in a runtime environment. For that cases you probably want to enable the real networking mode. Can I use ``pook`` with a custom HTTP traffic mock interceptor engine? ---------------------------------------------------------------------- Yes, you can. ``pook`` is very modular and open for extensibility. You can programmatically define the HTTP traffic mock engine you want to use via :func:`pook.set_mock_engine`. This will replace the built-in one. This can be particularly useful if you are already using another HTTP mocking engine that satisfy your needs, but you want to take benefit of ``pook`` features, versatility and simple to use expressive API. For mock engine implementation details, see :any:`pook.MockEngine` API documentation. Can I use ``pook`` with any test framework? ------------------------------------------- Yes. ``pook`` is framework-agnostic. You can use it within ``unittest``, ``pytest`` or others. pook-2.1.3/docs/history.rst000066400000000000000000000000341472770511200156530ustar00rootroot00000000000000.. include:: ../History.rst pook-2.1.3/docs/how_it_works.rst000066400000000000000000000051001472770511200166670ustar00rootroot00000000000000How it works ============ HTTP traffic interception ------------------------- In a nutshell, ``pook`` uses ``unittest.mock`` standard Python package in order to patch external library objects, allowing ``pook`` HTTP interceptor adaptor to patch any third-party packages and intercept specific function calls. ``pook`` entirely relies on this interception strategy, therefore in the meantime ``pook`` is active, any outgoing HTTP traffic intercepted by the supported HTTP clients won't imply any real TCP networking, except if you enabled the real networking mode via :func:`pook.enable_network`, which in that case real network traffic can be established. Worth clarifying that ``pook`` only works at Python programmatic runtime level. There's no network/socket programming involved. HTTP request matching --------------------- Outgoing HTTP traffic is intercepted and matched against a pool of mock matchers defined in your mock expectations. Matching process in sequential and executed as FIFO order, meaning the first has always preference. For instance, if you declare multiple identical mocks, the first one will be matched first and the others will be ignored. Once the first one expires, the subsequent mock definition in the chain will be matched instead. Real networking mode -------------------- By default, real networking mode is disabled. This basically means that real networking will not happen unless you explicitly enable it. This behaviour has been adopted to improve predictability, control and mitigate developers fear between behaviour boundaries and expectations. ``pook`` will prevent you to communicate with real HTTP servers unless you enable it via: :func:`pook.enable_network`. Also, you can partially restrict the real networking by filtering only certain hosts. You can do that via :func:`pook.use_network_filter`. Volatile vs Persistent mocks ---------------------------- By default, mocks are volatile. This means that once a mock has been matched, and therefore consumed, it will be flushed. You can modify this behaviour via: Explicitly defining the TTL of each mock, so effectively the number of times the mock can be matched and consumed: .. code:: python # Match a mock up to 5 times, then flush it pook.get('server.com/api').times(5) # The above is equivalent to pook.get('server.com/api', times=5) Explicitly defining a persistent mock: .. code:: python # Make a mock definition persistent, so it won't ever be flushed pook.get('server.com/api').persist() # The above is equivalent to pook.get('server.com/api', persist=True) pook-2.1.3/docs/index.rst000066400000000000000000000007421472770511200152670ustar00rootroot00000000000000Contents -------- .. toctree:: :maxdepth: 2 Features Supported HTTP clients install how_it_works examples api faq Development history .. include:: ../README.rst Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` pook-2.1.3/docs/install.rst000066400000000000000000000007271472770511200156310ustar00rootroot00000000000000Installation ============ PyPI ---- You can install the last stable release of pook from PyPI using pip:: $ pip install pook GitHub ------ Or install the latest sources from GitHub:: $ pip install -e git+git://github.com/h2non/pook.git#egg=pook You can also download a source code package from `GitHub `_ and install it using setuptools:: $ tar xvf pook-{version}.tar.gz $ cd pook $ python setup.py install pook-2.1.3/examples/000077500000000000000000000000001472770511200143115ustar00rootroot00000000000000pook-2.1.3/examples/README.rst000066400000000000000000000004161472770511200160010ustar00rootroot00000000000000``pook`` examples ================= Run examples via: .. code:: python $ python examples/.py Example: .. code:: python $ python examples/requests_client.py Contributing ------------ Pull requests are very welcome adding more useful examples. pook-2.1.3/examples/aiohttp_client.py000066400000000000000000000014641472770511200176760ustar00rootroot00000000000000# flake8: noqa import pook import aiohttp import asyncio import async_timeout async def fetch(session, url, data): with async_timeout.timeout(10): async with session.get(url, data=data) as res: print("Status:", res.status) print("Headers:", res.headers) print("Body:", await res.text()) with pook.use(network=True): pook.get( "http://httpbin.org/ip", reply=404, response_type="json", response_headers={"Server": "nginx"}, response_json={"error": "not found"}, ) async def main(loop): async with aiohttp.ClientSession(loop=loop) as session: await fetch(session, "http://httpbin.org/ip", bytearray("foo bar", "utf-8")) loop = asyncio.get_event_loop() loop.run_until_complete(main(loop)) pook-2.1.3/examples/basic.hy000066400000000000000000000005111472770511200157310ustar00rootroot00000000000000(import [pook]) (import [requests]) (defn request [url &optional [status 404]] (doto (.mock pook url) (.reply status)) (let [res (.get requests url)] (. res status_code))) (defn run [] (with [(.use pook)] (print "Status:" (request "http://foo.com/bar" :status 204)))) ;; Run test program (defmain [&args] (run)) pook-2.1.3/examples/chainable_api.py000066400000000000000000000011041472770511200174160ustar00rootroot00000000000000import json import requests import pook # Enable mock engine pook.on() ( pook.post("httpbin.org/post") .json({"foo": "bar"}) .type("json") .header("Client", "requests") .reply(201) .headers({"server": "pook"}) .json({"error": "simulated"}) ) res = requests.post( "http://httpbin.org/post", data=json.dumps({"foo": "bar"}), headers={"Client": "requests", "Content-Type": "application/json"}, ) print("Status:", res.status_code) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) pook-2.1.3/examples/context_manager.py000066400000000000000000000007771472770511200200540ustar00rootroot00000000000000import requests import pook with pook.api.context(): pook.get( "httpbin.org/ip", reply=403, response_headers={"pepe": "lopez"}, response_json={"error": "not found"}, ) res = requests.get("http://httpbin.org/ip") print("Status:", res.status_code) print("Headers:", res.headers) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) pook-2.1.3/examples/decorator_activate.py000066400000000000000000000010031472770511200205170ustar00rootroot00000000000000import requests import pook @pook.on def run(): pook.get( "httpbin.org/ip", reply=403, response_headers={"pepe": "lopez"}, response_json={"error": "not found"}, ) res = requests.get("http://httpbin.org/ip") print("Status:", res.status_code) print("Headers:", res.headers) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) run() pook-2.1.3/examples/decorator_activate_async.py000066400000000000000000000013331472770511200217220ustar00rootroot00000000000000# flake8: noqa import pook import asyncio import aiohttp @pook.on async def run(): pook.get( "httpbin.org/ip", reply=403, response_headers={"pepe": "lopez"}, response_json={"error": "not found"}, ) async with aiohttp.ClientSession(loop=loop) as session: async with session.get("http://httpbin.org/ip") as res: print("Status:", res.status) print("Headers:", res.headers) print("Body:", await res.text()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) loop = asyncio.get_event_loop() loop.run_until_complete(run()) pook-2.1.3/examples/decorators.py000066400000000000000000000011371472770511200170320ustar00rootroot00000000000000import requests import pook @pook.get("http://httpbin.org/status/500", reply=204) @pook.get("http://httpbin.org/status/400", reply=200, persist=True) def fetch(url): return requests.get(url) # Test function res = fetch("http://httpbin.org/status/400") print("#1 status:", res.status_code) res = fetch("http://httpbin.org/status/500") print("#2 status:", res.status_code) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) # Disable mock engine pook.off() # Test real request res = requests.get("http://httpbin.org/status/400") print("Test status:", res.status_code) pook-2.1.3/examples/http_client_native.py000066400000000000000000000010671472770511200205520ustar00rootroot00000000000000import http.client import pook # Enable mock engine pook.on() mock = pook.get( "http://httpbin.org/ip", reply=404, response_type="json", response_json={"error": "not found"}, ) conn = http.client.HTTPConnection("httpbin.org") conn.request("GET", "/ip") res = conn.getresponse() print("Status:", res.status, res.reason) print("Headers:", res.headers) print("Body:", res.read()) print("Mock calls:", mock.calls) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) pook-2.1.3/examples/json_matching.py000066400000000000000000000006331472770511200175100ustar00rootroot00000000000000import json import requests import pook # Enable mock engine pook.on() ( pook.post("httpbin.org/post") .json({"foo": "bar"}) .reply(201) .json({"error": "simulated"}) ) res = requests.post("http://httpbin.org/post", data=json.dumps({"foo": "bar"})) print("Status:", res.status_code) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) pook-2.1.3/examples/json_schema.py000066400000000000000000000007741472770511200171640ustar00rootroot00000000000000import json import requests import pook schema = { "type": "object", "properties": { "foo": {"type": "string"}, }, } # Enable mock engine pook.on() ( pook.post("httpbin.org/post") .jsonschema(schema) .reply(201) .json({"error": "simulated"}) ) res = requests.post("http://httpbin.org/post", data=json.dumps({"foo": "bar"})) print("Status:", res.status_code) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) pook-2.1.3/examples/match_callback.py000066400000000000000000000011111472770511200175650ustar00rootroot00000000000000import requests import pook def on_match(request, mock): print("On match:", request, mock) # Enable mock engine pook.on() pook.get( "httpbin.org/ip", reply=403, response_type="json", response_headers={"pepe": "lopez"}, response_json={"error": "not found"}, callback=on_match, ) res = requests.get("http://httpbin.org/ip") print("Status:", res.status_code) print("Headers:", res.headers) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) pook-2.1.3/examples/mock_context_manager.py000066400000000000000000000022041472770511200210500ustar00rootroot00000000000000import requests import pook # Define a new mock that will be only active within the context manager with pook.get( "httpbin.org/ip", reply=403, response_headers={"pepe": "lopez"}, response_json={"error": "not found"}, ) as mock: res = requests.get("http://httpbin.org/ip") print("#1 Status:", res.status_code) print("#1 Headers:", res.headers) print("#1 Body:", res.json()) print("----------------") res = requests.get("http://httpbin.org/ip") print("#2 Status:", res.status_code) print("#2 Headers:", res.headers) print("#2 Body:", res.json()) print("----------------") print("Mock is done:", mock.isdone()) print("Mock matches:", mock.total_matches) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) # Explicitly disable mock engine pook.off() # Perform a real HTTP request since we are running the # request outside of the context manager res = requests.get("http://httpbin.org/ip") print("#3 Status:", res.status_code) print("#3 Headers:", res.headers) print("#3 Body:", res.json()) pook-2.1.3/examples/mocket_example.py000066400000000000000000000014511472770511200176610ustar00rootroot00000000000000# Be sure you have `mocket` installed: # $ pip install mocket import requests from mocket.plugins.pook_mock_engine import MocketEngine import pook # Use mocket library as underlying mock engine pook.set_mock_engine(MocketEngine) # Explicitly enable pook HTTP mocking (required) pook.on() # Target server URL to mock out url = "http://twitter.com/api/1/foobar" # Define your mock mock = pook.get( url, reply=404, times=2, headers={"content-type": "application/json"}, response_json={"error": "foo"}, ) # Run first HTTP request requests.get(url) assert mock.calls == 1 # Run second HTTP request res = requests.get(url) assert mock.calls == 2 # Assert response data assert res.status_code == 404 assert res.json() == {"error": "foo"} # Explicitly disable pook (optional) pook.off() pook-2.1.3/examples/network_mode.py000066400000000000000000000011331472770511200173560ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() # Enable network mode pook.enable_network() ( pook.get("httpbin.org/headers") .reply(201) .headers({"server": "pook"}) .json({"error": "simulated"}) ) res = requests.get("http://httpbin.org/headers") print("Mock status:", res.status_code) # Real network request, since pook cannot match any mock res = requests.get("http://httpbin.org/ip") print("Real status:", res.status_code) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) # Disable network mode once we're done pook.disable_network() pook-2.1.3/examples/network_mode_httpx.py000066400000000000000000000011221472770511200206030ustar00rootroot00000000000000import httpx import pook # Enable mock engine pook.on() # Enable network mode pook.enable_network() ( pook.get("httpbin.org/headers") .reply(201) .headers({"server": "pook"}) .json({"error": "simulated"}) ) res = httpx.get("http://httpbin.org/headers") print("Mock status:", res.status_code) # Real network request, since pook cannot match any mock res = httpx.get("http://httpbin.org/ip") print("Real status:", res.status_code) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) # Disable network mode once we're done pook.disable_network() pook-2.1.3/examples/persistent_mock.py000066400000000000000000000007341472770511200201000ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() ( pook.get("httpbin.org") .headers({"Client": "requests"}) .persist() .reply(400) .headers({"server": "pook"}) .json({"error": "simulated"}) ) res = requests.get("http://httpbin.org", headers={"Client": "requests"}) print("Status:", res.status_code) print("Headers:", res.headers) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) pook-2.1.3/examples/pytest_example.py000066400000000000000000000020301472770511200177210ustar00rootroot00000000000000import pytest import requests import pook @pook.activate def test_simple_pook_request(): pook.get("server.com/foo").reply(204) res = requests.get("http://server.com/foo") assert res.status_code == 204 @pook.on def test_enable_engine(): pook.get("server.com/foo").reply(204) res = requests.get("http://server.com/foo") assert res.status_code == 204 pook.disable() @pook.get("server.com/bar", reply=204) def test_decorator(): res = requests.get("http://server.com/bar") assert res.status_code == 204 def test_context_manager(): with pook.use(): pook.get("server.com/baz", reply=204) res = requests.get("http://server.com/baz") assert res.status_code == 204 @pook.on def test_no_match_exception(): pook.get("server.com/bar", reply=204) with pytest.raises(Exception): requests.get("http://server.com/baz") def test_fixture(pook): pook.get("https://example.org/").reply(204) res = requests.get("https://example.org/") assert res.status_code == 204 pook-2.1.3/examples/query_params_matching.py000066400000000000000000000005671472770511200212550ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() ( pook.get("httpbin.org/get") .params({"foo": "bar"}) .reply(201) .json({"error": "simulated"}) ) res = requests.get("http://httpbin.org/get", params={"foo": "bar"}) assert res.status_code == 201 assert res.json() == {"error": "simulated"} assert pook.isdone() assert pook.pending_mocks() == [] pook-2.1.3/examples/regex.py000066400000000000000000000013261472770511200157770ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() # Mock definition based ( pook.get(pook.regex("h[t]{2}pbin.*")) .path(pook.regex("/foo/[a-z]+/baz")) .header("Client", pook.regex("requests|pook")) .times(2) .reply(200) .headers({"server": "pook"}) .json({"foo": "bar"}) ) # Perform request res = requests.get("http://httpbin.org/foo/bar/baz", headers={"Client": "requests"}) print("Status:", res.status_code) print("Body:", res.json()) # Perform second request res = requests.get("http://httpbin.org/foo/foo/baz", headers={"Client": "pook"}) print("Status:", res.status_code) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) pook-2.1.3/examples/requests_client.py000066400000000000000000000014371472770511200201010ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() pook.get( "httpbin.org/ip", reply=403, response_type="json", response_headers={"server": "pook"}, response_json={"error": "not found"}, ) pook.get( "httpbin.org/headers", reply=404, response_type="json", response_headers={"server": "pook"}, response_json={"error": "not found"}, ) res = requests.get("http://httpbin.org/ip") print("Status:", res.status_code) print("Headers:", res.headers) print("Body:", res.json()) res = requests.get("http://httpbin.org/headers") print("Status:", res.status_code) print("Headers:", res.headers) print("Body:", res.json()) print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) print("Unmatched requests:", pook.unmatched_requests()) pook-2.1.3/examples/simulated_error.py000066400000000000000000000004371472770511200200670ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() # Simulated error exception on request matching pook.get("httpbin.org/status/500", error=Exception("simulated error")) try: requests.get("http://httpbin.org/status/500") except Exception as err: print("Error:", err) pook-2.1.3/examples/time_ttl_mock.py000066400000000000000000000012421472770511200175140ustar00rootroot00000000000000import requests import pook # Enable mock engine pook.on() # Declare mock ( pook.get("httpbin.org") .times(2) .reply(400) .headers({"server": "pook"}) .json({"error": "simulated"}) ) # Mock request 1 res = requests.get("http://httpbin.org") print("#1 status:", res.status_code) print("#1 body:", res.json()) # Mock request 2 res = requests.get("http://httpbin.org") print("#2 status:", res.status_code) print("#2 body:", res.json()) # Real request 3 try: requests.get("http://httpbin.org") except Exception: print("Request #3 not matched due to expired mock") print("Is done:", pook.isdone()) print("Pending mocks:", pook.pending_mocks()) pook-2.1.3/examples/unittest_example.py000066400000000000000000000014151472770511200202560ustar00rootroot00000000000000import unittest import requests import pook class TestUnitTestEngine(unittest.TestCase): @pook.on def test_request(self): pook.get("server.com/foo").reply(204) res = requests.get("http://server.com/foo") self.assertEqual(res.status_code, 204) def test_request_with_context_manager(self): with pook.use(): pook.get("server.com/bar", reply=204) res = requests.get("http://server.com/bar") self.assertEqual(res.status_code, 204) @pook.on def test_no_match_exception(self): pook.get("server.com/bar", reply=204) try: requests.get("http://server.com/baz") except Exception: pass else: raise RuntimeError("expected to fail") pook-2.1.3/examples/urllib3_chunked_response.py000066400000000000000000000005661472770511200216650ustar00rootroot00000000000000import urllib3 import pook # Mock HTTP traffic only in the given context with pook.use(): ( pook.get("httpbin.org/chunky") .reply(200) .body(["returned", "as", "chunks"], chunked=True) ) # Intercept request http = urllib3.PoolManager() r = http.request("GET", "httpbin.org/chunky") print("Chunks:", list(r.read_chunked())) pook-2.1.3/examples/urllib3_client.py000066400000000000000000000007221472770511200175760ustar00rootroot00000000000000import urllib3 import pook # Mock HTTP traffic only in the given context with pook.use(): pook.get("http://httpbin.org/status/404").reply(204) # Intercept request http = urllib3.PoolManager() r = http.request("GET", "http://httpbin.org/status/404") print("#1 status:", r.status) # Real request outside of the context manager http = urllib3.PoolManager() r = http.request("GET", "http://httpbin.org/status/404") print("#2 status:", r.status) pook-2.1.3/pyproject.toml000066400000000000000000000052561472770511200154170ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "pook" dynamic = ["version"] description = "HTTP traffic mocking and expectations made easy" readme = "README.rst" license = "MIT" authors = [ { name = "Tomas Aparicio", email = "tomas@aparicio.me" }, { name = "Sara Marcondes", email = "git@sarayourfriend.pictures" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "furl>=0.5.6", "jsonschema>=2.5.1", "xmltodict>=0.11.0", ] requires-python = ">=3.9" [project.urls] Homepage = "https://github.com/h2non/pook" [project.entry-points.pytest11] pook = "pook.pytest_fixture" [tool.hatch.version] path = "src/pook/__init__.py" [tool.hatch.build.targets.sdist] packages = ["src/pook"] [tool.hatch.build.targets.wheel] packages = ["src/pook"] [tool.hatch.envs.default] python = "3.13" dependencies = [ "pre-commit~=4.0", "mypy>=1.11.2", "pytest~=8.3", "pytest-asyncio~=0.24", "pytest-pook==0.1.0b0", "pytest-httpbin==2.1.0", "requests~=2.20", "urllib3~=2.2", "httpx~=0.26", "aiohttp~=3.10", "async-timeout~=4.0", # mocket relies on httptools which does not support PyPy "mocket[pook]~=3.12.2; platform_python_implementation != 'PyPy'", ] [tool.hatch.envs.default.scripts] ci = [ "lint-install", "lint", "test", ] test = "pytest {args}" lint-install = "pre-commit install" lint = "pre-commit run --all-files" types = "mypy --install-types --non-interactive src/pook/interceptors {args}" [tool.hatch.envs.docs] extra-dependencies = [ "sphinx==8.1.3", "sphinx-autobuild==2024.10.3", "sphinx-rtd-theme==3.0.1", ] [tool.hatch.envs.docs.scripts] preview = "sphinx-autobuild docs docs/_build/html {args}" build = "sphinx-build -b html docs docs/_build/html {args}" [tool.hatch.envs.test] [[tool.hatch.envs.test.matrix]] python = ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" pook-2.1.3/src/000077500000000000000000000000001472770511200132625ustar00rootroot00000000000000pook-2.1.3/src/pook/000077500000000000000000000000001472770511200142325ustar00rootroot00000000000000pook-2.1.3/src/pook/__init__.py000066400000000000000000000023171472770511200163460ustar00rootroot00000000000000from .api import * # noqa: F403 # Delegate to API export __all__ = ( "activate", # noqa: F405 "on", # noqa: F405 "disable", # noqa: F405 "off", # noqa: F405 "reset", # noqa: F405 "engine", # noqa: F405 "use_network", # noqa: F405 "enable_network", # noqa: F405 "disable_network", # noqa: F405 "get", # noqa: F405 "post", # noqa: F405 "put", # noqa: F405 "patch", # noqa: F405 "head", # noqa: F405 "use", # noqa: F405 "set_mock_engine", # noqa: F405 "delete", # noqa: F405 "options", # noqa: F405 "pending", # noqa: F405 "ispending", # noqa: F405 "mock", # noqa: F405 "pending_mocks", # noqa: F405 "unmatched_requests", # noqa: F405 "isunmatched", # noqa: F405 "unmatched", # noqa: F405 "isactive", # noqa: F405 "isdone", # noqa: F405 "regex", # noqa: F405 "Engine", # noqa: F405 "Mock", # noqa: F405 "Request", # noqa: F405 "Response", # noqa: F405 "MatcherEngine", # noqa: F405 "MockEngine", # noqa: F405 "use_network_filter", # noqa: F405 ) # Package metadata __author__ = "Tomas Aparicio" __license__ = "MIT" # Current version __version__ = "2.1.3" pook-2.1.3/src/pook/activate_async.py000066400000000000000000000012311472770511200175760ustar00rootroot00000000000000import functools from inspect import iscoroutinefunction def activate_async(fn, _engine): """ Async version of activate decorator Arguments: fn (function): function that be wrapped by decorator. _engine (Engine): pook engine instance Returns: function: decorator wrapper function. """ @functools.wraps(fn) async def wrapper(*args, **kw): _engine.activate() try: if iscoroutinefunction(fn): return await fn(*args, **kw) else: fn(*args, **kw) finally: _engine.disable() _engine.reset() return wrapper pook-2.1.3/src/pook/api.py000066400000000000000000000302301472770511200153530ustar00rootroot00000000000000import functools import re from asyncio import iscoroutinefunction from contextlib import contextmanager from inspect import isfunction from .activate_async import activate_async from .engine import Engine from .matcher import MatcherEngine from .mock import Mock from .mock_engine import MockEngine from .request import Request from .response import Response # Public API symbols to export __all__ = ( "activate", "on", "disable", "off", "reset", "engine", "use_network", "enable_network", "disable_network", "get", "post", "put", "patch", "head", "use", "set_mock_engine", "delete", "options", "pending", "ispending", "mock", "pending_mocks", "unmatched_requests", "isunmatched", "unmatched", "isactive", "isdone", "regex", "Engine", "Mock", "Request", "Response", "MatcherEngine", "MockEngine", "use_network_filter", ) # Default singleton mock engine to be used _engine = Engine() def debug(enable=True): """ Enables or disables debug mode in the current mock engine. Arguments: enable (bool): ``True`` to enable debug mode. Otherwise ``False``. """ _engine.debug = enable def engine(): """ Returns the current running mock engine. Returns: pook.Engine: current used engine. """ return _engine def set_mock_engine(engine): """ Sets a custom mock engine, replacing the built-in one. This is particularly useful if you want to replace the built-in HTTP traffic mock interceptor engine with your custom one. For mock engine implementation details, see `pook.MockEngine`. Arguments: engine (pook.MockEngine): custom mock engine to use. """ _engine.set_mock_engine(engine) def activate(fn=None): """ Enables the HTTP traffic interceptors. This function can be used as decorator. Arguments: fn (function|coroutinefunction): Optional function argument if used as decorator. Returns: function: decorator wrapper function, only if called as decorator, otherwise ``None``. Example:: # Standard use case pook.activate() pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 pook.disable() # Decorator use case @pook.activate def test_request(): pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 """ # If not used as decorator, activate the engine and exit if not isfunction(fn): _engine.activate() return None # If used as decorator for an async coroutine, wrap it if iscoroutinefunction(fn): return activate_async(fn, _engine) @functools.wraps(fn) def wrapper(*args, **kw): _engine.activate() try: fn(*args, **kw) finally: _engine.disable() _engine.reset() return wrapper def on(fn=None): """ Enables the HTTP traffic interceptors. Alias to ``pook.activate()``. Arguments: fn (function|coroutinefunction): Optional function argument if used as decorator. Returns: function: decorator wrapper function, only if called as decorator. Example:: # Standard usage pook.on() pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 # Usage as decorator @pook.on def test_request(): pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 """ return activate(fn) def disable(): """ Disables HTTP traffic interceptors. """ _engine.disable() def off(): """ Disables mock engine, HTTP traffic interceptors and flushed all the registered mocks. Internally, it calls ``pook.disable()`` and ``pook.off()``. """ disable() reset() def reset(): """ Resets current mock engine state, flushing all the registered mocks. This action will not disable the mock engine. """ _engine.reset() @contextmanager def use(network=False): """ Creates a new isolated mock engine to be used via context manager. Example:: with pook.use() as engine: pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 """ global _engine # Create temporal engine __engine = _engine activated = __engine.active if activated: __engine.disable() _engine = Engine(network=network) _engine.activate() # Yield engine to be used by the context manager yield _engine # Restore engine state _engine.disable() if network: _engine.disable_network() # Restore previous engine _engine = __engine if activated: _engine.activate() @contextmanager def context(network=False): """ Create a new isolated mock engine to be used via context manager. Semantic alias to ``pook.context()``. Example:: with pook.use() as engine: pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 """ with use(network=network) as engine: yield engine @contextmanager def use_network(): """ Creates a new mock engine to be used as context manager Example:: with pook.use_network() as engine: pook.mock('server.com/foo').reply(404) res = requests.get('server.com/foo') assert res.status_code == 404 """ with use(network=True) as engine: yield engine def enable_network(*hostnames): """ Enables real networking mode for unmatched mocks in the current mock engine. """ _engine.enable_network(*hostnames) def disable_network(): """ Disables real traffic networking mode in the current mock engine. """ _engine.disable_network() def use_network_filter(*fn): """ Adds network filters to determine if certain outgoing unmatched HTTP traffic can stablish real network connections. Arguments: *fn (function): variadic function filter arguments to be used. """ _engine.use_network_filter(*fn) def flush_network_filters(): """ Flushes registered real networking filters in the current mock engine. """ _engine.flush_network_filters() def mock(url=None, **kw): """ Creates and register a new HTTP mock. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic keyword arguments. Returns: pook.Mock: mock instance """ return _engine.mock(url, **kw) def get(url, **kw): """ Registers a new mock HTTP request with GET method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: mock instance """ return mock(url, method="GET", **kw) def post(url, **kw): """ Registers a new mock HTTP request with POST method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: mock instance """ return mock(url, method="POST", **kw) def put(url, **kw): """ Registers a new mock HTTP request with PUT method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: mock instance """ return mock(url, method="PUT", **kw) def delete(url, **kw): """ Registers a new mock HTTP request with DELETE method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: mock instance """ return mock(url, method="DELETE", **kw) def head(url, **kw): """ Registers a new mock HTTP request with HEAD method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: mock instance """ return mock(url, method="HEAD", **kw) def patch(url=None, **kw): """ Registers a new mock HTTP request with PATCH method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: new mock instance. """ return mock(url, method="PATCH", **kw) def options(url=None, **kw): """ Registers a new mock HTTP request with OPTIONS method. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic arguments to ``pook.Mock`` constructor. Returns: pook.Mock: new mock instance. """ return mock(url, method="OPTIONS", **kw) def pending(): """ Returns the numbers of pending mocks to be matched. Returns: int: number of pending mocks to match. """ return _engine.pending() def ispending(): """ Returns the numbers of pending mocks to be matched. Returns: int: number of pending mocks to match. """ return _engine.ispending() def pending_mocks(): """ Returns pending mocks to be matched. Returns: list: pending mock instances. """ return _engine.pending_mocks() def unmatched_requests(): """ Returns a ``tuple`` of unmatched requests. Unmatched requests will be registered only if ``networking`` mode has been enabled. Returns: list: unmatched intercepted requests. """ return _engine.unmatched_requests() def unmatched(): """ Returns the total number of unmatched requests intercepted by pook. Unmatched requests will be registered only if ``networking`` mode has been enabled. Returns: int: total number of unmatched requests. """ return _engine.unmatched() def isunmatched(): """ Returns ``True`` if there are unmatched requests. Otherwise ``False``. Unmatched requests will be registered only if ``networking`` mode has been enabled. Returns: bool """ return _engine.isunmatched() def isactive(): """ Returns ``True`` if pook is active and intercepting traffic. Otherwise ``False``. Returns: bool: True if pook is active, otherwise False. """ return _engine.isactive() def isdone(): """ Returns True if all the registered mocks has been triggered. Returns: bool: True if all the registered mocks are gone, otherwise False. """ return _engine.isdone() def regex(expression, flags=re.IGNORECASE): """ Convenient shortcut to ``re.compile()`` for fast, easy to use regular expression compilation without an extra import statement. Arguments: expression (str): regular expression value. flags (int): optional regular expression flags. Defaults to ``re.IGNORECASE`` Returns: expression (str): string based regular expression. Raises: Exception: in case of regular expression compilation error Example:: (pook .get('api.com/foo') .header('Content-Type', pook.regex('[a-z]{1,4}'))) """ return re.compile(expression, flags=flags) pook-2.1.3/src/pook/assertion.py000066400000000000000000000034501472770511200166150ustar00rootroot00000000000000from unittest import TestCase from .regex import isregex, isregex_expr, strip_regex def test_case(): """ Creates a new ``unittest.TestCase`` instance. Returns: unittest.TestCase """ test = TestCase() test.maxDiff = None return test def equal(x, y): """ Shortcut function for ``unittest.TestCase.assertEqual()``. Arguments: x (mixed) y (mixed) Raises: AssertionError: in case of assertion error. Returns: bool """ return test_case().assertEqual(x, y) or True def matches(x, y, regex_expr=False): """ Tries to match a regular expression value ``x`` against ``y``. Aliast``unittest.TestCase.assertEqual()`` Arguments: x (regex|str): regular expression to test. y (str): value to match. regex_expr (bool): enables regex string based expression matching. Raises: AssertionError: in case of mismatching. Returns: bool """ # Parse regex expression, if needed x = strip_regex(x) if regex_expr and isregex_expr(x) else x if isinstance(getattr(x, "pattern", None), str) and hasattr(y, "decode"): y = y.decode("utf-8", "backslashescape") # Assert regular expression via unittest matchers return test_case().assertRegex(y, x) or True def test(x, y, regex_expr=False): """ Compares to values based on regular expression matching or strict equality comparison. Arguments: x (regex|str): string or regular expression to test. y (str): value to match. regex_expr (bool): enables regex string based expression matching. Raises: AssertionError: in case of matching error. Returns: bool """ return matches(x, y, regex_expr=regex_expr) if isregex(x) else equal(x, y) pook-2.1.3/src/pook/compare.py000066400000000000000000000025301472770511200162320ustar00rootroot00000000000000import re from .assertion import test # Negate is used a reserved token identifier to negate matching NEGATE = "!!" def compile(expr): try: return re.compile(expr, re.IGNORECASE) except Exception: pass def match(expr, value): regex = compile(expr) if not regex: return False return regex.match(value) is not None def strip_negate(value): return value[len(NEGATE) :].lstrip() def compare(expr, value, regex_expr=False): """ Compares an string or regular expression againast a given value. Arguments: expr (str|regex): string or regular expression value to compare. value (str): value to compare against to. regex_expr (bool, optional): enables string based regex matching. Raises: AssertionError: in case of assertion error. Returns: bool """ # Strict equality comparison if expr == value: return True # Infer negate expression to match, if needed negate = False if isinstance(expr, str): negate = expr.startswith(NEGATE) expr = strip_negate(expr) if negate else expr try: # RegExp or strict equality comparison test(expr, value, regex_expr=regex_expr) except Exception as err: if negate: return True else: raise err return True pook-2.1.3/src/pook/constants.py000066400000000000000000000005101472770511200166140ustar00rootroot00000000000000# MIME type aliases for semantic convenience TYPES = { "text": "text/plain", "html": "text/html", "json": "application/json", "xml": "application/xml", "urlencoded": "application/x-www-form-urlencoded", "form": "application/x-www-form-urlencoded", "form-data": "application/x-www-form-urlencoded", } pook-2.1.3/src/pook/engine.py000066400000000000000000000334471472770511200160640ustar00rootroot00000000000000from functools import partial from inspect import isfunction from .exceptions import PookNoMatches from .mock import Mock from .mock_engine import MockEngine from .regex import isregex class Engine: """ Engine represents the mock interceptor and matcher engine responsible of triggering interceptors and match outgoing HTTP traffic. Arguments: network (bool, optional): enables/disables real networking mode. Attributes: debug (bool): enables/disables debug mode. active (bool): stores the current engine activation status. networking (bool): stores the current engine networking mode status. mocks (list[pook.Mock]): stores engine mocks. filters (list[function]): stores engine-level mock filter functions. mappers (list[function]): stores engine-level mock mapper functions. interceptors (list[pook.BaseInterceptor]): stores engine-level HTTP traffic interceptors. unmatched_reqs (list[pook.Request]): stores engine-level unmatched outgoing HTTP requests. network_filters (list[function]): stores engine-level real networking mode filters. """ def __init__(self, network=False): # Enables/Disables debug mode. self.debug = True # Store the engine enable/disable status self.active = False # Enables/Disables real networking self.networking = network # Stores mocks self.mocks = [] # Store engine-level global filters self.filters = [] # Store engine-level global mappers self.mappers = [] # Store unmatched requests. self.unmatched_reqs = [] # Store network filters used to determine when a request # should be filtered or not. self.network_filters = [] # Built-in mock engine to be used self.mock_engine = MockEngine(self) def set_mock_engine(self, engine): """ Sets a custom mock engine, replacing the built-in one. This is particularly useful if you want to replace the built-in HTTP traffic mock interceptor engine with your custom one. For mock engine implementation details, see `pook.MockEngine`. Arguments: engine (pook.MockEngine): custom mock engine to use. """ if not engine: raise TypeError("engine must be a valid object") # Instantiate mock engine mock_engine = engine(self) # Validate minimum viable interface methods = ("activate", "disable") if not all([hasattr(mock_engine, method) for method in methods]): raise NotImplementedError( "engine must implementent the " "required methods" ) # Use the custom mock engine self.mock_engine = mock_engine # Enable mock engine, if needed if self.active: self.mock_engine.activate() def enable_network(self, *hostnames): """ Enables real networking mode, optionally passing one or multiple hostnames that would be used as filter. If at least one hostname matches with the outgoing traffic, the request will be executed via the real network. Arguments: *hostnames: optional list of host names to enable real network against them. hostname value can be a regular expression. """ def hostname_filter(hostname, req): if isregex(hostname): return hostname.match(req.url.hostname) return req.url.hostname == hostname for hostname in hostnames: self.use_network_filter(partial(hostname_filter, hostname)) self.networking = True def disable_network(self): """ Disables real networking mode. """ self.networking = False def use_network_filter(self, *fn): """ Adds network filters to determine if certain outgoing unmatched HTTP traffic can stablish real network connections. Arguments: *fn (function): variadic function filter arguments to be used. """ self.network_filters.extend(fn) def flush_network_filters(self): """ Flushes registered real networking filters in the current mock engine. """ self.network_filters = [] def mock(self, url=None, **kw): """ Creates and registers a new HTTP mock in the current engine. Arguments: url (str): request URL to mock. activate (bool): force mock engine activation. Defaults to ``False``. **kw (mixed): variadic keyword arguments for ``Mock`` constructor. Returns: pook.Mock: new mock instance. """ # Activate mock engine, if explicitly requested if kw.get("activate"): kw.pop("activate") self.activate() # Create the new HTTP mock expectation mock = Mock(url=url, **kw) # Expose current engine instance via mock mock._engine = self # Register the mock in the current engine self.add_mock(mock) # Return it for consumer satisfaction return mock def add_mock(self, mock): """ Adds a new mock instance to the current engine. Arguments: mock (pook.Mock): mock instance to add. """ self.mocks.append(mock) def remove_mock(self, mock): """ Removes a specific mock instance by object reference. Arguments: mock (pook.Mock): mock instance to remove. """ self.mocks = [m for m in self.mocks if m is not mock] def flush_mocks(self): """ Flushes the current mocks. """ self.mocks = [] def _engine_proxy(self, method, *args, **kw): engine_method = getattr(self.mock_engine, method, None) if not engine_method: raise NotImplementedError( "current mock engine does not implements" f' required "{method}" method' ) return engine_method(self.mock_engine, *args, **kw) def add_interceptor(self, *interceptors): """ Adds one or multiple HTTP traffic interceptors to the current mocking engine. Interceptors are typically HTTP client specific wrapper classes that implements the pook interceptor interface. Note: this method is may not be implemented if using a custom mock engine. Arguments: interceptors (pook.interceptors.BaseInterceptor) """ self._engine_proxy("add_interceptor", *interceptors) def flush_interceptors(self): """ Flushes registered interceptors in the current mocking engine. This method is low-level. Only call it if you know what you are doing. Note: this method is may not be implemented if using a custom mock engine. """ self._engine_proxy("flush_interceptors") def remove_interceptor(self, name): """ Removes a specific interceptor by name. Note: this method is may not be implemented if using a custom mock engine. Arguments: name (str): interceptor name to disable. Returns: bool: `True` if the interceptor was disabled, otherwise `False`. """ return self._engine_proxy("remove_interceptor", name) def activate(self): """ Activates the registered interceptors in the mocking engine. This means any HTTP traffic captures by those interceptors will trigger the HTTP mock matching engine in order to determine if a given HTTP transaction should be mocked out or not. """ if self.active: return None # Activate mock engine self.mock_engine.activate() # Enable engine state self.active = True def disable(self): """ Disables interceptors and stops intercepting any outgoing HTTP traffic. """ if not self.active: return None # Disable current mock engine self.mock_engine.disable() # Disable engine state self.active = False def reset(self): """ Resets and flushes engine state and mocks to defaults. """ # Reset engine Engine.__init__(self, network=self.networking) def unmatched_requests(self): """ Returns a ``tuple`` of unmatched requests. Unmatched requests will be registered only if ``networking`` mode has been enabled. Returns: list: unmatched intercepted requests. """ return [mock for mock in self.unmatched_reqs] def unmatched(self): """ Returns the total number of unmatched requests intercepted by pook. Unmatched requests will be registered only if ``networking`` mode has been enabled. Returns: int: total number of unmatched requests. """ return len(self.unmatched_requests()) def isunmatched(self): """ Returns ``True`` if there are unmatched requests. Otherwise ``False``. Unmatched requests will be registered only if ``networking`` mode has been enabled. Returns: bool """ return len(self.unmatched()) > 0 def pending(self): """ Returns the number of pending mocks to be matched. Returns: int: number of pending mocks. """ return len(self.pending_mocks()) def pending_mocks(self): """ Returns a ``tuple`` of pending mocks to be matched. Returns: tuple: pending mock instances. """ return [mock for mock in self.mocks if not mock.isdone()] def ispending(self): """ Returns the ``True`` if the engine has pending mocks to be matched. Otherwise ``False``. Returns: bool """ return len(self.pending_mocks()) def isactive(self): """ Returns the current engine enabled/disabled status. Returns: bool: ``True`` if the engine is active. Otherwise ``False``. """ return self.active def isdone(self): """ Returns True if all the registered mocks has been triggered. Returns: bool: True is all the registered mocks are gone, otherwise False. """ return all(mock.isdone() for mock in self.mocks) def _append(self, target, *fns): (target.append(fn) for fn in fns if isfunction(fn)) def filter(self, *filters): """ Append engine-level HTTP request filter functions. Arguments: filters*: variadic filter functions to be added. """ self._append(self.filters, *filters) def map(self, *mappers): """ Append engine-level HTTP request mapper functions. Arguments: filters*: variadic mapper functions to be added. """ self._append(self.mappers, *mappers) def should_use_network(self, request): """ Verifies if real networking mode should be used for the given request, passing it to the registered network filters. Arguments: request (pook.Request): outgoing HTTP request to test. Returns: bool """ if not self.networking: return False if not self.network_filters: # networking is enabled, and there are no filters, so # all unmatching requests should be allowed return True # Otherwise, only allow if at least one of the network filters matches return any(fn(request) for fn in self.network_filters) def match(self, request): """ Matches a given Request instance contract against the registered mocks. If a mock passes all the matchers, its response will be returned. Arguments: request (pook.Request): Request contract to match. Raises: pook.PookNoMatches: if networking is disabled and no mock matches with the given request contract. Returns: pook.Response: the mock response to be used by the interceptor. """ # Trigger engine-level request filters for test in self.filters: if not test(request, self): return False # Trigger engine-level request mappers for mapper in self.mappers: request = mapper(request, self) if not request: raise ValueError("map function must return a request object") # Store list of mock matching errors for further debugging match_errors = [] # Try to match the request against registered mock definitions for mock in self.mocks[:]: # Return the first matched HTTP request mock matches, errors = mock.match(request.copy()) if len(errors): match_errors += errors if matches: return mock # Validate that we have a mock if not self.should_use_network(request): msg = "pook error!\n\n" msg += "=> Cannot match any mock for the " f"following request:\n{request}" # Compose unmatch error details, if debug mode is enabled if self.debug: err = "\n\n".join([str(err) for err in match_errors]) if err: msg += f"\n\n=> Detailed matching errors:\n{err}\n" # Raise no matches exception self.no_matches(msg) # Register unmatched request self.unmatched_reqs.append(request) def no_matches(self, msg): """Raise `PookNoMatches` and reduce pytest printed stacktrace noise""" raise PookNoMatches(msg) pook-2.1.3/src/pook/exceptions.py000066400000000000000000000007741472770511200167750ustar00rootroot00000000000000import warnings class PookInvalidBody(Exception): pass class PookNoMatches(Exception): pass class PookNetworkFilterError(Exception): pass class PookExpiredMock(Exception): def __init__(self, *args, **kwargs): warnings.warn( "PookExpiredMock is deprecated and will be removed in a future version of Pook", DeprecationWarning, stacklevel=2, ) super().__init__(*args, **kwargs) class PookInvalidArgument(Exception): pass pook-2.1.3/src/pook/headers.py000066400000000000000000000203311472770511200162160ustar00rootroot00000000000000try: from collections.abc import Mapping, MutableMapping except ImportError: from collections.abc import Mapping, MutableMapping class HTTPHeaderDict(MutableMapping): """ :param headers: An iterable of field-value pairs. Must not contain multiple field names when compared case-insensitively. :param kwargs: Additional field-value pairs to pass in to ``dict.update``. A ``dict`` like container for storing HTTP Headers. Field names are stored and compared case-insensitively in compliance with RFC 7230. Iteration provides the first case-sensitive key seen for each case-insensitive pair. Using ``__setitem__`` syntax overwrites fields that compare equal case-insensitively in order to maintain ``dict``'s api. For fields that compare equal, instead create a new ``HTTPHeaderDict`` and use ``.add`` in a loop. If multiple fields that are equal case-insensitively are passed to the constructor or ``.update``, the behavior is undefined and some will be lost. Usage:: headers = HTTPHeaderDict() headers.add('Set-Cookie', 'foo=bar') headers.add('set-cookie', 'baz=quxx') headers['content-length'] = '7' headers['SET-cookie'] > 'foo=bar, baz=quxx' headers['Content-Length'] > '7' """ def __init__(self, headers=None, **kwargs): super(HTTPHeaderDict, self).__init__() self._container = {} if headers is not None: if isinstance(headers, HTTPHeaderDict): self._copy_from(headers) else: self.extend(headers) if kwargs: self.extend(kwargs) def __setitem__(self, key, val): self._container[key.lower()] = (key, val) return self._container[key.lower()] def __getitem__(self, key): val = self._container[key.lower()] return ", ".join([to_string_value(v) for v in val[1:]]) def __delitem__(self, key): del self._container[key.lower()] def __contains__(self, key): return key.lower() in self._container def __eq__(self, other): if not isinstance(other, Mapping) and not hasattr(other, "keys"): return False if not isinstance(other, type(self)): other = type(self)(other) return dict((k.lower(), v) for k, v in self.itermerged()) == dict( (k.lower(), v) for k, v in other.itermerged() ) def __ne__(self, other): return not self.__eq__(other) __marker = object() def __len__(self): return len(self._container) def __iter__(self): # Only provide the originally cased names for vals in self._container.values(): yield vals[0] def pop(self, key, default=__marker): """ D.pop(k[,d]) -> v, remove specified key and return the corresponding value. If key is not found, d is returned if given, otherwise KeyError is raised. """ # Using the MutableMapping function directly fails due to the # private marker. Using ordinary dict.pop would expose the # internal structures. So let's reinvent the wheel. try: value = self[key] except KeyError: if default is self.__marker: raise return default else: del self[key] return value def discard(self, key): try: del self[key] except KeyError: pass def add(self, key, val): """ Adds a (name, value) pair, doesn't overwrite the value if it already exists. Usage:: headers = HTTPHeaderDict(foo='bar') headers.add('Foo', 'baz') headers['foo'] > 'bar, baz' """ key_lower = key.lower() new_vals = key, val # Keep the common case aka no item present as fast as possible vals = self._container.setdefault(key_lower, new_vals) if new_vals is not vals: # new_vals was not inserted, as there was a previous one if isinstance(vals, list): # If already several items got inserted, we have a list vals.append(val) else: # vals should be a tuple then, i.e. only one item so far # Need to convert the tuple to list for further extension self._container[key_lower] = [vals[0], vals[1], val] def set(self, key, val): """ Sets a header field with the given value, removing previous values. Usage:: headers = HTTPHeaderDict(foo='bar') headers.set('Foo', 'baz') headers['foo'] > 'baz' """ key_lower = key.lower() new_vals = key, val # Keep the common case aka no item present as fast as possible vals = self._container.setdefault(key_lower, new_vals) if new_vals is not vals: self._container[key_lower] = [vals[0], vals[1], val] def extend(self, mapping, **kwargs): """ Generic import function for any type of header-like object. Adapted version of MutableMapping.update in order to insert items with self.add instead of self.__setitem__ """ if isinstance(mapping, HTTPHeaderDict): for key, val in mapping.iteritems(): self.add(key, val) elif isinstance(mapping, Mapping): for key in mapping: self.add(key, mapping[key]) elif hasattr(mapping, "keys"): for key in mapping.keys(): self.add(key, mapping[key]) else: for key, value in mapping: self.add(key, value) for key, value in kwargs.items(): self.add(key, value) def getlist(self, key): """ Returns a list of all the values for the named field. Returns an empty list if the key doesn't exist. """ try: vals = self._container[key.lower()] except KeyError: return [] else: if isinstance(vals, tuple): return [vals[1]] else: return vals[1:] # Backwards compatibility for httplib getheaders = getlist getallmatchingheaders = getlist iget = getlist def __repr__(self): return "%s(%s)" % (type(self).__name__, dict(self.itermerged())) def _copy_from(self, other): for key in other: val = other.getlist(key) if isinstance(val, list): # Don't need to convert tuples val = list(val) self._container[key.lower()] = [key] + val def copy(self): clone = type(self)() clone._copy_from(self) return clone def iteritems(self): """ Iterate over all header lines, including duplicate ones. """ for key in self: vals = self._container[key.lower()] for val in vals[1:]: yield vals[0], val def itermerged(self): """ Iterate over all headers, merging duplicate ones together. """ for key in self: val = self._container[key.lower()] yield val[0], ", ".join([to_string_value(v) for v in val[1:]]) def items(self): return list(self.iteritems()) def to_dict(self): return {key: values for key, values in self.items()} def to_string_value(value): """ Retrieve a string value for an arbitrary value. HTTP header values are specified as ASCII strings. However, the specificiation also states that non-ASCII bytes should be treated as arbitrary data. In that case, we just rely on unicode escaping to return a value that at least somewhat resembles the inputs (at least moreso than other encodings that would significantly obscure the input, like base 64). Arguments:: value (mixed): The value to cast to ``str``. Returns:: str: Unicode escaped ``value`` if it was ``bytes``; otherwise, ``value`` is returned, cast through ``str``. """ if hasattr(value, "decode"): return value.decode("unicode_escape") return str(value) pook-2.1.3/src/pook/helpers.py000066400000000000000000000035771472770511200162620ustar00rootroot00000000000000import re from inspect import isfunction, ismethod from .exceptions import PookInvalidArgument reply_response_re = re.compile("^(response|reply)_") def _get_key(key_order): def key(x): raw = reply_response_re.sub("", x) try: return key_order.index(raw) except KeyError: raise PookInvalidArgument(f"Unsupported argument: {x}") return key def trigger_methods(instance, args, key_order=None): """ Triggers specific class methods using a simple reflection mechanism based on the given input dictionary params. Arguments: instance (object): target instance to dynamically trigger methods. args (iterable): input arguments to trigger objects to key_order (None|iterable): optional order in which to process keys; falls back to `sorted`'s default behaviour if not present Returns: None """ # Start the magic if key_order: key = _get_key(key_order) sorted_args = sorted(args, key=key) else: sorted_args = sorted(args) for name in sorted_args: value = args[name] target = instance # If response attibutes if reply_response_re.match(name): name = reply_response_re.sub("", name) # If instance has response attribute, use it if hasattr(instance, "_response"): target = instance._response # Retrieve class member for inspection and future use member = getattr(target, name, None) # Is attribute isattr = name in dir(target) iscallable = ismethod(member) and not isfunction(member) if not iscallable and not isattr: raise PookInvalidArgument(f"Unsupported argument: {name}") # Set attribute or trigger method if iscallable: member(value) else: setattr(target, name, value) pook-2.1.3/src/pook/interceptors/000077500000000000000000000000001472770511200167535ustar00rootroot00000000000000pook-2.1.3/src/pook/interceptors/__init__.py000066400000000000000000000024061472770511200210660ustar00rootroot00000000000000from .base import BaseInterceptor from .http import HTTPClientInterceptor from .urllib3 import Urllib3Interceptor # Explicit symbols to export __all__ = ( "interceptors", "add", "get", "BaseInterceptor", "Urllib3Interceptor", "HTTPClientInterceptor", "HttpxInterceptor", "AIOHTTPInterceptor", ) # Store built-in interceptors in pook. interceptors = [Urllib3Interceptor, HTTPClientInterceptor] try: import aiohttp # noqa from .aiohttp import AIOHTTPInterceptor interceptors.append(AIOHTTPInterceptor) except ImportError: pass try: import httpx # noqa from ._httpx import HttpxInterceptor interceptors.append(HttpxInterceptor) except ImportError: pass def add(*custom_interceptors): """ Registers a new HTTP client interceptor. Arguments: *custom_interceptors (interceptor): interceptor(s) to be added. """ interceptors.append(*custom_interceptors) def get(name): """ Returns an interceptor by class name. Arguments: name (str): interceptor class name or alias. Returns: interceptor: found interceptor instance, otherwise ``None``. """ for interceptor in interceptors: if interceptor.__name__ == name: return interceptor pook-2.1.3/src/pook/interceptors/_httpx.py000066400000000000000000000103711472770511200206350ustar00rootroot00000000000000import asyncio from http.client import responses as http_reasons from unittest import mock import typing as t import httpx from pook.request import Request # type: ignore from pook.response import Response # type: ignore from pook.interceptors.base import BaseInterceptor PATCHES = ( "httpx.Client._transport_for_url", "httpx.AsyncClient._transport_for_url", ) HttpxClient = t.Union[httpx.Client, httpx.AsyncClient] TransportForUrl = t.Callable[ [HttpxClient, httpx.URL], t.Union[httpx.BaseTransport, httpx.AsyncBaseTransport] ] class HttpxInterceptor(BaseInterceptor): """ httpx client traffic interceptor. Intercepts synchronous and asynchronous httpx traffic. """ def _patch(self, path): if "AsyncClient" in path: transport_cls = AsyncTransport else: transport_cls = SyncTransport def handler(client, *_): return transport_cls(self, client, _original_transport_for_url) try: patcher = mock.patch(path, handler) _original_transport_for_url = patcher.get_original()[0] # type: ignore[var-annotated] patcher.start() except Exception: pass else: self.patchers.append(patcher) def activate(self): [self._patch(path) for path in PATCHES] def disable(self): [patch.stop() for patch in self.patchers] T = t.TypeVar("T", httpx.BaseTransport, httpx.AsyncBaseTransport) class MockedTransport(httpx.BaseTransport, t.Generic[T]): _original_transport_for_url: t.Callable[[HttpxClient, httpx.URL], T] def __init__( self, interceptor: HttpxInterceptor, client: HttpxClient, _original_transport_for_url: t.Callable[[HttpxClient, httpx.URL], T], ): self._interceptor = interceptor self._client = client self._original_transport_for_url = _original_transport_for_url def _get_pook_request(self, httpx_request: httpx.Request) -> Request: req = Request(httpx_request.method) req.url = str(httpx_request.url) req.headers = httpx_request.headers return req def _get_httpx_response( self, httpx_request: httpx.Request, mock_response: Response ) -> httpx.Response: res = httpx.Response( status_code=mock_response._status, headers=mock_response._headers, content=mock_response._body, extensions={ # TODO: Add HTTP2 response support "http_version": b"HTTP/1.1", "reason_phrase": http_reasons.get(mock_response._status, "").encode( "ascii" ), "network_stream": None, }, request=httpx_request, ) # Allow to read the response on client side res.is_stream_consumed = False res.is_closed = False if hasattr(res, "_content"): del res._content return res class AsyncTransport(MockedTransport[httpx.AsyncBaseTransport]): async def _get_pook_request(self, httpx_request): req = super()._get_pook_request(httpx_request) req.body = await httpx_request.aread() return req async def handle_async_request(self, request): pook_request = await self._get_pook_request(request) mock = self._interceptor.engine.match(pook_request) if not mock: transport = self._original_transport_for_url(self._client, request.url) return await transport.handle_async_request(request) if mock._delay: await asyncio.sleep(mock._delay / 1000) return self._get_httpx_response(request, mock._response) class SyncTransport(MockedTransport[httpx.BaseTransport]): def _get_pook_request(self, httpx_request): req = super()._get_pook_request(httpx_request) req.body = httpx_request.read() return req def handle_request(self, request): pook_request = self._get_pook_request(request) mock = self._interceptor.engine.match(pook_request) if not mock: transport = self._original_transport_for_url(self._client, request.url) return transport.handle_request(request) return self._get_httpx_response(request, mock._response) pook-2.1.3/src/pook/interceptors/aiohttp.py000066400000000000000000000142671472770511200210070ustar00rootroot00000000000000import asyncio from http.client import responses as http_reasons from typing import Callable, Optional from unittest import mock from urllib.parse import urlencode, urlunparse from collections.abc import Mapping import aiohttp from aiohttp.helpers import TimerNoop from aiohttp.streams import EmptyStreamReader from pook.request import Request # type: ignore from pook.interceptors.base import BaseInterceptor # Try to load yarl URL parser package used by aiohttp import multidict import yarl PATCHES = ("aiohttp.client.ClientSession._request",) RESPONSE_CLASS = "ClientResponse" RESPONSE_PATH = "aiohttp.client_reqrep" class SimpleContent(EmptyStreamReader): def __init__(self, content, *args, **kwargs): super().__init__(*args, **kwargs) self.content = content async def read(self, n=-1): return self.content def HTTPResponse(session: aiohttp.ClientSession, *args, **kw): return session._response_class( *args, request_info=mock.Mock(), writer=None, continue100=None, timer=TimerNoop(), traces=[], loop=mock.Mock(), session=mock.Mock(), **kw, ) class AIOHTTPInterceptor(BaseInterceptor): """ aiohttp HTTP client traffic interceptor. """ def _url(self, url) -> Optional[yarl.URL]: return yarl.URL(url) if yarl else None def set_headers(self, req, headers) -> None: # aiohttp's interface allows various mappings, as well as an iterable of key/value tuples # ``pook.request`` only allows a dict, so we need to map the iterable to the matchable interface if headers: if isinstance(headers, Mapping): req.headers.update(**headers) else: # If it isn't a mapping, then its an Iterable[Tuple[Union[str, istr], str]] for req_header, req_header_value in headers: normalised_header = req_header.lower() if normalised_header in req.headers: req.headers[normalised_header] += f", {req_header_value}" else: req.headers[normalised_header] = req_header_value async def _on_request( self, _request: Callable, session: aiohttp.ClientSession, method: str, url: str, data=None, headers=None, **kw, ) -> aiohttp.ClientResponse: # Create request contract based on incoming params req = Request(method) self.set_headers(req, headers) self.set_headers(req, session.headers) req.body = data # Expose extra variadic arguments req.extra = kw full_url = session._build_url(url) # Compose URL if not kw.get("params"): req.url = str(full_url) else: req.url = ( str(full_url) + "?" + urlencode([(x, y) for x, y in kw["params"].items()]) ) # If a json payload is provided, serialize it for JSONMatcher support if json_body := kw.get("json"): req.json = json_body if "Content-Type" not in req.headers: req.headers["Content-Type"] = "application/json" # Match the request against the registered mocks in pook mock = self.engine.match(req) # If cannot match any mock, run real HTTP request if networking # or silent model are enabled, otherwise this statement won't # be reached (an exception will be raised before). if not mock: return await _request( session, method, url, data=data, headers=headers, **kw ) # Simulate network delay if mock._delay: await asyncio.sleep(mock._delay / 1000) # noqa # Shortcut to mock response res = mock._response # Aggregate headers as list of tuples for interface compatibility headers = [] for key in res._headers: headers.append((key, res._headers[key])) # Create mock equivalent HTTP response _res = HTTPResponse(session, req.method, self._url(urlunparse(req.url))) # response status _res.version = aiohttp.HttpVersion(1, 1) _res.status = res._status _res.reason = http_reasons.get(res._status) # Add response headers _res._raw_headers = tuple(headers) _res._headers = multidict.CIMultiDictProxy(multidict.CIMultiDict(headers)) if res._body: _res.content = SimpleContent(res._body) else: # Define `_content` attribute with an empty string to # force do not read from stream (which won't exists) _res.content = EmptyStreamReader() # Return response based on mock definition return _res def _patch(self, path: str) -> None: # If not able to import aiohttp dependencies, skip if not yarl or not multidict: return None async def handler(session, method, url, data=None, headers=None, **kw): return await self._on_request( _request, session, method, url, data=data, headers=headers, **kw ) try: # Create a new patcher for Urllib3 urlopen function # used as entry point for all the HTTP communications patcher = mock.patch(path, handler) # Retrieve original patched function that we might need for real # networking _request = patcher.get_original()[0] # Start patching function calls patcher.start() except Exception: # Exceptions may accur due to missing package # Ignore all the exceptions for now pass else: self.patchers.append(patcher) def activate(self) -> None: """ Activates the traffic interceptor. This method must be implemented by any interceptor. """ for path in PATCHES: self._patch(path) def disable(self) -> None: """ Disables the traffic interceptor. This method must be implemented by any interceptor. """ for patch in self.patchers: patch.stop() pook-2.1.3/src/pook/interceptors/base.py000066400000000000000000000016401472770511200202400ustar00rootroot00000000000000from abc import ABCMeta, abstractmethod class BaseInterceptor: """ BaseInterceptor provides a base class for HTTP traffic interceptors implementations. """ __metaclass__ = ABCMeta def __init__(self, engine): self.patchers = [] self.engine = engine @property def name(self) -> str: """ Exposes the interceptor class name. """ return type(self).__name__ @abstractmethod def activate(self): """ Activates the traffic interceptor. This method must be implemented by any interceptor. """ raise NotImplementedError("Sub-classes must implement `activate`") @abstractmethod def disable(self): """ Disables the traffic interceptor. This method must be implemented by any interceptor. """ raise NotImplementedError("Sub-classes must implement `disable`") pook-2.1.3/src/pook/interceptors/http.py000066400000000000000000000104701472770511200203060ustar00rootroot00000000000000import socket from http.client import _CS_REQ_SENT, HTTPMessage # type: ignore[attr-defined] from http.client import HTTPSConnection from http.client import ( responses as http_reasons, ) from unittest import mock from pook.request import Request # type: ignore from pook.interceptors.base import BaseInterceptor PATCHES = ("http.client.HTTPConnection.request",) RESPONSE_CLASS = "HTTPResponse" RESPONSE_PATH = "http.client" URLLIB3_BYPASS = "__urllib3_bypass__" def HTTPResponse(*args, **kw): # Dynamically load package module = __import__(RESPONSE_PATH, fromlist=(RESPONSE_CLASS,)) HTTPResponse = getattr(module, RESPONSE_CLASS) # Return response instance return HTTPResponse(*args, **kw) class SocketMock(socket.socket): def __init__(self): pass def makefile(self, *args, **kw): pass def close(self, *args, **kw): pass class HTTPClientInterceptor(BaseInterceptor): """ urllib / http.client HTTP traffic interceptor. """ def _on_request(self, _request, conn, method, url, body=None, headers=None, **kw): # Create request contract based on incoming params req = Request(method) req.headers = headers or {} req.body = body if isinstance(conn, HTTPSConnection): schema = "https" else: schema = "http" # Compose URL req.url = f"{schema}://{conn.host}:{conn.port}{url}" # Match the request against the registered mocks in pook mock = self.engine.match(req) # If cannot match any mock, run real HTTP request since networking, # otherwise this statement won't be reached # (an exception will be raised before). if not mock: return _request(conn, method, url, body=body, headers=headers, **kw) # Shortcut to mock response res = mock._response mockres = HTTPResponse(SocketMock(), method=method, url=url) mockres.version = (1, 1) mockres.status = res._status # urllib requires `code` to be set, rather than `status` mockres.code = res._status mockres.reason = http_reasons.get(res._status) mockres.headers = HTTPMessage() for hkey, hval in res._headers.itermerged(): mockres.headers.add_header(hkey, hval) def getresponse(): return mockres conn.getresponse = getresponse conn.__response = mockres # type: ignore[attr-defined] conn.__state = _CS_REQ_SENT # type: ignore[attr-defined] # Path reader def read(): return res._body or b"" mockres.read = read return mockres def _patch(self, path): def handler(conn, method, url, body=None, headers=None, **kw): # Detect if httplib was called by urllib3 interceptor # This is a bit ugly, I know. Ideas are welcome! if headers and URLLIB3_BYPASS in headers: # Remove bypass header used as flag headers.pop(URLLIB3_BYPASS) # Call original patched function return request(conn, method, url, body=body, headers=headers, **kw) # Otherwise call the request interceptor return self._on_request( request, conn, method, url, body=body, headers=headers, **kw ) try: # Create a new patcher for Urllib3 urlopen function # used as entry point for all the HTTP communications patcher = mock.patch(path, handler) # Retrieve original patched function that we might need for real # networking request = patcher.get_original()[0] # Start patching function calls patcher.start() except Exception: # Exceptions may accur due to missing package # Ignore all the exceptions for now pass else: self.patchers.append(patcher) def activate(self): """ Activates the traffic interceptor. This method must be implemented by any interceptor. """ [self._patch(path) for path in PATCHES] def disable(self): """ Disables the traffic interceptor. This method must be implemented by any interceptor. """ [patch.stop() for patch in self.patchers] pook-2.1.3/src/pook/interceptors/urllib3.py000066400000000000000000000136241472770511200207070ustar00rootroot00000000000000import io from http.client import ( HTTPResponse as ClientHTTPResponse, ) from http.client import ( responses as http_reasons, ) from unittest import mock from pook.request import Request # type: ignore from pook.interceptors.base import BaseInterceptor from pook.interceptors.http import URLLIB3_BYPASS PATCHES = ( "requests.packages.urllib3.connectionpool.HTTPConnectionPool.urlopen", "urllib3.connectionpool.HTTPConnectionPool.urlopen", ) RESPONSE_CLASS = "HTTPResponse" RESPONSE_PATH = { "requests": "requests.packages.urllib3.response", "urllib3": "urllib3.response", } def HTTPResponse(path, *args, **kw): # Infer package package = path.split(".").pop(0) # Get import path import_path = RESPONSE_PATH[package] # Dynamically load package module = __import__(import_path, fromlist=(RESPONSE_CLASS,)) HTTPResponse = getattr(module, RESPONSE_CLASS) # Return response instance return HTTPResponse(*args, **kw) def is_chunked_response(headers): tencoding = dict(headers).get("Transfer-Encoding", "").lower() return "chunked" in tencoding.split(",") class MockSock: @classmethod def makefile(cls, *args, **kwargs): return class FakeHeaders(list): def get_all(self, key, default=None): key = key.lower() return [v for (k, v) in self if k.lower() == key] getheaders = get_all class FakeResponse: def __init__(self, method, headers): self._method = method # name expected by urllib3 self.msg = FakeHeaders(headers) self.closed = False def close(self): self.closed = True def isclosed(self): return self.closed class FakeChunkedResponseBody: def __init__(self, chunks): # append a terminating chunk chunks.append(b"") self.position = 0 self.stream = b"".join([self._encode(c) for c in chunks]) self.closed = False def _encode(self, chunk): length = "%X\r\n" % len(chunk) return length.encode() + chunk + b"\r\n" def read_chunk(self, amt=-1, whole=False): if whole or amt == -1: end_idx = self.stream.index(b"\r\n", self.position) + 2 else: end_idx = self.position + amt chunk = self.stream[self.position : end_idx] self.position = end_idx return chunk def readline(self): return self.read_chunk(whole=True) def read(self, amt=-1): return self.read_chunk(amt) def flush(self): pass def close(self): self.closed = True class Urllib3Interceptor(BaseInterceptor): """ Urllib3 HTTP traffic interceptor. """ def _on_request( self, urlopen, path, pool, method, url, body=None, headers=None, **kw ): # Remove bypass headers real_headers = dict(headers or {}) real_headers.pop(URLLIB3_BYPASS) # Create request contract based on incoming params req = Request(method) req.headers = real_headers req.body = body # Compose URL req.url = f"{pool.scheme}://{pool.host}:{pool.port or 80:d}{url}" # Match the request against the registered mocks in pook mock = self.engine.match(req) # If cannot match any mock, run real HTTP request since networking # or silent model will be enabled, otherwise this statement won't # be reached (an exception will be raised before). if not mock: return urlopen(pool, method, url, body=body, headers=headers, **kw) # Shortcut to mock response and response body res = mock._response body = res._body # Aggregate headers as list of tuples for interface compatibility headers = [] for key in res._headers: headers.append((key, res._headers[key])) if is_chunked_response(headers): body_chunks = body if isinstance(body, list) else [body] body = ClientHTTPResponse(MockSock) # type: ignore body.fp = FakeChunkedResponseBody(body_chunks) # type:ignore else: # Assume that the body is a bytes-like object body = io.BytesIO(res._body) # Return mocked HTTP response return HTTPResponse( path, body=body, status=res._status, headers=headers, preload_content=False, reason=http_reasons.get(res._status), original_response=FakeResponse(method, headers), ) def _patch(self, path): def handler(conn, method, url, body=None, headers=None, **kw): # Flag that the current request as urllib3 intercepted headers = headers or {} headers[URLLIB3_BYPASS] = "1" # Call request interceptor return self._on_request( urlopen, path, conn, method, url, body=body, headers=headers, **kw ) try: # Create a new patcher for Urllib3 urlopen function # used as entry point for all the HTTP communications patcher = mock.patch(path, handler) # Retrieve original patched function that we might need for real # networking urlopen = patcher.get_original()[0] # Start patching function calls patcher.start() except Exception: # Exceptions may accur due to missing package # Ignore all the exceptions for now pass else: self.patchers.append(patcher) def activate(self): """ Activates the traffic interceptor. This method must be implemented by any interceptor. """ [self._patch(path) for path in PATCHES] def disable(self): """ Disables the traffic interceptor. This method must be implemented by any interceptor. """ patchers_reversed = self.patchers[::-1] [patch.stop() for patch in patchers_reversed] pook-2.1.3/src/pook/matcher.py000066400000000000000000000031441472770511200162310ustar00rootroot00000000000000class MatcherEngine(list): """ HTTP request matcher engine used by `pook.Mock` to test if an intercepted outgoing HTTP request must be mocked out or not. """ def add(self, matcher): """ Adds a new matcher function to the current engine. Arguments: matcher (function): matcher function to be added. """ self.append(matcher) def flush(self): """ Flushes the current matcher engine, removing all the registered matcher functions. """ self.clear() def match(self, request): """ Match the given HTTP request instance against the registered matcher functions in the current engine. Arguments: request (pook.Request): outgoing request to match. Returns: tuple(bool, list[str]): ``True`` if all matcher tests passes, otherwise ``False``. Also returns an optional list of error exceptions. """ errors = [] def match(matcher): try: return matcher.match(request) except Exception as err: err = f"{type(matcher).__name__}: {err}" errors.append(err) return False return all([match(matcher) for matcher in self]), errors def __repr__(self): """ Returns an human friendly readable instance data representation. Returns: str """ matchers = [repr(matcher) for matcher in self] return "MatcherEngine([\n {}\n])".format(",\n ".join(matchers)) pook-2.1.3/src/pook/matchers/000077500000000000000000000000001472770511200160405ustar00rootroot00000000000000pook-2.1.3/src/pook/matchers/__init__.py000066400000000000000000000000331472770511200201450ustar00rootroot00000000000000from .api import * # noqa pook-2.1.3/src/pook/matchers/api.py000066400000000000000000000042111472770511200171610ustar00rootroot00000000000000from .base import BaseMatcher from .body import BodyMatcher from .headers import HeaderExistsMatcher, HeadersMatcher from .json import JSONMatcher from .json_schema import JSONSchemaMatcher from .method import MethodMatcher from .path import PathMatcher from .query import QueryMatcher, QueryParameterExistsMatcher from .url import URLMatcher from .xml import XMLMatcher # Explicit symbols to export __all__ = ( "init", "add", "get", "matchers", "BaseMatcher", "MethodMatcher", "URLMatcher", "HeadersMatcher", "QueryMatcher", "PathMatcher", "BodyMatcher", "XMLMatcher", "JSONMatcher", "JSONSchemaMatcher", "QueryMatcher", ) # List of built-in matchers # This is intended to be mutable. matchers = [ MethodMatcher, URLMatcher, HeadersMatcher, HeaderExistsMatcher, QueryMatcher, PathMatcher, BodyMatcher, XMLMatcher, JSONMatcher, JSONSchemaMatcher, QueryMatcher, QueryParameterExistsMatcher, ] def add(*matcher): """ Registers one or multiple matchers to be used by default from mocking engine. Arguments: *matcher (list[pook.BaseMatcher]): variadic matchers to add. """ [matchers.append(m) for m in matcher] def get(name): """ Returns a matcher instance by class or alias name. Arguments: name (str): matcher class name or alias. Returns: matcher: found matcher instance, otherwise ``None``. """ for matcher in matchers: if matcher.__name__ == name or getattr(matcher, "name", None) == name: return matcher def init(name, *args, **kwargs): """ Initializes a matcher instance passing variadic arguments to its constructor. Acts as a delegator proxy. Arguments: name (str): matcher class name or alias to execute. *args (mixed): variadic argument **kwargs (dict): key word arguments Returns: matcher: matcher instance. Raises: ValueError: if matcher was not found. """ matcher = get(name) if not matcher: raise ValueError(f"Cannot find matcher: {name}") return matcher(*args, **kwargs) pook-2.1.3/src/pook/matchers/base.py000066400000000000000000000060631472770511200173310ustar00rootroot00000000000000import functools from abc import ABCMeta, abstractmethod from copy import deepcopy from ..compare import compare class BaseMatcher: """ BaseMatcher implements the basic HTTP request matching interface. """ __metaclass__ = ABCMeta # Negate matching if necessary negate = False def __init__(self, expectation, negate=False): if not expectation: raise ValueError("expectation argument cannot be empty") self.negate = negate self._expectation = expectation @property def name(self): return type(self).__name__ @property def expectation(self): return self._expectation @expectation.setter def expectation(self, value): self._expectation = value @abstractmethod def match(self, request): """ Match performs the value matching. This is an abstract method that must be implemented by child classes. Arguments: request (pook.Request): request object to match. """ def compare(self, value, expectation, regex_expr=False): """ Compares two values with regular expression matching support. Arguments: value (mixed): value to compare. expectation (mixed): value to match. regex_expr (bool, optional): enables string based regex matching. Returns: bool """ return compare(value, expectation, regex_expr=regex_expr) def to_dict(self): """ Returns the current matcher representation as dictionary. Returns: dict """ return {self.name: deepcopy(self.expectation)} def __repr__(self): return f"{self.name}({self.expectation})" def __str__(self): return self.expectation @staticmethod def matcher(fn): @functools.wraps(fn) def wrapper(self, *args): result = fn(self, *args) return not result if self.negate else result return wrapper class ExistsMatcher(BaseMatcher, metaclass=ABCMeta): """ Base class for matchers that only check for existence. """ @property @abstractmethod def request_attr(self): """ The attribute from the request in which to check for existence of the expectation. """ ... def get_request_attribute(self, request): """ Retrieve attribute from the request in which existence should be checked. """ if self.request_attr is None: raise ValueError("`request_attr` must not be None") return getattr(request, self.request_attr) @BaseMatcher.matcher def match(self, request): attribute = self.get_request_attribute(request) assert ( attribute is not None ), f"Expected request to have {self.request_attr} with {self.expectation}, but no {self.request_attr} found on the request" assert ( self.expectation in attribute ), f"{self.expectation} not found in request's {self.request_attr}" return True pook-2.1.3/src/pook/matchers/body.py000066400000000000000000000004541472770511200173520ustar00rootroot00000000000000from .base import BaseMatcher class BodyMatcher(BaseMatcher): """ BodyMatchers matches the request body via strict value comparison or regular expression based matching. """ @BaseMatcher.matcher def match(self, req): return self.compare(self.expectation, req.body) pook-2.1.3/src/pook/matchers/headers.py000066400000000000000000000030561472770511200200310ustar00rootroot00000000000000from ..headers import to_string_value from ..regex import Pattern from .base import BaseMatcher, ExistsMatcher class HeadersMatcher(BaseMatcher): """ Headers HTTP request matcher. """ def __init__(self, headers): if not isinstance(headers, dict): raise TypeError("headers must be a dictionary") BaseMatcher.__init__(self, headers) @BaseMatcher.matcher def match(self, req): for key in self.expectation: assert key in req.headers, f"Header '{key}' not present" expected_value = self.to_comparable_value(self.expectation[key]) # Retrieve header value by key actual_value = req.headers.get(key) assert not all( [ expected_value is not None, actual_value is None, ] ), f"Expected a value `{expected_value}` " f"for '{key}' but found `None`" # Compare header value if not self.compare(expected_value, actual_value, regex_expr=True): return False return True def to_comparable_value(self, value): """ Return a comparable version of ``value``. Arguments: value (mixed): the value to cast. Returns: str|re.Pattern|None """ if isinstance(value, (str, Pattern)): return value if value is None: return value return to_string_value(value) class HeaderExistsMatcher(ExistsMatcher): request_attr = "headers" pook-2.1.3/src/pook/matchers/json.py000066400000000000000000000017321472770511200173660ustar00rootroot00000000000000import json from ..assertion import equal from .base import BaseMatcher class JSONMatcher(BaseMatcher): """ Match JSON documents of equivalent value. JSON documents are matched on the structured data in the document, rather than on the strict organisation of the document. The following two JSON snippets are treated as identical by this matcher: {"a": "one", "b": ["two"]} ... is considered idential to ... {"b": ["two"], "a": "one"} In other words, the order does not matter in comparison. Use ``BodyMatcher`` to strictly match the exact textual structure. """ def __init__(self, data): BaseMatcher.__init__(self, data) if isinstance(data, str): self.expectation = json.loads(data) @BaseMatcher.matcher def match(self, req): x = json.dumps(self.expectation, sort_keys=True, indent=4) y = json.dumps(req.json, sort_keys=True, indent=4) return equal(x, y) pook-2.1.3/src/pook/matchers/json_schema.py000066400000000000000000000012231472770511200207010ustar00rootroot00000000000000import json from jsonschema import validate from .base import BaseMatcher class JSONSchemaMatcher(BaseMatcher): """ JSONSchema matcher validates a request body against a given JSONSchema definition schema. """ def __init__(self, schema): BaseMatcher.__init__(self, schema) if isinstance(schema, str): self.expectation = json.loads(schema) @BaseMatcher.matcher def match(self, req): req_json = req.json if not req_json: return False try: validate(req_json, self.expectation) except Exception: return False return True pook-2.1.3/src/pook/matchers/method.py000066400000000000000000000004371472770511200176760ustar00rootroot00000000000000from .base import BaseMatcher class MethodMatcher(BaseMatcher): """ MethodMatcher implements. """ @BaseMatcher.matcher def match(self, req): return self.expectation == "*" or self.compare( req.method.lower(), self.expectation.lower() ) pook-2.1.3/src/pook/matchers/path.py000066400000000000000000000003601472770511200173450ustar00rootroot00000000000000from .base import BaseMatcher class PathMatcher(BaseMatcher): """ PathMatcher implements an URL path matcher. """ @BaseMatcher.matcher def match(self, req): return self.compare(self.expectation, req.url.path) pook-2.1.3/src/pook/matchers/query.py000066400000000000000000000037721472770511200175700ustar00rootroot00000000000000from urllib.parse import parse_qs from .base import BaseMatcher, ExistsMatcher class QueryMatcher(BaseMatcher): """ QueryMatcher implements an URL query params matcher. """ def match_query(self, query, req_query): def test(key, param): match = req_query.get(key) if match is None: return False # Normalize param value param = [param] if not isinstance(param, list) else param # Compare query params [[self.compare(value, expect) for expect in match] for value in param] return True return all([test(key, query[key]) for key in query]) @BaseMatcher.matcher def match(self, req): query = self.expectation # Parse and assert type if isinstance(query, str): query = parse_qs(self.expectation) # Validate query params if not isinstance(query, dict): raise ValueError("query params must be a str or dict") # Parse request URL query req_query = parse_qs(req.url.query) # Match query params return self.match_query(query, req_query) class QueryParameterExistsMatcher(ExistsMatcher): request_attr = "query" def __init__(self, expectation, allow_empty, negate=False): super().__init__(expectation, negate) self.allow_empty = allow_empty def match(self, request): if not super().match(request): return False if not self.allow_empty: attribute = self.get_request_attribute(request) assert not self.is_empty( attribute[self.expectation] ), f"The request's {self.expectation} query parameter was unexpectedly empty." return True def is_empty(self, value): """ Check for empty query parameter values. `urllib.parse.parse_qs` returns a value of `['']` for parameters that are present but without value. """ return not value or value == [""] pook-2.1.3/src/pook/matchers/url.py000066400000000000000000000035441472770511200172220ustar00rootroot00000000000000import re from urllib.parse import urlparse from ..regex import isregex from .base import BaseMatcher from .path import PathMatcher from .query import QueryMatcher # URI protocol test regular expression protoregex = re.compile("^http[s]?://", re.IGNORECASE) class URLMatcher(BaseMatcher): """ URLMatcher implements an URL schema matcher. """ # Matches URL as regular expression regex = False def __init__(self, url): if not url: raise ValueError("url argument cannot be empty") # Store original URL value self.url = url # Process as regex value if isregex(url): self.regex = True self.expectation = url else: # Add protocol prefix in the URL if not protoregex.match(url): self.url = f"http://{url}" self.expectation = urlparse(self.url) def match_path(self, req): path = self.expectation.path if not path: return True return PathMatcher(path).match(req) def match_query(self, req): query = self.expectation.query if not query: return True return QueryMatcher(query).match(req) @BaseMatcher.matcher def match(self, req): url = self.expectation # Match as regex if self.regex: return self.compare(url, req.url.geturl(), regex_expr=True) # Match URL return all( [ self.compare(url.scheme, req.url.scheme), self.compare(url.hostname, req.url.hostname), self.compare(url.port or req.url.port, req.url.port), self.match_path(req), self.match_query(req), ] ) def __str__(self): return self.url def __repr__(self): return f"{self.name}({self.url})" pook-2.1.3/src/pook/matchers/xml.py000066400000000000000000000022361472770511200172150ustar00rootroot00000000000000import json import xmltodict from ..assertion import equal from .base import BaseMatcher class XMLMatcher(BaseMatcher): """ Match XML documents of equivalent structural value. XML documents are matched on the structured data in the document, rather than on the strict organisation of the document. The following two XML snippets are treated as identical by this matcher: two ... is considered idential to ... two In other words, the order does not matter in comparison. Use ``BodyMatcher`` to strictly match the exact textual structure. """ def __init__(self, data): BaseMatcher.__init__(self, data) if isinstance(data, str): self.expectation = xmltodict.parse(data) def compare(self, data): x = json.dumps(xmltodict.parse(data), sort_keys=True) y = json.dumps(self.expectation, sort_keys=True) return equal(x, y) @BaseMatcher.matcher def match(self, req): xml = req.xml if not isinstance(xml, str): return False return self.compare(xml) pook-2.1.3/src/pook/mock.py000066400000000000000000000637231472770511200155500ustar00rootroot00000000000000import functools from inspect import isfunction, ismethod from furl import furl from .constants import TYPES from .helpers import trigger_methods from .matcher import MatcherEngine from .matchers import init as matcher from .request import Request from .response import Response def _append_funcs(target, items): """ Helper function to append functions into a given list. Arguments: target (list): receptor list to append functions. items (iterable): iterable that yields elements to append. """ [target.append(item) for item in items if isfunction(item) or ismethod(item)] def _trigger_request(instance, request): """ Triggers request mock definition methods dynamically based on input keyword arguments passed to `pook.Mock` constructor. This is used to provide a more Pythonic interface vs chainable API approach. """ if not isinstance(request, Request): raise TypeError("request must be instance of pook.Request") # Register request matchers for key in request.keys: if hasattr(instance, key): getattr(instance, key)(getattr(request, key)) class Mock: """ Mock is used to declare and compose the HTTP request/response mock definition and matching expectations, which provides fluent API DSL. Arguments: url (str): URL to match. E.g: ``server.com/api?foo=bar``. method (str): HTTP method name to match. E.g: ``GET``. path (str): URL path to match. E.g: ``/api/users``. headers (dict): Header values to match. E.g: ``{'server': 'nginx'}``. header_present (str): Matches is a header is present. headers_present (list|tuple): Matches if multiple headers are present. type (str): Matches MIME ``Content-Type`` header. E.g: ``json``, ``xml``, ``html``, ``text/plain`` content (str): Same as ``type`` argument. params (dict): Matches the given URL params. param_exists (str): Matches if a given URL param exists. params_exists (list|tuple): Matches if a given URL params exists. body (str|regex): Matches the payload body by regex or strict comparison. json (dict|list|str|regex): Matches the payload body against the given JSON or regular expression. jsonschema (dict|str): Matches the payload body against the given JSONSchema. xml (str|regex): matches the payload body against the given XML string or regular expression. file (str): Disk file path to load body from. Analog to ``body`` param. times (int): Mock TTL or maximum number of times that the mock can be matched. persist (bool): Enable persistent mode. Mock won't be flushed even if it matched one or multiple times. delay (int): Optional network delay simulation (only applicable when using ``aiohttp`` HTTP client). callback (function): optional callback function called every time the mock is matched. reply (int): Mock response status. Defaults to ``200``. response_status (int): Mock response status. Alias to ``reply`` param. response_headers (dict): Response headers to use. response_type (str): Response MIME type expression or alias. Analog to ``type`` param. E.g: ``json``, ``xml``, ``text/plain``. response_body (str): Response body to use. response_json (dict|list|str): Response JSON to use. If Python is passed, it will be serialized as JSON transparently. response_xml (str): XML body string to use. request (pook.Request): Optional. Request mock definition object. response (pook.Response): Optional. Response mock definition object. Returns: pook.Mock """ _KEY_ORDER = ( "add_matcher", "body", "callback", "calls", "content", "delay", "done", "error", "file", "filter", "header", "header_present", "headers", "headers_present", "isdone", "ismatched", "json", "jsonschema", "map", "match", "matched", "matches", "method", "url", "param", "param_exists", "params", "path", "persist", "reply", "response", "status", "times", "total_matches", "type", "use", "xml", ) def __init__(self, request=None, response=None, **kw): # Stores the number of times the mock should live self._times = 1 # Stores the number of times the mock has been matched self._matches = 0 # Stores the simulated error exception self._error = None # Stores the optional network delay in milliseconds self._delay = 0 # Stores the mock persistance mode. `True` means it will live forever self._persist = False # Optional binded engine where the mock belongs to self._engine = None # Store request-response mock matched calls self._calls = [] # Stores the input request instance self._request = request or Request() # Stores the response mock instance self._response = response or Response() # Stores the mock matcher engine used for outgoing traffic matching self.matchers = MatcherEngine() # Stores filters used to filter outgoing HTTP requests. self.filters = [] # Stores HTTP request mappers used by the mock. self.mappers = [] # Stores callback functions that will be triggered if the mock # matches outgoing traffic. self.callbacks = [] # Triggers instance methods based on argument names trigger_methods(self, kw, self._KEY_ORDER) # Trigger matchers based on predefined request object, if needed if request: _trigger_request(self, request) def url(self, url): """ Defines the mock URL to match. It can be a full URL with path and query params. Protocol schema is optional, defaults to ``http://``. Arguments: url (str): mock URL to match. E.g: ``server.com/api``. Returns: self: current Mock instance. """ self._request.url = url self.add_matcher(matcher("URLMatcher", url)) return self def method(self, method): """ Defines the HTTP method to match. Use ``*`` to match any method. Arguments: method (str): method value to match. E.g: ``GET``. Returns: self: current Mock instance. """ self._request.method = method self.add_matcher(matcher("MethodMatcher", method)) return self def path(self, path): """ Defines a URL path to match. Only call this method if the URL has no path already defined. Arguments: path (str): URL path value to match. E.g: ``/api/users``. Returns: self: current Mock instance. """ url = furl(self._request.rawurl) url.path = path self._request.url = url.url self.add_matcher(matcher("PathMatcher", path)) return self def header(self, name, value): """ Defines a URL path to match. Only call this method if the URL has no path already defined. Arguments: path (str): URL path value to match. E.g: ``/api/users``. Returns: self: current Mock instance. """ headers = {name: value} self._request.headers = headers self.add_matcher(matcher("HeadersMatcher", headers)) return self def headers(self, headers=None, **kw): """ Defines a dictionary of arguments. Header keys are case insensitive. Arguments: headers (dict): headers to match. **headers (dict): headers to match as variadic keyword arguments. Returns: self: current Mock instance. """ headers = kw if kw else headers self._request.headers = headers self.add_matcher(matcher("HeadersMatcher", headers)) return self def header_present(self, *names): """ Defines a new header matcher expectation that must be present in the outgoing request in order to be satisfied, no matter what value it hosts. Header keys are case insensitive. Arguments: *names (str): header or headers names to match. Returns: self: current Mock instance. Example:: (pook.get('server.com/api') .header_present('content-type')) """ return self.headers_present(names) def headers_present(self, headers): """ Defines a list of headers that must be present in the outgoing request in order to satisfy the matcher, no matter what value the headers hosts. Header keys are case insensitive. Arguments: headers (list|tuple): header keys to match. Returns: self: current Mock instance. Example:: (pook.get('server.com/api') .headers_present(['content-type', 'Authorization'])) """ if not headers: raise ValueError("`headers` must not be empty") for header in headers: self.add_matcher(matcher("HeaderExistsMatcher", header)) return self def type(self, value): """ Defines the request ``Content-Type`` header to match. You can pass one of the following aliases instead of the full MIME type representation: - ``json`` = ``application/json`` - ``xml`` = ``application/xml`` - ``html`` = ``text/html`` - ``text`` = ``text/plain`` - ``urlencoded`` = ``application/x-www-form-urlencoded`` - ``form`` = ``application/x-www-form-urlencoded`` - ``form-data`` = ``application/x-www-form-urlencoded`` Arguments: value (str): type alias or header value to match. Returns: self: current Mock instance. """ self.content(value) return self def content(self, value): """ Defines the ``Content-Type`` outgoing header value to match. You can pass one of the following type aliases instead of the full MIME type representation: - ``json`` = ``application/json`` - ``xml`` = ``application/xml`` - ``html`` = ``text/html`` - ``text`` = ``text/plain`` - ``urlencoded`` = ``application/x-www-form-urlencoded`` - ``form`` = ``application/x-www-form-urlencoded`` - ``form-data`` = ``application/x-www-form-urlencoded`` Arguments: value (str): type alias or header value to match. Returns: self: current Mock instance. """ header = {"Content-Type": TYPES.get(value, value)} self._request.headers = header self.add_matcher(matcher("HeadersMatcher", header)) return self def param(self, name, value): """ Defines an URL param key and value to match. Arguments: name (str): param name value to match. value (str): param name value to match. Returns: self: current Mock instance. """ self.params({name: value}) return self def param_exists(self, name, allow_empty=False): """ Checks if a given URL param name is present in the URL. Arguments: name (str): param name to check existence. allow_empty (bool): whether to allow an empty value of the param Returns: self: current Mock instance. """ self.add_matcher(matcher("QueryParameterExistsMatcher", name, allow_empty)) return self def params(self, params): """ Defines a set of URL query params to match. Arguments: params (dict): set of params to match. Returns: self: current Mock instance. """ url = furl(self._request.rawurl) url = url.add(params) self._request.url = url.url self.add_matcher(matcher("QueryMatcher", params)) return self def body(self, body): """ Defines the body data to match. ``body`` argument can be a ``str``, ``bytes`` or a regular expression. Arguments: body (str|bytes|regex): body data to match. Returns: self: current Mock instance. """ if hasattr(body, "encode"): body = body.encode("utf-8", "backslashreplace") self._request.body = body self.add_matcher(matcher("BodyMatcher", body)) return self def json(self, json): """ Defines the JSON body to match. ``json`` argument can be an JSON string, a JSON serializable Python structure, such as a ``dict`` or ``list`` or it can be a regular expression used to match the body. Arguments: json (str|dict|list|regex): body JSON to match. Returns: self: current Mock instance. """ self._request.json = json self.add_matcher(matcher("JSONMatcher", json)) return self def jsonschema(self, schema): """ Defines a JSONSchema representation to be used for body matching. Arguments: schema (str|dict): dict or JSONSchema string to use. Returns: self: current Mock instance. """ self.add_matcher(matcher("JSONSchemaMatcher", schema)) return self def xml(self, xml): """ Defines a XML body value to match. Arguments: xml (str|regex): body XML to match. Returns: self: current Mock instance. """ self._request.xml = xml self.add_matcher(matcher("XMLMatcher", xml)) return self def file(self, path): """ Reads the body to match from a disk file. Arguments: path (str): relative or absolute path to file to read from. Returns: self: current Mock instance. """ with open(path, "rb") as f: return self.body(f.read()) def add_matcher(self, matcher): """ Adds one or multiple custom matchers instances. Matchers must implement the following interface: - ``.__init__(expectation)`` - ``.match(request)`` - ``.name = str`` Matchers can optionally inherit from ``pook.matchers.BaseMatcher``. Arguments: *matchers (pook.matchers.BaseMatcher): matchers to add. Returns: self: current Mock instance. """ self.matchers.add(matcher) return self def use(self, *matchers): """ Adds one or multiple custom matchers instances. Matchers must implement the following interface: - ``.__init__(expectation)`` - ``.match(request)`` - ``.name = str`` Matchers can optionally inherit from ``pook.matchers.BaseMatcher``. Arguments: *matchers (pook.matchers.BaseMatcher): matchers to add. Returns: self: current Mock instance. """ [self.add_matcher(matcher) for matcher in matchers] return self def times(self, times=1): """ Defines the TTL limit for the current mock. The TTL number will determine the maximum number of times that the current mock can be matched and therefore consumed. Arguments: times (int): TTL number. Defaults to ``1``. Returns: self: current Mock instance. """ self._times = times return self def persist(self, status=None): """ Enables persistent mode for the current mock. Returns: self: current Mock instance. """ self._persist = status if isinstance(status, bool) else True return self def filter(self, *filters): """ Registers one o multiple request filters used during the matching phase. Arguments: *mappers (function): variadic mapper functions. Returns: self: current Mock instance. """ _append_funcs(self.filters, filters) return self def map(self, *mappers): """ Registers one o multiple request mappers used during the mapping phase. Arguments: *mappers (function): variadic mapper functions. Returns: self: current Mock instance. """ _append_funcs(self.mappers, mappers) return self def callback(self, *callbacks): """ Registers one or multiple callback that will be called every time the current mock matches an outgoing HTTP request. Arguments: *callbacks (function): callback functions to call. Returns: self: current Mock instance. """ _append_funcs(self.callbacks, callbacks) return self def delay(self, delay=1000): """ Delay network response with certain milliseconds. Only supported by asynchronous HTTP clients, such as ``aiohttp``. Arguments: delay (int): milliseconds to delay response. Returns: self: current Mock instance. """ self._delay = int(delay) return self def error(self, error): """ Defines a simulated exception error that will be raised. Arguments: error (str|Exception): error to raise. Returns: self: current Mock instance. """ self._error = RuntimeError(error) if isinstance(error, str) else error return self def reply(self, status=200, new_response=False, **kw): """ Defines the mock response. Arguments: status (int, optional): response status code. Defaults to ``200``. **kw (dict): optional keyword arguments passed to ``pook.Response`` constructor. Returns: pook.Response: mock response definition instance. """ # Use or create a Response mock instance res = Response(**kw) if new_response else self._response # Define HTTP mandatory response status res.status(status or res._status) # Expose current mock instance in response for self-reference res.mock = self # Define mock response self._response = res # Return response return res def status(self, code=200): """ Defines the response status code. Equivalent to ``self.reply(code)``. Arguments: code (int): response status code. Defaults to ``200``. Returns: pook.Response: mock response definition instance. """ return self.reply(status=code) def response(self, status=200, **kw): """ Defines the mock response. Alias to ``.reply()`` Arguments: status (int): response status code. Defaults to ``200``. **kw (dict): optional keyword arguments passed to ``pook.Response`` constructor. Returns: pook.Response: mock response definition instance. """ return self.reply(status=status, **kw) def isdone(self): """ Returns ``True`` if the mock has been matched by outgoing HTTP traffic. Returns: bool: ``True`` if the mock was matched succesfully. """ return (self._persist and self._matches > 0) or self._times <= 0 def ismatched(self): """ Returns ``True`` if the mock has been matched at least once time. Returns: bool """ return self._matches > 0 @property def done(self): """ Attribute accessor that would be ``True`` if the current mock is done, and therefore have been matched multiple times. Returns: bool """ return self.isdone() @property def matched(self): """ Accessor property that would be ``True`` if the current mock have been matched at least once. See ``Mock.total_matches`` for more information. Returns: bool """ return self._matches > 0 @property def total_matches(self): """ Accessor property to retrieve the total number of times that the current mock has been matched. Returns: int """ return self._matches @property def matches(self): """ Accessor to retrieve the mock match calls registry. Returns: list[MockCall] """ return self._calls @property def calls(self): """ Accessor to retrieve the amount of mock matched calls. Returns: int """ return len(self.matches) def match(self, request): """ Matches an outgoing HTTP request against the current mock matchers. This method acts like a delegator to `pook.MatcherEngine`. Arguments: request (pook.Request): request instance to match. Raises: Exception: if the mock has an exception defined. Returns: tuple(bool, list[Exception]): ``True`` if the mock matches the outgoing HTTP request, otherwise ``False``. Also returns an optional list of error exceptions. """ # Trigger mock filters for test in self.filters: if not test(request, self): return False, [] # Trigger mock mappers for mapper in self.mappers: request = mapper(request, self) if not request: raise ValueError("map function must return a request object") # Match incoming request against registered mock matchers matches, errors = self.matchers.match(request) # If not matched, return False if not matches: return False, errors if self._times <= 0: return False, [f"Mock matches request but is expired.\n{self!r}"] # Register matched request for further inspecion and reference self._calls.append(request) # Increase mock call counter self._matches += 1 if not self._persist: self._times -= 1 # Raise simulated error if self._error: raise self._error # Trigger callback when matched for callback in self.callbacks: callback(request, self) return True, [] def __call__(self, fn): """ Overload Mock instance as callable object in order to be used as decorator definition syntax. Arguments: fn (function): function to decorate. Returns: function or pook.Mock """ # Support chain sequences of mock definitions if isinstance(fn, Response): return fn.mock if isinstance(fn, Mock): return fn # Force type assertion and raise an error if it is not a function if not isfunction(fn) and not ismethod(fn): raise TypeError("first argument must be a method or function") # Remove mock to prevent decorator definition scope collision self._engine.remove_mock(self) @functools.wraps(fn) def decorator(*args, **kw): # Re-register mock on decorator call self._engine.add_mock(self) # Force engine activation, if available # This prevents state issue while declaring mocks as decorators. # This might be removed in the future. engine_active = self._engine.active if not engine_active: self._engine.activate() # Call decorated target function try: return fn(*args, **kw) finally: # Finally remove mock after function execution # to prevent shared state self._engine.remove_mock(self) # If the engine was not previously active, disable it if not engine_active: self._engine.disable() return decorator def __repr__(self): """ Returns an human friendly readable instance data representation. Returns: str """ keys = ("matches", "times", "persist", "matchers", "response") args = [] for key in keys: if key == "matchers": value = repr(self.matchers).replace("\n ", "\n ") value = value[:-2] + " ])" elif key == "response": value = repr(self._response) value = value[:-1] + " )" else: value = repr(getattr(self, "_" + key)) args.append(f"{key}={value}") args = "(\n {}\n)".format(",\n ".join(args)) return type(self).__name__ + args def __enter__(self): """ Implements context manager enter interface. """ # Make mock persistent if using default times if self._times == 1: self._persist = True # Automatically enable the mock engine, if needed if not self._engine.active: self._engine.activate() self._disable_engine = True return self def __exit__(self, etype, value, traceback): """ Implements context manager exit interface. """ # Force disable mock self._times = 0 # Automatically disable the mock engine, if needed if getattr(self, "_disable_engine", False): self._disable_engine = False self._engine.disable() if etype is not None: raise value pook-2.1.3/src/pook/mock_engine.py000066400000000000000000000067401472770511200170710ustar00rootroot00000000000000from .interceptors import interceptors class MockEngine: """ ``MockEngine`` represents the low-level mocking engine abstraction layer between ``pook`` and the underlying mocking mechanism responsible of intercepting and trigger outgoing HTTP traffic within the Python runtime. ``MockEngine`` implements the built-in `pook` mock engine based on HTTP interceptors strategy. Developers can implement and plug in their own ``MockEngine`` in order to fit custom mocking logic needs. You can see a custom ``MockEngine`` implementation here: http://bit.ly/2EymMro Custom mock engines must implementent at least the following methods: - `engine.__init__(self, engine)` - `engine.activate(self)` - `engine.disable(self)` Custom mock engines can optionally implement the following methods: - `engine.add_interceptors(self, *interceptors)` - `engine.flush_interceptors(self)` - `engine.disable_interceptor(self, name) -> bool` Arguments: engine (pook.Engine): injected pook engine to be used. Attributes: engine (pook.Engine): stores pook engine to be used. interceptors (list[pook.BaseInterceptor]): stores engine-level HTTP traffic interceptors. """ def __init__(self, engine): # Store pook engine self.engine = engine # Store HTTP client interceptors self.interceptors = [] # Self-register built-in interceptors self.add_interceptor(*interceptors) def add_interceptor(self, *interceptors): """ Adds one or multiple HTTP traffic interceptors to the current mocking engine. Interceptors are typically HTTP client specific wrapper classes that implements the pook interceptor interface. Arguments: interceptors (pook.interceptors.BaseInterceptor) """ for interceptor in interceptors: self.interceptors.append(interceptor(self.engine)) def flush_interceptors(self): """ Flushes registered interceptors in the current mocking engine. This method is low-level. Only call it if you know what you are doing. """ self.interceptors = [] def remove_interceptor(self, name): """ Removes a specific interceptor by name. Arguments: name (str): interceptor name to disable. Returns: bool: `True` if the interceptor was disabled, otherwise `False`. """ for index, interceptor in enumerate(self.interceptors): matches = type(interceptor).__name__ == name or interceptor.name == name if matches: self.interceptors.pop(index) return True return False def activate(self): """ Activates the registered interceptors in the mocking engine. This means any HTTP traffic captures by those interceptors will trigger the HTTP mock matching engine in order to determine if a given HTTP transaction should be mocked out or not. """ [interceptor.activate() for interceptor in self.interceptors] def disable(self): """ Disables interceptors and stops intercepting any outgoing HTTP traffic. """ # Restore HTTP interceptors for interceptor in self.interceptors: try: interceptor.disable() except RuntimeError: pass # explicitely ignore runtime patch errors pook-2.1.3/src/pook/pytest_fixture.py000066400000000000000000000002631472770511200177030ustar00rootroot00000000000000import pytest import pook as pook_mod @pytest.fixture def pook(): """Pytest fixture for HTTP traffic mocking and testing""" with pook_mod.use(): yield pook_mod pook-2.1.3/src/pook/regex.py000066400000000000000000000020721472770511200157170ustar00rootroot00000000000000import re Pattern = re.Pattern def isregex_expr(expr): """ Returns ``True`` is the given expression value is a regular expression like string with prefix ``re/`` and suffix ``/``, otherwise ``False``. Arguments: expr (mixed): expression value to test. Returns: bool """ if not isinstance(expr, str): return False return all([len(expr) > 3, expr.startswith("re/"), expr.endswith("/")]) def isregex(value): """ Returns ``True`` if the input argument object is a native regular expression object, otherwise ``False``. Arguments: value (mixed): input value to test. Returns: bool """ if not value: return False return any((isregex_expr(value), isinstance(value, Pattern))) def strip_regex(expr): """ Strips regular expression notation syntax characters from the given string expression. Arguments: expr (str): regular expression expression to strip Returns: str """ return expr[3:-1] if isregex_expr(expr) else expr pook-2.1.3/src/pook/request.py000066400000000000000000000110521472770511200162730ustar00rootroot00000000000000import json as _json from urllib.parse import parse_qs, urlparse, urlunparse from .headers import HTTPHeaderDict from .helpers import trigger_methods from .matchers.url import protoregex from .regex import isregex class Request: """ Request object representing the request mock expectation DSL. Arguments: method (str): HTTP method to match. Defaults to ``GET``. url (str): URL request to intercept and match. headers (dict): HTTP headers to match. query (dict): URL query params to match. Complementely to URL defined query params. body (bytes|regex): request body payload to match. json (str|dict|list): JSON payload body structure to match. xml (str): XML payload data structure to match. """ # Store keys keys = ("method", "headers", "body", "url", "query", "xml", "json") """ :meta private: """ def __init__(self, method="GET", **kw): self._url = None self._body = None self._query = None self._method = method self._extra = kw.get("extra") self._headers = HTTPHeaderDict() trigger_methods(self, kw, self.keys) @property def method(self): """HTTP method to match. Defaults to ``GET``.""" return self._method @method.setter def method(self, method): self._method = method @property def headers(self): """HTTP headers to match.""" return self._headers @headers.setter def headers(self, headers): if not hasattr(headers, "__setitem__"): raise TypeError("headers must be a dictionary") self._headers.extend(headers) @property def extra(self): return self._extra @extra.setter def extra(self, extra): if not isinstance(extra, dict): raise TypeError("extra must be a dictionary") self._extra = extra @property def url(self): """URL request to intercept and match.""" return self._url @url.setter def url(self, url): if isregex(url): self._url = url else: if not protoregex.match(url): url = f"http://{url}" self._url = urlparse(url) # keep_blank_values necessary for `param_exists` when a parameter has no value but is present self._query = ( parse_qs(self._url.query, keep_blank_values=True) if self._url.query else self._query ) @property def rawurl(self): return self._url if isregex(self._url) else urlunparse(self._url) @property def query(self): """URL query params to match. Complementary to URL defined query params.""" return self._query @query.setter def query(self, params): self._query = parse_qs(params) @property def body(self): """request body payload to match.""" return self._body @body.setter def body(self, body): if hasattr(body, "encode"): body = body.encode("utf-8", "backslashreplace") self._body = body @property def json(self): """JSON payload body structure to match.""" return _json.loads(self.body.decode("utf-8")) @json.setter def json(self, data): if isinstance(data, str): self.body = data else: self.body = _json.dumps(data) @property def xml(self): """XML payload data structure to match.""" return self.body.decode("utf-8") @xml.setter def xml(self, data): self.body = data def copy(self): """ Copies the current Request object instance for side-effects purposes. Returns: pook.Request: copy of the current Request instance. """ req = type(self)() req.__dict__ = self.__dict__.copy() req._headers = self.headers.copy() return req def __repr__(self): """ Returns an human friendly readable instance data representation. Returns: str """ entries = [] entries.append(f"Method: {self._method}") entries.append(f"URL: {self._url if isregex(self._url) else self.rawurl}") if self._query: entries.append(f"Query: {self._query}") if self._headers: entries.append(f"Headers: {self._headers}") if self._body: entries.append(f"Body: {self._body}") separator = "=" * 50 return (separator + "\n{}\n" + separator).format("\n".join(entries)) pook-2.1.3/src/pook/response.py000066400000000000000000000146671472770511200164600ustar00rootroot00000000000000import json from .constants import TYPES from .headers import HTTPHeaderDict from .helpers import trigger_methods class Response: """ Response is used to declare and compose an HTTP mock responses fields. It provides a chainable DSL interface for easier and declarative usage. Arguments: status (int): HTTP response status code. Defaults to ``200``. headers (dict): HTTP response headers. body (str|bytes): HTTP response body. json (str|dict|list): HTTP response JSON body. xml (str): HTTP response XML body. type (str): HTTP response content MIME type. file (str): file path to HTTP body response. """ _KEY_ORDER = ( "body", "content", "file", "header", "headers", "json", "mock", "set", "status", "type", "xml", ) def __init__(self, **kw): self._status = 200 self._mock = None self._body = None self._headers = HTTPHeaderDict() # Trigger response method based on input arguments trigger_methods(self, kw, self._KEY_ORDER) def status(self, code=200): """ Defines the response status code. Arguments: code (int): response status code. Returns: self: ``pook.Response`` current instance. """ self._status = int(code) return self def header(self, key, value): """ Defines a new response header. Alias to ``Response.header()``. Arguments: header (str): header name. value (str): header value. Returns: self: ``pook.Response`` current instance. """ if type(key) is tuple: key, value = str(key[0]), key[1] headers = {key: value} self._headers.extend(headers) return self def headers(self, headers): """ Defines a new response header. Alias to ``Response.header()``. Arguments: header (str): header name. value (str): header value. Returns: self: ``pook.Response`` current instance. """ self._headers.extend(headers) return self def set(self, header, value): """ Defines a new response header. Alias to ``Response.header()``. Arguments: header (str): header name. value (str): header value. Returns: self: ``pook.Response`` current instance. """ self._headers[header] = value return self def type(self, name): """ Defines the response ``Content-Type`` header. Alias to ``Response.content(mime)``. You can pass one of the following type aliases instead of the full MIME type representation: - ``json`` = ``application/json`` - ``xml`` = ``application/xml`` - ``html`` = ``text/html`` - ``text`` = ``text/plain`` - ``urlencoded`` = ``application/x-www-form-urlencoded`` - ``form`` = ``application/x-www-form-urlencoded`` - ``form-data`` = ``application/x-www-form-urlencoded`` Arguments: value (str): type alias or header value to match. Returns: self: ``pook.Response`` current instance. """ return self.content(name) def content(self, name): """ Defines the response ``Content-Type`` header. You can pass one of the following type aliases instead of the full MIME type representation: - ``json`` = ``application/json`` - ``xml`` = ``application/xml`` - ``html`` = ``text/html`` - ``text`` = ``text/plain`` - ``urlencoded`` = ``application/x-www-form-urlencoded`` - ``form`` = ``application/x-www-form-urlencoded`` - ``form-data`` = ``application/x-www-form-urlencoded`` Arguments: value (str): type alias or header value to match. Returns: self: ``pook.Response`` current instance. """ self._headers["Content-Type"] = TYPES.get(name, name) return self def body(self, body, *, chunked=False): """ Defines response body data. Arguments: body (str|bytes|list): response body to use. chunked (bool): return a chunked response. Returns: self: ``pook.Response`` current instance. """ if hasattr(body, "encode"): body = body.encode("utf-8", "backslashreplace") elif isinstance(body, list): for i, chunk in enumerate(body): if hasattr(chunk, "encode"): body[i] = chunk.encode("utf-8", "backslashreplace") self._body = body if chunked: self.header("Transfer-Encoding", "chunked") return self def json(self, data): """ Defines the mock response JSON body. Arguments: data (dict|list|str): JSON body data. Returns: self: ``pook.Response`` current instance. """ self._headers["Content-Type"] = "application/json" if not isinstance(data, str): data = json.dumps(data, indent=4) return self.body(data) def xml(self, xml): """ Defines the mock response XML body. For not it only supports ``str`` as input type. Arguments: xml (str): XML body data to use. Returns: self: ``pook.Response`` current instance. """ return self.body(xml) def file(self, path): """ Defines the response body from file contents. Arguments: path (str): disk file path to load. Returns: self: ``pook.Response`` current instance. """ with open(path, "rb") as f: return self.body(f.read()) @property def mock(self): """ Reference to mock instance. """ return self._mock @mock.setter def mock(self, mock): self._mock = mock def __repr__(self): """ Returns an human friendly readable instance data representation. Returns: str """ args = [] for key in ("headers", "status", "body"): value = getattr(self, f"_{key}") args.append(f"{key}={value}") return "Response(\n {}\n)".format(",\n ".join(args)) pook-2.1.3/tests/000077500000000000000000000000001472770511200136355ustar00rootroot00000000000000pook-2.1.3/tests/__init__.py000066400000000000000000000000001472770511200157340ustar00rootroot00000000000000pook-2.1.3/tests/integration/000077500000000000000000000000001472770511200161605ustar00rootroot00000000000000pook-2.1.3/tests/integration/__init__.py000066400000000000000000000000001472770511200202570ustar00rootroot00000000000000pook-2.1.3/tests/integration/engines/000077500000000000000000000000001472770511200176105ustar00rootroot00000000000000pook-2.1.3/tests/integration/engines/__init__.py000066400000000000000000000000001472770511200217070ustar00rootroot00000000000000pook-2.1.3/tests/integration/engines/pytest_suite.py000066400000000000000000000015711472770511200227270ustar00rootroot00000000000000import pytest import requests import pook @pook.on def test_simple_pook_request(): pook.get("httpbin.org/foo").reply(204) res = requests.get("http://httpbin.org/foo") assert res.status_code == 204 @pook.on def test_enable_engine(): pook.get("server.com/foo").reply(204) res = requests.get("http://server.com/foo") assert res.status_code == 204 pook.disable() @pook.get("server.com/bar", reply=204) def test_decorator(): res = requests.get("http://server.com/bar") assert res.status_code == 204 def test_context_manager(): with pook.use(): pook.get("server.com/baz", reply=204) res = requests.get("http://server.com/baz") assert res.status_code == 204 @pook.on def test_no_match_exception(): pook.get("server.com/bar", reply=204) with pytest.raises(Exception): requests.get("http://server.com/baz") pook-2.1.3/tests/integration/engines/unittest_suite.py000066400000000000000000000021721472770511200232540ustar00rootroot00000000000000import unittest import requests import pook class TestUnitTestEngine(unittest.TestCase): @pook.on def test_simple_pook_request(self): pook.get("server.com/foo").reply(204) res = requests.get("http://server.com/foo") self.assertEqual(res.status_code, 204) @pook.on def test_enable_engine(self): pook.get("server.com/foo").reply(204) res = requests.get("http://server.com/foo") self.assertEqual(res.status_code, 204) @pook.get("server.com/foo", reply=204) def test_decorator(self): res = requests.get("http://server.com/foo") self.assertEqual(res.status_code, 204) def test_context_manager(self): with pook.use(): pook.get("server.com/bar", reply=204) res = requests.get("http://server.com/bar") self.assertEqual(res.status_code, 204) @pook.on def test_no_match_exception(self): pook.get("server.com/bar", reply=204) try: requests.get("http://server.com/baz") except Exception: pass else: raise RuntimeError("expected to fail") pook-2.1.3/tests/integration/engines_test.py000066400000000000000000000007441472770511200212260ustar00rootroot00000000000000import subprocess import pytest @pytest.mark.parametrize( "test_command", ( pytest.param("pytest tests/integration/engines/pytest_suite.py", id="pytest"), pytest.param( "python -m unittest tests.integration.engines.unittest_suite", id="unittest" ), ), ) def test_engines(test_command): args = test_command.split(" ") assert ( subprocess.call(args) == 0 ), f"Engine smoke test failed for command '{test_command}'" pook-2.1.3/tests/integration/examples_test.py000066400000000000000000000010411472770511200214030ustar00rootroot00000000000000import platform import subprocess from pathlib import Path import sys import pytest examples_dir = Path(__file__).parents[2] / "examples" examples = [f.name for f in examples_dir.glob("*.py")] if platform.python_implementation() == "PyPy": # See pyproject.toml note on mocket dependency examples.remove("mocket_example.py") @pytest.mark.parametrize("example", examples) def test_examples(example): result = subprocess.run([sys.executable, f"examples/{example}"], check=False) assert result.returncode == 0, result.stdout pook-2.1.3/tests/integration/pook_requests_test.py000066400000000000000000000017221472770511200224760ustar00rootroot00000000000000import pytest import requests import pook pytestmark = [pytest.mark.pook] def test_requests_get(): body = {"error": "not found"} pook.get("http://foo.com").reply(404).json(body) res = requests.get("http://foo.com") assert res.status_code == 404 assert res.headers == {"Content-Type": "application/json"} assert res.json() == body def test_requests_match_url(): body = {"foo": "bar"} pook.get("http://foo.com").reply(200).json(body) res = requests.get("http://foo.com") assert res.status_code == 200 assert res.headers == {"Content-Type": "application/json"} assert res.json() == body def test_requests_match_query_params(): body = {"foo": "bar"} (pook.get("http://foo.com").params({"foo": "bar"}).reply(200).json(body)) res = requests.get("http://foo.com", params={"foo": "bar"}) assert res.status_code == 200 assert res.headers == {"Content-Type": "application/json"} assert res.json() == body pook-2.1.3/tests/unit/000077500000000000000000000000001472770511200146145ustar00rootroot00000000000000pook-2.1.3/tests/unit/__init__.py000066400000000000000000000000001472770511200167130ustar00rootroot00000000000000pook-2.1.3/tests/unit/api_test.py000066400000000000000000000030561472770511200170020ustar00rootroot00000000000000import asyncio import pytest from pook import api @pytest.fixture def engine(): return api.engine() def test_engine(engine): assert engine == api._engine def test_activate(engine): assert engine.active is False api.activate() assert engine.active is True api.disable() assert engine.active is False def test_on(engine): assert engine.active is False api.on() assert engine.active is True api.off() assert engine.active is False def test_use(engine): assert engine.active is False with api.use() as engine: assert engine.active is True assert engine.active is True assert engine.active is False def test_mock_contructors(engine): assert engine.active is False assert engine.isdone() is True api.mock("foo.com") assert engine.isdone() is False assert len(engine.mocks) == 1 api.off() assert len(engine.mocks) == 0 assert engine.active is False def test_activate_as_decorator(engine): @api.activate def test_activate(): api.get("foo.com") assert engine.active is True assert engine.isdone() is False test_activate() assert engine.active is False assert engine.isdone() is True async def test_activate_as_decorator_for_async(engine): @api.activate async def test_activate(): await asyncio.sleep(0) api.get("foo.com") assert engine.active is True assert engine.isdone() is False await test_activate() assert engine.active is False assert engine.isdone() is True pook-2.1.3/tests/unit/engine_test.py000066400000000000000000000007041472770511200174730ustar00rootroot00000000000000import pytest from pook import Engine @pytest.fixture def engine(): return Engine() def test_engine_use_network_filter(engine): assert len(engine.network_filters) == 0 engine.use_network_filter(lambda x: x) assert len(engine.network_filters) == 1 def test_engine_enable_network(engine): assert len(engine.network_filters) == 0 engine.enable_network("http://foo", "http://bar") assert len(engine.network_filters) == 2 pook-2.1.3/tests/unit/exceptions_test.py000066400000000000000000000004401472770511200204040ustar00rootroot00000000000000from pook import exceptions as ex def test_exceptions(): assert isinstance(ex.PookNoMatches(), Exception) assert isinstance(ex.PookInvalidBody(), Exception) assert isinstance(ex.PookNetworkFilterError(), Exception) assert isinstance(ex.PookInvalidArgument(), Exception) pook-2.1.3/tests/unit/fixtures/000077500000000000000000000000001472770511200164655ustar00rootroot00000000000000pook-2.1.3/tests/unit/fixtures/__init__.py000066400000000000000000000001771472770511200206030ustar00rootroot00000000000000from pathlib import Path BINARY_FILE_PATH = Path(__file__).parent / "nothing.bin" BINARY_FILE = BINARY_FILE_PATH.read_bytes() pook-2.1.3/tests/unit/fixtures/nothing.bin000066400000000000000000000020001472770511200206150ustar00rootroot00000000000000eX53-Fɇ!A} T1u9&jq *TPW% +8[[h|ܸ헸gyE5fr6Rgs `e)x"xr#bXBDSL'NÇQo*,Q1)<u2XMcԐkG=h-\i5w)! T悺j'" 9% raG՚pE22@q<2ˑ/;L:~zmp!I?:=6pdL'!v> ϔ\m6K2m"z!|x昲xUh .anu{#ͭk@^xcl?V(GMJa#J켰Vdv=?`r{9Р! gY?BؼDǍJdĻlF75wvGNǻ'G;_NR|>:˿lu-譙%;cٮei2^i]RO,;/F@k5Sa.68w#(|b h!{BT 4!R1.~AHD sW3 }v 调`ؘc*b"ˏDWH[{w 3_$v2fjr"݋ws̨bBc<{W||T -A1vx<u90K\pX:Nu^W@  W1i}ꦙ%[1%3~RQ T[ VmBRXD#NVqLY,L쵿e/gA6/cK;"%R:[./2~> &Hpook-2.1.3/tests/unit/interceptors/000077500000000000000000000000001472770511200173355ustar00rootroot00000000000000pook-2.1.3/tests/unit/interceptors/__init__.py000066400000000000000000000000001472770511200214340ustar00rootroot00000000000000pook-2.1.3/tests/unit/interceptors/aiohttp_test.py000066400000000000000000000074051472770511200224240ustar00rootroot00000000000000import aiohttp import pytest import pook from tests.unit.fixtures import BINARY_FILE from tests.unit.interceptors.base import StandardTests pytestmark = [pytest.mark.pook] class TestStandardAiohttp(StandardTests): is_async = True async def amake_request(self, method, url, content=None, headers=None): async with aiohttp.ClientSession(loop=self.loop) as session: response = await session.request( method=method, url=url, data=content, headers=headers ) response_content = await response.read() return response.status, response_content, response.headers def _pook_url(URL): return pook.head(URL).reply(200).mock @pytest.fixture def URL(httpbin): return f"{httpbin.url}/status/404" @pytest.mark.asyncio async def test_async_with_request(URL): mock = _pook_url(URL) async with aiohttp.ClientSession() as session: async with session.head(URL) as req: assert req.status == 200 assert len(mock.matches) == 1 @pytest.mark.asyncio async def test_await_request(URL): mock = _pook_url(URL) async with aiohttp.ClientSession() as session: req = await session.head(URL) assert req.status == 200 assert len(mock.matches) == 1 @pytest.mark.asyncio async def test_binary_body(URL): pook.get(URL).reply(200).body(BINARY_FILE) async with aiohttp.ClientSession() as session: req = await session.get(URL) assert await req.read() == BINARY_FILE @pytest.mark.asyncio async def test_json_matcher_json_payload(URL): payload = {"foo": "bar"} pook.post(URL).json(payload).reply(200).body(BINARY_FILE) async with aiohttp.ClientSession() as session: req = await session.post(URL, json=payload) assert await req.read() == BINARY_FILE @pytest.mark.asyncio async def test_client_base_url(httpbin): """Client base url should be matched.""" pook.get(httpbin + "/status/404").reply(200).body("hello from pook") async with aiohttp.ClientSession(base_url=httpbin.url) as session: res = await session.get("/status/404") assert res.status == 200 assert await res.read() == b"hello from pook" @pytest.mark.asyncio async def test_client_headers(httpbin): """Headers set on the client should be matched.""" pook.get(httpbin + "/status/404").header("x-pook", "hello").reply(200).body( "hello from pook" ) async with aiohttp.ClientSession(headers={"x-pook": "hello"}) as session: res = await session.get(httpbin + "/status/404") assert res.status == 200 assert await res.read() == b"hello from pook" @pytest.mark.asyncio async def test_client_headers_merged(httpbin): """Headers set on the client should be matched even if request-specific headers are sent.""" pook.get(httpbin + "/status/404").header("x-pook", "hello").reply(200).body( "hello from pook" ) async with aiohttp.ClientSession(headers={"x-pook": "hello"}) as session: res = await session.get( httpbin + "/status/404", headers={"x-pook-secondary": "xyz"} ) assert res.status == 200 assert await res.read() == b"hello from pook" @pytest.mark.asyncio async def test_client_headers_both_session_and_request(httpbin): """Headers should be matchable from both the session and request in the same matcher""" pook.get(httpbin + "/status/404").header("x-pook-session", "hello").header( "x-pook-request", "hey" ).reply(200).body("hello from pook") async with aiohttp.ClientSession(headers={"x-pook-session": "hello"}) as session: res = await session.get( httpbin + "/status/404", headers={"x-pook-request": "hey"} ) assert res.status == 200 assert await res.read() == b"hello from pook" pook-2.1.3/tests/unit/interceptors/base.py000066400000000000000000000205531472770511200206260ustar00rootroot00000000000000import asyncio from collections.abc import Sequence import json from typing import Mapping, Optional, Tuple import pytest import pook from pook.exceptions import PookNoMatches class StandardTests: is_async: bool = False loop: asyncio.AbstractEventLoop async def amake_request( self, method: str, url: str, content: Optional[bytes] = None, headers: Optional[Sequence[tuple[str, str]]] = None, ) -> Tuple[int, Optional[bytes], Mapping[str, str]]: raise NotImplementedError( "Sub-classes for async transports must implement `amake_request`" ) def make_request( self, method: str, url: str, content: Optional[bytes] = None, headers: Optional[Sequence[tuple[str, str]]] = None, ) -> Tuple[int, Optional[bytes], Mapping[str, str]]: if self.is_async: return self.loop.run_until_complete( self.amake_request(method, url, content, headers) ) raise NotImplementedError("Sub-classes must implement `make_request`") @pytest.fixture(autouse=True, scope="class") def _loop(self, request): if self.is_async: request.cls.loop = asyncio.new_event_loop() yield request.cls.loop.close() else: yield @pytest.fixture def url_404(self, httpbin): """404 httpbin URL. Useful in tests if pook is configured to reply 200, and the status is checked. If pook does not match the request (and if that was the intended behaviour) then the 404 status code makes that obvious!""" return f"{httpbin.url}/status/404" @pytest.fixture def url_500(self, httpbin): return f"{httpbin.url}/status/500" @pytest.fixture def url_401(self, httpbin): return httpbin + "/status/401" @pytest.mark.pook def test_activate_deactivate(self, url_404): """Deactivating pook allows requests to go through.""" pook.get(url_404).reply(200).body("hello from pook") status, body, *_ = self.make_request("GET", url_404) assert status == 200 assert body == b"hello from pook" pook.disable() status, body, *_ = self.make_request("GET", url_404) assert status == 404 @pytest.mark.pook(allow_pending_mocks=True) def test_network_mode(self, url_404, url_500): """Enabling network mode allows requests to pass through even if no mock is matched.""" pook.get(url_404).reply(200).body("hello from pook") pook.enable_network() # Avoid matching the mocks status, *_ = self.make_request("POST", url_500) assert status == 500 mocked_status, mocked_body, *_ = self.make_request("GET", url_404) assert mocked_status == 200 assert mocked_body == b"hello from pook" @pytest.mark.pook(allow_pending_mocks=True) def test_network_mode_hostname(self, url_401): example_com = "http://example.com" pook.get(example_com).header("x-pook", "1").reply(200).body("hello from pook") # httpbin runs on loopback pook.enable_network("127.0.0.1") httpbin_status, *_ = self.make_request("GET", url_401) # network is enabled for httpbin hostname so it goes through assert httpbin_status == 401 with pytest.raises(PookNoMatches): # Make the request without query params to avoid matching the mock # which should raise a no match exception, as network mode is not enabled # for example.com hostname self.make_request("GET", example_com) # this matches the mock on the header, so gets 200 with the hello from pook body example_status, body, *_ = self.make_request( "GET", example_com, headers=[("x-pook", "1")] ) assert example_status == 200 assert body == b"hello from pook" @pytest.mark.pook(allow_pending_mocks=True) def test_multiple_network_filters(self, url_401): """When multiple network filters are added, only one is required to match for the request to be allowed through the network.""" def has_x_header(request: pook.Request): return request.headers.get("x-pook") == "x" def has_y_header(request: pook.Request): return request.headers.get("x-pook") == "y" pook.enable_network() pook.use_network_filter(has_x_header, has_y_header) pook.get(url_401).header("x-pook", "z").reply(200).body("hello from pook") # Network filter matches, so request is allowed despite not matching a mock x_status, *_ = self.make_request("GET", url_401, headers=[("x-pook", "x")]) assert x_status == 401 # Network filter matches, so request is allowed despite not matching a mock y_status, *_ = self.make_request("GET", url_401, headers=[("x-pook", "y")]) assert y_status == 401 # Mock matches, so the response is mocked z_status, z_body, *_ = self.make_request( "GET", url_401, headers=[("x-pook", "z")] ) assert z_status == 200 assert z_body == b"hello from pook" @pytest.mark.pook def test_json_request(self, url_404): """JSON request bodies are correctly matched.""" json_request = {"hello": "json-request"} pook.get(url_404).json(json_request).reply(200).body("hello from pook") status, body, *_ = self.make_request( "GET", url_404, json.dumps(json_request).encode() ) assert status == 200 assert body == b"hello from pook" @pytest.mark.pook def test_json_response(self, url_404): """JSON responses are correctly mocked.""" json_response = {"hello": "json-request"} pook.get(url_404).reply(200).json(json_response) status, body, *_ = self.make_request("GET", url_404) assert status == 200 assert body assert json.loads(body) == json_response @pytest.mark.pook def test_json_request_and_response(self, url_404): """JSON requests and responses do not interfere with each other.""" json_request = {"id": "123abc"} json_response = {"title": "123abc title"} pook.get(url_404).json(json_request).reply(200).json(json_response) status, body, *_ = self.make_request( "GET", url_404, content=json.dumps(json_request).encode() ) assert status == 200 assert body assert json.loads(body) == json_response @pytest.mark.pook def test_header_sent(self, url_404): """Sent headers can be matched.""" headers = [("x-hello", "from pook")] pook.get(url_404).header("x-hello", "from pook").reply(200).body( "hello from pook" ) status, body, _ = self.make_request("GET", url_404, headers=headers) assert status == 200 assert body == b"hello from pook" @pytest.mark.pook def test_mocked_response_headers(self, url_404): """Mocked response headers are appropriately returned.""" pook.get(url_404).reply(200).header("x-hello", "from pook") status, _, headers = self.make_request("GET", url_404) assert status == 200 assert headers["x-hello"] == "from pook" @pytest.mark.pook def test_mutli_value_headers(self, url_404): """Multi-value headers can be matched.""" match_headers = [("x-hello", "from pook"), ("x-hello", "another time")] pook.get(url_404).header("x-hello", "from pook, another time").reply(200) status, *_ = self.make_request("GET", url_404, headers=match_headers) assert status == 200 @pytest.mark.pook def test_mutli_value_response_headers(self, url_404): """Multi-value response headers can be mocked.""" pook.get(url_404).reply(200).header("x-hello", "from pook").header( "x-hello", "another time" ) status, _, headers = self.make_request("GET", url_404) assert status == 200 assert headers["x-hello"] == "from pook, another time" @pytest.mark.pook(allow_pending_mocks=True) def test_unmatched_headers_none_sent(self, url_404): """Header matching will run, but not match, on requests that send no headers.""" pook.get(url_404).header("x-hello", "from pook").reply(200) with pytest.raises(PookNoMatches): self.make_request("GET", url_404) pook-2.1.3/tests/unit/interceptors/httpx_test.py000066400000000000000000000051471472770511200221240ustar00rootroot00000000000000from itertools import zip_longest import httpx import pytest import pook from tests.unit.interceptors.base import StandardTests pytestmark = [pytest.mark.pook] class TestStandardAsyncHttpx(StandardTests): is_async = True async def amake_request(self, method, url, content=None, headers=None): async with httpx.AsyncClient() as client: response = await client.request( method=method, url=url, content=content, headers=headers ) content = await response.aread() return response.status_code, content, response.headers class TestStandardSyncHttpx(StandardTests): def make_request(self, method, url, content=None, headers=None): response = httpx.request( method=method, url=url, content=content, headers=headers ) content = response.read() return response.status_code, content, response.headers @pytest.fixture def URL(httpbin): return f"{httpbin.url}/status/404" def test_sync(URL): pook.get(URL).times(1).reply(200).body("123") response = httpx.get(URL) assert response.status_code == 200 async def test_async(URL): pook.get(URL).times(1).reply(200).body(b"async_body").mock async with httpx.AsyncClient() as client: response = await client.get(URL) assert response.status_code == 200 assert (await response.aread()) == b"async_body" def test_json(URL): ( pook.post(URL) .times(1) .json({"id": "123abc"}) .reply(200) .json({"title": "123abc title"}) ) response = httpx.post(URL, json={"id": "123abc"}) assert response.status_code == 200 assert response.json() == {"title": "123abc title"} @pytest.mark.parametrize("response_method", ("iter_bytes", "iter_raw")) def test_streaming(URL, response_method): streamed_response = b"streamed response" pook.get(URL).times(1).reply(200).body(streamed_response).mock with httpx.stream("GET", URL) as r: read_bytes = list(getattr(r, response_method)(chunk_size=1)) assert len(read_bytes) == len(streamed_response) assert b"".join(read_bytes) == streamed_response def test_redirect_following(URL): urls = [URL, f"{URL}/redirected", f"{URL}/redirected_again"] for req, dest in zip_longest(urls, urls[1:], fillvalue=None): if not dest: pook.get(req).times(1).reply(200).body("found at last") else: pook.get(req).times(1).reply(302).header("Location", dest) response = httpx.get(URL, follow_redirects=True) assert response.status_code == 200 assert response.read() == b"found at last" pook-2.1.3/tests/unit/interceptors/module_test.py000066400000000000000000000004271472770511200222360ustar00rootroot00000000000000from pook import interceptors class CustomInterceptor(interceptors.BaseInterceptor): def activate(self): ... def disable(self): ... def test_add_custom_interceptor(): interceptors.add(CustomInterceptor) assert CustomInterceptor in interceptors.interceptors pook-2.1.3/tests/unit/interceptors/urllib3_test.py000066400000000000000000000057661472770511200223400ustar00rootroot00000000000000import pytest import urllib3 import requests import pook from tests.unit.fixtures import BINARY_FILE from tests.unit.interceptors.base import StandardTests class TestStandardUrllib3(StandardTests): def make_request(self, method, url, content=None, headers=None): req_headers = {} if headers: for header, value in headers: if header in req_headers: req_headers[header] += f", {value}" else: req_headers[header] = value http = urllib3.PoolManager() response = http.request(method, url, content, headers=req_headers) return response.status, response.read(), response.headers class TestStandardRequests(StandardTests): def make_request(self, method, url, content=None, headers=None): req_headers = {} if headers: for header, value in headers: if header in req_headers: req_headers[header] += f", {value}" else: req_headers[header] = value response = requests.request(method, url, data=content, headers=req_headers) return response.status_code, response.content, response.headers @pytest.fixture def URL(httpbin): return f"{httpbin.url}/foo" @pook.on def assert_chunked_response(URL, input_data, expected): (pook.get(URL).reply(201).body(input_data, chunked=True)) http = urllib3.PoolManager() r = http.request("GET", URL) assert r.status == 201 chunks = list(r.read_chunked()) assert chunks == expected def test_chunked_response_list(URL): assert_chunked_response(URL, ["a", "b", "c"], [b"a", b"b", b"c"]) def test_chunked_response_str(URL): assert_chunked_response(URL, "text", [b"text"]) def test_chunked_response_byte(URL): assert_chunked_response(URL, b"byteman", [b"byteman"]) def test_chunked_response_empty(URL): assert_chunked_response(URL, "", []) def test_chunked_response_contains_newline(URL): assert_chunked_response(URL, "newline\r\n", [b"newline\r\n"]) def test_activate_disable(): original = urllib3.connectionpool.HTTPConnectionPool.urlopen interceptor = pook.interceptors.Urllib3Interceptor(pook.MockEngine) interceptor.activate() interceptor.disable() assert urllib3.connectionpool.HTTPConnectionPool.urlopen == original @pook.on def test_binary_body(URL): (pook.get(URL).reply(200).body(BINARY_FILE)) http = urllib3.PoolManager() r = http.request("GET", URL) assert r.read() == BINARY_FILE @pook.on def test_binary_body_chunked(URL): (pook.get(URL).reply(200).body(BINARY_FILE, chunked=True)) http = urllib3.PoolManager() r = http.request("GET", URL) assert list(r.read_chunked()) == [BINARY_FILE] @pytest.mark.pook def test_post_with_headers(URL): mock = pook.post(URL).header("k", "v").reply(200).mock http = urllib3.PoolManager(headers={"k": "v"}) resp = http.request("POST", URL) assert resp.status == 200 assert len(mock.matches) == 1 pook-2.1.3/tests/unit/interceptors/urllib_test.py000066400000000000000000000024301472770511200222360ustar00rootroot00000000000000from urllib.error import HTTPError from urllib.request import Request, urlopen from http.client import HTTPResponse import pytest import pook from tests.unit.interceptors.base import StandardTests class TestUrllib(StandardTests): def make_request(self, method, url, content=None, headers=None): req_headers = {} if headers: for header, value in headers: if header in req_headers: req_headers[header] += f", {value}" else: req_headers[header] = value request = Request( url=url, method=method, data=content, headers=req_headers, ) try: response: HTTPResponse = urlopen(request) return response.status, response.read(), response.headers except HTTPError as e: return e.code, e.msg @pytest.mark.pook def test_urllib_ssl(): pook.get("https://example.com").reply(200).body("Hello from pook") res = urlopen("https://example.com") assert res.read() == b"Hello from pook" @pytest.mark.pook def test_urllib_clear(): pook.get("http://example.com").reply(200).body("Hello from pook") res = urlopen("http://example.com") assert res.read() == b"Hello from pook" pook-2.1.3/tests/unit/matcher_test.py000066400000000000000000000000001472770511200176360ustar00rootroot00000000000000pook-2.1.3/tests/unit/matchers/000077500000000000000000000000001472770511200164225ustar00rootroot00000000000000pook-2.1.3/tests/unit/matchers/__init__.py000066400000000000000000000000001472770511200205210ustar00rootroot00000000000000pook-2.1.3/tests/unit/matchers/base_test.py000066400000000000000000000021171472770511200207460ustar00rootroot00000000000000import pytest from pook.matchers.base import BaseMatcher class _BaseMatcher(BaseMatcher): def match(self, x): pass def test_base_matcher_instance(): matcher = _BaseMatcher("foo") assert matcher.name == "_BaseMatcher" assert matcher.negate is False assert matcher.expectation == "foo" assert matcher.to_dict() == {"_BaseMatcher": "foo"} assert matcher.__repr__() == "_BaseMatcher(foo)" assert matcher.__str__() == "foo" def test_base_matcher_compare(): assert _BaseMatcher("foo").compare("foo", "foo") assert _BaseMatcher("foo").compare("foo", "foo") with pytest.raises(AssertionError): assert _BaseMatcher("foo").compare("foo", "bar") def test_base_matcher_exceptions(): assert _BaseMatcher("foo").match(None) is None with pytest.raises(ValueError, match="expectation argument cannot be empty"): _BaseMatcher(None) def test_base_matcher_matcher(): assert BaseMatcher.matcher(lambda x: True)(BaseMatcher) matcher = _BaseMatcher("foo", negate=True) assert BaseMatcher.matcher(lambda x: False)(matcher) pook-2.1.3/tests/unit/matchers/headers_test.py000066400000000000000000000165701472770511200214570ustar00rootroot00000000000000import re import pytest import pook @pytest.mark.parametrize( ("expected", "requested"), ( pytest.param( {"Content-Type": b"application/pdf"}, {"Content-Type": b"application/pdf"}, id="Matching binary headers", ), pytest.param( { "Content-Type": b"application/pdf", "Authentication": "Bearer 123abc", }, { "Content-Type": b"application/pdf", "Authentication": "Bearer 123abc", }, id="Matching mixed headers", ), pytest.param( {"Authentication": "Bearer 123abc"}, {"Authentication": "Bearer 123abc"}, id="Matching string headers", ), pytest.param( {"Content-Type": b"application/pdf"}, { "Content-Type": b"application/pdf", "Authentication": "Bearer 123abc", }, id="Non-matching asymetric mixed headers", ), pytest.param( {"Content-Type": b"application/pdf"}, {"Content-Type": "application/pdf"}, id="Non-matching header types (matcher binary, request string)", ), pytest.param( {"Content-Type": "application/pdf"}, {"Content-Type": b"application/pdf"}, id="Non-matching header types (matcher string, request binary)", ), pytest.param( {"content-type": "application/pdf"}, {"Content-Type": "application/pdf"}, id="Non-matching field name casing", ), pytest.param( {}, {"Content-Type": "application/pdf"}, id="Missing matcher header" ), pytest.param( {"Content-Type": "application/pdf".encode("utf-16")}, {"Content-Type": "application/pdf".encode("utf-16")}, id="Arbitrary field value encoding", ), pytest.param( {"Content-Type": "re/json/"}, {"Content-Type": "application/json"}, id="Regex-format str expectation", ), pytest.param( {"Content-Type": re.compile("json", re.I)}, {"Content-Type": "APPLICATION/JSON"}, id="Regex pattern expectation", ), ), ) def test_headers_matcher_matching(expected, requested): mock = pook.get("https://example.com") if expected: mock.headers(expected) request = pook.Request() request.url = "https://example.com" if requested: request.headers = requested matched, explanation = mock.match(request) assert matched, explanation @pytest.mark.parametrize( ("expected", "requested", "explanation"), ( pytest.param( {"Content-Type": "application/pdf"}, {}, ["HeadersMatcher: Header 'Content-Type' not present"], id="Missing request header str expectation", ), pytest.param( {"Content-Type": b"application/pdf"}, {}, ["HeadersMatcher: Header 'Content-Type' not present"], id="Missing request header bytes expectation", ), pytest.param( {"Content-Type": "application/pdf"}, {"Content-Type": "application/xml"}, [ ( "HeadersMatcher: 'application/pdf' != 'application/xml'\n" "- application/pdf\n" "? ^^^\n" "+ application/xml\n" "? ^^^\n" ) ], id="Non-matching values, matching types", ), pytest.param( {"Content-Type": "application/pdf"}, {"Content-Type": b"application/xml"}, [ ( "HeadersMatcher: 'application/pdf' != 'application/xml'\n" "- application/pdf\n" "? ^^^\n" "+ application/xml\n" "? ^^^\n" ) ], id="Non-matching values, str expectation byte actual", ), pytest.param( {"Content-Type": b"application/pdf"}, {"Content-Type": "application/xml"}, [ ( "HeadersMatcher: 'application/pdf' != 'application/xml'\n" "- application/pdf\n" "? ^^^\n" "+ application/xml\n" "? ^^^\n" ) ], id="Non-matching values, bytes expectation str actual", ), pytest.param( {"Content-Type": "re/json/"}, {"Content-Type": b"application/xml"}, [ ( "HeadersMatcher: Regex didn't match: 'json' not found in " "'application/xml'" ) ], id="Non-matching values, re-format str expectation", ), ), ) def test_headers_not_matching(expected, requested, explanation): mock = pook.get("https://example.com") if expected: mock.headers(expected) request = pook.Request() request.url = "https://example.com" if requested: request.headers = requested matched, actual_explanation = mock.match(request) assert not matched assert explanation == actual_explanation @pytest.mark.parametrize( ("required_headers", "requested_headers", "should_match"), ( pytest.param( ["content-type", "Authorization"], { "Content-Type": "", "authorization": "Bearer NOT A TOKEN", }, True, id="case-insensitive-match-with-empty-value", ), pytest.param( ["content-type", "Authorization"], { "Content-Type": "application/json", "authorization": "Bearer NOT A TOKEN", }, True, id="case-insensitive-match-with-non-empty-values", ), pytest.param( ["x-requested-with"], { "content-type": "application/json", }, False, id="x-header-missing-with-other-headers", ), pytest.param( ["x-requested-with"], {}, False, id="x-header-no-headers", ), pytest.param( ["content-type"], {}, False, id="no-headers", ), pytest.param( ["x-requested-with"], {"x-requested-with": "com.example.app"}, True, id="x-header-with-value", ), pytest.param( ["x-requested-with"], {"x-requested-with": ""}, True, id="x-header-with-empty-value", ), ), ) def test_headers_present(required_headers, requested_headers, should_match): mock = pook.get("https://example.com").headers_present(required_headers) request = pook.Request() request.url = "https://example.com" request.headers = requested_headers matched, explanation = mock.match(request) assert matched == should_match, explanation def test_headers_present_empty_argument(): with pytest.raises(ValueError): pook.get("https://example.com").headers_present([]) pook-2.1.3/tests/unit/matchers/query_test.py000066400000000000000000000010751472770511200212030ustar00rootroot00000000000000from urllib.request import urlopen import pytest import pook from pook.exceptions import PookNoMatches @pytest.fixture def URL(httpbin): return f"{httpbin.url}/status/404" @pytest.mark.pook(allow_pending_mocks=True) def test_param_exists_empty_disallowed(URL): pook.get(URL).param_exists("x").reply(200) with pytest.raises(PookNoMatches): urlopen(f"{URL}?x") @pytest.mark.pook def test_param_exists_empty_allowed(URL): pook.get(URL).param_exists("x", allow_empty=True).reply(200) res = urlopen(f"{URL}?x") assert res.status == 200 pook-2.1.3/tests/unit/matchers/url_test.py000066400000000000000000000052461472770511200206440ustar00rootroot00000000000000# flake8: noqa import re import pytest from functools import partial from pook.request import Request from pook.matchers.url import URLMatcher def run_test(match_url, url, matches, regex=False): req = Request(url=url) if regex: match_url = re.compile(match_url, re.IGNORECASE) if matches: assert URLMatcher(match_url).match(req) else: with pytest.raises(Exception): URLMatcher(match_url).match(req) @pytest.mark.parametrize( ("match_url", "url", "matches"), ( # Valid cases ("http://foo.com", "http://foo.com", True), ("http://foo.com:80", "http://foo.com:80", True), ("http://foo.com", "http://foo.com/foo/bar", True), ("http://foo.com/foo", "http://foo.com/foo", True), ("http://foo.com/foo/bar", "http://foo.com/foo/bar", True), ("http://foo.com/foo/bar/baz", "http://foo.com/foo/bar/baz", True), ("http://foo.com/foo?x=y&z=w", "http://foo.com/foo?x=y&z=w", True), # Invalid cases ("http://foo.com", "http://bar.com", False), ("http://foo.com:80", "http://foo.com:443", False), ("http://foo.com/foo", "http://foo.com", False), ("http://foo.com/foo", "http://foo.com/bar", False), ("http://foo.com/foo/bar", "http://foo.com/bar/foo", False), ("http://foo.com/foo/bar/baz", "http://foo.com/baz/bar/foo", False), ("http://foo.com/foo?x=y&z=w", "http://foo.com/foo?x=x&y=y", False), ), ) def test_url_matcher_urlparse(match_url, url, matches): run_test(match_url, url, matches, regex=False) @pytest.mark.parametrize( ("match_url", "url", "matches"), ( # Valid cases ("http://foo.com", "http://foo.com", True), ("http://foo.com:80", "http://foo.com:80", True), ("^http://foo.com", "http://foo.com/foo/bar", True), ("http://foo.com/foo", "http://foo.com/foo", True), ("http://foo.com/foo/bar", "http://foo.com/foo/bar", True), ("http://foo.com/foo/bar/baz", "http://foo.com/foo/bar/baz", True), (r"http://foo.com/foo\?x=[0-9]", "http://foo.com/foo?x=5", True), # Invalid cases ("http://foo.com", "http://bar.com", False), ("http://foo.com:80", "http://foo.com:443", False), ("^http://foo.com$", "http://foo.com/bar", False), ("http://foo.com/foo", "http://foo.com/bar", False), ("http://foo.com/foo/bar", "http://foo.com/bar/foo", False), ("http://foo.com/foo/bar/baz", "http://foo.com/baz/bar/foo", False), (r"http://foo.com/foo\?x=[1-3]", "http://foo.com/foo?x=5", False), ), ) def test_url_matcher_regex(match_url, url, matches): run_test(match_url, url, matches, regex=True) pook-2.1.3/tests/unit/mock_engine_test.py000066400000000000000000000035201472770511200205030ustar00rootroot00000000000000from urllib.request import Request, urlopen import pytest import pook from pook import Engine, MockEngine from pook.interceptors import BaseInterceptor class Interceptor(BaseInterceptor): def activate(self): self.active = True def disable(self): self.active = False @pytest.fixture def engine(): return MockEngine(Engine()) def test_mock_engine_instance(engine): assert isinstance(engine.engine, Engine) assert isinstance(engine.interceptors, list) assert len(engine.interceptors) >= 2 def test_mock_engine_flush(engine): assert len(engine.interceptors) >= 2 engine.flush_interceptors() assert len(engine.interceptors) == 0 def test_mock_engine_interceptors(engine): engine.flush_interceptors() engine.add_interceptor(Interceptor) assert len(engine.interceptors) == 1 assert isinstance(engine.interceptors[0], Interceptor) engine.remove_interceptor("Interceptor") assert len(engine.interceptors) == 0 def test_mock_engine_status(engine): engine.flush_interceptors() engine.add_interceptor(Interceptor) assert len(engine.interceptors) == 1 interceptor = engine.interceptors[0] engine.activate() assert interceptor.active engine.disable() assert not interceptor.active @pytest.mark.xfail( reason="Pook cannot disambiguate the two mocks. Ideally it would try to find the most specific mock that matches, but that's not possible yet." ) @pytest.mark.pook(allow_pending_mocks=True) def test_mock_specificity(httpbin): url404 = f"{httpbin.url}/status/404" pook.get(url404).header_present("authorization").reply(201) pook.get(url404).headers({"Authorization": "Bearer pook"}).reply(200) res_with_headers = urlopen( Request(url404, headers={"Authorization": "Bearer pook"}) ) assert res_with_headers.status == 200 pook-2.1.3/tests/unit/mock_test.py000066400000000000000000000155061472770511200171650ustar00rootroot00000000000000import itertools import json import re from textwrap import dedent from urllib.request import urlopen import pytest import pook from pook.exceptions import PookNoMatches from pook.mock import Mock from pook.request import Request from tests.unit.fixtures import BINARY_FILE, BINARY_FILE_PATH @pytest.fixture def mock(): return Mock() def matcher(mock): return mock.matchers[0] def test_mock_url(mock): mock.url("http://google.es") assert str(matcher(mock)) == "http://google.es" @pytest.mark.parametrize( ("param_kwargs", "query_string"), ( pytest.param({"params": {"x": "1"}}, "?x=1", id="params"), pytest.param( {"param": ("y", "pook")}, "?y=pook", marks=pytest.mark.xfail( condition=True, reason="Constructor does not correctly handle multi-argument methods from kwargs", ), id="param", ), pytest.param( {"param_exists": "z"}, "?z", marks=pytest.mark.xfail( condition=True, reason="Constructor does not have a method for passing `allow_empty` to `param_exists`", ), id="param_exists_empty_on_request", ), pytest.param( {"param_exists": "z"}, "?z=123", id="param_exists_has_value", ), ), ) def test_mock_constructor(param_kwargs, query_string): # Should not raise mock = Mock( url="https://httpbin.org/404", reply_status=200, response_json={"hello": "from pook"}, **param_kwargs, ) with pook.use(): pook.engine().add_mock(mock) res = urlopen(f"https://httpbin.org/404{query_string}") assert res.status == 200 assert json.loads(res.read()) == {"hello": "from pook"} @pytest.mark.parametrize( "url, params, req, expected", [ ("http://google.es", {}, Request(url="http://google.es"), (True, [])), ( "http://google.es", {}, Request(url="http://google.es?foo=bar"), (True, []), ), ( "http://google.es", {}, Request(url="http://google.es?foo=bar&baz=qux"), (True, []), ), ( "http://google.es", {}, Request(url="http://google.es?baz=qux"), (True, []), ), ( "http://google.es", {"foo": "bar"}, Request(url="http://google.es"), (False, []), ), ( "http://google.es", {"foo": "bar"}, Request(url="http://google.es?foo=bar"), (True, []), ), ( "http://google.es", {"foo": "bar"}, Request(url="http://google.es?foo=bar&baz=qux"), (True, []), ), ( "http://google.es", {"foo": "bar"}, Request(url="http://google.es?baz=qux"), (False, []), ), ], ) def test_mock_params(url, params, req, expected, mock): mock.url(url) if params: mock.params(params) assert mock.matchers.match(req) == expected def test_new_response(mock): assert mock.reply() != mock.reply(new_response=True, json={}) def test_times(mock): url = "https://example.com" mock.url(url) mock.times(2) req = Request(url=url) assert mock.match(req) == (True, []) assert mock.match(req) == (True, []) matches, errors = mock.match(req) assert not matches assert len(errors) == 1 assert "Mock matches request but is expired." in errors[0] assert repr(mock) in errors[0] @pytest.mark.pook def test_times_integrated(httpbin): url = f"{httpbin.url}/status/404" pook.get(url).times(2).reply(200).body("hello from pook") res = urlopen(url) assert res.read() == b"hello from pook" res = urlopen(url) assert res.read() == b"hello from pook" with pytest.raises(PookNoMatches, match="Mock matches request but is expired."): urlopen(url) def test_file_matches(httpbin, mock): mock.file(BINARY_FILE_PATH) req = Request( url=httpbin.url, body=BINARY_FILE, ) assert mock.match(req) == (True, []) def test_file_not_matches(httpbin, mock): mock.file(BINARY_FILE_PATH) req = Request( url=httpbin.url, body=b"not the binary file you're looking for!", ) matches, errors = mock.match(req) assert not matches assert len(errors) == 1 assert errors[0].startswith("BodyMatcher") @pytest.mark.parametrize( "mock_body, request_body", # Both sides will always match, regardless of str/bytes list(itertools.product((b"hello", "hello"), (b"hello", "hello"))), ) def test_body_matches(httpbin, mock, mock_body, request_body): mock.body(mock_body) req = Request(url=httpbin.url, body=request_body) assert mock.match(req) == (True, []) @pytest.mark.parametrize( "mock_body, request_body", # Neither left nor right side will ever match due to differing contents list(itertools.product((b"bytes", "str"), (b"hello bytes", "hello str"))), ) def test_body_not_matches(httpbin, mock, mock_body, request_body): mock.body(mock_body) req = Request(url=httpbin.url, body=request_body) matches, errors = mock.match(req) assert not matches assert len(errors) == 1 assert errors[0].startswith("BodyMatcher: ") def test_body_matches_string_regex(httpbin, mock): mock.body(re.compile(r"hello, me!")) req = Request( url=httpbin.url, body="This is a big sentence... hello, me! wow, another part", ) assert mock.match(req) == (True, []) def test_body_matches_bytes_regex(httpbin, mock): mock.body(re.compile(rb"hello, me!")) req = Request( url=httpbin.url, body="This is a big sentence... hello, me! wow, another part", ) assert mock.match(req) == (True, []) def test_xml_matches(httpbin, mock): xml = dedent( """ A pook test for XML! """ ).strip() mock.xml(xml) req = Request( url=httpbin.url, xml=xml, ) assert mock.match(req) == (True, []) def test_xml_not_matches(httpbin, mock): xml = dedent( """ A pook test for XML! """ ).strip() mock.xml(xml) req = Request( url=httpbin.url, xml=xml.replace("A pook test for XML!", "Not this one!"), ) matches, errors = mock.match(req) assert not matches assert len(errors) == 1 assert errors[0].startswith("XMLMatcher:") pook-2.1.3/tests/unit/regex_test.py000066400000000000000000000015221472770511200173370ustar00rootroot00000000000000import re from pook.regex import isregex, isregex_expr def test_isregex_expr(): cases = ( ("re/[a-z]/", True), ("re/[0-9]/", True), ("re/[(.*)]/", True), ("re//", True), ("RE/[0-9]/", False), ("/[0-9]/", False), ("[0-9]", False), ("//", False), ("re/", False), ("re/[0-1]/-", False), ("e/[0-1]/-", False), ("e/[0-1]/", False), ("", False), ([], False), (1, False), (None, False), ) for case in cases: assert isregex_expr(case[0]) is case[1] def test_isregex(): cases = ( (re.compile("[a-z]"), True), ("[a-z]", False), ("", False), ([], False), (1, False), (None, False), ) for case in cases: assert isregex(case[0]) is case[1]