├── .codecov.yml ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── codespell.yml │ ├── docs.yml │ ├── main.yml │ ├── pre-commit-detect-outdated.yml │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ ├── vcr.png │ └── vcr.svg ├── advanced.rst ├── api.rst ├── changelog.rst ├── conf.py ├── configuration.rst ├── contributing.rst ├── debugging.rst ├── index.rst ├── installation.rst ├── requirements.txt └── usage.rst ├── pyproject.toml ├── runtests.sh ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── assertions.py ├── fixtures │ ├── migration │ │ ├── new_cassette.json │ │ ├── new_cassette.yaml │ │ ├── not_cassette.txt │ │ ├── old_cassette.json │ │ └── old_cassette.yaml │ └── wild │ │ └── domain_redirect.yaml ├── integration │ ├── __init__.py │ ├── aiohttp_utils.py │ ├── cassettes │ │ ├── gzip_httpx_old_format.yaml │ │ ├── gzip_requests.yaml │ │ └── test_httpx_test_test_behind_proxy.yml │ ├── test_aiohttp.py │ ├── test_basic.py │ ├── test_boto3.py │ ├── test_config.py │ ├── test_disksaver.py │ ├── test_filter.py │ ├── test_http │ ├── test_httplib2.py │ ├── test_httpx.py │ ├── test_ignore.py │ ├── test_matchers.py │ ├── test_multiple.py │ ├── test_proxy.py │ ├── test_record_mode.py │ ├── test_register_matcher.py │ ├── test_register_persister.py │ ├── test_register_serializer.py │ ├── test_request.py │ ├── test_requests.py │ ├── test_stubs.py │ ├── test_tornado.py │ ├── test_tornado_exception_can_be_caught.yaml │ ├── test_tornado_with_decorator_use_cassette.yaml │ ├── test_urllib2.py │ ├── test_urllib3.py │ └── test_wild.py └── unit │ ├── test_cassettes.py │ ├── test_errors.py │ ├── test_filters.py │ ├── test_json_serializer.py │ ├── test_matchers.py │ ├── test_migration.py │ ├── test_persist.py │ ├── test_request.py │ ├── test_response.py │ ├── test_serialize.py │ ├── test_stubs.py │ ├── test_unittest.py │ ├── test_util.py │ ├── test_vcr.py │ └── test_vcr_import.py ├── vcr.png └── vcr ├── __init__.py ├── _handle_coroutine.py ├── cassette.py ├── config.py ├── errors.py ├── filters.py ├── matchers.py ├── migration.py ├── patch.py ├── persisters ├── __init__.py └── filesystem.py ├── record_mode.py ├── request.py ├── serialize.py ├── serializers ├── __init__.py ├── compat.py ├── jsonserializer.py └── yamlserializer.py ├── stubs ├── __init__.py ├── aiohttp_stubs.py ├── boto3_stubs.py ├── compat.py ├── httplib2_stubs.py ├── httpx_stubs.py ├── requests_stubs.py ├── tornado_stubs.py └── urllib3_stubs.py ├── unittest.py └── util.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 75 6 | # Allow 0% coverage regression 7 | threshold: 0 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [Makefile] 11 | indent_style = tab 12 | 13 | [*.{yml,yaml}] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Codespell 3 | 4 | on: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | branches: [master] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | codespell: 15 | name: Check for spelling errors 16 | runs-on: ubuntu-24.04 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Codespell 22 | uses: codespell-project/actions-codespell@v2 23 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Validate docs 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'docs/**' 7 | 8 | jobs: 9 | validate: 10 | runs-on: ubuntu-24.04 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.12" 17 | 18 | - name: Install build dependencies 19 | run: pip install -r docs/requirements.txt 20 | - name: Rendering HTML documentation 21 | run: sphinx-build -b html docs/ html 22 | - name: Inspect html rendered 23 | run: cat html/index.html 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | schedule: 9 | - cron: '0 16 * * 5' # Every Friday 4pm 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-24.04 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: 19 | - "3.9" 20 | - "3.10" 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | - "pypy-3.9" 25 | - "pypy-3.10" 26 | urllib3-requirement: 27 | - "urllib3>=2" 28 | - "urllib3<2" 29 | 30 | exclude: 31 | - python-version: "3.9" 32 | urllib3-requirement: "urllib3>=2" 33 | - python-version: "pypy-3.9" 34 | urllib3-requirement: "urllib3>=2" 35 | - python-version: "pypy-3.10" 36 | urllib3-requirement: "urllib3>=2" 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v5 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | cache: pip 46 | allow-prereleases: true 47 | 48 | - name: Install project dependencies 49 | run: | 50 | pip install --upgrade pip setuptools 51 | pip install codecov '.[tests]' '${{ matrix.urllib3-requirement }}' 52 | pip check 53 | 54 | - name: Allow creation of user namespaces (e.g. to the unshare command) 55 | run: | 56 | # .. so that we don't get error: 57 | # unshare: write failed /proc/self/uid_map: Operation not permitted 58 | # Idea from https://github.com/YoYoGames/GameMaker-Bugs/issues/6015#issuecomment-2135552784 . 59 | sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 60 | 61 | - name: Run online tests 62 | run: ./runtests.sh --cov=./vcr --cov-branch --cov-report=xml --cov-append -m online 63 | 64 | - name: Run offline tests with no access to the Internet 65 | run: | 66 | # We're using unshare to take Internet access 67 | # away so that we'll notice whenever some new test 68 | # is missing @pytest.mark.online decoration in the future 69 | unshare --map-root-user --net -- \ 70 | sh -c 'ip link set lo up; ./runtests.sh --cov=./vcr --cov-branch --cov-report=xml --cov-append -m "not online"' 71 | 72 | - name: Run coverage 73 | run: codecov 74 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit-detect-outdated.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Sebastian Pipping 2 | # Licensed under the MIT license 3 | 4 | name: Detect outdated pre-commit hooks 5 | 6 | on: 7 | schedule: 8 | - cron: '0 16 * * 5' # Every Friday 4pm 9 | 10 | # NOTE: This will drop all permissions from GITHUB_TOKEN except metadata read, 11 | # and then (re)add the ones listed below: 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | 16 | jobs: 17 | pre_commit_detect_outdated: 18 | name: Detect outdated pre-commit hooks 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Python 3.12 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: 3.12 27 | 28 | - name: Install pre-commit 29 | run: |- 30 | pip install \ 31 | --disable-pip-version-check \ 32 | --no-warn-script-location \ 33 | --user \ 34 | pre-commit 35 | echo "PATH=${HOME}/.local/bin:${PATH}" >> "${GITHUB_ENV}" 36 | 37 | - name: Check for outdated hooks 38 | run: |- 39 | pre-commit autoupdate 40 | git diff -- .pre-commit-config.yaml 41 | 42 | - name: Create pull request from changes (if any) 43 | id: create-pull-request 44 | uses: peter-evans/create-pull-request@v7 45 | with: 46 | author: 'pre-commit ' 47 | base: master 48 | body: |- 49 | For your consideration. 50 | 51 | :warning: Please **CLOSE AND RE-OPEN** this pull request so that [further workflow runs get triggered](https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs) for this pull request. 52 | branch: precommit-autoupdate 53 | commit-message: "pre-commit: Autoupdate" 54 | delete-branch: true 55 | draft: true 56 | labels: enhancement 57 | title: "pre-commit: Autoupdate" 58 | 59 | - name: Log pull request URL 60 | if: "${{ steps.create-pull-request.outputs.pull-request-url }}" 61 | run: | 62 | echo "Pull request URL is: ${{ steps.create-pull-request.outputs.pull-request-url }}" 63 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Sebastian Pipping 2 | # Licensed under the MIT license 3 | 4 | name: Run pre-commit 5 | 6 | on: 7 | - pull_request 8 | - push 9 | - workflow_dispatch 10 | 11 | jobs: 12 | pre-commit: 13 | name: Run pre-commit 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.12 20 | - uses: pre-commit/action@v3.0.1 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .tox 3 | .cache 4 | .pytest_cache/ 5 | build/ 6 | dist/ 7 | *.egg/ 8 | .coverage 9 | coverage.xml 10 | htmlcov/ 11 | *.egg-info/ 12 | pytestdebug.log 13 | pip-wheel-metadata/ 14 | .python-version 15 | 16 | fixtures/ 17 | /docs/_build 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Sebastian Pipping 2 | # Licensed under the MIT license 3 | 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.11.2 7 | hooks: 8 | - id: ruff 9 | args: ["--output-format=full"] 10 | - id: ruff-format 11 | 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v5.0.0 14 | hooks: 15 | - id: check-merge-conflict 16 | - id: end-of-file-fixer 17 | - id: trailing-whitespace 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-24.04 11 | tools: 12 | python: "3.12" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | - method: pip 24 | path: . 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2015 Kevin McCarthy 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | recursive-include tests * 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | ########### 3 | VCR.py 📼 4 | ########### 5 | 6 | 7 | |PyPI| |Python versions| |Build Status| |CodeCov| |Gitter| 8 | 9 | ---- 10 | 11 | .. image:: https://vcrpy.readthedocs.io/en/latest/_images/vcr.svg 12 | :alt: vcr.py logo 13 | 14 | 15 | This is a Python version of `Ruby's VCR 16 | library `__. 17 | 18 | Source code 19 | https://github.com/kevin1024/vcrpy 20 | 21 | Documentation 22 | https://vcrpy.readthedocs.io/ 23 | 24 | Rationale 25 | --------- 26 | 27 | VCR.py simplifies and speeds up tests that make HTTP requests. The 28 | first time you run code that is inside a VCR.py context manager or 29 | decorated function, VCR.py records all HTTP interactions that take 30 | place through the libraries it supports and serializes and writes them 31 | to a flat file (in yaml format by default). This flat file is called a 32 | cassette. When the relevant piece of code is executed again, VCR.py 33 | will read the serialized requests and responses from the 34 | aforementioned cassette file, and intercept any HTTP requests that it 35 | recognizes from the original test run and return the responses that 36 | corresponded to those requests. This means that the requests will not 37 | actually result in HTTP traffic, which confers several benefits 38 | including: 39 | 40 | - The ability to work offline 41 | - Completely deterministic tests 42 | - Increased test execution speed 43 | 44 | If the server you are testing against ever changes its API, all you need 45 | to do is delete your existing cassette files, and run your tests again. 46 | VCR.py will detect the absence of a cassette file and once again record 47 | all HTTP interactions, which will update them to correspond to the new 48 | API. 49 | 50 | Usage with Pytest 51 | ----------------- 52 | 53 | There is a library to provide some pytest fixtures called pytest-recording https://github.com/kiwicom/pytest-recording 54 | 55 | License 56 | ------- 57 | 58 | This library uses the MIT license. See `LICENSE.txt `__ for 59 | more details 60 | 61 | .. |PyPI| image:: https://img.shields.io/pypi/v/vcrpy.svg 62 | :target: https://pypi.python.org/pypi/vcrpy 63 | .. |Python versions| image:: https://img.shields.io/pypi/pyversions/vcrpy.svg 64 | :target: https://pypi.python.org/pypi/vcrpy 65 | .. |Build Status| image:: https://github.com/kevin1024/vcrpy/actions/workflows/main.yml/badge.svg 66 | :target: https://github.com/kevin1024/vcrpy/actions 67 | .. |Gitter| image:: https://badges.gitter.im/Join%20Chat.svg 68 | :alt: Join the chat at https://gitter.im/kevin1024/vcrpy 69 | :target: https://gitter.im/kevin1024/vcrpy?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 70 | .. |CodeCov| image:: https://codecov.io/gh/kevin1024/vcrpy/branch/master/graph/badge.svg 71 | :target: https://codecov.io/gh/kevin1024/vcrpy 72 | :alt: Code Coverage Status 73 | -------------------------------------------------------------------------------- /docs/_static/vcr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin1024/vcrpy/19bd4e012c8fd6970fd7f2af3cc60aed1e5f1ab5/docs/_static/vcr.png -------------------------------------------------------------------------------- /docs/_static/vcr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | :mod:`~vcr.config` 5 | ------------------ 6 | 7 | .. automodule:: vcr.config 8 | :members: 9 | :special-members: __init__ 10 | 11 | :mod:`~vcr.cassette` 12 | -------------------- 13 | 14 | .. automodule:: vcr.cassette 15 | :members: 16 | :special-members: __init__ 17 | 18 | :mod:`~vcr.matchers` 19 | -------------------- 20 | 21 | .. automodule:: vcr.matchers 22 | :members: 23 | :special-members: __init__ 24 | 25 | :mod:`~vcr.filters` 26 | ------------------- 27 | 28 | .. automodule:: vcr.filters 29 | :members: 30 | :special-members: __init__ 31 | 32 | :mod:`~vcr.request` 33 | ------------------- 34 | 35 | .. automodule:: vcr.request 36 | :members: 37 | :special-members: __init__ 38 | 39 | :mod:`~vcr.serialize` 40 | --------------------- 41 | 42 | .. automodule:: vcr.serialize 43 | :members: 44 | :special-members: __init__ 45 | 46 | :mod:`~vcr.patch` 47 | ----------------- 48 | 49 | .. automodule:: vcr.patch 50 | :members: 51 | :special-members: __init__ 52 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | If you don't like VCR's defaults, you can set options by instantiating a 5 | ``VCR`` class and setting the options on it. 6 | 7 | .. code:: python 8 | 9 | 10 | import vcr 11 | 12 | my_vcr = vcr.VCR( 13 | serializer='json', 14 | cassette_library_dir='fixtures/cassettes', 15 | record_mode='once', 16 | match_on=['uri', 'method'], 17 | ) 18 | 19 | with my_vcr.use_cassette('test.json'): 20 | # your http code here 21 | 22 | Otherwise, you can override options each time you use a cassette. 23 | 24 | .. code:: python 25 | 26 | with vcr.use_cassette('test.yml', serializer='json', record_mode='once'): 27 | # your http code here 28 | 29 | Note: Per-cassette overrides take precedence over the global config. 30 | 31 | Request matching 32 | ---------------- 33 | 34 | Request matching is configurable and allows you to change which requests 35 | VCR considers identical. The default behavior is 36 | ``['method', 'scheme', 'host', 'port', 'path', 'query']`` which means 37 | that requests with both the same URL and method (ie POST or GET) are 38 | considered identical. 39 | 40 | This can be configured by changing the ``match_on`` setting. 41 | 42 | The following options are available : 43 | 44 | - method (for example, POST or GET) 45 | - uri (the full URI) 46 | - scheme (for example, HTTP or HTTPS) 47 | - host (the hostname of the server receiving the request) 48 | - port (the port of the server receiving the request) 49 | - path (the path of the request) 50 | - query (the query string of the request) 51 | - raw\_body (the entire request body as is) 52 | - body (the entire request body unmarshalled by content-type 53 | i.e. xmlrpc, json, form-urlencoded, falling back on raw\_body) 54 | - headers (the headers of the request) 55 | 56 | Backwards compatible matchers: 57 | - url (the ``uri`` alias) 58 | 59 | If these options don't work for you, you can also register your own 60 | request matcher. This is described in the Advanced section of this 61 | README. 62 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | .. image:: _static/vcr.svg 5 | :alt: vcr.py logo 6 | :align: right 7 | 8 | 🚀 Milestones 9 | -------------- 10 | For anyone interested in the roadmap and projected release milestones please see the following link: 11 | 12 | `MILESTONES `_ 13 | 14 | ---- 15 | 16 | 🎁 Contributing Issues and PRs 17 | ------------------------------- 18 | 19 | - Issues and PRs will get triaged and assigned to the appropriate milestone. 20 | - PRs get priority over issues. 21 | - The maintainers have limited bandwidth and do so **voluntarily**. 22 | 23 | So whilst reporting issues are valuable, please consider: 24 | - contributing an issue with a toy repo that replicates the issue. 25 | - contributing PRs is a more valuable donation of your time and effort. 26 | 27 | Thanks again for your interest and support in VCRpy. 28 | 29 | We really appreciate it. 30 | 31 | ---- 32 | 33 | 👥 Collaborators 34 | ----------------- 35 | 36 | We also have a large test matrix to cover and would like members to volunteer covering these roles. 37 | 38 | ============ ==================== ================= ================== ====================== 39 | **Library** **Issue Triager(s)** **Maintainer(s)** **PR Reviewer(s)** **Release Manager(s)** 40 | ------------ -------------------- ----------------- ------------------ ---------------------- 41 | ``core`` Needs support Needs support Needs support @neozenith 42 | ``requests`` @neozenith Needs support @neozenith @neozenith 43 | ``aiohttp`` Needs support Needs support Needs support @neozenith 44 | ``urllib3`` Needs support Needs support Needs support @neozenith 45 | ``httplib2`` Needs support Needs support Needs support @neozenith 46 | ``tornado4`` Needs support Needs support Needs support @neozenith 47 | ``boto3`` Needs support Needs support Needs support @neozenith 48 | ============ ==================== ================= ================== ====================== 49 | 50 | Role Descriptions 51 | ~~~~~~~~~~~~~~~~~ 52 | 53 | **Issue Triager:** 54 | 55 | Simply adding these three labels for incoming issues means a lot for maintaining this project: 56 | - ``bug`` or ``enhancement`` 57 | - Which library does it affect? ``core``, ``aiohttp``, ``requests``, ``urllib3``, ``tornado4``, ``httplib2`` 58 | - If it is a bug, is it ``Verified Can Replicate`` or ``Requires Help Replicating`` 59 | - Thanking people for raising issues. Feedback is always appreciated. 60 | - Politely asking if they are able to link to an example repo that replicates the issue if they haven't already. Being able to *clone and go* helps the next person and we like that. 😃 61 | 62 | **Maintainer:** 63 | 64 | This involves creating PRs to address bugs and enhancement requests. It also means maintaining the test suite, docstrings and documentation . 65 | 66 | **PR Reviewer:** 67 | 68 | The PR reviewer is a second set of eyes to see if: 69 | - Are there tests covering the code paths added/modified? 70 | - Do the tests and modifications make sense seem appropriate? 71 | - Add specific feedback, even on approvals, why it is accepted. eg "I like how you use a context manager there. 😄 " 72 | - Also make sure they add a line to `docs/changelog.rst` to claim credit for their contribution. 73 | 74 | **Release Manager:** 75 | - Ensure CI is passing. 76 | - Create a release on github and tag it with the changelog release notes. 77 | - ``python3 setup.py build sdist bdist_wheel`` 78 | - ``twine upload dist/*`` 79 | - Go to ReadTheDocs build page and trigger a build https://readthedocs.org/projects/vcrpy/builds/ 80 | 81 | ---- 82 | 83 | Running VCR's test suite 84 | ------------------------ 85 | 86 | The tests are all run automatically on `Github Actions CI `__, 87 | but you can also run them yourself using `pytest `__. 88 | 89 | In order for the boto3 tests to run, you will need an AWS key. 90 | Refer to the `boto3 91 | documentation `__ 92 | for how to set this up. I have marked the boto3 tests as optional in 93 | Travis so you don't have to worry about them failing if you submit a 94 | pull request. 95 | 96 | Using Pyenv with VCR's test suite 97 | --------------------------------- 98 | 99 | Pyenv is a tool for managing multiple installation of python on your system. 100 | See the full documentation at their `github `_ 101 | in this example:: 102 | 103 | git clone https://github.com/pyenv/pyenv ~/.pyenv 104 | 105 | # Add ~/.pyenv/bin to your PATH 106 | export PATH="$PATH:~/.pyenv/bin" 107 | 108 | # Setup shim paths 109 | eval "$(pyenv init -)" 110 | 111 | # Install supported versions (at time of writing), this does not activate them 112 | pyenv install 3.12.0 pypy3.10 113 | 114 | # This activates them 115 | pyenv local 3.12.0 pypy3.10 116 | 117 | # Run the whole test suite 118 | pip install .[tests] 119 | ./runtests.sh 120 | 121 | 122 | Troubleshooting on MacOSX 123 | ------------------------- 124 | 125 | If you have this kind of error when running tests : 126 | 127 | .. code:: python 128 | 129 | __main__.ConfigurationError: Curl is configured to use SSL, but we have 130 | not been able to determine which SSL backend it is using. Please see PycURL documentation for how to specify the SSL backend manually. 131 | 132 | Then you need to define some environment variables: 133 | 134 | .. code:: bash 135 | 136 | export PYCURL_SSL_LIBRARY=openssl 137 | export LDFLAGS=-L/usr/local/opt/openssl/lib 138 | export CPPFLAGS=-I/usr/local/opt/openssl/include 139 | 140 | Reference : `stackoverflow issue `__ 141 | -------------------------------------------------------------------------------- /docs/debugging.rst: -------------------------------------------------------------------------------- 1 | Debugging 2 | ========= 3 | 4 | VCR.py has a few log messages you can turn on to help you figure out if 5 | HTTP requests are hitting a real server or not. You can turn them on 6 | like this: 7 | 8 | .. code:: python 9 | 10 | import vcr 11 | import requests 12 | import logging 13 | 14 | logging.basicConfig() # you need to initialize logging, otherwise you will not see anything from vcrpy 15 | vcr_log = logging.getLogger("vcr") 16 | vcr_log.setLevel(logging.INFO) 17 | 18 | with vcr.use_cassette('headers.yml'): 19 | requests.get('http://httpbin.org/headers') 20 | 21 | The first time you run this, you will see:: 22 | 23 | INFO:vcr.stubs: not in cassette, sending to real server 24 | 25 | The second time, you will see:: 26 | 27 | INFO:vcr.stubs:Playing response for from cassette 28 | 29 | If you set the loglevel to DEBUG, you will also get information about 30 | which matchers didn't match. This can help you with debugging custom 31 | matchers. 32 | 33 | CannotOverwriteExistingCassetteException 34 | ---------------------------------------- 35 | 36 | When a request failed to be found in an existing cassette, 37 | VCR.py tries to get the request(s) that may be similar to the one being searched. 38 | The goal is to see which matcher(s) failed and understand what part of the failed request may have changed. 39 | It can return multiple similar requests with : 40 | 41 | - the matchers that have succeeded 42 | - the matchers that have failed 43 | - for each failed matchers, why it has failed with an assertion message 44 | 45 | CannotOverwriteExistingCassetteException message example : 46 | 47 | .. code:: 48 | 49 | CannotOverwriteExistingCassetteException: Can't overwrite existing cassette ('cassette.yaml') in your current record mode ('once'). 50 | No match for the request () was found. 51 | Found 1 similar requests with 1 different matchers : 52 | 53 | 1 - (). 54 | Matchers succeeded : ['method', 'scheme', 'host', 'port', 'path'] 55 | Matchers failed : 56 | query - assertion failure : 57 | [('alt', 'json'), ('maxResults', '200')] != [('alt', 'json'), ('maxResults', '500')] 58 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 3 8 | 9 | installation 10 | usage 11 | configuration 12 | advanced 13 | api 14 | debugging 15 | contributing 16 | changelog 17 | 18 | 19 | ================== 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | VCR.py is a package on `PyPI `__, so you can install 5 | with pip:: 6 | 7 | pip3 install vcrpy 8 | 9 | Compatibility 10 | ------------- 11 | 12 | VCR.py supports Python 3.9+, and `pypy `__. 13 | 14 | The following HTTP libraries are supported: 15 | 16 | - ``aiohttp`` 17 | - ``boto3`` 18 | - ``http.client`` 19 | - ``httplib2`` 20 | - ``requests`` (>=2.16.2 versions) 21 | - ``tornado.httpclient`` 22 | - ``urllib2`` 23 | - ``urllib3`` 24 | - ``httpx`` 25 | 26 | Speed 27 | ----- 28 | 29 | VCR.py runs about 10x faster when `pyyaml `__ can use the 30 | `libyaml extensions `__. In order for this to 31 | work, libyaml needs to be available when pyyaml is built. Additionally the flag 32 | is cached by pip, so you might need to explicitly avoid the cache when 33 | rebuilding pyyaml. 34 | 35 | 1. Test if pyyaml is built with libyaml. This should work:: 36 | 37 | python3 -c 'from yaml import CLoader' 38 | 39 | 2. Install libyaml according to your Linux distribution, or using `Homebrew 40 | `__ on Mac:: 41 | 42 | brew install libyaml # Mac with Homebrew 43 | apt-get install libyaml-dev # Ubuntu 44 | dnf install libyaml-devel # Fedora 45 | 46 | 3. Rebuild pyyaml with libyaml:: 47 | 48 | pip3 uninstall pyyaml 49 | pip3 --no-cache-dir install pyyaml 50 | 51 | Upgrade 52 | ------- 53 | 54 | New Cassette Format 55 | ~~~~~~~~~~~~~~~~~~~ 56 | 57 | The cassette format has changed in *VCR.py 1.x*, the *VCR.py 0.x* 58 | cassettes cannot be used with *VCR.py 1.x*. The easiest way to upgrade 59 | is to simply delete your cassettes and re-record all of them. VCR.py 60 | also provides a migration script that attempts to upgrade your 0.x 61 | cassettes to the new 1.x format. To use it, run the following command:: 62 | 63 | python3 -m vcr.migration PATH 64 | 65 | The PATH can be either a path to the directory with cassettes or the 66 | path to a single cassette. 67 | 68 | *Note*: Back up your cassettes files before migration. The migration 69 | *should* only modify cassettes using the old 0.x format. 70 | 71 | New serializer / deserializer API 72 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73 | 74 | If you made a custom serializer, you will need to update it to match the 75 | new API in version 1.0.x 76 | 77 | - Serializers now take dicts and return strings. 78 | - Deserializers take strings and return dicts (instead of requests, 79 | responses pair) 80 | 81 | Ruby VCR compatibility 82 | ---------------------- 83 | 84 | VCR.py does not aim to match the format of the Ruby VCR YAML files. 85 | Cassettes generated by Ruby's VCR are not compatible with VCR.py. 86 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx<9 2 | sphinx_rtd_theme==3.0.2 3 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | .. code:: python 5 | 6 | import vcr 7 | import urllib.request 8 | 9 | with vcr.use_cassette('fixtures/vcr_cassettes/synopsis.yaml'): 10 | response = urllib.request.urlopen('http://www.iana.org/domains/reserved').read() 11 | assert b'Example domains' in response 12 | 13 | Run this test once, and VCR.py will record the HTTP request to 14 | ``fixtures/vcr_cassettes/synopsis.yaml``. Run it again, and VCR.py will 15 | replay the response from iana.org when the http request is made. This 16 | test is now fast (no real HTTP requests are made anymore), deterministic 17 | (the test will continue to pass, even if you are offline, or iana.org 18 | goes down for maintenance) and accurate (the response will contain the 19 | same headers and body you get from a real request). 20 | 21 | You can also use VCR.py as a decorator. The same request above would 22 | look like this: 23 | 24 | .. code:: python 25 | 26 | @vcr.use_cassette('fixtures/vcr_cassettes/synopsis.yaml') 27 | def test_iana(): 28 | response = urllib.request.urlopen('http://www.iana.org/domains/reserved').read() 29 | assert b'Example domains' in response 30 | 31 | When using the decorator version of ``use_cassette``, it is possible to 32 | omit the path to the cassette file. 33 | 34 | .. code:: python 35 | 36 | @vcr.use_cassette() 37 | def test_iana(): 38 | response = urllib.request.urlopen('http://www.iana.org/domains/reserved').read() 39 | assert b'Example domains' in response 40 | 41 | In this case, the cassette file will be given the same name as the test 42 | function, and it will be placed in the same directory as the file in 43 | which the test is defined. See the Automatic Test Naming section below 44 | for more details. 45 | 46 | Record Modes 47 | ------------ 48 | 49 | VCR supports 4 record modes (with the same behavior as Ruby's VCR): 50 | 51 | once 52 | ~~~~ 53 | 54 | - Replay previously recorded interactions. 55 | - Record new interactions if there is no cassette file. 56 | - Cause an error to be raised for new requests if there is a cassette 57 | file. 58 | 59 | It is similar to the new\_episodes record mode, but will prevent new, 60 | unexpected requests from being made (e.g. because the request URI 61 | changed). 62 | 63 | once is the default record mode, used when you do not set one. 64 | 65 | new\_episodes 66 | ~~~~~~~~~~~~~ 67 | 68 | - Record new interactions. 69 | - Replay previously recorded interactions. It is similar to the once 70 | record mode, but will always record new interactions, even if you 71 | have an existing recorded one that is similar, but not identical. 72 | 73 | This was the default behavior in versions < 0.3.0 74 | 75 | none 76 | ~~~~ 77 | 78 | - Replay previously recorded interactions. 79 | - Cause an error to be raised for any new requests. This is useful when 80 | your code makes potentially dangerous HTTP requests. The none record 81 | mode guarantees that no new HTTP requests will be made. 82 | 83 | all 84 | ~~~ 85 | 86 | - Record new interactions. 87 | - Never replay previously recorded interactions. This can be 88 | temporarily used to force VCR to re-record a cassette (i.e. to ensure 89 | the responses are not out of date) or can be used when you simply 90 | want to log all HTTP requests. 91 | 92 | Unittest Integration 93 | -------------------- 94 | 95 | Inherit from ``VCRTestCase`` for automatic recording and playback of HTTP 96 | interactions. 97 | 98 | .. code:: python 99 | 100 | from vcr.unittest import VCRTestCase 101 | import requests 102 | 103 | class MyTestCase(VCRTestCase): 104 | def test_something(self): 105 | response = requests.get('http://example.com') 106 | 107 | Similar to how VCR.py returns the cassette from the context manager, 108 | ``VCRTestCase`` makes the cassette available as ``self.cassette``: 109 | 110 | .. code:: python 111 | 112 | self.assertEqual(len(self.cassette), 1) 113 | self.assertEqual(self.cassette.requests[0].uri, 'http://example.com') 114 | 115 | By default cassettes will be placed in the ``cassettes`` subdirectory next to the 116 | test, named according to the test class and method. For example, the above test 117 | would read from and write to ``cassettes/MyTestCase.test_something.yaml`` 118 | 119 | The configuration can be modified by overriding methods on your subclass: 120 | ``_get_vcr_kwargs``, ``_get_cassette_library_dir`` and ``_get_cassette_name``. 121 | To modify the ``VCR`` object after instantiation, for example to add a matcher, 122 | you can hook on ``_get_vcr``, for example: 123 | 124 | .. code:: python 125 | 126 | class MyTestCase(VCRTestCase): 127 | def _get_vcr(self, **kwargs): 128 | myvcr = super(MyTestCase, self)._get_vcr(**kwargs) 129 | myvcr.register_matcher('mymatcher', mymatcher) 130 | myvcr.match_on = ['mymatcher'] 131 | return myvcr 132 | 133 | See 134 | `the source 135 | `__ 136 | for the default implementations of these methods. 137 | 138 | If you implement a ``setUp`` method on your test class then make sure to call 139 | the parent version ``super().setUp()`` in your own in order to continue getting 140 | the cassettes produced. 141 | 142 | VCRMixin 143 | ~~~~~~~~ 144 | 145 | In case inheriting from ``VCRTestCase`` is difficult because of an existing 146 | class hierarchy containing tests in the base classes, inherit from ``VCRMixin`` 147 | instead. 148 | 149 | .. code:: python 150 | 151 | from vcr.unittest import VCRMixin 152 | import requests 153 | import unittest 154 | 155 | class MyTestMixin(VCRMixin): 156 | def test_something(self): 157 | response = requests.get(self.url) 158 | 159 | class MyTestCase(MyTestMixin, unittest.TestCase): 160 | url = 'http://example.com' 161 | 162 | 163 | Pytest Integration 164 | ------------------ 165 | 166 | A Pytest plugin is available here : `pytest-vcr 167 | `__. 168 | 169 | Alternative plugin, that also provides network access blocking: `pytest-recording 170 | `__. 171 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.codespell] 2 | skip = '.git,*.pdf,*.svg,.tox' 3 | ignore-regex = "\\\\[fnrstv]" 4 | 5 | [tool.pytest.ini_options] 6 | addopts = ["--strict-config", "--strict-markers"] 7 | asyncio_default_fixture_loop_scope = "function" 8 | markers = ["online"] 9 | 10 | [tool.ruff] 11 | line-length = 110 12 | target-version = "py39" 13 | 14 | [tool.ruff.lint] 15 | select = [ 16 | "B", # flake8-bugbear 17 | "C4", # flake8-comprehensions 18 | "COM", # flake8-commas 19 | "E", # pycodestyle error 20 | "F", # pyflakes 21 | "I", # isort 22 | "ISC", # flake8-implicit-str-concat 23 | "PIE", # flake8-pie 24 | "RUF", # Ruff-specific rules 25 | "UP", # pyupgrade 26 | "W", # pycodestyle warning 27 | ] 28 | 29 | [tool.ruff.lint.isort] 30 | known-first-party = ["vcr"] 31 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # If you are getting an INVOCATION ERROR for this script then there is a good chance you are running on Windows. 4 | # You can and should use WSL for running tests on Windows when it calls bash scripts. 5 | REQUESTS_CA_BUNDLE=`python3 -m pytest_httpbin.certs` exec pytest "$@" 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import codecs 4 | import os 5 | import re 6 | 7 | from setuptools import find_packages, setup 8 | 9 | long_description = open("README.rst").read() 10 | here = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | 13 | def read(*parts): 14 | # intentionally *not* adding an encoding option to open, See: 15 | # https://github.com/pypa/virtualenv/issues/201#issuecomment-3145690 16 | with codecs.open(os.path.join(here, *parts), "r") as fp: 17 | return fp.read() 18 | 19 | 20 | def find_version(*file_paths): 21 | version_file = read(*file_paths) 22 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 23 | if version_match: 24 | return version_match.group(1) 25 | 26 | raise RuntimeError("Unable to find version string.") 27 | 28 | 29 | install_requires = [ 30 | "PyYAML", 31 | "wrapt", 32 | "yarl", 33 | # Support for urllib3 >=2 needs CPython >=3.10 34 | # so we need to block urllib3 >=2 for Python <3.10 and PyPy for now. 35 | # Note that vcrpy would work fine without any urllib3 around, 36 | # so this block and the dependency can be dropped at some point 37 | # in the future. For more Details: 38 | # https://github.com/kevin1024/vcrpy/pull/699#issuecomment-1551439663 39 | "urllib3 <2; python_version <'3.10'", 40 | # https://github.com/kevin1024/vcrpy/pull/775#issuecomment-1847849962 41 | "urllib3 <2; platform_python_implementation =='PyPy'", 42 | # Workaround for Poetry with CPython >= 3.10, problem description at: 43 | # https://github.com/kevin1024/vcrpy/pull/826 44 | "urllib3; platform_python_implementation !='PyPy' and python_version >='3.10'", 45 | ] 46 | 47 | extras_require = { 48 | "tests": [ 49 | "aiohttp", 50 | "boto3", 51 | "httplib2", 52 | "httpx", 53 | "pytest-aiohttp", 54 | "pytest-asyncio", 55 | "pytest-cov", 56 | "pytest-httpbin", 57 | "pytest", 58 | "requests>=2.22.0", 59 | "tornado", 60 | "urllib3", 61 | # Needed to un-break httpbin 0.7.0. For httpbin >=0.7.1 and after, 62 | # this pin and the dependency itself can be removed, provided 63 | # that the related bug in httpbin has been fixed: 64 | # https://github.com/kevin1024/vcrpy/issues/645#issuecomment-1562489489 65 | # https://github.com/postmanlabs/httpbin/issues/673 66 | # https://github.com/postmanlabs/httpbin/pull/674 67 | "Werkzeug==2.0.3", 68 | ], 69 | } 70 | 71 | setup( 72 | name="vcrpy", 73 | version=find_version("vcr", "__init__.py"), 74 | description=("Automatically mock your HTTP interactions to simplify and speed up testing"), 75 | long_description=long_description, 76 | long_description_content_type="text/x-rst", 77 | author="Kevin McCarthy", 78 | author_email="me@kevinmccarthy.org", 79 | url="https://github.com/kevin1024/vcrpy", 80 | packages=find_packages(exclude=["tests*"]), 81 | python_requires=">=3.9", 82 | install_requires=install_requires, 83 | license="MIT", 84 | extras_require=extras_require, 85 | tests_require=extras_require["tests"], 86 | classifiers=[ 87 | "Development Status :: 5 - Production/Stable", 88 | "Environment :: Console", 89 | "Intended Audience :: Developers", 90 | "Programming Language :: Python", 91 | "Programming Language :: Python :: 3", 92 | "Programming Language :: Python :: 3.9", 93 | "Programming Language :: Python :: 3.10", 94 | "Programming Language :: Python :: 3.11", 95 | "Programming Language :: Python :: 3.12", 96 | "Programming Language :: Python :: 3.13", 97 | "Programming Language :: Python :: 3 :: Only", 98 | "Programming Language :: Python :: Implementation :: CPython", 99 | "Programming Language :: Python :: Implementation :: PyPy", 100 | "Topic :: Software Development :: Testing", 101 | "Topic :: Internet :: WWW/HTTP", 102 | "License :: OSI Approved :: MIT License", 103 | ], 104 | ) 105 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin1024/vcrpy/19bd4e012c8fd6970fd7f2af3cc60aed1e5f1ab5/tests/__init__.py -------------------------------------------------------------------------------- /tests/assertions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def assert_cassette_empty(cass): 5 | assert len(cass) == 0 6 | assert cass.play_count == 0 7 | 8 | 9 | def assert_cassette_has_one_response(cass): 10 | assert len(cass) == 1 11 | assert cass.play_count == 1 12 | 13 | 14 | def assert_is_json_bytes(b: bytes): 15 | assert isinstance(b, bytes) 16 | 17 | try: 18 | json.loads(b) 19 | except Exception as error: 20 | raise AssertionError() from error 21 | 22 | assert True 23 | -------------------------------------------------------------------------------- /tests/fixtures/migration/new_cassette.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "interactions": 4 | [ 5 | { 6 | "request": { 7 | "body": null, 8 | "headers": { 9 | "accept": ["*/*"], 10 | "accept-encoding": ["gzip, deflate, compress"], 11 | "user-agent": ["python-requests/2.2.1 CPython/2.6.1 Darwin/10.8.0"] 12 | }, 13 | "method": "GET", 14 | "uri": "http://httpbin.org/ip" 15 | }, 16 | "response": { 17 | "status": { 18 | "message": "OK", 19 | "code": 200 20 | }, 21 | "headers": { 22 | "access-control-allow-origin": ["*"], 23 | "content-type": ["application/json"], 24 | "date": ["Mon, 21 Apr 2014 23:13:40 GMT"], 25 | "server": ["gunicorn/0.17.4"], 26 | "content-length": ["32"], 27 | "connection": ["keep-alive"] 28 | }, 29 | "body": { 30 | "string": "{\n \"origin\": \"217.122.164.194\"\n}" 31 | } 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tests/fixtures/migration/new_cassette.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | interactions: 3 | - request: 4 | body: null 5 | headers: 6 | accept: ['*/*'] 7 | accept-encoding: ['gzip, deflate, compress'] 8 | user-agent: ['python-requests/2.2.1 CPython/2.6.1 Darwin/10.8.0'] 9 | method: GET 10 | uri: http://httpbin.org/ip 11 | response: 12 | body: {string: "{\n \"origin\": \"217.122.164.194\"\n}"} 13 | headers: 14 | access-control-allow-origin: ['*'] 15 | content-type: [application/json] 16 | date: ['Mon, 21 Apr 2014 23:06:09 GMT'] 17 | server: [gunicorn/0.17.4] 18 | content-length: ['32'] 19 | connection: [keep-alive] 20 | status: {code: 200, message: OK} 21 | -------------------------------------------------------------------------------- /tests/fixtures/migration/not_cassette.txt: -------------------------------------------------------------------------------- 1 | This is not a cassette 2 | -------------------------------------------------------------------------------- /tests/fixtures/migration/old_cassette.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "request": { 4 | "body": null, 5 | "protocol": "http", 6 | "method": "GET", 7 | "headers": { 8 | "accept-encoding": "gzip, deflate, compress", 9 | "accept": "*/*", 10 | "user-agent": "python-requests/2.2.1 CPython/2.6.1 Darwin/10.8.0" 11 | }, 12 | "host": "httpbin.org", 13 | "path": "/ip", 14 | "port": 80 15 | }, 16 | "response": { 17 | "status": { 18 | "message": "OK", 19 | "code": 200 20 | }, 21 | "headers": [ 22 | "access-control-allow-origin: *\r\n", 23 | "content-type: application/json\r\n", 24 | "date: Mon, 21 Apr 2014 23:13:40 GMT\r\n", 25 | "server: gunicorn/0.17.4\r\n", 26 | "content-length: 32\r\n", 27 | "connection: keep-alive\r\n" 28 | ], 29 | "body": { 30 | "string": "{\n \"origin\": \"217.122.164.194\"\n}" 31 | } 32 | } 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /tests/fixtures/migration/old_cassette.yaml: -------------------------------------------------------------------------------- 1 | - request: !!python/object:vcr.request.Request 2 | body: null 3 | headers: !!python/object/apply:__builtin__.frozenset 4 | - - !!python/tuple [accept-encoding, 'gzip, deflate, compress'] 5 | - !!python/tuple [user-agent, python-requests/2.2.1 CPython/2.6.1 Darwin/10.8.0] 6 | - !!python/tuple [accept, '*/*'] 7 | host: httpbin.org 8 | method: GET 9 | path: /ip 10 | port: 80 11 | protocol: http 12 | response: 13 | body: {string: !!python/unicode "{\n \"origin\": \"217.122.164.194\"\n}"} 14 | headers: [!!python/unicode "access-control-allow-origin: *\r\n", !!python/unicode "content-type: 15 | application/json\r\n", !!python/unicode "date: Mon, 21 Apr 2014 23:06:09 GMT\r\n", 16 | !!python/unicode "server: gunicorn/0.17.4\r\n", !!python/unicode "content-length: 17 | 32\r\n", !!python/unicode "connection: keep-alive\r\n"] 18 | status: {code: 200, message: OK} 19 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin1024/vcrpy/19bd4e012c8fd6970fd7f2af3cc60aed1e5f1ab5/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/aiohttp_utils.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | import asyncio 3 | 4 | import aiohttp 5 | 6 | 7 | async def aiohttp_request(loop, method, url, output="text", encoding="utf-8", content_type=None, **kwargs): 8 | async with aiohttp.ClientSession(loop=loop) as session: 9 | response_ctx = session.request(method, url, **kwargs) 10 | 11 | response = await response_ctx.__aenter__() 12 | if output == "text": 13 | content = await response.text() 14 | elif output == "json": 15 | content_type = content_type or "application/json" 16 | content = await response.json(encoding=encoding, content_type=content_type) 17 | elif output == "raw": 18 | content = await response.read() 19 | elif output == "stream": 20 | content = await response.content.read() 21 | 22 | response_ctx._resp.close() 23 | await session.close() 24 | 25 | return response, content 26 | 27 | 28 | def aiohttp_app(): 29 | async def hello(request): 30 | return aiohttp.web.Response(text="hello") 31 | 32 | async def json(request): 33 | return aiohttp.web.json_response({}) 34 | 35 | async def json_empty_body(request): 36 | return aiohttp.web.json_response() 37 | 38 | app = aiohttp.web.Application() 39 | app.router.add_get("/", hello) 40 | app.router.add_get("/json", json) 41 | app.router.add_get("/json/empty", json_empty_body) 42 | return app 43 | -------------------------------------------------------------------------------- /tests/integration/cassettes/gzip_httpx_old_format.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '' 4 | headers: 5 | accept: 6 | - '*/*' 7 | accept-encoding: 8 | - gzip, deflate, br 9 | connection: 10 | - keep-alive 11 | host: 12 | - httpbin.org 13 | user-agent: 14 | - python-httpx/0.23.0 15 | method: GET 16 | uri: https://httpbin.org/gzip 17 | response: 18 | content: "{\n \"gzipped\": true, \n \"headers\": {\n \"Accept\": \"*/*\", 19 | \n \"Accept-Encoding\": \"gzip, deflate, br\", \n \"Host\": \"httpbin.org\", 20 | \n \"User-Agent\": \"python-httpx/0.23.0\", \n \"X-Amzn-Trace-Id\": \"Root=1-62a62a8d-5f39b5c50c744da821d6ea99\"\n 21 | \ }, \n \"method\": \"GET\", \n \"origin\": \"146.200.25.115\"\n}\n" 22 | headers: 23 | Access-Control-Allow-Credentials: 24 | - 'true' 25 | Access-Control-Allow-Origin: 26 | - '*' 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Length: 32 | - '230' 33 | Content-Type: 34 | - application/json 35 | Date: 36 | - Sun, 12 Jun 2022 18:03:57 GMT 37 | Server: 38 | - gunicorn/19.9.0 39 | http_version: HTTP/1.1 40 | status_code: 200 41 | version: 1 42 | -------------------------------------------------------------------------------- /tests/integration/cassettes/gzip_requests.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate, br 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.28.0 13 | method: GET 14 | uri: https://httpbin.org/gzip 15 | response: 16 | body: 17 | string: !!binary | 18 | H4sIAKwrpmIA/z2OSwrCMBCG956izLIkfQSxkl2RogfQA9R2bIM1iUkqaOndnYDIrGa+/zELDB9l 19 | LfYgg5uRwYhtj86DXKDuOrQBJKR5Cuy38kZ3pld6oHu0sqTH29QGZMnVkepgtMYuKKNJcEe0vJ3U 20 | C4mcjI9hpaiygqaUW7ETFYGLR8frAXXE9h1Go7nD54w++FxkYp8VsDJ4IBH6E47NmVzGqUHFkn8g 21 | rJsvp2omYs8AAAA= 22 | headers: 23 | Access-Control-Allow-Credentials: 24 | - 'true' 25 | Access-Control-Allow-Origin: 26 | - '*' 27 | Connection: 28 | - Close 29 | Content-Encoding: 30 | - gzip 31 | Content-Length: 32 | - '182' 33 | Content-Type: 34 | - application/json 35 | Date: 36 | - Sun, 12 Jun 2022 18:08:44 GMT 37 | Server: 38 | - Pytest-HTTPBIN/0.1.0 39 | status: 40 | code: 200 41 | message: great 42 | version: 1 43 | -------------------------------------------------------------------------------- /tests/integration/cassettes/test_httpx_test_test_behind_proxy.yml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '' 4 | headers: 5 | accept: 6 | - '*/*' 7 | accept-encoding: 8 | - gzip, deflate, br 9 | connection: 10 | - keep-alive 11 | host: 12 | - httpbin.org 13 | user-agent: 14 | - python-httpx/0.12.1 15 | method: GET 16 | uri: https://mockbin.org/headers 17 | response: 18 | content: "{\n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\"\ 19 | : \"gzip, deflate, br\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\"\ 20 | : \"python-httpx/0.12.1\", \n \"X-Amzn-Trace-Id\": \"Root=1-5ea778c9-ea76170da792abdbf7614067\"\ 21 | \n }\n}\n" 22 | headers: 23 | access-control-allow-credentials: 24 | - 'true' 25 | access-control-allow-origin: 26 | - '*' 27 | connection: 28 | - keep-alive 29 | content-length: 30 | - '226' 31 | content-type: 32 | - application/json 33 | date: 34 | - Tue, 28 Apr 2020 00:28:57 GMT 35 | server: 36 | - gunicorn/19.9.0 37 | via: 38 | - my_own_proxy 39 | http_version: HTTP/1.1 40 | status_code: 200 41 | version: 1 42 | -------------------------------------------------------------------------------- /tests/integration/test_basic.py: -------------------------------------------------------------------------------- 1 | """Basic tests for cassettes""" 2 | 3 | # External imports 4 | import os 5 | from urllib.request import urlopen 6 | 7 | # Internal imports 8 | import vcr 9 | 10 | 11 | def test_nonexistent_directory(tmpdir, httpbin): 12 | """If we load a cassette in a nonexistent directory, it can save ok""" 13 | # Check to make sure directory doesn't exist 14 | assert not os.path.exists(str(tmpdir.join("nonexistent"))) 15 | 16 | # Run VCR to create dir and cassette file 17 | with vcr.use_cassette(str(tmpdir.join("nonexistent", "cassette.yml"))): 18 | urlopen(httpbin.url).read() 19 | 20 | # This should have made the file and the directory 21 | assert os.path.exists(str(tmpdir.join("nonexistent", "cassette.yml"))) 22 | 23 | 24 | def test_unpatch(tmpdir, httpbin): 25 | """Ensure that our cassette gets unpatched when we're done""" 26 | with vcr.use_cassette(str(tmpdir.join("unpatch.yaml"))) as cass: 27 | urlopen(httpbin.url).read() 28 | 29 | # Make the same request, and assert that we haven't served any more 30 | # requests out of cache 31 | urlopen(httpbin.url).read() 32 | assert cass.play_count == 0 33 | 34 | 35 | def test_basic_json_use(tmpdir, httpbin): 36 | """ 37 | Ensure you can load a json serialized cassette 38 | """ 39 | test_fixture = str(tmpdir.join("synopsis.json")) 40 | with vcr.use_cassette(test_fixture, serializer="json"): 41 | response = urlopen(httpbin.url).read() 42 | assert b"HTTP Request & Response Service" in response 43 | 44 | 45 | def test_patched_content(tmpdir, httpbin): 46 | """ 47 | Ensure that what you pull from a cassette is what came from the 48 | request 49 | """ 50 | with vcr.use_cassette(str(tmpdir.join("synopsis.yaml"))) as cass: 51 | response = urlopen(httpbin.url).read() 52 | assert cass.play_count == 0 53 | 54 | with vcr.use_cassette(str(tmpdir.join("synopsis.yaml"))) as cass: 55 | response2 = urlopen(httpbin.url).read() 56 | assert cass.play_count == 1 57 | cass._save(force=True) 58 | 59 | with vcr.use_cassette(str(tmpdir.join("synopsis.yaml"))) as cass: 60 | response3 = urlopen(httpbin.url).read() 61 | assert cass.play_count == 1 62 | 63 | assert response == response2 64 | assert response2 == response3 65 | 66 | 67 | def test_patched_content_json(tmpdir, httpbin): 68 | """ 69 | Ensure that what you pull from a json cassette is what came from the 70 | request 71 | """ 72 | 73 | testfile = str(tmpdir.join("synopsis.json")) 74 | 75 | with vcr.use_cassette(testfile) as cass: 76 | response = urlopen(httpbin.url).read() 77 | assert cass.play_count == 0 78 | 79 | with vcr.use_cassette(testfile) as cass: 80 | response2 = urlopen(httpbin.url).read() 81 | assert cass.play_count == 1 82 | cass._save(force=True) 83 | 84 | with vcr.use_cassette(testfile) as cass: 85 | response3 = urlopen(httpbin.url).read() 86 | assert cass.play_count == 1 87 | 88 | assert response == response2 89 | assert response2 == response3 90 | -------------------------------------------------------------------------------- /tests/integration/test_boto3.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | import vcr 6 | 7 | boto3 = pytest.importorskip("boto3") 8 | 9 | import botocore # noqa 10 | 11 | try: 12 | from botocore import awsrequest # noqa 13 | 14 | botocore_awsrequest = True 15 | except ImportError: 16 | botocore_awsrequest = False 17 | 18 | # skip tests if boto does not use vendored requests anymore 19 | # https://github.com/boto/botocore/pull/1495 20 | boto3_skip_vendored_requests = pytest.mark.skipif( 21 | botocore_awsrequest, 22 | reason=f"botocore version {botocore.__version__} does not use vendored requests anymore.", 23 | ) 24 | 25 | boto3_skip_awsrequest = pytest.mark.skipif( 26 | not botocore_awsrequest, 27 | reason=f"botocore version {botocore.__version__} still uses vendored requests.", 28 | ) 29 | 30 | IAM_USER_NAME = "vcrpy" 31 | 32 | 33 | @pytest.fixture 34 | def iam_client(): 35 | def _iam_client(boto3_session=None): 36 | if boto3_session is None: 37 | boto3_session = boto3.Session( 38 | aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID", "default"), 39 | aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "default"), 40 | aws_session_token=None, 41 | region_name=os.environ.get("AWS_DEFAULT_REGION", "default"), 42 | ) 43 | return boto3_session.client("iam") 44 | 45 | return _iam_client 46 | 47 | 48 | @pytest.fixture 49 | def get_user(iam_client): 50 | def _get_user(client=None, user_name=IAM_USER_NAME): 51 | if client is None: 52 | # Default client set with fixture `iam_client` 53 | client = iam_client() 54 | return client.get_user(UserName=user_name) 55 | 56 | return _get_user 57 | 58 | 59 | @pytest.mark.skipif( 60 | os.environ.get("TRAVIS_PULL_REQUEST") != "false", 61 | reason="Encrypted Environment Variables from Travis Repository Settings" 62 | " are disabled on PRs from forks. " 63 | "https://docs.travis-ci.com/user/pull-requests/#pull-requests-and-security-restrictions", 64 | ) 65 | def test_boto_medium_difficulty(tmpdir, get_user): 66 | with vcr.use_cassette(str(tmpdir.join("boto3-medium.yml"))): 67 | response = get_user() 68 | assert response["User"]["UserName"] == IAM_USER_NAME 69 | 70 | with vcr.use_cassette(str(tmpdir.join("boto3-medium.yml"))) as cass: 71 | response = get_user() 72 | assert response["User"]["UserName"] == IAM_USER_NAME 73 | assert cass.all_played 74 | 75 | 76 | @pytest.mark.skipif( 77 | os.environ.get("TRAVIS_PULL_REQUEST") != "false", 78 | reason="Encrypted Environment Variables from Travis Repository Settings" 79 | " are disabled on PRs from forks. " 80 | "https://docs.travis-ci.com/user/pull-requests/#pull-requests-and-security-restrictions", 81 | ) 82 | def test_boto_hardcore_mode(tmpdir, iam_client, get_user): 83 | with vcr.use_cassette(str(tmpdir.join("boto3-hardcore.yml"))): 84 | ses = boto3.Session( 85 | aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), 86 | aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), 87 | region_name=os.environ.get("AWS_DEFAULT_REGION"), 88 | ) 89 | client = iam_client(ses) 90 | response = get_user(client=client) 91 | assert response["User"]["UserName"] == IAM_USER_NAME 92 | 93 | with vcr.use_cassette(str(tmpdir.join("boto3-hardcore.yml"))) as cass: 94 | ses = boto3.Session( 95 | aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), 96 | aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), 97 | aws_session_token=None, 98 | region_name=os.environ.get("AWS_DEFAULT_REGION"), 99 | ) 100 | 101 | client = iam_client(ses) 102 | response = get_user(client=client) 103 | assert response["User"]["UserName"] == IAM_USER_NAME 104 | assert cass.all_played 105 | -------------------------------------------------------------------------------- /tests/integration/test_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from urllib.request import urlopen 4 | 5 | import pytest 6 | 7 | import vcr 8 | from vcr.cassette import Cassette 9 | 10 | 11 | @pytest.mark.online 12 | def test_set_serializer_default_config(tmpdir, httpbin): 13 | my_vcr = vcr.VCR(serializer="json") 14 | 15 | with my_vcr.use_cassette(str(tmpdir.join("test.json"))): 16 | assert my_vcr.serializer == "json" 17 | urlopen(httpbin.url) 18 | 19 | with open(str(tmpdir.join("test.json"))) as f: 20 | file_content = f.read() 21 | assert file_content.endswith("\n") 22 | assert json.loads(file_content) 23 | 24 | 25 | @pytest.mark.online 26 | def test_default_set_cassette_library_dir(tmpdir, httpbin): 27 | my_vcr = vcr.VCR(cassette_library_dir=str(tmpdir.join("subdir"))) 28 | 29 | with my_vcr.use_cassette("test.json"): 30 | urlopen(httpbin.url) 31 | 32 | assert os.path.exists(str(tmpdir.join("subdir").join("test.json"))) 33 | 34 | 35 | @pytest.mark.online 36 | def test_override_set_cassette_library_dir(tmpdir, httpbin): 37 | my_vcr = vcr.VCR(cassette_library_dir=str(tmpdir.join("subdir"))) 38 | 39 | cld = str(tmpdir.join("subdir2")) 40 | 41 | with my_vcr.use_cassette("test.json", cassette_library_dir=cld): 42 | urlopen(httpbin.url) 43 | 44 | assert os.path.exists(str(tmpdir.join("subdir2").join("test.json"))) 45 | assert not os.path.exists(str(tmpdir.join("subdir").join("test.json"))) 46 | 47 | 48 | @pytest.mark.online 49 | def test_override_match_on(tmpdir, httpbin): 50 | my_vcr = vcr.VCR(match_on=["method"]) 51 | 52 | with my_vcr.use_cassette(str(tmpdir.join("test.json"))): 53 | urlopen(httpbin.url) 54 | 55 | with my_vcr.use_cassette(str(tmpdir.join("test.json"))) as cass: 56 | urlopen(httpbin.url) 57 | 58 | assert len(cass) == 1 59 | assert cass.play_count == 1 60 | 61 | 62 | def test_missing_matcher(): 63 | my_vcr = vcr.VCR() 64 | my_vcr.register_matcher("awesome", object) 65 | with pytest.raises(KeyError): 66 | with my_vcr.use_cassette("test.yaml", match_on=["notawesome"]): 67 | pass 68 | 69 | 70 | @pytest.mark.online 71 | def test_dont_record_on_exception(tmpdir, httpbin): 72 | my_vcr = vcr.VCR(record_on_exception=False) 73 | 74 | @my_vcr.use_cassette(str(tmpdir.join("dontsave.yml"))) 75 | def some_test(): 76 | assert b"Not in content" in urlopen(httpbin.url) 77 | 78 | with pytest.raises(AssertionError): 79 | some_test() 80 | 81 | assert not os.path.exists(str(tmpdir.join("dontsave.yml"))) 82 | 83 | # Make sure context decorator has the same behavior 84 | with pytest.raises(AssertionError): 85 | with my_vcr.use_cassette(str(tmpdir.join("dontsave2.yml"))): 86 | assert b"Not in content" in urlopen(httpbin.url).read() 87 | 88 | assert not os.path.exists(str(tmpdir.join("dontsave2.yml"))) 89 | 90 | 91 | def test_set_drop_unused_requests(tmpdir, httpbin): 92 | my_vcr = vcr.VCR(drop_unused_requests=True) 93 | file = str(tmpdir.join("test.yaml")) 94 | 95 | with my_vcr.use_cassette(file): 96 | urlopen(httpbin.url) 97 | urlopen(httpbin.url + "/get") 98 | 99 | cassette = Cassette.load(path=file) 100 | assert len(cassette) == 2 101 | 102 | with my_vcr.use_cassette(file): 103 | urlopen(httpbin.url) 104 | 105 | cassette = Cassette.load(path=file) 106 | assert len(cassette) == 1 107 | -------------------------------------------------------------------------------- /tests/integration/test_disksaver.py: -------------------------------------------------------------------------------- 1 | """Basic tests about save behavior""" 2 | 3 | # External imports 4 | import os 5 | import time 6 | from urllib.request import urlopen 7 | 8 | import pytest 9 | 10 | # Internal imports 11 | import vcr 12 | 13 | 14 | @pytest.mark.online 15 | def test_disk_saver_nowrite(tmpdir, httpbin): 16 | """ 17 | Ensure that when you close a cassette without changing it it doesn't 18 | rewrite the file 19 | """ 20 | fname = str(tmpdir.join("synopsis.yaml")) 21 | with vcr.use_cassette(fname) as cass: 22 | urlopen(httpbin.url).read() 23 | assert cass.play_count == 0 24 | last_mod = os.path.getmtime(fname) 25 | 26 | with vcr.use_cassette(fname) as cass: 27 | urlopen(httpbin.url).read() 28 | assert cass.play_count == 1 29 | assert cass.dirty is False 30 | last_mod2 = os.path.getmtime(fname) 31 | 32 | assert last_mod == last_mod2 33 | 34 | 35 | @pytest.mark.online 36 | def test_disk_saver_write(tmpdir, httpbin): 37 | """ 38 | Ensure that when you close a cassette after changing it it does 39 | rewrite the file 40 | """ 41 | fname = str(tmpdir.join("synopsis.yaml")) 42 | with vcr.use_cassette(fname) as cass: 43 | urlopen(httpbin.url).read() 44 | assert cass.play_count == 0 45 | last_mod = os.path.getmtime(fname) 46 | 47 | # Make sure at least 1 second passes, otherwise sometimes 48 | # the mtime doesn't change 49 | time.sleep(1) 50 | 51 | with vcr.use_cassette(fname, record_mode=vcr.mode.ANY) as cass: 52 | urlopen(httpbin.url).read() 53 | urlopen(httpbin.url + "/get").read() 54 | assert cass.play_count == 1 55 | assert cass.dirty 56 | last_mod2 = os.path.getmtime(fname) 57 | 58 | assert last_mod != last_mod2 59 | -------------------------------------------------------------------------------- /tests/integration/test_filter.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from urllib.error import HTTPError 4 | from urllib.parse import urlencode 5 | from urllib.request import Request, urlopen 6 | 7 | import pytest 8 | 9 | import vcr 10 | 11 | from ..assertions import assert_cassette_has_one_response, assert_is_json_bytes 12 | 13 | 14 | def _request_with_auth(url, username, password): 15 | request = Request(url) 16 | base64string = base64.b64encode(username.encode("ascii") + b":" + password.encode("ascii")) 17 | request.add_header(b"Authorization", b"Basic " + base64string) 18 | return urlopen(request) 19 | 20 | 21 | def _find_header(cassette, header): 22 | return any(header in request.headers for request in cassette.requests) 23 | 24 | 25 | def test_filter_basic_auth(tmpdir, httpbin): 26 | url = httpbin.url + "/basic-auth/user/passwd" 27 | cass_file = str(tmpdir.join("basic_auth_filter.yaml")) 28 | my_vcr = vcr.VCR(match_on=["uri", "method", "headers"]) 29 | # 2 requests, one with auth failure and one with auth success 30 | with my_vcr.use_cassette(cass_file, filter_headers=["authorization"]): 31 | with pytest.raises(HTTPError): 32 | resp = _request_with_auth(url, "user", "wrongpasswd") 33 | assert resp.getcode() == 401 34 | resp = _request_with_auth(url, "user", "passwd") 35 | assert resp.getcode() == 200 36 | # make same 2 requests, this time both served from cassette. 37 | with my_vcr.use_cassette(cass_file, filter_headers=["authorization"]) as cass: 38 | with pytest.raises(HTTPError): 39 | resp = _request_with_auth(url, "user", "wrongpasswd") 40 | assert resp.getcode() == 401 41 | resp = _request_with_auth(url, "user", "passwd") 42 | assert resp.getcode() == 200 43 | # authorization header should not have been recorded 44 | assert not _find_header(cass, "authorization") 45 | assert len(cass) == 2 46 | 47 | 48 | def test_filter_querystring(tmpdir, httpbin): 49 | url = httpbin.url + "/?password=secret" 50 | cass_file = str(tmpdir.join("filter_qs.yaml")) 51 | with vcr.use_cassette(cass_file, filter_query_parameters=["password"]): 52 | urlopen(url) 53 | with vcr.use_cassette(cass_file, filter_query_parameters=["password"]) as cass: 54 | urlopen(url) 55 | assert "password" not in cass.requests[0].url 56 | assert "secret" not in cass.requests[0].url 57 | with open(cass_file) as f: 58 | cassette_content = f.read() 59 | assert "password" not in cassette_content 60 | assert "secret" not in cassette_content 61 | 62 | 63 | def test_filter_post_data(tmpdir, httpbin): 64 | url = httpbin.url + "/post" 65 | data = urlencode({"id": "secret", "foo": "bar"}).encode("utf-8") 66 | cass_file = str(tmpdir.join("filter_pd.yaml")) 67 | with vcr.use_cassette(cass_file, filter_post_data_parameters=["id"]): 68 | urlopen(url, data) 69 | with vcr.use_cassette(cass_file, filter_post_data_parameters=["id"]) as cass: 70 | assert b"id=secret" not in cass.requests[0].body 71 | 72 | 73 | def test_filter_json_post_data(tmpdir, httpbin): 74 | data = json.dumps({"id": "secret", "foo": "bar"}).encode("utf-8") 75 | request = Request(httpbin.url + "/post", data=data) 76 | request.add_header("Content-Type", "application/json") 77 | 78 | cass_file = str(tmpdir.join("filter_jpd.yaml")) 79 | with vcr.use_cassette(cass_file, filter_post_data_parameters=["id"]): 80 | urlopen(request) 81 | with vcr.use_cassette(cass_file, filter_post_data_parameters=["id"]) as cass: 82 | assert b'"id": "secret"' not in cass.requests[0].body 83 | 84 | 85 | def test_filter_callback(tmpdir, httpbin): 86 | url = httpbin.url + "/get" 87 | cass_file = str(tmpdir.join("basic_auth_filter.yaml")) 88 | 89 | def before_record_cb(request): 90 | if request.path != "/get": 91 | return request 92 | 93 | # Test the legacy keyword. 94 | my_vcr = vcr.VCR(before_record=before_record_cb) 95 | with my_vcr.use_cassette(cass_file, filter_headers=["authorization"]) as cass: 96 | urlopen(url) 97 | assert len(cass) == 0 98 | 99 | my_vcr = vcr.VCR(before_record_request=before_record_cb) 100 | with my_vcr.use_cassette(cass_file, filter_headers=["authorization"]) as cass: 101 | urlopen(url) 102 | assert len(cass) == 0 103 | 104 | 105 | def test_decompress_gzip(tmpdir, httpbin): 106 | url = httpbin.url + "/gzip" 107 | request = Request(url, headers={"Accept-Encoding": ["gzip, deflate"]}) 108 | cass_file = str(tmpdir.join("gzip_response.yaml")) 109 | with vcr.use_cassette(cass_file, decode_compressed_response=True): 110 | urlopen(request) 111 | with vcr.use_cassette(cass_file) as cass: 112 | decoded_response = urlopen(url).read() 113 | assert_cassette_has_one_response(cass) 114 | assert_is_json_bytes(decoded_response) 115 | 116 | 117 | def test_decomptess_empty_body(tmpdir, httpbin): 118 | url = httpbin.url + "/gzip" 119 | request = Request(url, headers={"Accept-Encoding": ["gzip, deflate"]}, method="HEAD") 120 | cass_file = str(tmpdir.join("gzip_empty_response.yaml")) 121 | with vcr.use_cassette(cass_file, decode_compressed_response=True): 122 | response = urlopen(request).read() 123 | with vcr.use_cassette(cass_file) as cass: 124 | decoded_response = urlopen(request).read() 125 | assert_cassette_has_one_response(cass) 126 | assert decoded_response == response 127 | 128 | 129 | def test_decompress_deflate(tmpdir, httpbin): 130 | url = httpbin.url + "/deflate" 131 | request = Request(url, headers={"Accept-Encoding": ["gzip, deflate"]}) 132 | cass_file = str(tmpdir.join("deflate_response.yaml")) 133 | with vcr.use_cassette(cass_file, decode_compressed_response=True): 134 | urlopen(request) 135 | with vcr.use_cassette(cass_file) as cass: 136 | decoded_response = urlopen(url).read() 137 | assert_cassette_has_one_response(cass) 138 | assert_is_json_bytes(decoded_response) 139 | 140 | 141 | def test_decompress_regular(tmpdir, httpbin): 142 | """Test that it doesn't try to decompress content that isn't compressed""" 143 | url = httpbin.url + "/get" 144 | cass_file = str(tmpdir.join("noncompressed_response.yaml")) 145 | with vcr.use_cassette(cass_file, decode_compressed_response=True): 146 | urlopen(url) 147 | with vcr.use_cassette(cass_file) as cass: 148 | resp = urlopen(url).read() 149 | assert_cassette_has_one_response(cass) 150 | assert_is_json_bytes(resp) 151 | 152 | 153 | def test_before_record_request_corruption(tmpdir, httpbin): 154 | """Modifying request in before_record_request should not affect outgoing request""" 155 | 156 | def before_record(request): 157 | request.headers.clear() 158 | request.body = b"" 159 | return request 160 | 161 | req = Request( 162 | httpbin.url + "/post", 163 | data=urlencode({"test": "exists"}).encode(), 164 | headers={"X-Test": "exists"}, 165 | ) 166 | cass_file = str(tmpdir.join("modified_response.yaml")) 167 | with vcr.use_cassette(cass_file, before_record_request=before_record): 168 | resp = json.loads(urlopen(req).read()) 169 | 170 | assert resp["headers"]["X-Test"] == "exists" 171 | assert resp["form"]["test"] == "exists" 172 | -------------------------------------------------------------------------------- /tests/integration/test_http: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: {} 5 | method: GET 6 | uri: https://httpbin.org/get?ham=spam 7 | response: 8 | body: {string: "{\n \"args\": {\n \"ham\": \"spam\"\n }, \n \"headers\"\ 9 | : {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\"\ 10 | , \n \"Connection\": \"close\", \n \"Host\": \"httpbin.org\", \n \ 11 | \ \"User-Agent\": \"Python/3.5 aiohttp/2.0.1\"\n }, \n \"origin\": \"213.86.221.35\"\ 12 | , \n \"url\": \"https://httpbin.org/get?ham=spam\"\n}\n"} 13 | headers: {Access-Control-Allow-Credentials: 'true', Access-Control-Allow-Origin: '*', 14 | Connection: keep-alive, Content-Length: '299', Content-Type: application/json, 15 | Date: 'Wed, 22 Mar 2017 20:08:29 GMT', Server: gunicorn/19.7.1, Via: 1.1 vegur} 16 | status: {code: 200, message: OK} 17 | url: !!python/object/new:yarl.URL 18 | state: !!python/tuple 19 | - !!python/object/new:urllib.parse.SplitResult [https, httpbin.org, /get, ham=spam, 20 | ''] 21 | - false 22 | version: 1 23 | -------------------------------------------------------------------------------- /tests/integration/test_httplib2.py: -------------------------------------------------------------------------------- 1 | """Integration tests with httplib2""" 2 | 3 | from urllib.parse import urlencode 4 | 5 | import pytest 6 | import pytest_httpbin.certs 7 | 8 | import vcr 9 | 10 | from ..assertions import assert_cassette_has_one_response 11 | 12 | httplib2 = pytest.importorskip("httplib2") 13 | 14 | 15 | def http(): 16 | """ 17 | Returns an httplib2 HTTP instance 18 | with the certificate replaced by the httpbin one. 19 | """ 20 | kwargs = {"ca_certs": pytest_httpbin.certs.where()} 21 | return httplib2.Http(**kwargs) 22 | 23 | 24 | def test_response_code(tmpdir, httpbin_both): 25 | """Ensure we can read a response code from a fetch""" 26 | url = httpbin_both.url 27 | with vcr.use_cassette(str(tmpdir.join("atts.yaml"))): 28 | resp, _ = http().request(url) 29 | code = resp.status 30 | 31 | with vcr.use_cassette(str(tmpdir.join("atts.yaml"))): 32 | resp, _ = http().request(url) 33 | assert code == resp.status 34 | 35 | 36 | def test_random_body(httpbin_both, tmpdir): 37 | """Ensure we can read the content, and that it's served from cache""" 38 | url = httpbin_both.url + "/bytes/1024" 39 | with vcr.use_cassette(str(tmpdir.join("body.yaml"))): 40 | _, content = http().request(url) 41 | body = content 42 | 43 | with vcr.use_cassette(str(tmpdir.join("body.yaml"))): 44 | _, content = http().request(url) 45 | assert body == content 46 | 47 | 48 | def test_response_headers(tmpdir, httpbin_both): 49 | """Ensure we can get information from the response""" 50 | url = httpbin_both.url 51 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 52 | resp, _ = http().request(url) 53 | headers = resp.items() 54 | 55 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 56 | resp, _ = http().request(url) 57 | assert set(headers) == set(resp.items()) 58 | 59 | 60 | @pytest.mark.online 61 | def test_effective_url(tmpdir, httpbin): 62 | """Ensure that the effective_url is captured""" 63 | url = httpbin.url + "/redirect-to?url=.%2F&status_code=301" 64 | 65 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 66 | resp, _ = http().request(url) 67 | effective_url = resp["content-location"] 68 | assert effective_url == httpbin.url + "/" 69 | 70 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 71 | resp, _ = http().request(url) 72 | assert effective_url == resp["content-location"] 73 | 74 | 75 | def test_multiple_requests(tmpdir, httpbin_both): 76 | """Ensure that we can cache multiple requests""" 77 | urls = [httpbin_both.url, httpbin_both.url, httpbin_both.url + "/get", httpbin_both.url + "/bytes/1024"] 78 | with vcr.use_cassette(str(tmpdir.join("multiple.yaml"))) as cass: 79 | [http().request(url) for url in urls] 80 | assert len(cass) == len(urls) 81 | 82 | 83 | def test_get_data(tmpdir, httpbin_both): 84 | """Ensure that it works with query data""" 85 | data = urlencode({"some": 1, "data": "here"}) 86 | url = httpbin_both.url + "/get?" + data 87 | with vcr.use_cassette(str(tmpdir.join("get_data.yaml"))): 88 | _, res1 = http().request(url) 89 | 90 | with vcr.use_cassette(str(tmpdir.join("get_data.yaml"))): 91 | _, res2 = http().request(url) 92 | 93 | assert res1 == res2 94 | 95 | 96 | def test_post_data(tmpdir, httpbin_both): 97 | """Ensure that it works when posting data""" 98 | data = urlencode({"some": 1, "data": "here"}) 99 | url = httpbin_both.url + "/post" 100 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))): 101 | _, res1 = http().request(url, "POST", data) 102 | 103 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))) as cass: 104 | _, res2 = http().request(url, "POST", data) 105 | 106 | assert res1 == res2 107 | assert_cassette_has_one_response(cass) 108 | 109 | 110 | def test_post_unicode_data(tmpdir, httpbin_both): 111 | """Ensure that it works when posting unicode data""" 112 | data = urlencode({"snowman": "☃".encode()}) 113 | url = httpbin_both.url + "/post" 114 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))): 115 | _, res1 = http().request(url, "POST", data) 116 | 117 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))) as cass: 118 | _, res2 = http().request(url, "POST", data) 119 | 120 | assert res1 == res2 121 | assert_cassette_has_one_response(cass) 122 | 123 | 124 | def test_cross_scheme(tmpdir, httpbin, httpbin_secure): 125 | """Ensure that requests between schemes are treated separately""" 126 | # First fetch a url under https, and then again under https and then 127 | # ensure that we haven't served anything out of cache, and we have two 128 | # requests / response pairs in the cassette 129 | with vcr.use_cassette(str(tmpdir.join("cross_scheme.yaml"))) as cass: 130 | http().request(httpbin_secure.url) 131 | http().request(httpbin.url) 132 | assert len(cass) == 2 133 | assert cass.play_count == 0 134 | 135 | 136 | def test_decorator(tmpdir, httpbin_both): 137 | """Test the decorator version of VCR.py""" 138 | url = httpbin_both.url 139 | 140 | @vcr.use_cassette(str(tmpdir.join("atts.yaml"))) 141 | def inner1(): 142 | resp, _ = http().request(url) 143 | return resp["status"] 144 | 145 | @vcr.use_cassette(str(tmpdir.join("atts.yaml"))) 146 | def inner2(): 147 | resp, _ = http().request(url) 148 | return resp["status"] 149 | 150 | assert inner1() == inner2() 151 | -------------------------------------------------------------------------------- /tests/integration/test_ignore.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from contextlib import contextmanager 3 | from urllib.request import urlopen 4 | 5 | import vcr 6 | 7 | 8 | @contextmanager 9 | def overridden_dns(overrides): 10 | """ 11 | Monkeypatch socket.getaddrinfo() to override DNS lookups (name will resolve 12 | to address) 13 | """ 14 | real_getaddrinfo = socket.getaddrinfo 15 | 16 | def fake_getaddrinfo(*args, **kwargs): 17 | if args[0] in overrides: 18 | address = overrides[args[0]] 19 | return [(2, 1, 6, "", (address, args[1]))] 20 | return real_getaddrinfo(*args, **kwargs) 21 | 22 | socket.getaddrinfo = fake_getaddrinfo 23 | yield 24 | socket.getaddrinfo = real_getaddrinfo 25 | 26 | 27 | def test_ignore_localhost(tmpdir, httpbin): 28 | with overridden_dns({"httpbin.org": "127.0.0.1"}): 29 | cass_file = str(tmpdir.join("filter_qs.yaml")) 30 | with vcr.use_cassette(cass_file, ignore_localhost=True) as cass: 31 | urlopen(f"http://localhost:{httpbin.port}/") 32 | assert len(cass) == 0 33 | urlopen(f"http://httpbin.org:{httpbin.port}/") 34 | assert len(cass) == 1 35 | 36 | 37 | def test_ignore_httpbin(tmpdir, httpbin): 38 | with overridden_dns({"httpbin.org": "127.0.0.1"}): 39 | cass_file = str(tmpdir.join("filter_qs.yaml")) 40 | with vcr.use_cassette(cass_file, ignore_hosts=["httpbin.org"]) as cass: 41 | urlopen(f"http://httpbin.org:{httpbin.port}/") 42 | assert len(cass) == 0 43 | urlopen(f"http://localhost:{httpbin.port}/") 44 | assert len(cass) == 1 45 | 46 | 47 | def test_ignore_localhost_and_httpbin(tmpdir, httpbin): 48 | with overridden_dns({"httpbin.org": "127.0.0.1"}): 49 | cass_file = str(tmpdir.join("filter_qs.yaml")) 50 | with vcr.use_cassette(cass_file, ignore_hosts=["httpbin.org"], ignore_localhost=True) as cass: 51 | urlopen(f"http://httpbin.org:{httpbin.port}") 52 | urlopen(f"http://localhost:{httpbin.port}") 53 | assert len(cass) == 0 54 | 55 | 56 | def test_ignore_localhost_twice(tmpdir, httpbin): 57 | with overridden_dns({"httpbin.org": "127.0.0.1"}): 58 | cass_file = str(tmpdir.join("filter_qs.yaml")) 59 | with vcr.use_cassette(cass_file, ignore_localhost=True) as cass: 60 | urlopen(f"http://localhost:{httpbin.port}") 61 | assert len(cass) == 0 62 | urlopen(f"http://httpbin.org:{httpbin.port}") 63 | assert len(cass) == 1 64 | with vcr.use_cassette(cass_file, ignore_localhost=True) as cass: 65 | assert len(cass) == 1 66 | urlopen(f"http://localhost:{httpbin.port}") 67 | urlopen(f"http://httpbin.org:{httpbin.port}") 68 | assert len(cass) == 1 69 | -------------------------------------------------------------------------------- /tests/integration/test_matchers.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import pytest 4 | 5 | import vcr 6 | 7 | DEFAULT_URI = "http://httpbin.org/get?p1=q1&p2=q2" # base uri for testing 8 | 9 | 10 | def _replace_httpbin(uri, httpbin, httpbin_secure): 11 | return uri.replace("http://httpbin.org", httpbin.url).replace("https://httpbin.org", httpbin_secure.url) 12 | 13 | 14 | @pytest.fixture 15 | def cassette(tmpdir, httpbin, httpbin_secure): 16 | """ 17 | Helper fixture used to prepare the cassette 18 | returns path to the recorded cassette 19 | """ 20 | default_uri = _replace_httpbin(DEFAULT_URI, httpbin, httpbin_secure) 21 | 22 | cassette_path = str(tmpdir.join("test.yml")) 23 | with vcr.use_cassette(cassette_path, record_mode=vcr.mode.ALL): 24 | urlopen(default_uri) 25 | return cassette_path 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "matcher, matching_uri, not_matching_uri", 30 | [ 31 | ("uri", "http://httpbin.org/get?p1=q1&p2=q2", "http://httpbin.org/get?p2=q2&p1=q1"), 32 | ("scheme", "http://google.com/post?a=b", "https://httpbin.org/get?p1=q1&p2=q2"), 33 | ("host", "https://httpbin.org/post?a=b", "http://google.com/get?p1=q1&p2=q2"), 34 | ("path", "https://google.com/get?a=b", "http://httpbin.org/post?p1=q1&p2=q2"), 35 | ("query", "https://google.com/get?p2=q2&p1=q1", "http://httpbin.org/get?p1=q1&a=b"), 36 | ], 37 | ) 38 | def test_matchers(httpbin, httpbin_secure, cassette, matcher, matching_uri, not_matching_uri): 39 | matching_uri = _replace_httpbin(matching_uri, httpbin, httpbin_secure) 40 | not_matching_uri = _replace_httpbin(not_matching_uri, httpbin, httpbin_secure) 41 | default_uri = _replace_httpbin(DEFAULT_URI, httpbin, httpbin_secure) 42 | 43 | # play cassette with default uri 44 | with vcr.use_cassette(cassette, match_on=[matcher]) as cass: 45 | urlopen(default_uri) 46 | assert cass.play_count == 1 47 | 48 | # play cassette with matching on uri 49 | with vcr.use_cassette(cassette, match_on=[matcher]) as cass: 50 | urlopen(matching_uri) 51 | assert cass.play_count == 1 52 | 53 | # play cassette with not matching on uri, it should fail 54 | with pytest.raises(vcr.errors.CannotOverwriteExistingCassetteException): 55 | with vcr.use_cassette(cassette, match_on=[matcher]) as cass: 56 | urlopen(not_matching_uri) 57 | 58 | 59 | def test_method_matcher(cassette, httpbin, httpbin_secure): 60 | default_uri = _replace_httpbin(DEFAULT_URI, httpbin, httpbin_secure) 61 | 62 | # play cassette with matching on method 63 | with vcr.use_cassette(cassette, match_on=["method"]) as cass: 64 | urlopen("https://google.com/get?a=b") 65 | assert cass.play_count == 1 66 | 67 | # should fail if method does not match 68 | with pytest.raises(vcr.errors.CannotOverwriteExistingCassetteException): 69 | with vcr.use_cassette(cassette, match_on=["method"]) as cass: 70 | # is a POST request 71 | urlopen(default_uri, data=b"") 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "uri", 76 | ( 77 | DEFAULT_URI, 78 | "http://httpbin.org/get?p2=q2&p1=q1", 79 | "http://httpbin.org/get?p2=q2&p1=q1", 80 | ), 81 | ) 82 | def test_default_matcher_matches(cassette, uri, httpbin, httpbin_secure): 83 | uri = _replace_httpbin(uri, httpbin, httpbin_secure) 84 | 85 | with vcr.use_cassette(cassette) as cass: 86 | urlopen(uri) 87 | assert cass.play_count == 1 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "uri", 92 | [ 93 | "https://httpbin.org/get?p1=q1&p2=q2", 94 | "http://google.com/get?p1=q1&p2=q2", 95 | "http://httpbin.org/post?p1=q1&p2=q2", 96 | "http://httpbin.org/get?p1=q1&a=b", 97 | ], 98 | ) 99 | def test_default_matcher_does_not_match(cassette, uri, httpbin, httpbin_secure): 100 | uri = _replace_httpbin(uri, httpbin, httpbin_secure) 101 | with pytest.raises(vcr.errors.CannotOverwriteExistingCassetteException): 102 | with vcr.use_cassette(cassette): 103 | urlopen(uri) 104 | 105 | 106 | def test_default_matcher_does_not_match_on_method(cassette, httpbin, httpbin_secure): 107 | default_uri = _replace_httpbin(DEFAULT_URI, httpbin, httpbin_secure) 108 | with pytest.raises(vcr.errors.CannotOverwriteExistingCassetteException): 109 | with vcr.use_cassette(cassette): 110 | # is a POST request 111 | urlopen(default_uri, data=b"") 112 | -------------------------------------------------------------------------------- /tests/integration/test_multiple.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import pytest 4 | 5 | import vcr 6 | from vcr.errors import CannotOverwriteExistingCassetteException 7 | 8 | 9 | def test_making_extra_request_raises_exception(tmpdir, httpbin): 10 | # make two requests in the first request that are considered 11 | # identical (since the match is based on method) 12 | with vcr.use_cassette(str(tmpdir.join("test.json")), match_on=["method"]): 13 | urlopen(httpbin.url + "/status/200") 14 | urlopen(httpbin.url + "/status/201") 15 | 16 | # Now, try to make three requests. The first two should return the 17 | # correct status codes in order, and the third should raise an 18 | # exception. 19 | with vcr.use_cassette(str(tmpdir.join("test.json")), match_on=["method"]): 20 | assert urlopen(httpbin.url + "/status/200").getcode() == 200 21 | assert urlopen(httpbin.url + "/status/201").getcode() == 201 22 | with pytest.raises(CannotOverwriteExistingCassetteException): 23 | urlopen(httpbin.url + "/status/200") 24 | -------------------------------------------------------------------------------- /tests/integration/test_proxy.py: -------------------------------------------------------------------------------- 1 | """Test using a proxy.""" 2 | 3 | import asyncio 4 | import http.server 5 | import socketserver 6 | import threading 7 | from urllib.request import urlopen 8 | 9 | import pytest 10 | 11 | import vcr 12 | 13 | # Conditional imports 14 | requests = pytest.importorskip("requests") 15 | 16 | 17 | class Proxy(http.server.SimpleHTTPRequestHandler): 18 | """ 19 | Simple proxy server. 20 | 21 | (Inspired by: http://effbot.org/librarybook/simplehttpserver.htm). 22 | """ 23 | 24 | def do_GET(self): 25 | upstream_response = urlopen(self.path) 26 | try: 27 | status = upstream_response.status 28 | headers = upstream_response.headers.items() 29 | except AttributeError: 30 | # In Python 2 the response is an addinfourl instance. 31 | status = upstream_response.code 32 | headers = upstream_response.info().items() 33 | self.log_request(status) 34 | self.send_response_only(status, upstream_response.msg) 35 | for header in headers: 36 | self.send_header(*header) 37 | self.end_headers() 38 | self.copyfile(upstream_response, self.wfile) 39 | 40 | def do_CONNECT(self): 41 | host, port = self.path.split(":") 42 | 43 | asyncio.run(self._tunnel(host, port, self.connection)) 44 | 45 | async def _tunnel(self, host, port, client_sock): 46 | target_r, target_w = await asyncio.open_connection(host=host, port=port) 47 | 48 | self.send_response(http.HTTPStatus.OK) 49 | self.end_headers() 50 | 51 | source_r, source_w = await asyncio.open_connection(sock=client_sock) 52 | 53 | async def channel(reader, writer): 54 | while True: 55 | data = await reader.read(1024) 56 | if not data: 57 | break 58 | writer.write(data) 59 | await writer.drain() 60 | 61 | writer.close() 62 | await writer.wait_closed() 63 | 64 | await asyncio.gather( 65 | channel(target_r, source_w), 66 | channel(source_r, target_w), 67 | ) 68 | 69 | 70 | @pytest.fixture(scope="session") 71 | def proxy_server(): 72 | with socketserver.ThreadingTCPServer(("", 0), Proxy) as httpd: 73 | proxy_process = threading.Thread(target=httpd.serve_forever) 74 | proxy_process.start() 75 | yield "http://{}:{}".format(*httpd.server_address) 76 | httpd.shutdown() 77 | proxy_process.join() 78 | 79 | 80 | def test_use_proxy(tmpdir, httpbin, proxy_server): 81 | """Ensure that it works with a proxy.""" 82 | with vcr.use_cassette(str(tmpdir.join("proxy.yaml"))): 83 | response = requests.get(httpbin.url, proxies={"http": proxy_server}) 84 | 85 | with vcr.use_cassette(str(tmpdir.join("proxy.yaml")), mode="none") as cassette: 86 | cassette_response = requests.get(httpbin.url, proxies={"http": proxy_server}) 87 | 88 | assert cassette_response.headers == response.headers 89 | assert cassette.play_count == 1 90 | 91 | 92 | def test_use_https_proxy(tmpdir, httpbin_secure, proxy_server): 93 | """Ensure that it works with an HTTPS proxy.""" 94 | with vcr.use_cassette(str(tmpdir.join("proxy.yaml"))): 95 | response = requests.get(httpbin_secure.url, proxies={"https": proxy_server}) 96 | 97 | with vcr.use_cassette(str(tmpdir.join("proxy.yaml")), mode="none") as cassette: 98 | cassette_response = requests.get( 99 | httpbin_secure.url, 100 | proxies={"https": proxy_server}, 101 | ) 102 | 103 | assert cassette_response.headers == response.headers 104 | assert cassette.play_count == 1 105 | 106 | # The cassette URL points to httpbin, not the proxy 107 | assert cassette.requests[0].url == httpbin_secure.url + "/" 108 | -------------------------------------------------------------------------------- /tests/integration/test_record_mode.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import pytest 4 | 5 | import vcr 6 | from vcr.errors import CannotOverwriteExistingCassetteException 7 | 8 | 9 | def test_once_record_mode(tmpdir, httpbin): 10 | testfile = str(tmpdir.join("recordmode.yml")) 11 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE): 12 | # cassette file doesn't exist, so create. 13 | urlopen(httpbin.url).read() 14 | 15 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE): 16 | # make the same request again 17 | urlopen(httpbin.url).read() 18 | 19 | # the first time, it's played from the cassette. 20 | # but, try to access something else from the same cassette, and an 21 | # exception is raised. 22 | with pytest.raises(CannotOverwriteExistingCassetteException): 23 | urlopen(httpbin.url + "/get").read() 24 | 25 | 26 | def test_once_record_mode_two_times(tmpdir, httpbin): 27 | testfile = str(tmpdir.join("recordmode.yml")) 28 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE): 29 | # get two of the same file 30 | urlopen(httpbin.url).read() 31 | urlopen(httpbin.url).read() 32 | 33 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE): 34 | # do it again 35 | urlopen(httpbin.url).read() 36 | urlopen(httpbin.url).read() 37 | 38 | 39 | def test_once_mode_three_times(tmpdir, httpbin): 40 | testfile = str(tmpdir.join("recordmode.yml")) 41 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE): 42 | # get three of the same file 43 | urlopen(httpbin.url).read() 44 | urlopen(httpbin.url).read() 45 | urlopen(httpbin.url).read() 46 | 47 | 48 | def test_new_episodes_record_mode(tmpdir, httpbin): 49 | testfile = str(tmpdir.join("recordmode.yml")) 50 | 51 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES): 52 | # cassette file doesn't exist, so create. 53 | urlopen(httpbin.url).read() 54 | 55 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES) as cass: 56 | # make the same request again 57 | urlopen(httpbin.url).read() 58 | 59 | # all responses have been played 60 | assert cass.all_played 61 | 62 | # in the "new_episodes" record mode, we can add more requests to 63 | # a cassette without repercussions. 64 | urlopen(httpbin.url + "/get").read() 65 | 66 | # one of the responses has been played 67 | assert cass.play_count == 1 68 | 69 | # not all responses have been played 70 | assert not cass.all_played 71 | 72 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES) as cass: 73 | # the cassette should now have 2 responses 74 | assert len(cass.responses) == 2 75 | 76 | 77 | def test_new_episodes_record_mode_two_times(tmpdir, httpbin): 78 | testfile = str(tmpdir.join("recordmode.yml")) 79 | url = httpbin.url + "/bytes/1024" 80 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES): 81 | # cassette file doesn't exist, so create. 82 | original_first_response = urlopen(url).read() 83 | 84 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES): 85 | # make the same request again 86 | assert urlopen(url).read() == original_first_response 87 | 88 | # in the "new_episodes" record mode, we can add the same request 89 | # to the cassette without repercussions 90 | original_second_response = urlopen(url).read() 91 | 92 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE): 93 | # make the same request again 94 | assert urlopen(url).read() == original_first_response 95 | assert urlopen(url).read() == original_second_response 96 | # now that we are back in once mode, this should raise 97 | # an error. 98 | with pytest.raises(CannotOverwriteExistingCassetteException): 99 | urlopen(url).read() 100 | 101 | 102 | def test_all_record_mode(tmpdir, httpbin): 103 | testfile = str(tmpdir.join("recordmode.yml")) 104 | 105 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ALL): 106 | # cassette file doesn't exist, so create. 107 | urlopen(httpbin.url).read() 108 | 109 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ALL) as cass: 110 | # make the same request again 111 | urlopen(httpbin.url).read() 112 | 113 | # in the "all" record mode, we can add more requests to 114 | # a cassette without repercussions. 115 | urlopen(httpbin.url + "/get").read() 116 | 117 | # The cassette was never actually played, even though it existed. 118 | # that's because, in "all" mode, the requests all go directly to 119 | # the source and bypass the cassette. 120 | assert cass.play_count == 0 121 | 122 | 123 | def test_none_record_mode(tmpdir, httpbin): 124 | # Cassette file doesn't exist, yet we are trying to make a request. 125 | # raise hell. 126 | testfile = str(tmpdir.join("recordmode.yml")) 127 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NONE): 128 | with pytest.raises(CannotOverwriteExistingCassetteException): 129 | urlopen(httpbin.url).read() 130 | 131 | 132 | def test_none_record_mode_with_existing_cassette(tmpdir, httpbin): 133 | # create a cassette file 134 | testfile = str(tmpdir.join("recordmode.yml")) 135 | 136 | with vcr.use_cassette(testfile, record_mode=vcr.mode.ALL): 137 | urlopen(httpbin.url).read() 138 | 139 | # play from cassette file 140 | with vcr.use_cassette(testfile, record_mode=vcr.mode.NONE) as cass: 141 | urlopen(httpbin.url).read() 142 | assert cass.play_count == 1 143 | # but if I try to hit the net, raise an exception. 144 | with pytest.raises(CannotOverwriteExistingCassetteException): 145 | urlopen(httpbin.url + "/get").read() 146 | -------------------------------------------------------------------------------- /tests/integration/test_register_matcher.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import pytest 4 | 5 | import vcr 6 | 7 | 8 | def true_matcher(r1, r2): 9 | return True 10 | 11 | 12 | def false_matcher(r1, r2): 13 | return False 14 | 15 | 16 | @pytest.mark.online 17 | def test_registered_true_matcher(tmpdir, httpbin): 18 | my_vcr = vcr.VCR() 19 | my_vcr.register_matcher("true", true_matcher) 20 | testfile = str(tmpdir.join("test.yml")) 21 | with my_vcr.use_cassette(testfile, match_on=["true"]): 22 | # These 2 different urls are stored as the same request 23 | urlopen(httpbin.url) 24 | urlopen(httpbin.url + "/get") 25 | 26 | with my_vcr.use_cassette(testfile, match_on=["true"]): 27 | # I can get the response twice even though I only asked for it once 28 | urlopen(httpbin.url) 29 | urlopen(httpbin.url) 30 | 31 | 32 | @pytest.mark.online 33 | def test_registered_false_matcher(tmpdir, httpbin): 34 | my_vcr = vcr.VCR() 35 | my_vcr.register_matcher("false", false_matcher) 36 | testfile = str(tmpdir.join("test.yml")) 37 | with my_vcr.use_cassette(testfile, match_on=["false"]) as cass: 38 | # These 2 different urls are stored as different requests 39 | urlopen(httpbin.url) 40 | urlopen(httpbin.url + "/get") 41 | assert len(cass) == 2 42 | -------------------------------------------------------------------------------- /tests/integration/test_register_persister.py: -------------------------------------------------------------------------------- 1 | """Tests for cassettes with custom persistence""" 2 | 3 | # External imports 4 | import os 5 | from urllib.request import urlopen 6 | 7 | import pytest 8 | 9 | # Internal imports 10 | import vcr 11 | from vcr.persisters.filesystem import CassetteDecodeError, CassetteNotFoundError, FilesystemPersister 12 | 13 | 14 | class CustomFilesystemPersister: 15 | """Behaves just like default FilesystemPersister but adds .test extension 16 | to the cassette file""" 17 | 18 | @staticmethod 19 | def load_cassette(cassette_path, serializer): 20 | cassette_path += ".test" 21 | return FilesystemPersister.load_cassette(cassette_path, serializer) 22 | 23 | @staticmethod 24 | def save_cassette(cassette_path, cassette_dict, serializer): 25 | cassette_path += ".test" 26 | FilesystemPersister.save_cassette(cassette_path, cassette_dict, serializer) 27 | 28 | 29 | class BadPersister(FilesystemPersister): 30 | """A bad persister that raises different errors.""" 31 | 32 | @staticmethod 33 | def load_cassette(cassette_path, serializer): 34 | if "nonexistent" in cassette_path: 35 | raise CassetteNotFoundError() 36 | elif "encoding" in cassette_path: 37 | raise CassetteDecodeError() 38 | else: 39 | raise ValueError("buggy persister") 40 | 41 | 42 | def test_save_cassette_with_custom_persister(tmpdir, httpbin): 43 | """Ensure you can save a cassette using custom persister""" 44 | my_vcr = vcr.VCR() 45 | my_vcr.register_persister(CustomFilesystemPersister) 46 | 47 | # Check to make sure directory doesn't exist 48 | assert not os.path.exists(str(tmpdir.join("nonexistent"))) 49 | 50 | # Run VCR to create dir and cassette file using new save_cassette callback 51 | with my_vcr.use_cassette(str(tmpdir.join("nonexistent", "cassette.yml"))): 52 | urlopen(httpbin.url).read() 53 | 54 | # Callback should have made the file and the directory 55 | assert os.path.exists(str(tmpdir.join("nonexistent", "cassette.yml.test"))) 56 | 57 | 58 | def test_load_cassette_with_custom_persister(tmpdir, httpbin): 59 | """ 60 | Ensure you can load a cassette using custom persister 61 | """ 62 | my_vcr = vcr.VCR() 63 | my_vcr.register_persister(CustomFilesystemPersister) 64 | 65 | test_fixture = str(tmpdir.join("synopsis.json.test")) 66 | 67 | with my_vcr.use_cassette(test_fixture, serializer="json"): 68 | response = urlopen(httpbin.url).read() 69 | assert b"HTTP Request & Response Service" in response 70 | 71 | 72 | def test_load_cassette_persister_exception_handling(tmpdir, httpbin): 73 | """ 74 | Ensure expected errors from persister are swallowed while unexpected ones 75 | are passed up the call stack. 76 | """ 77 | my_vcr = vcr.VCR() 78 | my_vcr.register_persister(BadPersister) 79 | 80 | with my_vcr.use_cassette("bad/nonexistent") as cass: 81 | assert len(cass) == 0 82 | 83 | with my_vcr.use_cassette("bad/encoding") as cass: 84 | assert len(cass) == 0 85 | 86 | with pytest.raises(ValueError): 87 | with my_vcr.use_cassette("bad/buggy") as cass: 88 | pass 89 | -------------------------------------------------------------------------------- /tests/integration/test_register_serializer.py: -------------------------------------------------------------------------------- 1 | import vcr 2 | 3 | 4 | class MockSerializer: 5 | def __init__(self): 6 | self.serialize_count = 0 7 | self.deserialize_count = 0 8 | self.load_args = None 9 | 10 | def deserialize(self, cassette_string): 11 | self.serialize_count += 1 12 | self.cassette_string = cassette_string 13 | return {"interactions": []} 14 | 15 | def serialize(self, cassette_dict): 16 | self.deserialize_count += 1 17 | return "" 18 | 19 | 20 | def test_registered_serializer(tmpdir): 21 | ms = MockSerializer() 22 | my_vcr = vcr.VCR() 23 | my_vcr.register_serializer("mock", ms) 24 | tmpdir.join("test.mock").write("test_data") 25 | with my_vcr.use_cassette(str(tmpdir.join("test.mock")), serializer="mock"): 26 | # Serializer deserialized once 27 | assert ms.serialize_count == 1 28 | # and serialized the test data string 29 | assert ms.cassette_string == "test_data" 30 | # and hasn't serialized yet 31 | assert ms.deserialize_count == 0 32 | 33 | assert ms.serialize_count == 1 34 | -------------------------------------------------------------------------------- /tests/integration/test_request.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import vcr 4 | 5 | 6 | def test_recorded_request_uri_with_redirected_request(tmpdir, httpbin): 7 | with vcr.use_cassette(str(tmpdir.join("test.yml"))) as cass: 8 | assert len(cass) == 0 9 | urlopen(httpbin.url + "/redirect/3") 10 | assert cass.requests[0].uri == httpbin.url + "/redirect/3" 11 | assert cass.requests[3].uri == httpbin.url + "/get" 12 | assert len(cass) == 4 13 | 14 | 15 | def test_records_multiple_header_values(tmpdir, httpbin): 16 | with vcr.use_cassette(str(tmpdir.join("test.yml"))) as cass: 17 | assert len(cass) == 0 18 | urlopen(httpbin.url + "/response-headers?foo=bar&foo=baz") 19 | assert len(cass) == 1 20 | assert cass.responses[0]["headers"]["foo"] == ["bar", "baz"] 21 | -------------------------------------------------------------------------------- /tests/integration/test_stubs.py: -------------------------------------------------------------------------------- 1 | import http.client as httplib 2 | import json 3 | import zlib 4 | 5 | import vcr 6 | 7 | from ..assertions import assert_is_json_bytes 8 | 9 | 10 | def _headers_are_case_insensitive(host, port): 11 | conn = httplib.HTTPConnection(host, port) 12 | conn.request("GET", "/cookies/set?k1=v1") 13 | r1 = conn.getresponse() 14 | cookie_data1 = r1.getheader("set-cookie") 15 | conn = httplib.HTTPConnection(host, port) 16 | conn.request("GET", "/cookies/set?k1=v1") 17 | r2 = conn.getresponse() 18 | cookie_data2 = r2.getheader("Set-Cookie") 19 | return cookie_data1 == cookie_data2 20 | 21 | 22 | def test_case_insensitivity(tmpdir, httpbin): 23 | testfile = str(tmpdir.join("case_insensitivity.yml")) 24 | # check if headers are case insensitive outside of vcrpy 25 | host, port = httpbin.host, httpbin.port 26 | outside = _headers_are_case_insensitive(host, port) 27 | with vcr.use_cassette(testfile): 28 | # check if headers are case insensitive inside of vcrpy 29 | inside = _headers_are_case_insensitive(host, port) 30 | # check if headers are case insensitive after vcrpy deserializes headers 31 | inside2 = _headers_are_case_insensitive(host, port) 32 | 33 | # behavior should be the same both inside and outside 34 | assert outside == inside == inside2 35 | 36 | 37 | def _multiple_header_value(httpbin): 38 | conn = httplib.HTTPConnection(httpbin.host, httpbin.port) 39 | conn.request("GET", "/response-headers?foo=bar&foo=baz") 40 | r = conn.getresponse() 41 | return r.getheader("foo") 42 | 43 | 44 | def test_multiple_headers(tmpdir, httpbin): 45 | testfile = str(tmpdir.join("multiple_headers.yaml")) 46 | outside = _multiple_header_value(httpbin) 47 | 48 | with vcr.use_cassette(testfile): 49 | inside = _multiple_header_value(httpbin) 50 | 51 | assert outside == inside 52 | 53 | 54 | def test_original_decoded_response_is_not_modified(tmpdir, httpbin): 55 | testfile = str(tmpdir.join("decoded_response.yml")) 56 | host, port = httpbin.host, httpbin.port 57 | 58 | conn = httplib.HTTPConnection(host, port) 59 | conn.request("GET", "/gzip") 60 | outside = conn.getresponse() 61 | 62 | with vcr.use_cassette(testfile, decode_compressed_response=True): 63 | conn = httplib.HTTPConnection(host, port) 64 | conn.request("GET", "/gzip") 65 | inside = conn.getresponse() 66 | 67 | # Assert that we do not modify the original response while appending 68 | # to the cassette. 69 | assert "gzip" == inside.headers["content-encoding"] 70 | 71 | # They should effectively be the same response. 72 | inside_headers = (h for h in inside.headers.items() if h[0].lower() != "date") 73 | outside_headers = (h for h in outside.getheaders() if h[0].lower() != "date") 74 | assert set(inside_headers) == set(outside_headers) 75 | inside = zlib.decompress(inside.read(), 16 + zlib.MAX_WBITS) 76 | outside = zlib.decompress(outside.read(), 16 + zlib.MAX_WBITS) 77 | assert inside == outside 78 | 79 | # Even though the above are raw bytes, the JSON data should have been 80 | # decoded and saved to the cassette. 81 | with vcr.use_cassette(testfile): 82 | conn = httplib.HTTPConnection(host, port) 83 | conn.request("GET", "/gzip") 84 | inside = conn.getresponse() 85 | 86 | assert "content-encoding" not in inside.headers 87 | assert_is_json_bytes(inside.read()) 88 | 89 | 90 | def _make_before_record_response(fields, replacement="[REDACTED]"): 91 | def before_record_response(response): 92 | string_body = response["body"]["string"].decode("utf8") 93 | body = json.loads(string_body) 94 | 95 | for field in fields: 96 | if field in body: 97 | body[field] = replacement 98 | 99 | response["body"]["string"] = json.dumps(body).encode() 100 | return response 101 | 102 | return before_record_response 103 | 104 | 105 | def test_original_response_is_not_modified_by_before_filter(tmpdir, httpbin): 106 | testfile = str(tmpdir.join("sensitive_data_scrubbed_response.yml")) 107 | host, port = httpbin.host, httpbin.port 108 | field_to_scrub = "url" 109 | replacement = "[YOU_CANT_HAVE_THE_MANGO]" 110 | 111 | conn = httplib.HTTPConnection(host, port) 112 | conn.request("GET", "/get") 113 | outside = conn.getresponse() 114 | 115 | callback = _make_before_record_response([field_to_scrub], replacement) 116 | with vcr.use_cassette(testfile, before_record_response=callback): 117 | conn = httplib.HTTPConnection(host, port) 118 | conn.request("GET", "/get") 119 | inside = conn.getresponse() 120 | 121 | # The scrubbed field should be the same, because no cassette existed. 122 | # Furthermore, the responses should be identical. 123 | inside_body = json.loads(inside.read()) 124 | outside_body = json.loads(outside.read()) 125 | assert not inside_body[field_to_scrub] == replacement 126 | assert inside_body[field_to_scrub] == outside_body[field_to_scrub] 127 | 128 | # Ensure that when a cassette exists, the scrubbed response is returned. 129 | with vcr.use_cassette(testfile, before_record_response=callback): 130 | conn = httplib.HTTPConnection(host, port) 131 | conn.request("GET", "/get") 132 | inside = conn.getresponse() 133 | 134 | inside_body = json.loads(inside.read()) 135 | assert inside_body[field_to_scrub] == replacement 136 | -------------------------------------------------------------------------------- /tests/integration/test_tornado_exception_can_be_caught.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: {} 5 | method: GET 6 | uri: http://httpbin.org/status/500 7 | response: 8 | body: {string: !!python/unicode ''} 9 | headers: 10 | - !!python/tuple 11 | - Content-Length 12 | - ['0'] 13 | - !!python/tuple 14 | - Server 15 | - [nginx] 16 | - !!python/tuple 17 | - Connection 18 | - [close] 19 | - !!python/tuple 20 | - Access-Control-Allow-Credentials 21 | - ['true'] 22 | - !!python/tuple 23 | - Date 24 | - ['Thu, 30 Jul 2015 17:32:39 GMT'] 25 | - !!python/tuple 26 | - Access-Control-Allow-Origin 27 | - ['*'] 28 | - !!python/tuple 29 | - Content-Type 30 | - [text/html; charset=utf-8] 31 | status: {code: 500, message: INTERNAL SERVER ERROR} 32 | - request: 33 | body: null 34 | headers: {} 35 | method: GET 36 | uri: http://httpbin.org/status/404 37 | response: 38 | body: {string: !!python/unicode ''} 39 | headers: 40 | - !!python/tuple 41 | - Content-Length 42 | - ['0'] 43 | - !!python/tuple 44 | - Server 45 | - [nginx] 46 | - !!python/tuple 47 | - Connection 48 | - [close] 49 | - !!python/tuple 50 | - Access-Control-Allow-Credentials 51 | - ['true'] 52 | - !!python/tuple 53 | - Date 54 | - ['Thu, 30 Jul 2015 17:32:39 GMT'] 55 | - !!python/tuple 56 | - Access-Control-Allow-Origin 57 | - ['*'] 58 | - !!python/tuple 59 | - Content-Type 60 | - [text/html; charset=utf-8] 61 | status: {code: 404, message: NOT FOUND} 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/integration/test_tornado_with_decorator_use_cassette.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: {} 5 | method: GET 6 | uri: http://www.google.com/ 7 | response: 8 | body: {string: !!python/unicode 'not actually google'} 9 | headers: 10 | - !!python/tuple 11 | - Expires 12 | - ['-1'] 13 | - !!python/tuple 14 | - Connection 15 | - [close] 16 | - !!python/tuple 17 | - P3p 18 | - ['CP="This is not a P3P policy! See http://www.google.com/support/accounts/bin/answer.py?hl=en&answer=151657 19 | for more info."'] 20 | - !!python/tuple 21 | - Alternate-Protocol 22 | - ['80:quic,p=0'] 23 | - !!python/tuple 24 | - Accept-Ranges 25 | - [none] 26 | - !!python/tuple 27 | - X-Xss-Protection 28 | - [1; mode=block] 29 | - !!python/tuple 30 | - Vary 31 | - [Accept-Encoding] 32 | - !!python/tuple 33 | - Date 34 | - ['Thu, 30 Jul 2015 08:41:40 GMT'] 35 | - !!python/tuple 36 | - Cache-Control 37 | - ['private, max-age=0'] 38 | - !!python/tuple 39 | - Content-Type 40 | - [text/html; charset=ISO-8859-1] 41 | - !!python/tuple 42 | - Set-Cookie 43 | - ['PREF=ID=1111111111111111:FF=0:TM=1438245700:LM=1438245700:V=1:S=GAzVO0ALebSpC_cJ; 44 | expires=Sat, 29-Jul-2017 08:41:40 GMT; path=/; domain=.google.com', 'NID=69=Br7oRAwgmKoK__HC6FEnuxglTFDmFxqP6Md63lKhzW1w6WkDbp3U90CDxnUKvDP6wJH8yxY5Lk5ZnFf66Q1B0d4OsYoKgq0vjfBAYXuCIAWtOuGZEOsFXanXs7pt2Mjx; 45 | expires=Fri, 29-Jan-2016 08:41:40 GMT; path=/; domain=.google.com; HttpOnly'] 46 | - !!python/tuple 47 | - X-Frame-Options 48 | - [SAMEORIGIN] 49 | - !!python/tuple 50 | - Server 51 | - [gws] 52 | status: {code: 200, message: OK} 53 | version: 1 54 | -------------------------------------------------------------------------------- /tests/integration/test_urllib2.py: -------------------------------------------------------------------------------- 1 | """Integration tests with urllib2""" 2 | 3 | import ssl 4 | from urllib.parse import urlencode 5 | from urllib.request import urlopen 6 | 7 | import pytest_httpbin.certs 8 | from pytest import mark 9 | 10 | # Internal imports 11 | import vcr 12 | 13 | from ..assertions import assert_cassette_has_one_response 14 | 15 | 16 | def urlopen_with_cafile(*args, **kwargs): 17 | context = ssl.create_default_context(cafile=pytest_httpbin.certs.where()) 18 | context.check_hostname = False 19 | kwargs["context"] = context 20 | try: 21 | return urlopen(*args, **kwargs) 22 | except TypeError: 23 | # python2/pypi don't let us override this 24 | del kwargs["cafile"] 25 | return urlopen(*args, **kwargs) 26 | 27 | 28 | def test_response_code(httpbin_both, tmpdir): 29 | """Ensure we can read a response code from a fetch""" 30 | url = httpbin_both.url 31 | with vcr.use_cassette(str(tmpdir.join("atts.yaml"))): 32 | code = urlopen_with_cafile(url).getcode() 33 | 34 | with vcr.use_cassette(str(tmpdir.join("atts.yaml"))): 35 | assert code == urlopen_with_cafile(url).getcode() 36 | 37 | 38 | def test_random_body(httpbin_both, tmpdir): 39 | """Ensure we can read the content, and that it's served from cache""" 40 | url = httpbin_both.url + "/bytes/1024" 41 | with vcr.use_cassette(str(tmpdir.join("body.yaml"))): 42 | body = urlopen_with_cafile(url).read() 43 | 44 | with vcr.use_cassette(str(tmpdir.join("body.yaml"))): 45 | assert body == urlopen_with_cafile(url).read() 46 | 47 | 48 | def test_response_headers(httpbin_both, tmpdir): 49 | """Ensure we can get information from the response""" 50 | url = httpbin_both.url 51 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 52 | open1 = urlopen_with_cafile(url).info().items() 53 | 54 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 55 | open2 = urlopen_with_cafile(url).info().items() 56 | 57 | assert sorted(open1) == sorted(open2) 58 | 59 | 60 | @mark.online 61 | def test_effective_url(tmpdir, httpbin): 62 | """Ensure that the effective_url is captured""" 63 | url = httpbin.url + "/redirect-to?url=.%2F&status_code=301" 64 | 65 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 66 | effective_url = urlopen_with_cafile(url).geturl() 67 | assert effective_url == httpbin.url + "/" 68 | 69 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 70 | assert effective_url == urlopen_with_cafile(url).geturl() 71 | 72 | 73 | def test_multiple_requests(httpbin_both, tmpdir): 74 | """Ensure that we can cache multiple requests""" 75 | urls = [httpbin_both.url, httpbin_both.url, httpbin_both.url + "/get", httpbin_both.url + "/bytes/1024"] 76 | with vcr.use_cassette(str(tmpdir.join("multiple.yaml"))) as cass: 77 | [urlopen_with_cafile(url) for url in urls] 78 | assert len(cass) == len(urls) 79 | 80 | 81 | def test_get_data(httpbin_both, tmpdir): 82 | """Ensure that it works with query data""" 83 | data = urlencode({"some": 1, "data": "here"}) 84 | url = httpbin_both.url + "/get?" + data 85 | with vcr.use_cassette(str(tmpdir.join("get_data.yaml"))): 86 | res1 = urlopen_with_cafile(url).read() 87 | 88 | with vcr.use_cassette(str(tmpdir.join("get_data.yaml"))): 89 | res2 = urlopen_with_cafile(url).read() 90 | assert res1 == res2 91 | 92 | 93 | def test_post_data(httpbin_both, tmpdir): 94 | """Ensure that it works when posting data""" 95 | data = urlencode({"some": 1, "data": "here"}).encode("utf-8") 96 | url = httpbin_both.url + "/post" 97 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))): 98 | res1 = urlopen_with_cafile(url, data).read() 99 | 100 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))) as cass: 101 | res2 = urlopen_with_cafile(url, data).read() 102 | assert len(cass) == 1 103 | 104 | assert res1 == res2 105 | assert_cassette_has_one_response(cass) 106 | 107 | 108 | def test_post_unicode_data(httpbin_both, tmpdir): 109 | """Ensure that it works when posting unicode data""" 110 | data = urlencode({"snowman": "☃".encode()}).encode("utf-8") 111 | url = httpbin_both.url + "/post" 112 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))): 113 | res1 = urlopen_with_cafile(url, data).read() 114 | 115 | with vcr.use_cassette(str(tmpdir.join("post_data.yaml"))) as cass: 116 | res2 = urlopen_with_cafile(url, data).read() 117 | assert len(cass) == 1 118 | 119 | assert res1 == res2 120 | assert_cassette_has_one_response(cass) 121 | 122 | 123 | def test_cross_scheme(tmpdir, httpbin_secure, httpbin): 124 | """Ensure that requests between schemes are treated separately""" 125 | # First fetch a url under https, and then again under https and then 126 | # ensure that we haven't served anything out of cache, and we have two 127 | # requests / response pairs in the cassette 128 | with vcr.use_cassette(str(tmpdir.join("cross_scheme.yaml"))) as cass: 129 | urlopen_with_cafile(httpbin_secure.url) 130 | urlopen_with_cafile(httpbin.url) 131 | assert len(cass) == 2 132 | assert cass.play_count == 0 133 | 134 | 135 | def test_decorator(httpbin_both, tmpdir): 136 | """Test the decorator version of VCR.py""" 137 | url = httpbin_both.url 138 | 139 | @vcr.use_cassette(str(tmpdir.join("atts.yaml"))) 140 | def inner1(): 141 | return urlopen_with_cafile(url).getcode() 142 | 143 | @vcr.use_cassette(str(tmpdir.join("atts.yaml"))) 144 | def inner2(): 145 | return urlopen_with_cafile(url).getcode() 146 | 147 | assert inner1() == inner2() 148 | -------------------------------------------------------------------------------- /tests/integration/test_urllib3.py: -------------------------------------------------------------------------------- 1 | """Integration tests with urllib3""" 2 | 3 | # coding=utf-8 4 | 5 | import pytest 6 | import pytest_httpbin 7 | 8 | import vcr 9 | from vcr.patch import force_reset 10 | from vcr.stubs.compat import get_headers 11 | 12 | from ..assertions import assert_cassette_empty, assert_is_json_bytes 13 | 14 | urllib3 = pytest.importorskip("urllib3") 15 | 16 | 17 | @pytest.fixture(scope="module") 18 | def verify_pool_mgr(): 19 | return urllib3.PoolManager( 20 | cert_reqs="CERT_REQUIRED", 21 | ca_certs=pytest_httpbin.certs.where(), # Force certificate check. 22 | ) 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def pool_mgr(): 27 | return urllib3.PoolManager(cert_reqs="CERT_NONE") 28 | 29 | 30 | def test_status_code(httpbin_both, tmpdir, verify_pool_mgr): 31 | """Ensure that we can read the status code""" 32 | url = httpbin_both.url 33 | with vcr.use_cassette(str(tmpdir.join("atts.yaml"))): 34 | status_code = verify_pool_mgr.request("GET", url).status 35 | 36 | with vcr.use_cassette(str(tmpdir.join("atts.yaml"))): 37 | assert status_code == verify_pool_mgr.request("GET", url).status 38 | 39 | 40 | def test_headers(tmpdir, httpbin_both, verify_pool_mgr): 41 | """Ensure that we can read the headers back""" 42 | url = httpbin_both.url 43 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 44 | headers = verify_pool_mgr.request("GET", url).headers 45 | 46 | with vcr.use_cassette(str(tmpdir.join("headers.yaml"))): 47 | new_headers = verify_pool_mgr.request("GET", url).headers 48 | assert sorted(get_headers(headers)) == sorted(get_headers(new_headers)) 49 | 50 | 51 | def test_body(tmpdir, httpbin_both, verify_pool_mgr): 52 | """Ensure the responses are all identical enough""" 53 | url = httpbin_both.url + "/bytes/1024" 54 | with vcr.use_cassette(str(tmpdir.join("body.yaml"))): 55 | content = verify_pool_mgr.request("GET", url).data 56 | 57 | with vcr.use_cassette(str(tmpdir.join("body.yaml"))): 58 | assert content == verify_pool_mgr.request("GET", url).data 59 | 60 | 61 | def test_auth(tmpdir, httpbin_both, verify_pool_mgr): 62 | """Ensure that we can handle basic auth""" 63 | auth = ("user", "passwd") 64 | headers = urllib3.util.make_headers(basic_auth="{}:{}".format(*auth)) 65 | url = httpbin_both.url + "/basic-auth/user/passwd" 66 | with vcr.use_cassette(str(tmpdir.join("auth.yaml"))): 67 | one = verify_pool_mgr.request("GET", url, headers=headers) 68 | 69 | with vcr.use_cassette(str(tmpdir.join("auth.yaml"))): 70 | two = verify_pool_mgr.request("GET", url, headers=headers) 71 | assert one.data == two.data 72 | assert one.status == two.status 73 | 74 | 75 | def test_auth_failed(tmpdir, httpbin_both, verify_pool_mgr): 76 | """Ensure that we can save failed auth statuses""" 77 | auth = ("user", "wrongwrongwrong") 78 | headers = urllib3.util.make_headers(basic_auth="{}:{}".format(*auth)) 79 | url = httpbin_both.url + "/basic-auth/user/passwd" 80 | with vcr.use_cassette(str(tmpdir.join("auth-failed.yaml"))) as cass: 81 | # Ensure that this is empty to begin with 82 | assert_cassette_empty(cass) 83 | one = verify_pool_mgr.request("GET", url, headers=headers) 84 | two = verify_pool_mgr.request("GET", url, headers=headers) 85 | assert one.data == two.data 86 | assert one.status == two.status == 401 87 | 88 | 89 | def test_post(tmpdir, httpbin_both, verify_pool_mgr): 90 | """Ensure that we can post and cache the results""" 91 | data = {"key1": "value1", "key2": "value2"} 92 | url = httpbin_both.url + "/post" 93 | with vcr.use_cassette(str(tmpdir.join("verify_pool_mgr.yaml"))): 94 | req1 = verify_pool_mgr.request("POST", url, data).data 95 | 96 | with vcr.use_cassette(str(tmpdir.join("verify_pool_mgr.yaml"))): 97 | req2 = verify_pool_mgr.request("POST", url, data).data 98 | 99 | assert req1 == req2 100 | 101 | 102 | @pytest.mark.online 103 | def test_redirects(tmpdir, verify_pool_mgr, httpbin): 104 | """Ensure that we can handle redirects""" 105 | url = httpbin.url + "/redirect/1" 106 | 107 | with vcr.use_cassette(str(tmpdir.join("verify_pool_mgr.yaml"))): 108 | content = verify_pool_mgr.request("GET", url).data 109 | 110 | with vcr.use_cassette(str(tmpdir.join("verify_pool_mgr.yaml"))) as cass: 111 | assert content == verify_pool_mgr.request("GET", url).data 112 | # Ensure that we've now cached *two* responses. One for the redirect 113 | # and one for the final fetch 114 | 115 | assert len(cass) == 2 116 | assert cass.play_count == 2 117 | 118 | 119 | def test_cross_scheme(tmpdir, httpbin, httpbin_secure, verify_pool_mgr): 120 | """Ensure that requests between schemes are treated separately""" 121 | # First fetch a url under http, and then again under https and then 122 | # ensure that we haven't served anything out of cache, and we have two 123 | # requests / response pairs in the cassette 124 | with vcr.use_cassette(str(tmpdir.join("cross_scheme.yaml"))) as cass: 125 | verify_pool_mgr.request("GET", httpbin_secure.url) 126 | verify_pool_mgr.request("GET", httpbin.url) 127 | assert cass.play_count == 0 128 | assert len(cass) == 2 129 | 130 | 131 | def test_gzip(tmpdir, httpbin_both, verify_pool_mgr): 132 | """ 133 | Ensure that requests (actually urllib3) is able to automatically decompress 134 | the response body 135 | """ 136 | url = httpbin_both.url + "/gzip" 137 | response = verify_pool_mgr.request("GET", url) 138 | 139 | with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))): 140 | response = verify_pool_mgr.request("GET", url) 141 | assert_is_json_bytes(response.data) 142 | 143 | with vcr.use_cassette(str(tmpdir.join("gzip.yaml"))): 144 | assert_is_json_bytes(response.data) 145 | 146 | 147 | def test_https_with_cert_validation_disabled(tmpdir, httpbin_secure, pool_mgr): 148 | with vcr.use_cassette(str(tmpdir.join("cert_validation_disabled.yaml"))): 149 | pool_mgr.request("GET", httpbin_secure.url) 150 | 151 | 152 | def test_urllib3_force_reset(): 153 | conn = urllib3.connection 154 | http_original = conn.HTTPConnection 155 | https_original = conn.HTTPSConnection 156 | verified_https_original = conn.VerifiedHTTPSConnection 157 | with vcr.use_cassette(path="test"): 158 | first_cassette_HTTPConnection = conn.HTTPConnection 159 | first_cassette_HTTPSConnection = conn.HTTPSConnection 160 | first_cassette_VerifiedHTTPSConnection = conn.VerifiedHTTPSConnection 161 | with force_reset(): 162 | assert conn.HTTPConnection is http_original 163 | assert conn.HTTPSConnection is https_original 164 | assert conn.VerifiedHTTPSConnection is verified_https_original 165 | assert conn.HTTPConnection is first_cassette_HTTPConnection 166 | assert conn.HTTPSConnection is first_cassette_HTTPSConnection 167 | assert conn.VerifiedHTTPSConnection is first_cassette_VerifiedHTTPSConnection 168 | -------------------------------------------------------------------------------- /tests/integration/test_wild.py: -------------------------------------------------------------------------------- 1 | import http.client as httplib 2 | import multiprocessing 3 | from xmlrpc.client import ServerProxy 4 | from xmlrpc.server import SimpleXMLRPCServer 5 | 6 | import pytest 7 | 8 | import vcr 9 | 10 | requests = pytest.importorskip("requests") 11 | 12 | 13 | def test_domain_redirect(): 14 | """Ensure that redirects across domains are considered unique""" 15 | # In this example, seomoz.org redirects to moz.com, and if those 16 | # requests are considered identical, then we'll be stuck in a redirect 17 | # loop. 18 | url = "http://seomoz.org/" 19 | with vcr.use_cassette("tests/fixtures/wild/domain_redirect.yaml") as cass: 20 | requests.get(url, headers={"User-Agent": "vcrpy-test"}) 21 | # Ensure that we've now served two responses. One for the original 22 | # redirect, and a second for the actual fetch 23 | assert len(cass) == 2 24 | 25 | 26 | def test_flickr_multipart_upload(httpbin, tmpdir): 27 | """ 28 | The python-flickr-api project does a multipart 29 | upload that confuses vcrpy 30 | """ 31 | 32 | def _pretend_to_be_flickr_library(): 33 | content_type, body = "text/plain", "HELLO WORLD" 34 | h = httplib.HTTPConnection(httpbin.host, httpbin.port) 35 | headers = {"Content-Type": content_type, "content-length": str(len(body))} 36 | h.request("POST", "/post/", headers=headers) 37 | h.send(body) 38 | r = h.getresponse() 39 | data = r.read() 40 | h.close() 41 | 42 | return data 43 | 44 | testfile = str(tmpdir.join("flickr.yml")) 45 | with vcr.use_cassette(testfile) as cass: 46 | _pretend_to_be_flickr_library() 47 | assert len(cass) == 1 48 | 49 | with vcr.use_cassette(testfile) as cass: 50 | assert len(cass) == 1 51 | _pretend_to_be_flickr_library() 52 | assert cass.play_count == 1 53 | 54 | 55 | @pytest.mark.online 56 | def test_flickr_should_respond_with_200(tmpdir): 57 | testfile = str(tmpdir.join("flickr.yml")) 58 | with vcr.use_cassette(testfile): 59 | r = requests.post("https://api.flickr.com/services/upload", verify=False) 60 | assert r.status_code == 200 61 | 62 | 63 | def test_cookies(tmpdir, httpbin): 64 | testfile = str(tmpdir.join("cookies.yml")) 65 | with vcr.use_cassette(testfile): 66 | with requests.Session() as s: 67 | s.get(httpbin.url + "/cookies/set?k1=v1&k2=v2") 68 | assert s.cookies.keys() == ["k1", "k2"] 69 | 70 | r2 = s.get(httpbin.url + "/cookies") 71 | assert sorted(r2.json()["cookies"].keys()) == ["k1", "k2"] 72 | 73 | 74 | @pytest.mark.online 75 | def test_amazon_doctype(tmpdir): 76 | # amazon gzips its homepage. For some reason, in requests 2.7, it's not 77 | # getting gunzipped. 78 | with vcr.use_cassette(str(tmpdir.join("amz.yml"))): 79 | r = requests.get("http://www.amazon.com", verify=False) 80 | assert "html" in r.text 81 | 82 | 83 | def start_rpc_server(q): 84 | httpd = SimpleXMLRPCServer(("127.0.0.1", 0)) 85 | httpd.register_function(pow) 86 | q.put("http://{}:{}".format(*httpd.server_address)) 87 | httpd.serve_forever() 88 | 89 | 90 | @pytest.fixture(scope="session") 91 | def rpc_server(): 92 | q = multiprocessing.Queue() 93 | proxy_process = multiprocessing.Process(target=start_rpc_server, args=(q,)) 94 | try: 95 | proxy_process.start() 96 | yield q.get() 97 | finally: 98 | proxy_process.terminate() 99 | 100 | 101 | def test_xmlrpclib(tmpdir, rpc_server): 102 | with vcr.use_cassette(str(tmpdir.join("xmlrpcvideo.yaml"))): 103 | roundup_server = ServerProxy(rpc_server, allow_none=True) 104 | original_schema = roundup_server.pow(2, 4) 105 | 106 | with vcr.use_cassette(str(tmpdir.join("xmlrpcvideo.yaml"))): 107 | roundup_server = ServerProxy(rpc_server, allow_none=True) 108 | second_schema = roundup_server.pow(2, 4) 109 | 110 | assert original_schema == second_schema 111 | -------------------------------------------------------------------------------- /tests/unit/test_errors.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from vcr import errors 6 | from vcr.cassette import Cassette 7 | 8 | 9 | @mock.patch("vcr.cassette.Cassette.find_requests_with_most_matches") 10 | @pytest.mark.parametrize( 11 | "most_matches, expected_message", 12 | [ 13 | # No request match found 14 | ([], "No similar requests, that have not been played, found."), 15 | # One matcher failed 16 | ( 17 | [("similar request", ["method", "path"], [("query", "failed : query")])], 18 | "Found 1 similar requests with 1 different matcher(s) :\n" 19 | "\n1 - ('similar request').\n" 20 | "Matchers succeeded : ['method', 'path']\n" 21 | "Matchers failed :\n" 22 | "query - assertion failure :\n" 23 | "failed : query\n", 24 | ), 25 | # Multiple failed matchers 26 | ( 27 | [("similar request", ["method"], [("query", "failed : query"), ("path", "failed : path")])], 28 | "Found 1 similar requests with 2 different matcher(s) :\n" 29 | "\n1 - ('similar request').\n" 30 | "Matchers succeeded : ['method']\n" 31 | "Matchers failed :\n" 32 | "query - assertion failure :\n" 33 | "failed : query\n" 34 | "path - assertion failure :\n" 35 | "failed : path\n", 36 | ), 37 | # Multiple similar requests 38 | ( 39 | [ 40 | ("similar request", ["method"], [("query", "failed : query")]), 41 | ("similar request 2", ["method"], [("query", "failed : query 2")]), 42 | ], 43 | "Found 2 similar requests with 1 different matcher(s) :\n" 44 | "\n1 - ('similar request').\n" 45 | "Matchers succeeded : ['method']\n" 46 | "Matchers failed :\n" 47 | "query - assertion failure :\n" 48 | "failed : query\n" 49 | "\n2 - ('similar request 2').\n" 50 | "Matchers succeeded : ['method']\n" 51 | "Matchers failed :\n" 52 | "query - assertion failure :\n" 53 | "failed : query 2\n", 54 | ), 55 | ], 56 | ) 57 | def test_CannotOverwriteExistingCassetteException_get_message( 58 | mock_find_requests_with_most_matches, 59 | most_matches, 60 | expected_message, 61 | ): 62 | mock_find_requests_with_most_matches.return_value = most_matches 63 | cassette = Cassette("path") 64 | failed_request = "request" 65 | exception_message = errors.CannotOverwriteExistingCassetteException._get_message(cassette, "request") 66 | expected = ( 67 | f"Can't overwrite existing cassette ({cassette._path!r}) " 68 | f"in your current record mode ({cassette.record_mode!r}).\n" 69 | f"No match for the request ({failed_request!r}) was found.\n" 70 | f"{expected_message}" 71 | ) 72 | assert exception_message == expected 73 | -------------------------------------------------------------------------------- /tests/unit/test_json_serializer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vcr.request import Request 4 | from vcr.serializers.jsonserializer import serialize 5 | 6 | 7 | def test_serialize_binary(): 8 | request = Request(method="GET", uri="http://localhost/", body="", headers={}) 9 | cassette = {"requests": [request], "responses": [{"body": b"\x8c"}]} 10 | 11 | with pytest.raises(Exception) as e: 12 | serialize(cassette) 13 | assert ( 14 | e.message 15 | == "Error serializing cassette to JSON. Does this \ 16 | HTTP interaction contain binary data? If so, use a different \ 17 | serializer (like the yaml serializer) for this request" 18 | ) 19 | -------------------------------------------------------------------------------- /tests/unit/test_migration.py: -------------------------------------------------------------------------------- 1 | import filecmp 2 | import json 3 | import shutil 4 | 5 | import yaml 6 | 7 | import vcr.migration 8 | 9 | # Use the libYAML versions if possible 10 | try: 11 | from yaml import CLoader as Loader 12 | except ImportError: 13 | from yaml import Loader 14 | 15 | 16 | def test_try_migrate_with_json(tmpdir): 17 | cassette = tmpdir.join("cassette.json").strpath 18 | shutil.copy("tests/fixtures/migration/old_cassette.json", cassette) 19 | assert vcr.migration.try_migrate(cassette) 20 | with open("tests/fixtures/migration/new_cassette.json") as f: 21 | expected_json = json.load(f) 22 | with open(cassette) as f: 23 | actual_json = json.load(f) 24 | assert actual_json == expected_json 25 | 26 | 27 | def test_try_migrate_with_yaml(tmpdir): 28 | cassette = tmpdir.join("cassette.yaml").strpath 29 | shutil.copy("tests/fixtures/migration/old_cassette.yaml", cassette) 30 | assert vcr.migration.try_migrate(cassette) 31 | with open("tests/fixtures/migration/new_cassette.yaml") as f: 32 | expected_yaml = yaml.load(f, Loader=Loader) 33 | with open(cassette) as f: 34 | actual_yaml = yaml.load(f, Loader=Loader) 35 | assert actual_yaml == expected_yaml 36 | 37 | 38 | def test_try_migrate_with_invalid_or_new_cassettes(tmpdir): 39 | cassette = tmpdir.join("cassette").strpath 40 | files = [ 41 | "tests/fixtures/migration/not_cassette.txt", 42 | "tests/fixtures/migration/new_cassette.yaml", 43 | "tests/fixtures/migration/new_cassette.json", 44 | ] 45 | for file_path in files: 46 | shutil.copy(file_path, cassette) 47 | assert not vcr.migration.try_migrate(cassette) 48 | assert filecmp.cmp(cassette, file_path) # should not change file 49 | -------------------------------------------------------------------------------- /tests/unit/test_persist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vcr.persisters.filesystem import FilesystemPersister 4 | from vcr.serializers import jsonserializer, yamlserializer 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "cassette_path, serializer", 9 | [ 10 | ("tests/fixtures/migration/old_cassette.json", jsonserializer), 11 | ("tests/fixtures/migration/old_cassette.yaml", yamlserializer), 12 | ], 13 | ) 14 | def test_load_cassette_with_old_cassettes(cassette_path, serializer): 15 | with pytest.raises(ValueError) as excinfo: 16 | FilesystemPersister.load_cassette(cassette_path, serializer) 17 | assert "run the migration script" in excinfo.exconly() 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "cassette_path, serializer", 22 | [ 23 | ("tests/fixtures/migration/not_cassette.txt", jsonserializer), 24 | ("tests/fixtures/migration/not_cassette.txt", yamlserializer), 25 | ], 26 | ) 27 | def test_load_cassette_with_invalid_cassettes(cassette_path, serializer): 28 | with pytest.raises(Exception) as excinfo: 29 | FilesystemPersister.load_cassette(cassette_path, serializer) 30 | assert "run the migration script" not in excinfo.exconly() 31 | -------------------------------------------------------------------------------- /tests/unit/test_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vcr.request import HeadersDict, Request 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "method, uri, expected_str", 8 | [ 9 | ("GET", "http://www.google.com/", ""), 10 | ("OPTIONS", "*", ""), 11 | ("CONNECT", "host.some.where:1234", ""), 12 | ], 13 | ) 14 | def test_str(method, uri, expected_str): 15 | assert str(Request(method, uri, "", {})) == expected_str 16 | 17 | 18 | def test_headers(): 19 | headers = {"X-Header1": ["h1"], "X-Header2": "h2"} 20 | req = Request("GET", "http://go.com/", "", headers) 21 | assert req.headers == {"X-Header1": "h1", "X-Header2": "h2"} 22 | req.headers["X-Header1"] = "h11" 23 | assert req.headers == {"X-Header1": "h11", "X-Header2": "h2"} 24 | 25 | 26 | def test_add_header_deprecated(): 27 | req = Request("GET", "http://go.com/", "", {}) 28 | pytest.deprecated_call(req.add_header, "foo", "bar") 29 | assert req.headers == {"foo": "bar"} 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "uri, expected_port", 34 | [ 35 | ("http://go.com/", 80), 36 | ("http://go.com:80/", 80), 37 | ("http://go.com:3000/", 3000), 38 | ("https://go.com/", 443), 39 | ("https://go.com:443/", 443), 40 | ("https://go.com:3000/", 3000), 41 | ("*", None), 42 | ], 43 | ) 44 | def test_port(uri, expected_port): 45 | req = Request("GET", uri, "", {}) 46 | assert req.port == expected_port 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "method, uri", 51 | [ 52 | ("GET", "http://go.com/"), 53 | ("GET", "http://go.com:80/"), 54 | ("CONNECT", "localhost:1234"), 55 | ("OPTIONS", "*"), 56 | ], 57 | ) 58 | def test_uri(method, uri): 59 | assert Request(method, uri, "", {}).uri == uri 60 | 61 | 62 | def test_HeadersDict(): 63 | # Simple test of CaseInsensitiveDict 64 | h = HeadersDict() 65 | assert h == {} 66 | h["Content-Type"] = "application/json" 67 | assert h == {"Content-Type": "application/json"} 68 | assert h["content-type"] == "application/json" 69 | assert h["CONTENT-TYPE"] == "application/json" 70 | 71 | # Test feature of HeadersDict: devolve list to first element 72 | h = HeadersDict() 73 | assert h == {} 74 | h["x"] = ["foo", "bar"] 75 | assert h == {"x": "foo"} 76 | 77 | # Test feature of HeadersDict: preserve original key case 78 | h = HeadersDict() 79 | assert h == {} 80 | h["Content-Type"] = "application/json" 81 | assert h == {"Content-Type": "application/json"} 82 | h["content-type"] = "text/plain" 83 | assert h == {"Content-Type": "text/plain"} 84 | h["CONtent-tyPE"] = "whoa" 85 | assert h == {"Content-Type": "whoa"} 86 | -------------------------------------------------------------------------------- /tests/unit/test_response.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from vcr.stubs import VCRHTTPResponse 4 | 5 | 6 | def test_response_should_have_headers_field(): 7 | recorded_response = { 8 | "status": {"message": "OK", "code": 200}, 9 | "headers": { 10 | "content-length": ["0"], 11 | "server": ["gunicorn/18.0"], 12 | "connection": ["Close"], 13 | "access-control-allow-credentials": ["true"], 14 | "date": ["Fri, 24 Oct 2014 18:35:37 GMT"], 15 | "access-control-allow-origin": ["*"], 16 | "content-type": ["text/html; charset=utf-8"], 17 | }, 18 | "body": {"string": b""}, 19 | } 20 | response = VCRHTTPResponse(recorded_response) 21 | 22 | assert response.headers is not None 23 | 24 | 25 | def test_response_headers_should_be_equal_to_msg(): 26 | recorded_response = { 27 | "status": {"message": b"OK", "code": 200}, 28 | "headers": { 29 | "content-length": ["0"], 30 | "server": ["gunicorn/18.0"], 31 | "connection": ["Close"], 32 | "content-type": ["text/html; charset=utf-8"], 33 | }, 34 | "body": {"string": b""}, 35 | } 36 | response = VCRHTTPResponse(recorded_response) 37 | 38 | assert response.headers == response.msg 39 | 40 | 41 | def test_response_headers_should_have_correct_values(): 42 | recorded_response = { 43 | "status": {"message": "OK", "code": 200}, 44 | "headers": { 45 | "content-length": ["10806"], 46 | "date": ["Fri, 24 Oct 2014 18:35:37 GMT"], 47 | "content-type": ["text/html; charset=utf-8"], 48 | }, 49 | "body": {"string": b""}, 50 | } 51 | response = VCRHTTPResponse(recorded_response) 52 | 53 | assert response.headers.get("content-length") == "10806" 54 | assert response.headers.get("date") == "Fri, 24 Oct 2014 18:35:37 GMT" 55 | 56 | 57 | def test_response_parses_correctly_and_fp_attribute_error_is_not_thrown(): 58 | """ 59 | Regression test for https://github.com/kevin1024/vcrpy/issues/440 60 | :return: 61 | """ 62 | recorded_response = { 63 | "status": {"message": "OK", "code": 200}, 64 | "headers": { 65 | "content-length": ["0"], 66 | "server": ["gunicorn/18.0"], 67 | "connection": ["Close"], 68 | "access-control-allow-credentials": ["true"], 69 | "date": ["Fri, 24 Oct 2014 18:35:37 GMT"], 70 | "access-control-allow-origin": ["*"], 71 | "content-type": ["text/html; charset=utf-8"], 72 | }, 73 | "body": { 74 | "string": b"\nPMID- 19416910\nOWN - NLM\nSTAT- MEDLINE\nDA - 20090513\nDCOM- " 75 | b"20090622\nLR - " 76 | b"20141209\nIS - 1091-6490 (Electronic)\nIS - 0027-8424 (Linking)\nVI - " 77 | b"106\nIP - " 78 | b"19\nDP - 2009 May 12\nTI - Genetic dissection of histone deacetylase " 79 | b"requirement in " 80 | b"tumor cells.\nPG - 7751-5\nLID - 10.1073/pnas.0903139106 [doi]\nAB - " 81 | b"Histone " 82 | b"deacetylase inhibitors (HDACi) represent a new group of drugs currently\n " 83 | b" being " 84 | b"tested in a wide variety of clinical applications. They are especially\n " 85 | b" effective " 86 | b"in preclinical models of cancer where they show antiproliferative\n " 87 | b"action in many " 88 | b"different types of cancer cells. Recently, the first HDACi was\n " 89 | b"approved for the " 90 | b"treatment of cutaneous T cell lymphomas. Most HDACi currently in\n " 91 | b"clinical ", 92 | }, 93 | } 94 | vcr_response = VCRHTTPResponse(recorded_response) 95 | handle = io.TextIOWrapper(vcr_response, encoding="utf-8") 96 | handle = iter(handle) 97 | articles = list(handle) 98 | assert len(articles) > 1 99 | -------------------------------------------------------------------------------- /tests/unit/test_serialize.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from vcr.request import Request 6 | from vcr.serialize import deserialize, serialize 7 | from vcr.serializers import compat, jsonserializer, yamlserializer 8 | 9 | 10 | def test_deserialize_old_yaml_cassette(): 11 | with open("tests/fixtures/migration/old_cassette.yaml") as f: 12 | with pytest.raises(ValueError): 13 | deserialize(f.read(), yamlserializer) 14 | 15 | 16 | def test_deserialize_old_json_cassette(): 17 | with open("tests/fixtures/migration/old_cassette.json") as f: 18 | with pytest.raises(ValueError): 19 | deserialize(f.read(), jsonserializer) 20 | 21 | 22 | def test_deserialize_new_yaml_cassette(): 23 | with open("tests/fixtures/migration/new_cassette.yaml") as f: 24 | deserialize(f.read(), yamlserializer) 25 | 26 | 27 | def test_deserialize_new_json_cassette(): 28 | with open("tests/fixtures/migration/new_cassette.json") as f: 29 | deserialize(f.read(), jsonserializer) 30 | 31 | 32 | REQBODY_TEMPLATE = """\ 33 | interactions: 34 | - request: 35 | body: {req_body} 36 | headers: 37 | Content-Type: [application/x-www-form-urlencoded] 38 | Host: [httpbin.org] 39 | method: POST 40 | uri: http://httpbin.org/post 41 | response: 42 | body: {{string: ""}} 43 | headers: 44 | content-length: ['0'] 45 | content-type: [application/json] 46 | status: {{code: 200, message: OK}} 47 | """ 48 | 49 | 50 | # A cassette generated under Python 2 stores the request body as a string, 51 | # but the same cassette generated under Python 3 stores it as "!!binary". 52 | # Make sure we accept both forms, regardless of whether we're running under 53 | # Python 2 or 3. 54 | @pytest.mark.parametrize( 55 | "req_body, expect", 56 | [ 57 | # Cassette written under Python 2 (pure ASCII body) 58 | ("x=5&y=2", b"x=5&y=2"), 59 | # Cassette written under Python 3 (pure ASCII body) 60 | ("!!binary |\n eD01Jnk9Mg==", b"x=5&y=2"), 61 | # Request body has non-ASCII chars (x=föo&y=2), encoded in UTF-8. 62 | ('!!python/str "x=f\\xF6o&y=2"', b"x=f\xc3\xb6o&y=2"), 63 | ("!!binary |\n eD1mw7ZvJnk9Mg==", b"x=f\xc3\xb6o&y=2"), 64 | # Same request body, this time encoded in UTF-16. In this case, we 65 | # write the same YAML file under both Python 2 and 3, so there's only 66 | # one test case here. 67 | ( 68 | "!!binary |\n //54AD0AZgD2AG8AJgB5AD0AMgA=", 69 | b"\xff\xfex\x00=\x00f\x00\xf6\x00o\x00&\x00y\x00=\x002\x00", 70 | ), 71 | # Same again, this time encoded in ISO-8859-1. 72 | ("!!binary |\n eD1m9m8meT0y", b"x=f\xf6o&y=2"), 73 | ], 74 | ) 75 | def test_deserialize_py2py3_yaml_cassette(tmpdir, req_body, expect): 76 | cfile = tmpdir.join("test_cassette.yaml") 77 | cfile.write(REQBODY_TEMPLATE.format(req_body=req_body)) 78 | with open(str(cfile)) as f: 79 | (requests, responses) = deserialize(f.read(), yamlserializer) 80 | assert requests[0].body == expect 81 | 82 | 83 | @mock.patch.object( 84 | jsonserializer.json, 85 | "dumps", 86 | side_effect=UnicodeDecodeError("utf-8", b"unicode error in serialization", 0, 10, "blew up"), 87 | ) 88 | def test_serialize_constructs_UnicodeDecodeError(mock_dumps): 89 | with pytest.raises(UnicodeDecodeError): 90 | jsonserializer.serialize({}) 91 | 92 | 93 | def test_serialize_empty_request(): 94 | request = Request(method="POST", uri="http://localhost/", body="", headers={}) 95 | 96 | serialize({"requests": [request], "responses": [{}]}, jsonserializer) 97 | 98 | 99 | def test_serialize_json_request(): 100 | request = Request(method="POST", uri="http://localhost/", body="{'hello': 'world'}", headers={}) 101 | 102 | serialize({"requests": [request], "responses": [{}]}, jsonserializer) 103 | 104 | 105 | def test_serialize_binary_request(): 106 | msg = "Does this HTTP interaction contain binary data?" 107 | 108 | request = Request(method="POST", uri="http://localhost/", body=b"\x8c", headers={}) 109 | 110 | try: 111 | serialize({"requests": [request], "responses": [{}]}, jsonserializer) 112 | except (UnicodeDecodeError, TypeError) as exc: 113 | assert msg in str(exc) 114 | 115 | 116 | def test_deserialize_no_body_string(): 117 | data = {"body": {"string": None}} 118 | output = compat.convert_to_bytes(data) 119 | assert data == output 120 | -------------------------------------------------------------------------------- /tests/unit/test_stubs.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import http.client as httplib 3 | from io import BytesIO 4 | from tempfile import NamedTemporaryFile 5 | from unittest import mock 6 | 7 | from pytest import mark 8 | 9 | from vcr import mode, use_cassette 10 | from vcr.cassette import Cassette 11 | from vcr.stubs import VCRHTTPSConnection 12 | 13 | 14 | class TestVCRConnection: 15 | def test_setting_of_attributes_get_propagated_to_real_connection(self): 16 | vcr_connection = VCRHTTPSConnection("www.examplehost.com") 17 | vcr_connection.ssl_version = "example_ssl_version" 18 | assert vcr_connection.real_connection.ssl_version == "example_ssl_version" 19 | 20 | @mark.online 21 | @mock.patch("vcr.cassette.Cassette.can_play_response_for", return_value=False) 22 | def testing_connect(*args): 23 | with contextlib.closing(VCRHTTPSConnection("www.google.com")) as vcr_connection: 24 | vcr_connection.cassette = Cassette("test", record_mode=mode.ALL) 25 | vcr_connection.real_connection.connect() 26 | assert vcr_connection.real_connection.sock is not None 27 | 28 | def test_body_consumed_once_stream(self, tmpdir, httpbin): 29 | self._test_body_consumed_once( 30 | tmpdir, 31 | httpbin, 32 | BytesIO(b"1234567890"), 33 | BytesIO(b"9876543210"), 34 | BytesIO(b"9876543210"), 35 | ) 36 | 37 | def test_body_consumed_once_iterator(self, tmpdir, httpbin): 38 | self._test_body_consumed_once( 39 | tmpdir, 40 | httpbin, 41 | iter([b"1234567890"]), 42 | iter([b"9876543210"]), 43 | iter([b"9876543210"]), 44 | ) 45 | 46 | # data2 and data3 should serve the same data, potentially as iterators 47 | def _test_body_consumed_once( 48 | self, 49 | tmpdir, 50 | httpbin, 51 | data1, 52 | data2, 53 | data3, 54 | ): 55 | with NamedTemporaryFile(dir=tmpdir, suffix=".yml") as f: 56 | testpath = f.name 57 | # NOTE: ``use_cassette`` is not okay with the file existing 58 | # already. So we using ``.close()`` to not only 59 | # close but also delete the empty file, before we start. 60 | f.close() 61 | host, port = httpbin.host, httpbin.port 62 | match_on = ["method", "uri", "body"] 63 | with use_cassette(testpath, match_on=match_on): 64 | conn1 = httplib.HTTPConnection(host, port) 65 | conn1.request("POST", "/anything", body=data1) 66 | conn1.getresponse() 67 | conn2 = httplib.HTTPConnection(host, port) 68 | conn2.request("POST", "/anything", body=data2) 69 | conn2.getresponse() 70 | with use_cassette(testpath, match_on=match_on) as cass: 71 | conn3 = httplib.HTTPConnection(host, port) 72 | conn3.request("POST", "/anything", body=data3) 73 | conn3.getresponse() 74 | assert cass.play_counts[0] == 0 75 | assert cass.play_counts[1] == 1 76 | -------------------------------------------------------------------------------- /tests/unit/test_unittest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TextTestRunner, defaultTestLoader 3 | from unittest.mock import MagicMock 4 | from urllib.request import urlopen 5 | 6 | import pytest 7 | 8 | from vcr.unittest import VCRTestCase 9 | 10 | 11 | def test_defaults(): 12 | class MyTest(VCRTestCase): 13 | def test_foo(self): 14 | pass 15 | 16 | test = run_testcase(MyTest)[0][0] 17 | expected_path = os.path.join(os.path.dirname(__file__), "cassettes") 18 | expected_name = "MyTest.test_foo.yaml" 19 | assert os.path.dirname(test.cassette._path) == expected_path 20 | assert os.path.basename(test.cassette._path) == expected_name 21 | 22 | 23 | def test_disabled(): 24 | # Baseline vcr_enabled = True 25 | class MyTest(VCRTestCase): 26 | def test_foo(self): 27 | pass 28 | 29 | test = run_testcase(MyTest)[0][0] 30 | assert hasattr(test, "cassette") 31 | 32 | # Test vcr_enabled = False 33 | class MyTest(VCRTestCase): 34 | vcr_enabled = False 35 | 36 | def test_foo(self): 37 | pass 38 | 39 | test = run_testcase(MyTest)[0][0] 40 | assert not hasattr(test, "cassette") 41 | 42 | 43 | def test_cassette_library_dir(): 44 | class MyTest(VCRTestCase): 45 | def test_foo(self): 46 | pass 47 | 48 | def _get_cassette_library_dir(self): 49 | return "/testing" 50 | 51 | test = run_testcase(MyTest)[0][0] 52 | assert test.cassette._path.startswith("/testing/") 53 | 54 | 55 | def test_cassette_name(): 56 | class MyTest(VCRTestCase): 57 | def test_foo(self): 58 | pass 59 | 60 | def _get_cassette_name(self): 61 | return "my-custom-name" 62 | 63 | test = run_testcase(MyTest)[0][0] 64 | assert os.path.basename(test.cassette._path) == "my-custom-name" 65 | 66 | 67 | def test_vcr_kwargs_overridden(): 68 | class MyTest(VCRTestCase): 69 | def test_foo(self): 70 | pass 71 | 72 | def _get_vcr_kwargs(self): 73 | kwargs = super()._get_vcr_kwargs() 74 | kwargs["record_mode"] = "new_episodes" 75 | return kwargs 76 | 77 | test = run_testcase(MyTest)[0][0] 78 | assert test.cassette.record_mode == "new_episodes" 79 | 80 | 81 | def test_vcr_kwargs_passed(): 82 | class MyTest(VCRTestCase): 83 | def test_foo(self): 84 | pass 85 | 86 | def _get_vcr_kwargs(self): 87 | return super()._get_vcr_kwargs( 88 | record_mode="new_episodes", 89 | ) 90 | 91 | test = run_testcase(MyTest)[0][0] 92 | assert test.cassette.record_mode == "new_episodes" 93 | 94 | 95 | def test_vcr_kwargs_cassette_dir(): 96 | # Test that _get_cassette_library_dir applies if cassette_library_dir 97 | # is absent from vcr kwargs. 98 | class MyTest(VCRTestCase): 99 | def test_foo(self): 100 | pass 101 | 102 | def _get_vcr_kwargs(self): 103 | return { 104 | "record_mode": "new_episodes", 105 | } 106 | 107 | _get_cassette_library_dir = MagicMock(return_value="/testing") 108 | 109 | test = run_testcase(MyTest)[0][0] 110 | assert test.cassette._path.startswith("/testing/") 111 | assert test._get_cassette_library_dir.call_count == 1 112 | 113 | # Test that _get_cassette_library_dir is ignored if cassette_library_dir 114 | # is present in vcr kwargs. 115 | class MyTest(VCRTestCase): 116 | def test_foo(self): 117 | pass 118 | 119 | def _get_vcr_kwargs(self): 120 | return { 121 | "cassette_library_dir": "/testing", 122 | } 123 | 124 | _get_cassette_library_dir = MagicMock(return_value="/ignored") 125 | 126 | test = run_testcase(MyTest)[0][0] 127 | assert test.cassette._path.startswith("/testing/") 128 | assert test._get_cassette_library_dir.call_count == 0 129 | 130 | 131 | @pytest.mark.online 132 | def test_get_vcr_with_matcher(tmpdir): 133 | cassette_dir = tmpdir.mkdir("cassettes") 134 | assert len(cassette_dir.listdir()) == 0 135 | 136 | mock_matcher = MagicMock(return_value=True, __name__="MockMatcher") 137 | 138 | class MyTest(VCRTestCase): 139 | def test_foo(self): 140 | self.response = urlopen("http://example.com").read() 141 | 142 | def _get_vcr(self): 143 | myvcr = super()._get_vcr() 144 | myvcr.register_matcher("mymatcher", mock_matcher) 145 | myvcr.match_on = ["mymatcher"] 146 | return myvcr 147 | 148 | def _get_cassette_library_dir(self): 149 | return str(cassette_dir) 150 | 151 | # First run to fill cassette. 152 | test = run_testcase(MyTest)[0][0] 153 | assert len(test.cassette.requests) == 1 154 | assert not mock_matcher.called # nothing in cassette 155 | 156 | # Second run to call matcher. 157 | test = run_testcase(MyTest)[0][0] 158 | assert len(test.cassette.requests) == 1 159 | assert mock_matcher.called 160 | assert ( 161 | repr(mock_matcher.mock_calls[0]) 162 | == "call(, )" 163 | ) 164 | 165 | 166 | @pytest.mark.online 167 | def test_testcase_playback(tmpdir): 168 | cassette_dir = tmpdir.mkdir("cassettes") 169 | assert len(cassette_dir.listdir()) == 0 170 | 171 | # First test actually reads from the web. 172 | 173 | class MyTest(VCRTestCase): 174 | def test_foo(self): 175 | self.response = urlopen("http://example.com").read() 176 | 177 | def _get_cassette_library_dir(self): 178 | return str(cassette_dir) 179 | 180 | test = run_testcase(MyTest)[0][0] 181 | assert b"illustrative examples" in test.response 182 | assert len(test.cassette.requests) == 1 183 | assert test.cassette.play_count == 0 184 | 185 | # Second test reads from cassette. 186 | 187 | test2 = run_testcase(MyTest)[0][0] 188 | assert test.cassette is not test2.cassette 189 | assert b"illustrative examples" in test.response 190 | assert len(test2.cassette.requests) == 1 191 | assert test2.cassette.play_count == 1 192 | 193 | 194 | def run_testcase(testcase_class): 195 | """Run all the tests in a TestCase and return them.""" 196 | suite = defaultTestLoader.loadTestsFromTestCase(testcase_class) 197 | tests = list(suite._tests) 198 | result = TextTestRunner().run(suite) 199 | return tests, result 200 | -------------------------------------------------------------------------------- /tests/unit/test_util.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO, StringIO 2 | 3 | import pytest 4 | 5 | from vcr import request 6 | from vcr.util import read_body 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "input_, expected_output", 11 | [ 12 | (BytesIO(b"Stream"), b"Stream"), 13 | (StringIO("Stream"), b"Stream"), 14 | (iter(["StringIter"]), b"StringIter"), 15 | (iter(["String", "Iter"]), b"StringIter"), 16 | (iter([b"BytesIter"]), b"BytesIter"), 17 | (iter([b"Bytes", b"Iter"]), b"BytesIter"), 18 | (iter([70, 111, 111]), b"Foo"), 19 | (iter([]), b""), 20 | ("String", b"String"), 21 | (b"Bytes", b"Bytes"), 22 | ], 23 | ) 24 | def test_read_body(input_, expected_output): 25 | r = request.Request("POST", "http://host.com/", input_, {}) 26 | assert read_body(r) == expected_output 27 | 28 | 29 | def test_unsupported_read_body(): 30 | r = request.Request("POST", "http://host.com/", iter([[]]), {}) 31 | with pytest.raises(ValueError) as excinfo: 32 | assert read_body(r) 33 | assert excinfo.value.args == ("Body type not supported",) 34 | -------------------------------------------------------------------------------- /tests/unit/test_vcr_import.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def test_vcr_import_deprecation(recwarn): 5 | if "vcr" in sys.modules: 6 | # Remove imported module entry if already loaded in another test 7 | del sys.modules["vcr"] 8 | 9 | import vcr # noqa: F401 10 | 11 | assert len(recwarn) == 0 12 | -------------------------------------------------------------------------------- /vcr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin1024/vcrpy/19bd4e012c8fd6970fd7f2af3cc60aed1e5f1ab5/vcr.png -------------------------------------------------------------------------------- /vcr/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging import NullHandler 3 | 4 | from .config import VCR 5 | from .record_mode import RecordMode as mode # noqa: F401 6 | 7 | __version__ = "7.0.0" 8 | 9 | logging.getLogger(__name__).addHandler(NullHandler()) 10 | 11 | 12 | default_vcr = VCR() 13 | use_cassette = default_vcr.use_cassette 14 | -------------------------------------------------------------------------------- /vcr/_handle_coroutine.py: -------------------------------------------------------------------------------- 1 | async def handle_coroutine(vcr, fn): 2 | with vcr as cassette: 3 | return await fn(cassette) 4 | -------------------------------------------------------------------------------- /vcr/errors.py: -------------------------------------------------------------------------------- 1 | class CannotOverwriteExistingCassetteException(Exception): 2 | def __init__(self, *args, **kwargs): 3 | self.cassette = kwargs["cassette"] 4 | self.failed_request = kwargs["failed_request"] 5 | message = self._get_message(kwargs["cassette"], kwargs["failed_request"]) 6 | super().__init__(message) 7 | 8 | @staticmethod 9 | def _get_message(cassette, failed_request): 10 | """Get the final message related to the exception""" 11 | # Get the similar requests in the cassette that 12 | # have match the most with the request. 13 | best_matches = cassette.find_requests_with_most_matches(failed_request) 14 | if best_matches: 15 | # Build a comprehensible message to put in the exception. 16 | best_matches_msg = ( 17 | f"Found {len(best_matches)} similar requests " 18 | f"with {len(best_matches[0][2])} different matcher(s) :\n" 19 | ) 20 | 21 | for idx, best_match in enumerate(best_matches, start=1): 22 | request, succeeded_matchers, failed_matchers_assertion_msgs = best_match 23 | best_matches_msg += ( 24 | f"\n{idx} - ({request!r}).\n" 25 | f"Matchers succeeded : {succeeded_matchers}\n" 26 | "Matchers failed :\n" 27 | ) 28 | for failed_matcher, assertion_msg in failed_matchers_assertion_msgs: 29 | best_matches_msg += f"{failed_matcher} - assertion failure :\n{assertion_msg}\n" 30 | else: 31 | best_matches_msg = "No similar requests, that have not been played, found." 32 | return ( 33 | f"Can't overwrite existing cassette ({cassette._path!r}) in " 34 | f"your current record mode ({cassette.record_mode!r}).\n" 35 | f"No match for the request ({failed_request!r}) was found.\n" 36 | f"{best_matches_msg}" 37 | ) 38 | 39 | 40 | class UnhandledHTTPRequestError(KeyError): 41 | """Raised when a cassette does not contain the request we want.""" 42 | -------------------------------------------------------------------------------- /vcr/filters.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import zlib 4 | from io import BytesIO 5 | from urllib.parse import urlencode, urlparse, urlunparse 6 | 7 | from .util import CaseInsensitiveDict 8 | 9 | 10 | def replace_headers(request, replacements): 11 | """Replace headers in request according to replacements. 12 | The replacements should be a list of (key, value) pairs where the value can be any of: 13 | 1. A simple replacement string value. 14 | 2. None to remove the given header. 15 | 3. A callable which accepts (key, value, request) and returns a string value or None. 16 | """ 17 | new_headers = request.headers.copy() 18 | for k, rv in replacements: 19 | if k in new_headers: 20 | ov = new_headers.pop(k) 21 | if callable(rv): 22 | rv = rv(key=k, value=ov, request=request) 23 | if rv is not None: 24 | new_headers[k] = rv 25 | request.headers = new_headers 26 | return request 27 | 28 | 29 | def remove_headers(request, headers_to_remove): 30 | """ 31 | Wrap replace_headers() for API backward compatibility. 32 | """ 33 | replacements = [(k, None) for k in headers_to_remove] 34 | return replace_headers(request, replacements) 35 | 36 | 37 | def replace_query_parameters(request, replacements): 38 | """Replace query parameters in request according to replacements. 39 | 40 | The replacements should be a list of (key, value) pairs where the value can be any of: 41 | 1. A simple replacement string value. 42 | 2. None to remove the given header. 43 | 3. A callable which accepts (key, value, request) and returns a string 44 | value or None. 45 | """ 46 | query = request.query 47 | new_query = [] 48 | replacements = dict(replacements) 49 | for k, ov in query: 50 | if k not in replacements: 51 | new_query.append((k, ov)) 52 | else: 53 | rv = replacements[k] 54 | if callable(rv): 55 | rv = rv(key=k, value=ov, request=request) 56 | if rv is not None: 57 | new_query.append((k, rv)) 58 | uri_parts = list(urlparse(request.uri)) 59 | uri_parts[4] = urlencode(new_query) 60 | request.uri = urlunparse(uri_parts) 61 | return request 62 | 63 | 64 | def remove_query_parameters(request, query_parameters_to_remove): 65 | """ 66 | Wrap replace_query_parameters() for API backward compatibility. 67 | """ 68 | replacements = [(k, None) for k in query_parameters_to_remove] 69 | return replace_query_parameters(request, replacements) 70 | 71 | 72 | def replace_post_data_parameters(request, replacements): 73 | """Replace post data in request--either form data or json--according to replacements. 74 | 75 | The replacements should be a list of (key, value) pairs where the value can be any of: 76 | 1. A simple replacement string value. 77 | 2. None to remove the given header. 78 | 3. A callable which accepts (key, value, request) and returns a string 79 | value or None. 80 | """ 81 | if not request.body: 82 | # Nothing to replace 83 | return request 84 | 85 | replacements = dict(replacements) 86 | if request.method == "POST" and not isinstance(request.body, BytesIO): 87 | if isinstance(request.body, dict): 88 | new_body = request.body.copy() 89 | for k, rv in replacements.items(): 90 | if k in new_body: 91 | ov = new_body.pop(k) 92 | if callable(rv): 93 | rv = rv(key=k, value=ov, request=request) 94 | if rv is not None: 95 | new_body[k] = rv 96 | request.body = new_body 97 | elif request.headers.get("Content-Type") == "application/json": 98 | json_data = json.loads(request.body) 99 | for k, rv in replacements.items(): 100 | if k in json_data: 101 | ov = json_data.pop(k) 102 | if callable(rv): 103 | rv = rv(key=k, value=ov, request=request) 104 | if rv is not None: 105 | json_data[k] = rv 106 | request.body = json.dumps(json_data).encode("utf-8") 107 | else: 108 | if isinstance(request.body, str): 109 | request.body = request.body.encode("utf-8") 110 | splits = [p.partition(b"=") for p in request.body.split(b"&")] 111 | new_splits = [] 112 | for k, sep, ov in splits: 113 | if sep is None: 114 | new_splits.append((k, sep, ov)) 115 | else: 116 | rk = k.decode("utf-8") 117 | if rk not in replacements: 118 | new_splits.append((k, sep, ov)) 119 | else: 120 | rv = replacements[rk] 121 | if callable(rv): 122 | rv = rv(key=rk, value=ov.decode("utf-8"), request=request) 123 | if rv is not None: 124 | new_splits.append((k, sep, rv.encode("utf-8"))) 125 | request.body = b"&".join(k if sep is None else b"".join([k, sep, v]) for k, sep, v in new_splits) 126 | return request 127 | 128 | 129 | def remove_post_data_parameters(request, post_data_parameters_to_remove): 130 | """ 131 | Wrap replace_post_data_parameters() for API backward compatibility. 132 | """ 133 | replacements = [(k, None) for k in post_data_parameters_to_remove] 134 | return replace_post_data_parameters(request, replacements) 135 | 136 | 137 | def decode_response(response): 138 | """ 139 | If the response is compressed with gzip or deflate: 140 | 1. decompress the response body 141 | 2. delete the content-encoding header 142 | 3. update content-length header to decompressed length 143 | """ 144 | 145 | def is_compressed(headers): 146 | encoding = headers.get("content-encoding", []) 147 | return encoding and encoding[0] in ("gzip", "deflate") 148 | 149 | def decompress_body(body, encoding): 150 | """Returns decompressed body according to encoding using zlib. 151 | to (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16 152 | """ 153 | if not body: 154 | return "" 155 | if encoding == "gzip": 156 | try: 157 | return zlib.decompress(body, zlib.MAX_WBITS | 16) 158 | except zlib.error: 159 | return body # assumes that the data was already decompressed 160 | else: # encoding == 'deflate' 161 | try: 162 | return zlib.decompress(body) 163 | except zlib.error: 164 | return body # assumes that the data was already decompressed 165 | 166 | # Deepcopy here in case `headers` contain objects that could 167 | # be mutated by a shallow copy and corrupt the real response. 168 | response = copy.deepcopy(response) 169 | headers = CaseInsensitiveDict(response["headers"]) 170 | if is_compressed(headers): 171 | encoding = headers["content-encoding"][0] 172 | headers["content-encoding"].remove(encoding) 173 | if not headers["content-encoding"]: 174 | del headers["content-encoding"] 175 | 176 | new_body = decompress_body(response["body"]["string"], encoding) 177 | response["body"]["string"] = new_body 178 | headers["content-length"] = [str(len(new_body))] 179 | response["headers"] = dict(headers) 180 | return response 181 | -------------------------------------------------------------------------------- /vcr/matchers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import urllib 4 | import xmlrpc.client 5 | from string import hexdigits 6 | 7 | from .util import read_body 8 | 9 | _HEXDIG_CODE_POINTS: set[int] = {ord(s.encode("ascii")) for s in hexdigits} 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def method(r1, r2): 15 | if r1.method != r2.method: 16 | raise AssertionError(f"{r1.method} != {r2.method}") 17 | 18 | 19 | def uri(r1, r2): 20 | if r1.uri != r2.uri: 21 | raise AssertionError(f"{r1.uri} != {r2.uri}") 22 | 23 | 24 | def host(r1, r2): 25 | if r1.host != r2.host: 26 | raise AssertionError(f"{r1.host} != {r2.host}") 27 | 28 | 29 | def scheme(r1, r2): 30 | if r1.scheme != r2.scheme: 31 | raise AssertionError(f"{r1.scheme} != {r2.scheme}") 32 | 33 | 34 | def port(r1, r2): 35 | if r1.port != r2.port: 36 | raise AssertionError(f"{r1.port} != {r2.port}") 37 | 38 | 39 | def path(r1, r2): 40 | if r1.path != r2.path: 41 | raise AssertionError(f"{r1.path} != {r2.path}") 42 | 43 | 44 | def query(r1, r2): 45 | if r1.query != r2.query: 46 | raise AssertionError(f"{r1.query} != {r2.query}") 47 | 48 | 49 | def raw_body(r1, r2): 50 | if read_body(r1) != read_body(r2): 51 | raise AssertionError 52 | 53 | 54 | def body(r1, r2): 55 | transformers = list(_get_transformers(r1)) 56 | if transformers != list(_get_transformers(r2)): 57 | transformers = [] 58 | 59 | b1 = read_body(r1) 60 | b2 = read_body(r2) 61 | for transform in transformers: 62 | b1 = transform(b1) 63 | b2 = transform(b2) 64 | 65 | if b1 != b2: 66 | raise AssertionError 67 | 68 | 69 | def headers(r1, r2): 70 | if r1.headers != r2.headers: 71 | raise AssertionError(f"{r1.headers} != {r2.headers}") 72 | 73 | 74 | def _header_checker(value, header="Content-Type"): 75 | def checker(headers): 76 | _header = headers.get(header, "") 77 | if isinstance(_header, bytes): 78 | _header = _header.decode("utf-8") 79 | return value in _header.lower() 80 | 81 | return checker 82 | 83 | 84 | def _dechunk(body): 85 | if isinstance(body, str): 86 | body = body.encode("utf-8") 87 | elif isinstance(body, bytearray): 88 | body = bytes(body) 89 | elif hasattr(body, "__iter__"): 90 | body = list(body) 91 | if body: 92 | if isinstance(body[0], str): 93 | body = ("".join(body)).encode("utf-8") 94 | elif isinstance(body[0], bytes): 95 | body = b"".join(body) 96 | elif isinstance(body[0], int): 97 | body = bytes(body) 98 | else: 99 | raise ValueError(f"Body chunk type {type(body[0])} not supported") 100 | else: 101 | body = None 102 | 103 | if not isinstance(body, bytes): 104 | return body 105 | 106 | # Now decode chunked data format (https://en.wikipedia.org/wiki/Chunked_transfer_encoding) 107 | # Example input: b"45\r\n<69 bytes>\r\n0\r\n\r\n" where int(b"45", 16) == 69. 108 | CHUNK_GAP = b"\r\n" 109 | BODY_LEN: int = len(body) 110 | 111 | chunks: list[bytes] = [] 112 | pos: int = 0 113 | 114 | while True: 115 | for i in range(pos, BODY_LEN): 116 | if body[i] not in _HEXDIG_CODE_POINTS: 117 | break 118 | 119 | if i == 0 or body[i : i + len(CHUNK_GAP)] != CHUNK_GAP: 120 | if pos == 0: 121 | return body # i.e. assume non-chunk data 122 | raise ValueError("Malformed chunked data") 123 | 124 | size_bytes = int(body[pos:i], 16) 125 | if size_bytes == 0: # i.e. well-formed ending 126 | return b"".join(chunks) 127 | 128 | chunk_data_first = i + len(CHUNK_GAP) 129 | chunk_data_after_last = chunk_data_first + size_bytes 130 | 131 | if body[chunk_data_after_last : chunk_data_after_last + len(CHUNK_GAP)] != CHUNK_GAP: 132 | raise ValueError("Malformed chunked data") 133 | 134 | chunk_data = body[chunk_data_first:chunk_data_after_last] 135 | chunks.append(chunk_data) 136 | 137 | pos = chunk_data_after_last + len(CHUNK_GAP) 138 | 139 | 140 | def _transform_json(body): 141 | if body: 142 | return json.loads(body) 143 | 144 | 145 | _xml_header_checker = _header_checker("text/xml") 146 | _xmlrpc_header_checker = _header_checker("xmlrpc", header="User-Agent") 147 | _checker_transformer_pairs = ( 148 | (_header_checker("chunked", header="Transfer-Encoding"), _dechunk), 149 | ( 150 | _header_checker("application/x-www-form-urlencoded"), 151 | lambda body: urllib.parse.parse_qs(body.decode("ascii")), 152 | ), 153 | (_header_checker("application/json"), _transform_json), 154 | (lambda request: _xml_header_checker(request) and _xmlrpc_header_checker(request), xmlrpc.client.loads), 155 | ) 156 | 157 | 158 | def _get_transformers(request): 159 | for checker, transformer in _checker_transformer_pairs: 160 | if checker(request.headers): 161 | yield transformer 162 | 163 | 164 | def requests_match(r1, r2, matchers): 165 | successes, failures = get_matchers_results(r1, r2, matchers) 166 | if failures: 167 | log.debug(f"Requests {r1} and {r2} differ.\nFailure details:\n{failures}") 168 | return len(failures) == 0 169 | 170 | 171 | def _evaluate_matcher(matcher_function, *args): 172 | """ 173 | Evaluate the result of a given matcher as a boolean with an assertion error message if any. 174 | It handles two types of matcher : 175 | - a matcher returning a boolean value. 176 | - a matcher that only makes an assert, returning None or raises an assertion error. 177 | """ 178 | assertion_message = None 179 | try: 180 | match = matcher_function(*args) 181 | match = True if match is None else match 182 | except AssertionError as e: 183 | match = False 184 | assertion_message = str(e) 185 | return match, assertion_message 186 | 187 | 188 | def get_matchers_results(r1, r2, matchers): 189 | """ 190 | Get the comparison results of two requests as two list. 191 | The first returned list represents the matchers names that passed. 192 | The second list is the failed matchers as a string with failed assertion details if any. 193 | """ 194 | matches_success, matches_fails = [], [] 195 | for m in matchers: 196 | matcher_name = m.__name__ 197 | match, assertion_message = _evaluate_matcher(m, r1, r2) 198 | if match: 199 | matches_success.append(matcher_name) 200 | else: 201 | assertion_message = get_assertion_message(assertion_message) 202 | matches_fails.append((matcher_name, assertion_message)) 203 | return matches_success, matches_fails 204 | 205 | 206 | def get_assertion_message(assertion_details): 207 | """ 208 | Get a detailed message about the failing matcher. 209 | """ 210 | return assertion_details 211 | -------------------------------------------------------------------------------- /vcr/migration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Migration script for old 'yaml' and 'json' cassettes 3 | 4 | .. warning:: Backup your cassettes files before migration. 5 | 6 | It merges and deletes the request obsolete keys (protocol, host, port, path) 7 | into new 'uri' key. 8 | Usage:: 9 | 10 | python3 -m vcr.migration PATH 11 | 12 | The PATH can be path to the directory with cassettes or cassette itself 13 | """ 14 | 15 | import json 16 | import os 17 | import shutil 18 | import sys 19 | import tempfile 20 | 21 | import yaml 22 | 23 | from . import request 24 | from .serialize import serialize 25 | from .serializers import jsonserializer, yamlserializer 26 | from .stubs.compat import get_httpmessage 27 | 28 | # Use the libYAML versions if possible 29 | try: 30 | from yaml import CLoader as Loader 31 | except ImportError: 32 | from yaml import Loader 33 | 34 | 35 | def preprocess_yaml(cassette): 36 | # this is the hack that makes the whole thing work. The old version used 37 | # to deserialize to Request objects automatically using pyYaml's !!python 38 | # tag system. This made it difficult to deserialize old cassettes on new 39 | # versions. So this just strips the tags before deserializing. 40 | 41 | STRINGS_TO_NUKE = [ 42 | "!!python/object:vcr.request.Request", 43 | "!!python/object/apply:__builtin__.frozenset", 44 | "!!python/object/apply:builtins.frozenset", 45 | ] 46 | for s in STRINGS_TO_NUKE: 47 | cassette = cassette.replace(s, "") 48 | return cassette 49 | 50 | 51 | PARTS = ["protocol", "host", "port", "path"] 52 | 53 | 54 | def build_uri(**parts): 55 | port = parts["port"] 56 | scheme = parts["protocol"] 57 | default_port = {"https": 443, "http": 80}[scheme] 58 | parts["port"] = f":{port}" if port != default_port else "" 59 | return "{protocol}://{host}{port}{path}".format(**parts) 60 | 61 | 62 | def _migrate(data): 63 | interactions = [] 64 | for item in data: 65 | req = item["request"] 66 | res = item["response"] 67 | uri = {k: req.pop(k) for k in PARTS} 68 | req["uri"] = build_uri(**uri) 69 | # convert headers to dict of lists 70 | headers = req["headers"] 71 | for k in headers: 72 | headers[k] = [headers[k]] 73 | response_headers = {} 74 | for k, v in get_httpmessage(b"".join(h.encode("utf-8") for h in res["headers"])).items(): 75 | response_headers.setdefault(k, []) 76 | response_headers[k].append(v) 77 | res["headers"] = response_headers 78 | interactions.append({"request": req, "response": res}) 79 | return { 80 | "requests": [request.Request._from_dict(i["request"]) for i in interactions], 81 | "responses": [i["response"] for i in interactions], 82 | } 83 | 84 | 85 | def migrate_json(in_fp, out_fp): 86 | data = json.load(in_fp) 87 | if _already_migrated(data): 88 | return False 89 | interactions = _migrate(data) 90 | out_fp.write(serialize(interactions, jsonserializer)) 91 | return True 92 | 93 | 94 | def _list_of_tuples_to_dict(fs): 95 | return dict(fs[0]) 96 | 97 | 98 | def _already_migrated(data): 99 | try: 100 | if data.get("version") == 1: 101 | return True 102 | except AttributeError: 103 | return False 104 | 105 | 106 | def migrate_yml(in_fp, out_fp): 107 | data = yaml.load(preprocess_yaml(in_fp.read()), Loader=Loader) 108 | if _already_migrated(data): 109 | return False 110 | for i in range(len(data)): 111 | data[i]["request"]["headers"] = _list_of_tuples_to_dict(data[i]["request"]["headers"]) 112 | interactions = _migrate(data) 113 | out_fp.write(serialize(interactions, yamlserializer)) 114 | return True 115 | 116 | 117 | def migrate(file_path, migration_fn): 118 | # because we assume that original files can be reverted 119 | # we will try to copy the content. (os.rename not needed) 120 | with tempfile.TemporaryFile(mode="w+") as out_fp: 121 | with open(file_path) as in_fp: 122 | if not migration_fn(in_fp, out_fp): 123 | return False 124 | with open(file_path, "w") as in_fp: 125 | out_fp.seek(0) 126 | shutil.copyfileobj(out_fp, in_fp) 127 | return True 128 | 129 | 130 | def try_migrate(path): 131 | if path.endswith(".json"): 132 | return migrate(path, migrate_json) 133 | elif path.endswith((".yaml", ".yml")): 134 | return migrate(path, migrate_yml) 135 | return False 136 | 137 | 138 | def main(): 139 | if len(sys.argv) != 2: 140 | raise SystemExit( 141 | "Please provide path to cassettes directory or file. Usage: python3 -m vcr.migration PATH", 142 | ) 143 | 144 | path = sys.argv[1] 145 | if not os.path.isabs(path): 146 | path = os.path.abspath(path) 147 | files = [path] 148 | if os.path.isdir(path): 149 | files = (os.path.join(root, name) for (root, dirs, files) in os.walk(path) for name in files) 150 | for file_path in files: 151 | migrated = try_migrate(file_path) 152 | status = "OK" if migrated else "FAIL" 153 | sys.stderr.write(f"[{status}] {file_path}\n") 154 | sys.stderr.write("Done.\n") 155 | 156 | 157 | if __name__ == "__main__": 158 | main() 159 | -------------------------------------------------------------------------------- /vcr/persisters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin1024/vcrpy/19bd4e012c8fd6970fd7f2af3cc60aed1e5f1ab5/vcr/persisters/__init__.py -------------------------------------------------------------------------------- /vcr/persisters/filesystem.py: -------------------------------------------------------------------------------- 1 | # .. _persister_example: 2 | 3 | from pathlib import Path 4 | 5 | from ..serialize import deserialize, serialize 6 | 7 | 8 | class CassetteNotFoundError(FileNotFoundError): 9 | pass 10 | 11 | 12 | class CassetteDecodeError(ValueError): 13 | pass 14 | 15 | 16 | class FilesystemPersister: 17 | @classmethod 18 | def load_cassette(cls, cassette_path, serializer): 19 | cassette_path = Path(cassette_path) # if cassette path is already Path this is no operation 20 | if not cassette_path.is_file(): 21 | raise CassetteNotFoundError() 22 | try: 23 | with cassette_path.open() as f: 24 | data = f.read() 25 | except UnicodeDecodeError as err: 26 | raise CassetteDecodeError("Can't read Cassette, Encoding is broken") from err 27 | 28 | return deserialize(data, serializer) 29 | 30 | @staticmethod 31 | def save_cassette(cassette_path, cassette_dict, serializer): 32 | data = serialize(cassette_dict, serializer) 33 | cassette_path = Path(cassette_path) # if cassette path is already Path this is no operation 34 | 35 | cassette_folder = cassette_path.parent 36 | if not cassette_folder.exists(): 37 | cassette_folder.mkdir(parents=True) 38 | 39 | with cassette_path.open("w") as f: 40 | f.write(data) 41 | -------------------------------------------------------------------------------- /vcr/record_mode.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class RecordMode(str, Enum): 5 | """ 6 | Configures when VCR will record to the cassette. 7 | 8 | Can be declared by either using the enumerated value (`vcr.mode.ONCE`) 9 | or by simply using the defined string (`once`). 10 | 11 | `ALL`: Every request is recorded. 12 | `ANY`: ? 13 | `NEW_EPISODES`: Any request not found in the cassette is recorded. 14 | `NONE`: No requests are recorded. 15 | `ONCE`: First set of requests is recorded, all others are replayed. 16 | Attempting to add a new episode fails. 17 | """ 18 | 19 | ALL = "all" 20 | ANY = "any" 21 | NEW_EPISODES = "new_episodes" 22 | NONE = "none" 23 | ONCE = "once" 24 | -------------------------------------------------------------------------------- /vcr/request.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | from io import BytesIO 4 | from urllib.parse import parse_qsl, urlparse 5 | 6 | from .util import CaseInsensitiveDict, _is_nonsequence_iterator 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class Request: 12 | """ 13 | VCR's representation of a request. 14 | """ 15 | 16 | def __init__(self, method, uri, body, headers): 17 | self.method = method 18 | self.uri = uri 19 | self._was_file = hasattr(body, "read") 20 | self._was_iter = _is_nonsequence_iterator(body) 21 | if self._was_file: 22 | self.body = body.read() 23 | elif self._was_iter: 24 | self.body = list(body) 25 | else: 26 | self.body = body 27 | self.headers = headers 28 | log.debug("Invoking Request %s", self.uri) 29 | 30 | @property 31 | def uri(self): 32 | return self._uri 33 | 34 | @uri.setter 35 | def uri(self, uri): 36 | self._uri = uri 37 | self.parsed_uri = urlparse(uri) 38 | 39 | @property 40 | def headers(self): 41 | return self._headers 42 | 43 | @headers.setter 44 | def headers(self, value): 45 | if not isinstance(value, HeadersDict): 46 | value = HeadersDict(value) 47 | self._headers = value 48 | 49 | @property 50 | def body(self): 51 | if self._was_file: 52 | return BytesIO(self._body) 53 | if self._was_iter: 54 | return iter(self._body) 55 | return self._body 56 | 57 | @body.setter 58 | def body(self, value): 59 | if isinstance(value, str): 60 | value = value.encode("utf-8") 61 | self._body = value 62 | 63 | def add_header(self, key, value): 64 | warnings.warn( 65 | "Request.add_header is deprecated. Please assign to request.headers instead.", 66 | DeprecationWarning, 67 | stacklevel=2, 68 | ) 69 | self.headers[key] = value 70 | 71 | @property 72 | def scheme(self): 73 | return self.parsed_uri.scheme 74 | 75 | @property 76 | def host(self): 77 | return self.parsed_uri.hostname 78 | 79 | @property 80 | def port(self): 81 | port = self.parsed_uri.port 82 | if port is None: 83 | try: 84 | port = {"https": 443, "http": 80}[self.parsed_uri.scheme] 85 | except KeyError: 86 | pass 87 | return port 88 | 89 | @property 90 | def path(self): 91 | return self.parsed_uri.path 92 | 93 | @property 94 | def query(self): 95 | q = self.parsed_uri.query 96 | return sorted(parse_qsl(q)) 97 | 98 | # alias for backwards compatibility 99 | @property 100 | def url(self): 101 | return self.uri 102 | 103 | # alias for backwards compatibility 104 | @property 105 | def protocol(self): 106 | return self.scheme 107 | 108 | def __str__(self): 109 | return f"" 110 | 111 | def __repr__(self): 112 | return self.__str__() 113 | 114 | def _to_dict(self): 115 | return { 116 | "method": self.method, 117 | "uri": self.uri, 118 | "body": self.body, 119 | "headers": {k: [v] for k, v in self.headers.items()}, 120 | } 121 | 122 | @classmethod 123 | def _from_dict(cls, dct): 124 | return Request(**dct) 125 | 126 | 127 | class HeadersDict(CaseInsensitiveDict): 128 | """ 129 | There is a weird quirk in HTTP. You can send the same header twice. For 130 | this reason, headers are represented by a dict, with lists as the values. 131 | However, it appears that HTTPlib is completely incapable of sending the 132 | same header twice. This puts me in a weird position: I want to be able to 133 | accurately represent HTTP headers in cassettes, but I don't want the extra 134 | step of always having to do [0] in the general case, i.e. 135 | request.headers['key'][0] 136 | 137 | In addition, some servers sometimes send the same header more than once, 138 | and httplib *can* deal with this situation. 139 | 140 | Furthermore, I wanted to keep the request and response cassette format as 141 | similar as possible. 142 | 143 | For this reason, in cassettes I keep a dict with lists as keys, but once 144 | deserialized into VCR, I keep them as plain, naked dicts. 145 | """ 146 | 147 | def __setitem__(self, key, value): 148 | if isinstance(value, (tuple, list)): 149 | value = value[0] 150 | 151 | # Preserve the case from the first time this key was set. 152 | old = self._store.get(key.lower()) 153 | if old: 154 | key = old[0] 155 | 156 | super().__setitem__(key, value) 157 | -------------------------------------------------------------------------------- /vcr/serialize.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | from vcr.request import Request 4 | from vcr.serializers import compat 5 | 6 | # version 1 cassettes started with VCR 1.0.x. 7 | # Before 1.0.x, there was no versioning. 8 | CASSETTE_FORMAT_VERSION = 1 9 | 10 | """ 11 | Just a general note on the serialization philosophy here: 12 | I prefer cassettes to be human-readable if possible. Yaml serializes 13 | bytestrings to !!binary, which isn't readable, so I would like to serialize to 14 | strings and from strings, which yaml will encode as utf-8 automatically. 15 | All the internal HTTP stuff expects bytestrings, so this whole serialization 16 | process feels backwards. 17 | 18 | Serializing: bytestring -> string (yaml persists to utf-8) 19 | Deserializing: string (yaml converts from utf-8) -> bytestring 20 | """ 21 | 22 | 23 | def _looks_like_an_old_cassette(data): 24 | return isinstance(data, list) and len(data) and "request" in data[0] 25 | 26 | 27 | def _warn_about_old_cassette_format(): 28 | raise ValueError( 29 | "Your cassette files were generated in an older version " 30 | "of VCR. Delete your cassettes or run the migration script." 31 | "See http://git.io/mHhLBg for more details.", 32 | ) 33 | 34 | 35 | def deserialize(cassette_string, serializer): 36 | try: 37 | data = serializer.deserialize(cassette_string) 38 | # Old cassettes used to use yaml object thingy so I have to 39 | # check for some fairly stupid exceptions here 40 | except (ImportError, yaml.constructor.ConstructorError): 41 | _warn_about_old_cassette_format() 42 | if _looks_like_an_old_cassette(data): 43 | _warn_about_old_cassette_format() 44 | 45 | requests = [Request._from_dict(r["request"]) for r in data["interactions"]] 46 | responses = [compat.convert_to_bytes(r["response"]) for r in data["interactions"]] 47 | return requests, responses 48 | 49 | 50 | def serialize(cassette_dict, serializer): 51 | interactions = [ 52 | { 53 | "request": compat.convert_to_unicode(request._to_dict()), 54 | "response": compat.convert_to_unicode(response), 55 | } 56 | for request, response in zip(cassette_dict["requests"], cassette_dict["responses"]) 57 | ] 58 | data = {"version": CASSETTE_FORMAT_VERSION, "interactions": interactions} 59 | return serializer.serialize(data) 60 | -------------------------------------------------------------------------------- /vcr/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin1024/vcrpy/19bd4e012c8fd6970fd7f2af3cc60aed1e5f1ab5/vcr/serializers/__init__.py -------------------------------------------------------------------------------- /vcr/serializers/compat.py: -------------------------------------------------------------------------------- 1 | def convert_to_bytes(resp): 2 | resp = convert_body_to_bytes(resp) 3 | return resp 4 | 5 | 6 | def convert_to_unicode(resp): 7 | resp = convert_body_to_unicode(resp) 8 | return resp 9 | 10 | 11 | def convert_body_to_bytes(resp): 12 | """ 13 | If the request body is a string, encode it to bytes (for python3 support) 14 | 15 | By default yaml serializes to utf-8 encoded bytestrings. 16 | When this cassette is loaded by python3, it's automatically decoded 17 | into unicode strings. This makes sure that it stays a bytestring, since 18 | that's what all the internal httplib machinery is expecting. 19 | 20 | For more info on py3 yaml: 21 | http://pyyaml.org/wiki/PyYAMLDocumentation#Python3support 22 | """ 23 | try: 24 | if resp["body"]["string"] is not None and not isinstance(resp["body"]["string"], bytes): 25 | resp["body"]["string"] = resp["body"]["string"].encode("utf-8") 26 | except (KeyError, TypeError, UnicodeEncodeError): 27 | # The thing we were converting either wasn't a dictionary or didn't 28 | # have the keys we were expecting. Some of the tests just serialize 29 | # and deserialize a string. 30 | 31 | # Also, sometimes the thing actually is binary, so if you can't encode 32 | # it, just give up. 33 | pass 34 | return resp 35 | 36 | 37 | def _convert_string_to_unicode(string): 38 | """ 39 | If the string is bytes, decode it to a string (for python3 support) 40 | """ 41 | result = string 42 | 43 | try: 44 | if string is not None and not isinstance(string, str): 45 | result = string.decode("utf-8") 46 | except (TypeError, UnicodeDecodeError, AttributeError): 47 | # Sometimes the string actually is binary or StringIO object, 48 | # so if you can't decode it, just give up. 49 | pass 50 | 51 | return result 52 | 53 | 54 | def convert_body_to_unicode(resp): 55 | """ 56 | If the request or responses body is bytes, decode it to a string 57 | (for python3 support) 58 | """ 59 | if not isinstance(resp, dict): 60 | # Some of the tests just serialize and deserialize a string. 61 | return _convert_string_to_unicode(resp) 62 | else: 63 | body = resp.get("body") 64 | 65 | if body is not None: 66 | try: 67 | body["string"] = _convert_string_to_unicode(body["string"]) 68 | except (KeyError, TypeError, AttributeError): 69 | # The thing we were converting either wasn't a dictionary or 70 | # didn't have the keys we were expecting. 71 | # For example request object has no 'string' key. 72 | resp["body"] = _convert_string_to_unicode(body) 73 | 74 | return resp 75 | -------------------------------------------------------------------------------- /vcr/serializers/jsonserializer.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def deserialize(cassette_string): 5 | return json.loads(cassette_string) 6 | 7 | 8 | def serialize(cassette_dict): 9 | error_message = ( 10 | "Does this HTTP interaction contain binary data? " 11 | "If so, use a different serializer (like the yaml serializer) " 12 | "for this request?" 13 | ) 14 | 15 | try: 16 | return json.dumps(cassette_dict, indent=4) + "\n" 17 | except TypeError: 18 | raise TypeError(error_message) from None 19 | -------------------------------------------------------------------------------- /vcr/serializers/yamlserializer.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | # Use the libYAML versions if possible 4 | try: 5 | from yaml import CDumper as Dumper 6 | from yaml import CLoader as Loader 7 | except ImportError: 8 | from yaml import Dumper, Loader 9 | 10 | 11 | def deserialize(cassette_string): 12 | return yaml.load(cassette_string, Loader=Loader) 13 | 14 | 15 | def serialize(cassette_dict): 16 | return yaml.dump(cassette_dict, Dumper=Dumper) 17 | -------------------------------------------------------------------------------- /vcr/stubs/boto3_stubs.py: -------------------------------------------------------------------------------- 1 | """Stubs for boto3""" 2 | 3 | from botocore.awsrequest import AWSHTTPConnection as HTTPConnection 4 | from botocore.awsrequest import AWSHTTPSConnection as VerifiedHTTPSConnection 5 | 6 | from ..stubs import VCRHTTPConnection, VCRHTTPSConnection 7 | 8 | 9 | class VCRRequestsHTTPConnection(VCRHTTPConnection, HTTPConnection): 10 | _baseclass = HTTPConnection 11 | 12 | 13 | class VCRRequestsHTTPSConnection(VCRHTTPSConnection, VerifiedHTTPSConnection): 14 | _baseclass = VerifiedHTTPSConnection 15 | 16 | def __init__(self, *args, **kwargs): 17 | kwargs.pop("strict", None) 18 | 19 | # need to temporarily reset here because the real connection 20 | # inherits from the thing that we are mocking out. Take out 21 | # the reset if you want to see what I mean :) 22 | from vcr.patch import force_reset 23 | 24 | with force_reset(): 25 | self.real_connection = self._baseclass(*args, **kwargs) 26 | # Make sure to set those attributes as it seems `AWSHTTPConnection` does not 27 | # set them, making the connection to fail ! 28 | self.real_connection.assert_hostname = kwargs.get("assert_hostname", False) 29 | self.real_connection.cert_reqs = kwargs.get("cert_reqs", "CERT_NONE") 30 | 31 | self._sock = None 32 | -------------------------------------------------------------------------------- /vcr/stubs/compat.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | from io import BytesIO 3 | 4 | """ 5 | The python3 http.client api moved some stuff around, so this is an abstraction 6 | layer that tries to cope with this move. 7 | """ 8 | 9 | 10 | def get_header(message, name): 11 | return message.getallmatchingheaders(name) 12 | 13 | 14 | def get_header_items(message): 15 | for key, values in get_headers(message): 16 | for value in values: 17 | yield key, value 18 | 19 | 20 | def get_headers(message): 21 | for key in set(message.keys()): 22 | yield key, message.get_all(key) 23 | 24 | 25 | def get_httpmessage(headers): 26 | return http.client.parse_headers(BytesIO(headers)) 27 | -------------------------------------------------------------------------------- /vcr/stubs/httplib2_stubs.py: -------------------------------------------------------------------------------- 1 | """Stubs for httplib2""" 2 | 3 | from httplib2 import HTTPConnectionWithTimeout, HTTPSConnectionWithTimeout 4 | 5 | from ..stubs import VCRHTTPConnection, VCRHTTPSConnection 6 | 7 | 8 | class VCRHTTPConnectionWithTimeout(VCRHTTPConnection, HTTPConnectionWithTimeout): 9 | _baseclass = HTTPConnectionWithTimeout 10 | 11 | def __init__(self, *args, **kwargs): 12 | """I overrode the init because I need to clean kwargs before calling 13 | HTTPConnection.__init__.""" 14 | 15 | # Delete the keyword arguments that HTTPConnection would not recognize 16 | safe_keys = {"host", "port", "strict", "timeout", "source_address"} 17 | unknown_keys = set(kwargs.keys()) - safe_keys 18 | safe_kwargs = kwargs.copy() 19 | for kw in unknown_keys: 20 | del safe_kwargs[kw] 21 | 22 | self.proxy_info = kwargs.pop("proxy_info", None) 23 | VCRHTTPConnection.__init__(self, *args, **safe_kwargs) 24 | self.sock = self.real_connection.sock 25 | 26 | 27 | class VCRHTTPSConnectionWithTimeout(VCRHTTPSConnection, HTTPSConnectionWithTimeout): 28 | _baseclass = HTTPSConnectionWithTimeout 29 | 30 | def __init__(self, *args, **kwargs): 31 | # Delete the keyword arguments that HTTPSConnection would not recognize 32 | safe_keys = { 33 | "host", 34 | "port", 35 | "key_file", 36 | "cert_file", 37 | "strict", 38 | "timeout", 39 | "source_address", 40 | "ca_certs", 41 | "disable_ssl_certificate_validation", 42 | } 43 | unknown_keys = set(kwargs.keys()) - safe_keys 44 | safe_kwargs = kwargs.copy() 45 | for kw in unknown_keys: 46 | del safe_kwargs[kw] 47 | self.proxy_info = kwargs.pop("proxy_info", None) 48 | if "ca_certs" not in kwargs or kwargs["ca_certs"] is None: 49 | try: 50 | import httplib2 51 | 52 | self.ca_certs = httplib2.CA_CERTS 53 | except ImportError: 54 | self.ca_certs = None 55 | else: 56 | self.ca_certs = kwargs["ca_certs"] 57 | 58 | self.disable_ssl_certificate_validation = kwargs.pop("disable_ssl_certificate_validation", None) 59 | VCRHTTPSConnection.__init__(self, *args, **safe_kwargs) 60 | self.sock = self.real_connection.sock 61 | -------------------------------------------------------------------------------- /vcr/stubs/httpx_stubs.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import inspect 4 | import logging 5 | from unittest.mock import MagicMock, patch 6 | 7 | import httpx 8 | 9 | from vcr.errors import CannotOverwriteExistingCassetteException 10 | from vcr.filters import decode_response 11 | from vcr.request import Request as VcrRequest 12 | from vcr.serializers.compat import convert_body_to_bytes 13 | 14 | _httpx_signature = inspect.signature(httpx.Client.request) 15 | 16 | try: 17 | HTTPX_REDIRECT_PARAM = _httpx_signature.parameters["follow_redirects"] 18 | except KeyError: 19 | HTTPX_REDIRECT_PARAM = _httpx_signature.parameters["allow_redirects"] 20 | 21 | 22 | _logger = logging.getLogger(__name__) 23 | 24 | 25 | def _transform_headers(httpx_response): 26 | """ 27 | Some headers can appear multiple times, like "Set-Cookie". 28 | Therefore transform to every header key to list of values. 29 | """ 30 | 31 | out = {} 32 | for key, var in httpx_response.headers.raw: 33 | decoded_key = key.decode("utf-8") 34 | out.setdefault(decoded_key, []) 35 | out[decoded_key].append(var.decode("utf-8")) 36 | return out 37 | 38 | 39 | async def _to_serialized_response(resp, aread): 40 | # The content shouldn't already have been read in by HTTPX. 41 | assert not hasattr(resp, "_decoder") 42 | 43 | # Retrieve the content, but without decoding it. 44 | with patch.dict(resp.headers, {"Content-Encoding": ""}): 45 | if aread: 46 | await resp.aread() 47 | else: 48 | resp.read() 49 | 50 | result = { 51 | "status": {"code": resp.status_code, "message": resp.reason_phrase}, 52 | "headers": _transform_headers(resp), 53 | "body": {"string": resp.content}, 54 | } 55 | 56 | # As the content wasn't decoded, we restore the response to a state which 57 | # will be capable of decoding the content for the consumer. 58 | del resp._decoder 59 | resp._content = resp._get_content_decoder().decode(resp.content) 60 | return result 61 | 62 | 63 | def _from_serialized_headers(headers): 64 | """ 65 | httpx accepts headers as list of tuples of header key and value. 66 | """ 67 | 68 | header_list = [] 69 | for key, values in headers.items(): 70 | for v in values: 71 | header_list.append((key, v)) 72 | return header_list 73 | 74 | 75 | @patch("httpx.Response.close", MagicMock()) 76 | @patch("httpx.Response.read", MagicMock()) 77 | def _from_serialized_response(request, serialized_response, history=None): 78 | # Cassette format generated for HTTPX requests by older versions of 79 | # vcrpy. We restructure the content to resemble what a regular 80 | # cassette looks like. 81 | if "status_code" in serialized_response: 82 | serialized_response = decode_response( 83 | convert_body_to_bytes( 84 | { 85 | "headers": serialized_response["headers"], 86 | "body": {"string": serialized_response["content"]}, 87 | "status": {"code": serialized_response["status_code"]}, 88 | }, 89 | ), 90 | ) 91 | extensions = None 92 | else: 93 | extensions = {"reason_phrase": serialized_response["status"]["message"].encode()} 94 | 95 | response = httpx.Response( 96 | status_code=serialized_response["status"]["code"], 97 | request=request, 98 | headers=_from_serialized_headers(serialized_response["headers"]), 99 | content=serialized_response["body"]["string"], 100 | history=history or [], 101 | extensions=extensions, 102 | ) 103 | 104 | return response 105 | 106 | 107 | def _make_vcr_request(httpx_request, **kwargs): 108 | body = httpx_request.read().decode("utf-8") 109 | uri = str(httpx_request.url) 110 | headers = dict(httpx_request.headers) 111 | return VcrRequest(httpx_request.method, uri, body, headers) 112 | 113 | 114 | def _shared_vcr_send(cassette, real_send, *args, **kwargs): 115 | real_request = args[1] 116 | 117 | vcr_request = _make_vcr_request(real_request, **kwargs) 118 | 119 | if cassette.can_play_response_for(vcr_request): 120 | return vcr_request, _play_responses(cassette, real_request, vcr_request, args[0], kwargs) 121 | 122 | if cassette.write_protected and cassette.filter_request(vcr_request): 123 | raise CannotOverwriteExistingCassetteException(cassette=cassette, failed_request=vcr_request) 124 | 125 | _logger.info("%s not in cassette, sending to real server", vcr_request) 126 | return vcr_request, None 127 | 128 | 129 | async def _record_responses(cassette, vcr_request, real_response, aread): 130 | for past_real_response in real_response.history: 131 | past_vcr_request = _make_vcr_request(past_real_response.request) 132 | cassette.append(past_vcr_request, await _to_serialized_response(past_real_response, aread)) 133 | 134 | if real_response.history: 135 | # If there was a redirection keep we want the request which will hold the 136 | # final redirect value 137 | vcr_request = _make_vcr_request(real_response.request) 138 | 139 | cassette.append(vcr_request, await _to_serialized_response(real_response, aread)) 140 | return real_response 141 | 142 | 143 | def _play_responses(cassette, request, vcr_request, client, kwargs): 144 | vcr_response = cassette.play_response(vcr_request) 145 | response = _from_serialized_response(request, vcr_response) 146 | return response 147 | 148 | 149 | async def _async_vcr_send(cassette, real_send, *args, **kwargs): 150 | vcr_request, response = _shared_vcr_send(cassette, real_send, *args, **kwargs) 151 | if response: 152 | # add cookies from response to session cookie store 153 | args[0].cookies.extract_cookies(response) 154 | return response 155 | 156 | real_response = await real_send(*args, **kwargs) 157 | await _record_responses(cassette, vcr_request, real_response, aread=True) 158 | return real_response 159 | 160 | 161 | def async_vcr_send(cassette, real_send): 162 | @functools.wraps(real_send) 163 | def _inner_send(*args, **kwargs): 164 | return _async_vcr_send(cassette, real_send, *args, **kwargs) 165 | 166 | return _inner_send 167 | 168 | 169 | def _run_async_function(sync_func, *args, **kwargs): 170 | """ 171 | Safely run an asynchronous function from a synchronous context. 172 | Handles both cases: 173 | - An event loop is already running. 174 | - No event loop exists yet. 175 | """ 176 | try: 177 | asyncio.get_running_loop() 178 | except RuntimeError: 179 | return asyncio.run(sync_func(*args, **kwargs)) 180 | else: 181 | # If inside a running loop, create a task and wait for it 182 | return asyncio.ensure_future(sync_func(*args, **kwargs)) 183 | 184 | 185 | def _sync_vcr_send(cassette, real_send, *args, **kwargs): 186 | vcr_request, response = _shared_vcr_send(cassette, real_send, *args, **kwargs) 187 | if response: 188 | # add cookies from response to session cookie store 189 | args[0].cookies.extract_cookies(response) 190 | return response 191 | 192 | real_response = real_send(*args, **kwargs) 193 | _run_async_function(_record_responses, cassette, vcr_request, real_response, aread=False) 194 | return real_response 195 | 196 | 197 | def sync_vcr_send(cassette, real_send): 198 | @functools.wraps(real_send) 199 | def _inner_send(*args, **kwargs): 200 | return _sync_vcr_send(cassette, real_send, *args, **kwargs) 201 | 202 | return _inner_send 203 | -------------------------------------------------------------------------------- /vcr/stubs/requests_stubs.py: -------------------------------------------------------------------------------- 1 | """Stubs for requests""" 2 | 3 | from urllib3.connection import HTTPConnection, VerifiedHTTPSConnection 4 | 5 | from ..stubs import VCRHTTPConnection, VCRHTTPSConnection 6 | 7 | # urllib3 defines its own HTTPConnection classes, which requests goes ahead and assumes 8 | # you're using. It includes some polyfills for newer features missing in older pythons. 9 | 10 | 11 | class VCRRequestsHTTPConnection(VCRHTTPConnection, HTTPConnection): 12 | _baseclass = HTTPConnection 13 | 14 | 15 | class VCRRequestsHTTPSConnection(VCRHTTPSConnection, VerifiedHTTPSConnection): 16 | _baseclass = VerifiedHTTPSConnection 17 | -------------------------------------------------------------------------------- /vcr/stubs/tornado_stubs.py: -------------------------------------------------------------------------------- 1 | """Stubs for tornado HTTP clients""" 2 | 3 | import functools 4 | from io import BytesIO 5 | 6 | from tornado import httputil 7 | from tornado.httpclient import HTTPResponse 8 | 9 | from vcr.errors import CannotOverwriteExistingCassetteException 10 | from vcr.request import Request 11 | 12 | 13 | def vcr_fetch_impl(cassette, real_fetch_impl): 14 | @functools.wraps(real_fetch_impl) 15 | def new_fetch_impl(self, request, callback): 16 | headers = request.headers.copy() 17 | if request.user_agent: 18 | headers.setdefault("User-Agent", request.user_agent) 19 | 20 | # TODO body_producer, header_callback, and streaming_callback are not 21 | # yet supported. 22 | 23 | unsupported_call = ( 24 | getattr(request, "body_producer", None) is not None 25 | or request.header_callback is not None 26 | or request.streaming_callback is not None 27 | ) 28 | if unsupported_call: 29 | response = HTTPResponse( 30 | request, 31 | 599, 32 | error=Exception( 33 | f"The request ({request!r}) uses AsyncHTTPClient functionality " 34 | "that is not yet supported by VCR.py. Please make the " 35 | "request outside a VCR.py context.", 36 | ), 37 | request_time=self.io_loop.time() - request.start_time, 38 | ) 39 | return callback(response) 40 | 41 | vcr_request = Request(request.method, request.url, request.body, headers) 42 | 43 | if cassette.can_play_response_for(vcr_request): 44 | vcr_response = cassette.play_response(vcr_request) 45 | headers = httputil.HTTPHeaders() 46 | 47 | recorded_headers = vcr_response["headers"] 48 | if isinstance(recorded_headers, dict): 49 | recorded_headers = recorded_headers.items() 50 | for k, vs in recorded_headers: 51 | for v in vs: 52 | headers.add(k, v) 53 | response = HTTPResponse( 54 | request, 55 | code=vcr_response["status"]["code"], 56 | reason=vcr_response["status"]["message"], 57 | headers=headers, 58 | buffer=BytesIO(vcr_response["body"]["string"]), 59 | effective_url=vcr_response.get("url"), 60 | request_time=self.io_loop.time() - request.start_time, 61 | ) 62 | return callback(response) 63 | else: 64 | if cassette.write_protected and cassette.filter_request(vcr_request): 65 | response = HTTPResponse( 66 | request, 67 | 599, 68 | error=CannotOverwriteExistingCassetteException( 69 | cassette=cassette, 70 | failed_request=vcr_request, 71 | ), 72 | request_time=self.io_loop.time() - request.start_time, 73 | ) 74 | return callback(response) 75 | 76 | def new_callback(response): 77 | headers = [(k, response.headers.get_list(k)) for k in response.headers.keys()] 78 | 79 | vcr_response = { 80 | "status": {"code": response.code, "message": response.reason}, 81 | "headers": headers, 82 | "body": {"string": response.body}, 83 | "url": response.effective_url, 84 | } 85 | cassette.append(vcr_request, vcr_response) 86 | return callback(response) 87 | 88 | real_fetch_impl(self, request, new_callback) 89 | 90 | return new_fetch_impl 91 | -------------------------------------------------------------------------------- /vcr/stubs/urllib3_stubs.py: -------------------------------------------------------------------------------- 1 | """Stubs for urllib3""" 2 | 3 | from urllib3.connection import HTTPConnection, VerifiedHTTPSConnection 4 | 5 | from ..stubs import VCRHTTPConnection, VCRHTTPSConnection 6 | 7 | # urllib3 defines its own HTTPConnection classes. It includes some polyfills 8 | # for newer features missing in older pythons. 9 | 10 | 11 | class VCRRequestsHTTPConnection(VCRHTTPConnection, HTTPConnection): 12 | _baseclass = HTTPConnection 13 | 14 | 15 | class VCRRequestsHTTPSConnection(VCRHTTPSConnection, VerifiedHTTPSConnection): 16 | _baseclass = VerifiedHTTPSConnection 17 | -------------------------------------------------------------------------------- /vcr/unittest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import unittest 4 | 5 | from .config import VCR 6 | 7 | 8 | class VCRMixin: 9 | """A TestCase mixin that provides VCR integration.""" 10 | 11 | vcr_enabled = True 12 | 13 | def setUp(self): 14 | super().setUp() 15 | if self.vcr_enabled: 16 | kwargs = self._get_vcr_kwargs() 17 | myvcr = self._get_vcr(**kwargs) 18 | cm = myvcr.use_cassette(self._get_cassette_name()) 19 | self.cassette = cm.__enter__() 20 | self.addCleanup(cm.__exit__, None, None, None) 21 | 22 | def _get_vcr(self, **kwargs): 23 | if "cassette_library_dir" not in kwargs: 24 | kwargs["cassette_library_dir"] = self._get_cassette_library_dir() 25 | return VCR(**kwargs) 26 | 27 | def _get_vcr_kwargs(self, **kwargs): 28 | return kwargs 29 | 30 | def _get_cassette_library_dir(self): 31 | testdir = os.path.dirname(inspect.getfile(self.__class__)) 32 | return os.path.join(testdir, "cassettes") 33 | 34 | def _get_cassette_name(self): 35 | return f"{self.__class__.__name__}.{self._testMethodName}.yaml" 36 | 37 | 38 | class VCRTestCase(VCRMixin, unittest.TestCase): 39 | pass 40 | -------------------------------------------------------------------------------- /vcr/util.py: -------------------------------------------------------------------------------- 1 | import types 2 | from collections.abc import Mapping, MutableMapping 3 | 4 | 5 | # Shamelessly stolen from https://github.com/kennethreitz/requests/blob/master/requests/structures.py 6 | class CaseInsensitiveDict(MutableMapping): 7 | """ 8 | A case-insensitive ``dict``-like object. 9 | Implements all methods and operations of 10 | ``collections.abc.MutableMapping`` as well as dict's ``copy``. Also 11 | provides ``lower_items``. 12 | All keys are expected to be strings. The structure remembers the 13 | case of the last key to be set, and ``iter(instance)``, 14 | ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` 15 | will contain case-sensitive keys. However, querying and contains 16 | testing is case insensitive:: 17 | cid = CaseInsensitiveDict() 18 | cid['Accept'] = 'application/json' 19 | cid['aCCEPT'] == 'application/json' # True 20 | list(cid) == ['Accept'] # True 21 | For example, ``headers['content-encoding']`` will return the 22 | value of a ``'Content-Encoding'`` response header, regardless 23 | of how the header name was originally stored. 24 | If the constructor, ``.update``, or equality comparison 25 | operations are given keys that have equal ``.lower()``s, the 26 | behavior is undefined. 27 | """ 28 | 29 | def __init__(self, data=None, **kwargs): 30 | self._store = {} 31 | if data is None: 32 | data = {} 33 | self.update(data, **kwargs) 34 | 35 | def __setitem__(self, key, value): 36 | # Use the lowercased key for lookups, but store the actual 37 | # key alongside the value. 38 | self._store[key.lower()] = (key, value) 39 | 40 | def __getitem__(self, key): 41 | return self._store[key.lower()][1] 42 | 43 | def __delitem__(self, key): 44 | del self._store[key.lower()] 45 | 46 | def __iter__(self): 47 | return (casedkey for casedkey, mappedvalue in self._store.values()) 48 | 49 | def __len__(self): 50 | return len(self._store) 51 | 52 | def lower_items(self): 53 | """Like iteritems(), but with all lowercase keys.""" 54 | return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items()) 55 | 56 | def __eq__(self, other): 57 | if isinstance(other, Mapping): 58 | other = CaseInsensitiveDict(other) 59 | else: 60 | return NotImplemented 61 | # Compare insensitively 62 | return dict(self.lower_items()) == dict(other.lower_items()) 63 | 64 | # Copy is required 65 | def copy(self): 66 | return CaseInsensitiveDict(self._store.values()) 67 | 68 | def __repr__(self): 69 | return str(dict(self.items())) 70 | 71 | 72 | def partition_dict(predicate, dictionary): 73 | true_dict = {} 74 | false_dict = {} 75 | for key, value in dictionary.items(): 76 | this_dict = true_dict if predicate(key, value) else false_dict 77 | this_dict[key] = value 78 | return true_dict, false_dict 79 | 80 | 81 | def compose(*functions): 82 | def composed(incoming): 83 | res = incoming 84 | for function in reversed(functions): 85 | if function: 86 | res = function(res) 87 | return res 88 | 89 | return composed 90 | 91 | 92 | def _is_nonsequence_iterator(obj): 93 | return hasattr(obj, "__iter__") and not isinstance( 94 | obj, 95 | (bytearray, bytes, dict, list, str), 96 | ) 97 | 98 | 99 | def read_body(request): 100 | if hasattr(request.body, "read"): 101 | return request.body.read() 102 | if _is_nonsequence_iterator(request.body): 103 | body = list(request.body) 104 | if body: 105 | if isinstance(body[0], str): 106 | return "".join(body).encode("utf-8") 107 | elif isinstance(body[0], (bytes, bytearray)): 108 | return b"".join(body) 109 | elif isinstance(body[0], int): 110 | return bytes(body) 111 | else: 112 | raise ValueError(f"Body type {type(body[0])} not supported") 113 | return b"" 114 | return request.body 115 | 116 | 117 | def auto_decorate(decorator, predicate=lambda name, value: isinstance(value, types.FunctionType)): 118 | def maybe_decorate(attribute, value): 119 | if predicate(attribute, value): 120 | value = decorator(value) 121 | return value 122 | 123 | class DecorateAll(type): 124 | def __setattr__(cls, attribute, value): 125 | return super().__setattr__(attribute, maybe_decorate(attribute, value)) 126 | 127 | def __new__(cls, name, bases, attributes_dict): 128 | new_attributes_dict = { 129 | attribute: maybe_decorate(attribute, value) for attribute, value in attributes_dict.items() 130 | } 131 | return super().__new__(cls, name, bases, new_attributes_dict) 132 | 133 | return DecorateAll 134 | --------------------------------------------------------------------------------