├── .github └── workflows │ ├── release.yml │ └── tox.yml ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CODE_OF_CONDUCT.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── requirements.txt └── source │ ├── api.rst │ ├── cassettes.rst │ ├── conf.py │ ├── configuring.rst │ ├── implementation_details.rst │ ├── index.rst │ ├── integrations.rst │ ├── introduction.rst │ ├── long_term_usage.rst │ ├── matchers.rst │ ├── record_modes.rst │ ├── serializers.rst │ ├── third_party_packages.rst │ └── usage_patterns.rst ├── examples ├── cassettes │ ├── more-complicated-cassettes.json │ └── our-first-recorded-session.json ├── more_complicated_cassettes.py ├── more_complicated_cassettes_2.py ├── more_complicated_cassettes_2.traceback ├── our_first_recorded_session.py └── record_modes │ ├── all │ ├── all-example.json │ └── example.py │ ├── new_episodes │ ├── example_original.py │ ├── example_updated.py │ └── new-episodes-example.json │ ├── none │ ├── example_original.py │ ├── example_updated.py │ ├── example_updated.traceback │ └── none-example.json │ └── once │ ├── example_original.py │ ├── example_updated.py │ └── example_updated.traceback ├── setup.cfg ├── setup.py ├── src └── betamax │ ├── __init__.py │ ├── adapter.py │ ├── cassette │ ├── __init__.py │ ├── cassette.py │ └── interaction.py │ ├── configure.py │ ├── decorator.py │ ├── exceptions.py │ ├── fixtures │ ├── __init__.py │ ├── pytest.py │ └── unittest.py │ ├── headers.py │ ├── matchers │ ├── __init__.py │ ├── base.py │ ├── body.py │ ├── digest_auth.py │ ├── headers.py │ ├── host.py │ ├── method.py │ ├── path.py │ ├── query.py │ └── uri.py │ ├── mock_response.py │ ├── options.py │ ├── recorder.py │ ├── serializers │ ├── __init__.py │ ├── base.py │ ├── json_serializer.py │ └── proxy.py │ └── util.py ├── tests ├── __init__.py ├── cassettes │ ├── FakeBetamaxTestCase.test_fake.json │ ├── GitHub_create_issue.json │ ├── GitHub_emojis.json │ ├── global_preserve_exact_body_bytes.json │ ├── handles_digest_auth.json │ ├── once_record_mode.json │ ├── preserve_exact_bytes.json │ ├── replay_interactions.json │ ├── replay_multiple_times.json │ ├── test-multiple-cookies-regression.json │ ├── test.json │ ├── test_replays_response_on_right_order.json │ ├── tests.integration.test_fixtures.TestPyTestFixtures.test_pytest_fixture.json │ ├── tests.integration.test_fixtures.TestPyTestParametrizedFixtures.test_pytest_fixture[https---httpbin.org-get].json │ ├── tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[aaa-bbb].json │ ├── tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[ccc-ddd].json │ └── tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[eee-fff].json ├── conftest.py ├── integration │ ├── __init__.py │ ├── helper.py │ ├── test_allow_playback_repeats.py │ ├── test_backwards_compat.py │ ├── test_fixtures.py │ ├── test_hooks.py │ ├── test_multiple_cookies.py │ ├── test_placeholders.py │ ├── test_preserve_exact_body_bytes.py │ ├── test_record_modes.py │ └── test_unicode.py ├── regression │ ├── test_can_replay_interactions_multiple_times.py │ ├── test_cassettes_retain_global_configuration.py │ ├── test_gzip_compression.py │ ├── test_once_prevents_new_interactions.py │ ├── test_requests_2_11_body_matcher.py │ └── test_works_with_digest_auth.py └── unit │ ├── test_adapter.py │ ├── test_betamax.py │ ├── test_cassette.py │ ├── test_configure.py │ ├── test_decorator.py │ ├── test_exceptions.py │ ├── test_fixtures.py │ ├── test_matchers.py │ ├── test_options.py │ ├── test_recorder.py │ ├── test_replays.py │ └── test_serializers.py └── tox.ini /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | name: "Build dists" 14 | runs-on: "ubuntu-latest" 15 | environment: 16 | name: "publish" 17 | outputs: 18 | hashes: ${{ steps.hash.outputs.hashes }} 19 | 20 | steps: 21 | - name: "Checkout repository" 22 | uses: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3" 23 | 24 | - name: "Setup Python" 25 | uses: "actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b" 26 | with: 27 | python-version: "3.x" 28 | 29 | - name: "Install dependencies" 30 | run: python -m pip install build==0.8.0 31 | 32 | - name: "Build dists" 33 | run: | 34 | SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \ 35 | python -m build 36 | 37 | - name: "Generate hashes" 38 | id: hash 39 | run: | 40 | cd dist && echo "::set-output name=hashes::$(sha256sum * | base64 -w0)" 41 | 42 | - name: "Upload dists" 43 | uses: "actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce" 44 | with: 45 | name: "dist" 46 | path: "dist/" 47 | if-no-files-found: error 48 | retention-days: 5 49 | 50 | provenance: 51 | needs: [build] 52 | permissions: 53 | actions: read 54 | contents: write 55 | id-token: write # Needed to access the workflow's OIDC identity. 56 | uses: "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.5.0" 57 | with: 58 | base64-subjects: "${{ needs.build.outputs.hashes }}" 59 | upload-assets: true 60 | compile-generator: true # Workaround for https://github.com/slsa-framework/slsa-github-generator/issues/1163 61 | 62 | publish: 63 | name: "Publish" 64 | if: startsWith(github.ref, 'refs/tags/') 65 | needs: ["build", "provenance"] 66 | permissions: 67 | contents: write 68 | id-token: write 69 | runs-on: "ubuntu-latest" 70 | 71 | steps: 72 | - name: "Download dists" 73 | uses: "actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a" 74 | with: 75 | name: "dist" 76 | path: "dist/" 77 | 78 | - name: "Publish dists to PyPI" 79 | uses: "pypa/gh-action-pypi-publish@48b317d84d5f59668bb13be49d1697e36b3ad009" 80 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: tox 2 | on: [push, pull_request] 3 | jobs: 4 | tox-jobs: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | job: [py38-flake8, py38-docstrings] 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-python@v4 13 | with: 14 | python-version: 3.8 15 | - run: pip install --upgrade pip 16 | - run: pip install tox 17 | - run: tox -e ${{ matrix.job }} 18 | 19 | tox: 20 | strategy: 21 | fail-fast: false 22 | # max-parallel: 6 23 | matrix: 24 | os: [ubuntu-latest] # [macos-latest, ubuntu-latest, windows-latest] 25 | python: ['3.8', '3.9', '3.10', '3.11', 'pypy3.9'] 26 | runs-on: ${{ matrix.os }} 27 | steps: 28 | - uses: actions/checkout@v3 29 | - uses: actions/setup-python@v4 30 | with: 31 | python-version: ${{ matrix.python }} 32 | - run: pip install --upgrade pip 33 | - run: pip install tox 34 | - run: tox -e py 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | *.swo 4 | *.egg* 5 | .coverage 6 | .tox 7 | htmlcov 8 | vcr/* 9 | docs/_build 10 | tags 11 | build/ 12 | dist/ 13 | .pytest_cache/* 14 | test_cassette.test 15 | tests/cassettes/test_record_once.json 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | install: 6 | pip install tox 7 | 8 | script: tox 9 | 10 | notifications: 11 | on_success: change 12 | on_failure: always 13 | 14 | matrix: 15 | include: 16 | - python: 3.8 17 | env: TOXENV=py38 18 | - python: 3.9 19 | env: TOXENV=py39 20 | - python: 3.10 21 | env: TOXENV=py310 22 | - python: 3.11 23 | env: TOXENV=py311 24 | - python: 3.8 25 | env: TOXENV=py38 REQUESTS_VERSION="===2.2.1" 26 | - python: 3.9 27 | env: TOXENV=py39 REQUESTS_VERSION="===2.2.1" 28 | - python: 3.10 29 | env: TOXENV=py310 REQUESTS_VERSION="===2.2.1" 30 | - python: 3.11 31 | env: TOXENV=py311 REQUESTS_VERSION="===2.2.1" 32 | - python: pypy 33 | env: TOXENV=pypy REQUESTS_VERSION="===2.2.1" 34 | - env: TOXENV=py38-flake8 35 | python: 3.8 36 | - env: TOXENV=docstrings 37 | - env: TOXENV=docs 38 | - env: TOXENV=readme 39 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Development Lead 2 | ---------------- 3 | 4 | - Ian Cordasco 5 | 6 | Requests 7 | ```````` 8 | 9 | - Kenneth Reitz 10 | 11 | Design Advice 12 | ------------- 13 | 14 | - Cory Benfield 15 | 16 | Contributors 17 | ------------ 18 | 19 | - Marc Abramowitz (@msabramo) 20 | - Bryce Boe (@bboe) 21 | - Alex Richard-Hoyling <@arhoyling) 22 | - Joey RH (@jarhill0) 23 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Code of Conduct 2 | --------------------------- 3 | 4 | As contributors and maintainers of this project, and in the interest of 5 | fostering an open and welcoming community, we pledge to respect all 6 | people who contribute through reporting issues, posting feature 7 | requests, updating documentation, submitting pull requests or patches, 8 | and other activities. 9 | 10 | We are committed to making participation in this project a 11 | harassment-free experience for everyone, regardless of level of 12 | experience, gender, gender identity and expression, sexual orientation, 13 | disability, personal appearance, body size, race, ethnicity, age, 14 | religion, or nationality. 15 | 16 | Examples of unacceptable behavior by participants include: 17 | 18 | * The use of sexualized language or imagery 19 | * Personal attacks 20 | * Trolling or insulting/derogatory comments 21 | * Public or private harassment 22 | * Publishing other's private information, such as physical or electronic 23 | addresses, without explicit permission 24 | * Other unethical or unprofessional conduct 25 | 26 | Project maintainers have the right and responsibility to remove, edit, 27 | or reject comments, commits, code, wiki edits, issues, and other 28 | contributions that are not aligned to this Code of Conduct, or to ban 29 | temporarily or permanently any contributor for other behaviors that they 30 | deem inappropriate, threatening, offensive, or harmful. 31 | 32 | By adopting this Code of Conduct, project maintainers commit themselves 33 | to fairly and consistently applying these principles to every aspect of 34 | managing this project. Project maintainers who do not follow or enforce 35 | the Code of Conduct may be permanently removed from the project team. 36 | 37 | This code of conduct applies both within project spaces and in public 38 | spaces when an individual is representing the project or its community. 39 | 40 | Instances of abusive, harassing, or otherwise unacceptable behavior may 41 | be reported by contacting a project maintainer at [INSERT EMAIL 42 | ADDRESS]. All complaints will be reviewed and investigated and will 43 | result in a response that is deemed necessary and appropriate to the 44 | circumstances. Maintainers are obligated to maintain confidentiality 45 | with regard to the reporter of an incident. 46 | 47 | This Code of Conduct is adapted from the Contributor Covenant 48 | (http://contributor-covenant.org), version 1.3.0, available at 49 | http://contributor-covenant.org/version/1/3/0/ 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 Ian Cordasco 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include HISTORY.rst 4 | include AUTHORS.rst 5 | recursive-include docs Makefile *.py *.rst 6 | recursive-include tests *.json *.py 7 | prune *.pyc 8 | prune docs/_build 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | betamax 2 | ======= 3 | 4 | Betamax is a VCR_ imitation for requests. This will make mocking out requests 5 | much easier. It is tested on `Travis CI`_. 6 | 7 | Put in a more humorous way: "Betamax records your HTTP interactions so the NSA 8 | does not have to." 9 | 10 | Example Use 11 | ----------- 12 | 13 | .. code-block:: python 14 | 15 | from betamax import Betamax 16 | from requests import Session 17 | from unittest import TestCase 18 | 19 | with Betamax.configure() as config: 20 | config.cassette_library_dir = 'tests/fixtures/cassettes' 21 | 22 | 23 | class TestGitHubAPI(TestCase): 24 | def setUp(self): 25 | self.session = Session() 26 | self.headers.update(...) 27 | 28 | # Set the cassette in a line other than the context declaration 29 | def test_user(self): 30 | with Betamax(self.session) as vcr: 31 | vcr.use_cassette('user') 32 | resp = self.session.get('https://api.github.com/user', 33 | auth=('user', 'pass')) 34 | assert resp.json()['login'] is not None 35 | 36 | # Set the cassette in line with the context declaration 37 | def test_repo(self): 38 | with Betamax(self.session).use_cassette('repo'): 39 | resp = self.session.get( 40 | 'https://api.github.com/repos/sigmavirus24/github3.py' 41 | ) 42 | assert resp.json()['owner'] != {} 43 | 44 | What does it even do? 45 | --------------------- 46 | 47 | If you are unfamiliar with VCR_, you might need a better explanation of what 48 | Betamax does. 49 | 50 | Betamax intercepts every request you make and attempts to find a matching 51 | request that has already been intercepted and recorded. Two things can then 52 | happen: 53 | 54 | 1. If there is a matching request, it will return the response that is 55 | associated with it. 56 | 2. If there is **not** a matching request and it is allowed to record new 57 | responses, it will make the request, record the response and return the 58 | response. 59 | 60 | Recorded requests and corresponding responses - also known as interactions - 61 | are stored in files called cassettes. (An example cassette can be seen in 62 | the `examples section of the documentation`_.) The directory you store your 63 | cassettes in is called your library, or your `cassette library`_. 64 | 65 | VCR Cassette Compatibility 66 | -------------------------- 67 | 68 | Betamax can use any VCR-recorded cassette as of this point in time. The only 69 | caveat is that python-requests returns a URL on each response. VCR does not 70 | store that in a cassette now but we will. Any VCR-recorded cassette used to 71 | playback a response will unfortunately not have a URL attribute on responses 72 | that are returned. This is a minor annoyance but not something that can be 73 | fixed. 74 | 75 | .. _VCR: https://github.com/vcr/vcr 76 | .. _Travis CI: https://travis-ci.org/sigmavirus24/betamax 77 | .. _examples section of the documentation: 78 | http://betamax.readthedocs.org/en/latest/api.html#examples 79 | .. _cassette library: 80 | http://betamax.readthedocs.org/en/latest/cassettes.html 81 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3.0 2 | pytest 3 | betamax_matchers 4 | betamax_serializers 5 | requests 6 | readme 7 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. module:: betamax 5 | 6 | .. autoclass:: Betamax 7 | :members: 8 | 9 | .. autofunction:: betamax.decorator.use_cassette 10 | 11 | .. autoclass:: betamax.configure.Configuration 12 | :members: 13 | 14 | .. automodule:: betamax.fixtures.pytest 15 | 16 | .. automodule:: betamax.fixtures.unittest 17 | 18 | Examples 19 | -------- 20 | 21 | Basic Usage 22 | ^^^^^^^^^^^ 23 | 24 | Let `example.json` be a file in a directory called `cassettes` with the 25 | content: 26 | 27 | .. code-block:: javascript 28 | 29 | { 30 | "http_interactions": [ 31 | { 32 | "request": { 33 | "body": { 34 | "string": "", 35 | "encoding": "utf-8" 36 | }, 37 | "headers": { 38 | "User-Agent": ["python-requests/v1.2.3"] 39 | }, 40 | "method": "GET", 41 | "uri": "https://httpbin.org/get" 42 | }, 43 | "response": { 44 | "body": { 45 | "string": "example body", 46 | "encoding": "utf-8" 47 | }, 48 | "headers": {}, 49 | "status": { 50 | "code": 200, 51 | "message": "OK" 52 | }, 53 | "url": "https://httpbin.org/get" 54 | } 55 | } 56 | ], 57 | "recorded_with": "betamax" 58 | } 59 | 60 | The following snippet will not raise any exceptions 61 | 62 | .. code-block:: python 63 | 64 | from betamax import Betamax 65 | from requests import Session 66 | 67 | 68 | s = Session() 69 | 70 | with Betamax(s, cassette_library_dir='cassettes') as betamax: 71 | betamax.use_cassette('example', record='none') 72 | r = s.get("https://httpbin.org/get") 73 | 74 | On the other hand, this will raise an exception: 75 | 76 | .. code-block:: python 77 | 78 | from betamax import Betamax 79 | from requests import Session 80 | 81 | 82 | s = Session() 83 | 84 | with Betamax(s, cassette_library_dir='cassettes') as betamax: 85 | betamax.use_cassette('example', record='none') 86 | r = s.post("https://httpbin.org/post", 87 | data={"key": "value"}) 88 | 89 | 90 | Finally, we can also use a decorator in order to simplify things: 91 | 92 | .. code-block:: python 93 | 94 | import unittest 95 | 96 | from betamax.decorator import use_cassette 97 | 98 | class TestExample(unittest.TestCase): 99 | @use_cassette('example', cassette_library_dir='cassettes') 100 | def test_example(self, session): 101 | session.get('https://httpbin.org/get') 102 | 103 | 104 | # Or if you're using something like py.test 105 | @use_cassette('example', cassette_library_dir='cassettes') 106 | def test_example_pytest(session): 107 | session.get('https://httpbin.org/get') 108 | 109 | .. _opinions: 110 | 111 | Opinions at Work 112 | ---------------- 113 | 114 | If you use ``requests``'s default ``Accept-Encoding`` header, servers that 115 | support gzip content encoding will return responses that Betamax cannot 116 | serialize in a human-readable format. In this event, the cassette will look 117 | like this: 118 | 119 | .. code-block:: javascript 120 | :emphasize-lines: 17 121 | 122 | { 123 | "http_interactions": [ 124 | { 125 | "request": { 126 | "body": { 127 | "base64_string": "", 128 | "encoding": "utf-8" 129 | }, 130 | "headers": { 131 | "User-Agent": ["python-requests/v1.2.3"] 132 | }, 133 | "method": "GET", 134 | "uri": "https://httpbin.org/get" 135 | }, 136 | "response": { 137 | "body": { 138 | "base64_string": "Zm9vIGJhcgo=", 139 | "encoding": "utf-8" 140 | }, 141 | "headers": { 142 | "Content-Encoding": ["gzip"] 143 | }, 144 | "status": { 145 | "code": 200, 146 | "message": "OK" 147 | }, 148 | "url": "https://httpbin.org/get" 149 | } 150 | } 151 | ], 152 | "recorded_with": "betamax" 153 | } 154 | 155 | 156 | Forcing bytes to be preserved 157 | ----------------------------- 158 | 159 | You may want to force betamax to preserve the exact bytes in the body of a 160 | response (or request) instead of relying on the :ref:`opinions held by the 161 | library `. In this case you have two ways of telling betamax to do 162 | this. 163 | 164 | The first, is on a per-cassette basis, like so: 165 | 166 | .. code-block:: python 167 | 168 | from betamax import Betamax 169 | import requests 170 | 171 | 172 | session = Session() 173 | 174 | with Betamax.configure() as config: 175 | c.cassette_library_dir = '.' 176 | 177 | with Betamax(session).use_cassette('some_cassette', 178 | preserve_exact_body_bytes=True): 179 | r = session.get('http://example.com') 180 | 181 | 182 | On the other hand, you may want to preserve exact body bytes for all 183 | cassettes. In this case, you can do: 184 | 185 | .. code-block:: python 186 | 187 | from betamax import Betamax 188 | import requests 189 | 190 | 191 | session = Session() 192 | 193 | with Betamax.configure() as config: 194 | config.cassette_library_dir = '.' 195 | config.preserve_exact_body_bytes = True 196 | 197 | with Betamax(session).use_cassette('some_cassette'): 198 | r = session.get('http://example.com') 199 | -------------------------------------------------------------------------------- /docs/source/cassettes.rst: -------------------------------------------------------------------------------- 1 | What is a cassette? 2 | =================== 3 | 4 | A cassette is a set of recorded interactions serialized to a specific format. 5 | Currently the only supported format is JSON_. A cassette has a list (or array) 6 | of interactions and information about the library that recorded it. This means 7 | that the cassette's structure (using JSON) is 8 | 9 | .. code:: javascript 10 | 11 | { 12 | "http_interactions": [ 13 | // ... 14 | ], 15 | "recorded_with": "betamax" 16 | } 17 | 18 | Each interaction is the object representing the request and response as well 19 | as the date it was recorded. The structure of an interaction is 20 | 21 | .. code:: javascript 22 | 23 | { 24 | "request": { 25 | // ... 26 | }, 27 | "response": { 28 | // ... 29 | }, 30 | "recorded_at": "2013-09-28T01:25:38" 31 | } 32 | 33 | Each request has the body, method, uri, and an object representing the 34 | headers. A serialized request looks like: 35 | 36 | .. code:: javascript 37 | 38 | { 39 | "body": { 40 | "string": "...", 41 | "encoding": "utf-8" 42 | }, 43 | "method": "GET", 44 | "uri": "http://example.com", 45 | "headers": { 46 | // ... 47 | } 48 | } 49 | 50 | A serialized response has the status_code, url, and objects 51 | representing the headers and the body. A serialized response looks like: 52 | 53 | .. code:: javascript 54 | 55 | { 56 | "body": { 57 | "encoding": "utf-8", 58 | "string": "..." 59 | }, 60 | "url": "http://example.com", 61 | "status": { 62 | "code": 200, 63 | "message": "OK" 64 | }, 65 | "headers": { 66 | // ... 67 | } 68 | } 69 | 70 | If you put everything together, you get: 71 | 72 | .. _cassette-dict: 73 | 74 | .. code:: javascript 75 | 76 | { 77 | "http_interactions": [ 78 | { 79 | "request": { 80 | { 81 | "body": { 82 | "string": "...", 83 | "encoding": "utf-8" 84 | }, 85 | "method": "GET", 86 | "uri": "http://example.com", 87 | "headers": { 88 | // ... 89 | } 90 | } 91 | }, 92 | "response": { 93 | { 94 | "body": { 95 | "encoding": "utf-8", 96 | "string": "..." 97 | }, 98 | "url": "http://example.com", 99 | "status": { 100 | "code": 200, 101 | "message": "OK" 102 | }, 103 | "headers": { 104 | // ... 105 | } 106 | } 107 | }, 108 | "recorded_at": "2013-09-28T01:25:38" 109 | } 110 | ], 111 | "recorded_with": "betamax" 112 | } 113 | 114 | If you were to pretty-print a cassette, this is vaguely what you would see. 115 | Keep in mind that since Python does not keep dictionaries ordered, the items 116 | may not be in the same order as this example. 117 | 118 | .. note:: 119 | 120 | **Pro-tip** You can pretty print a cassette like so: 121 | ``python -m json.tool cassette.json``. 122 | 123 | What is a cassette library? 124 | =========================== 125 | 126 | When configuring Betamax, you can choose your own cassette library directory. 127 | This is the directory available from the current directory in which you want 128 | to store your cassettes. 129 | 130 | For example, let's say that you set your cassette library to be 131 | ``tests/cassettes/``. In that case, when you record a cassette, it will be 132 | saved there. To continue the example, let's say you use the following code: 133 | 134 | .. code:: python 135 | 136 | from requests import Session 137 | from betamax import Betamax 138 | 139 | 140 | s = Session() 141 | with Betamax(s, cassette_library_dir='tests/cassettes').use_cassette('example'): 142 | r = s.get('https://httpbin.org/get') 143 | 144 | You would then have the following directory structure:: 145 | 146 | . 147 | `-- tests 148 | `-- cassettes 149 | `-- example.json 150 | 151 | .. _JSON: http://json.org 152 | -------------------------------------------------------------------------------- /docs/source/implementation_details.rst: -------------------------------------------------------------------------------- 1 | Implementation Details 2 | ====================== 3 | 4 | Everything here is an implementation detail and subject to volatile change. I 5 | would not rely on anything here for any mission critical code. 6 | 7 | Gzip Content-Encoding 8 | --------------------- 9 | 10 | By default, requests sets an ``Accept-Encoding`` header value that includes 11 | ``gzip`` (specifically, unless overridden, requests always sends 12 | ``Accept-Encoding: gzip, deflate, compress``). When a server supports this and 13 | responds with a response that has the ``Content-Encoding`` header set to 14 | ``gzip``, ``urllib3`` automatically decompresses the body for requests. This 15 | can only be prevented in the case where the ``stream`` parameter is set to 16 | ``True``. Since Betamax refuses to alter the headers on the response object in 17 | any way, we force ``stream`` to be ``True`` so we can capture the compressed 18 | data before it is decompressed. We then properly repopulate the response 19 | object so you perceive no difference in the interaction. 20 | 21 | To preserve the response exactly as is, we then must ``base64`` encode the 22 | body of the response before saving it to the file object. In other words, 23 | whenever a server responds with a compressed body, you will not have a human 24 | readable response body. There is, at the present moment, no way to configure 25 | this so that this does not happen and because of the way that Betamax works, 26 | you can not remove the ``Content-Encoding`` header to prevent this from 27 | happening. 28 | 29 | Class Details 30 | ------------- 31 | 32 | .. autoclass:: betamax.cassette.Cassette 33 | :members: 34 | 35 | .. autoclass:: betamax.cassette.Interaction 36 | :members: 37 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | Contents of Betamax's Documentation 4 | =================================== 5 | 6 | .. toctree:: 7 | :caption: Narrative Documentation 8 | :maxdepth: 3 9 | 10 | introduction 11 | long_term_usage 12 | configuring 13 | record_modes 14 | third_party_packages 15 | usage_patterns 16 | integrations 17 | 18 | .. toctree:: 19 | :caption: API Documentation 20 | :maxdepth: 2 21 | 22 | api 23 | cassettes 24 | implementation_details 25 | matchers 26 | serializers 27 | 28 | Indices and tables 29 | ------------------ 30 | 31 | * :ref:`genindex` 32 | * :ref:`modindex` 33 | * :ref:`search` 34 | -------------------------------------------------------------------------------- /docs/source/integrations.rst: -------------------------------------------------------------------------------- 1 | Integrating Betamax with Test Frameworks 2 | ======================================== 3 | 4 | It's nice to have a way to integrate libraries you use for testing into your 5 | testing frameworks. Having considered this, the authors of and contributors to 6 | Betamax have included integrations in the package. Betamax comes with 7 | integrations for py.test and unittest. (If you need an integration for another 8 | framework, please suggest it and send a patch!) 9 | 10 | PyTest Integration 11 | ------------------ 12 | 13 | .. versionadded:: 0.5.0 14 | 15 | .. versionchanged:: 0.6.0 16 | 17 | When you install Betamax, it now installs two `py.test`_ fixtures by default. 18 | To use it in your tests you need only follow the `instructions`_ on pytest's 19 | documentation. To use the ``betamax_session`` fixture for an entire class of 20 | tests you would do: 21 | 22 | .. code-block:: python 23 | 24 | # tests/test_http_integration.py 25 | import pytest 26 | 27 | @pytest.mark.usefixtures('betamax_session') 28 | class TestMyHttpClient: 29 | def test_get(self, betamax_session): 30 | betamax_session.get('https://httpbin.org/get') 31 | 32 | This will generate a cassette name for you, e.g., 33 | ``tests.test_http_integration.TestMyHttpClient.test_get``. After running this 34 | test you would have a cassette file stored in your cassette library directory 35 | named ``tests.test_http_integration.TestMyHttpClient.test_get.json``. To use 36 | this fixture at the module level, you need only do 37 | 38 | .. code-block:: python 39 | 40 | # tests/test_http_integration.py 41 | import pytest 42 | 43 | pytest.mark.usefixtures('betamax_session') 44 | 45 | 46 | class TestMyHttpClient: 47 | def test_get(self, betamax_session): 48 | betamax_session.get('https://httpbin.org/get') 49 | 50 | class TestMyOtherHttpClient: 51 | def test_post(self, betamax_session): 52 | betamax_session.post('https://httpbin.org/post') 53 | 54 | If you need to customize the recorder object, however, you can instead use the 55 | ``betamax_recorder`` fixture: 56 | 57 | .. code-block:: python 58 | 59 | # tests/test_http_integration.py 60 | import pytest 61 | 62 | pytest.mark.usefixtures('betamax_recorder') 63 | 64 | 65 | class TestMyHttpClient: 66 | def test_post(self, betamax_recorder): 67 | betamax_recorder.current_cassette.match_options.add('json-body') 68 | session = betamax_recorder.session 69 | 70 | session.post('https://httpbin.org/post', json={'foo': 'bar'}) 71 | 72 | 73 | Unittest Integration 74 | -------------------- 75 | 76 | .. versionadded:: 0.5.0 77 | 78 | When writing tests with unittest, a common pattern is to either import 79 | :class:`unittest.TestCase` or subclass that and use that subclass in your 80 | tests. When integrating Betamax with your unittest testsuite, you should do 81 | the following: 82 | 83 | .. code-block:: python 84 | 85 | from betamax.fixtures import unittest 86 | 87 | 88 | class IntegrationTestCase(unittest.BetamaxTestCase): 89 | # Add the rest of the helper methods you want for your 90 | # integration tests 91 | 92 | 93 | class SpecificTestCase(IntegrationTestCase): 94 | def test_something(self): 95 | # Test something 96 | 97 | The unittest integration provides the following attributes on the test case 98 | instance: 99 | 100 | - ``session`` the instance of ``BetamaxTestCase.SESSION_CLASS`` created for 101 | that test. 102 | 103 | - ``recorder`` the instance of :class:`betamax.Betamax` created. 104 | 105 | The integration also generates a cassette name from the test case class name 106 | and test method. So the cassette generated for the above example would be 107 | named ``SpecificTestCase.test_something``. To override that behaviour, you 108 | need to override the 109 | :meth:`~betamax.fixtures.BetamaxTestCase.generate_cassette_name` method in 110 | your subclass. 111 | 112 | The default path to save cassette is `./vcr/cassettes`. 113 | To override the path uses the follow code at the top of file. 114 | 115 | .. code-block:: python 116 | 117 | with betamax.Betamax.configure() as config: 118 | config.cassette_library_dir = 'your/path/here' 119 | 120 | 121 | If you are subclassing :class:`requests.Session` in your application, then it 122 | follows that you will want to use that in your tests. To facilitate this, you 123 | can set the ``SESSION_CLASS`` attribute. To give a fuller example, let's say 124 | you're changing the default cassette name and you're providing your own 125 | session class, your code might look like: 126 | 127 | .. code-block:: python 128 | 129 | from betamax.fixtures import unittest 130 | 131 | from myapi import session 132 | 133 | 134 | class IntegrationTestCase(unittest.BetamaxTestCase): 135 | # Add the rest of the helper methods you want for your 136 | # integration tests 137 | SESSION_CLASS = session.MyApiSession 138 | 139 | def generate_cassette_name(self): 140 | classname = self.__class__.__name__ 141 | method = self._testMethodName 142 | return 'integration_{0}_{1}'.format(classname, method) 143 | 144 | .. _py.test: http://pytest.org/latest/ 145 | .. _instructions: 146 | http://pytest.org/latest/fixture.html#using-fixtures-from-classes-modules-or-projects 147 | -------------------------------------------------------------------------------- /docs/source/introduction.rst: -------------------------------------------------------------------------------- 1 | .. _getting_started: 2 | 3 | Getting Started 4 | =============== 5 | 6 | The first step is to make sure Betamax is right for you. Let's start by 7 | answering the following questions 8 | 9 | - Are you using `Requests`_? 10 | 11 | If you're not using Requests, Betamax is not for you. You should checkout 12 | `VCRpy`_. 13 | 14 | - Are you using Sessions or are you using the functional API (e.g., 15 | ``requests.get``)? 16 | 17 | If you're using the functional API, and aren't willing to use Sessions, 18 | Betamax is not *yet* for you. 19 | 20 | So if you're using Requests and you're using Sessions, you're in the right 21 | place. 22 | 23 | Betamax officially supports `py.test`_ and `unittest`_ but it should integrate 24 | well with nose as well. 25 | 26 | Installation 27 | ------------ 28 | 29 | .. code-block:: bash 30 | 31 | $ pip install betamax 32 | 33 | Configuration 34 | ------------- 35 | 36 | When starting with Betamax, you need to tell it where to store the cassettes 37 | that it creates. There's two ways to do this: 38 | 39 | 1. If you're using :class:`~betamax.recorder.Betamax` or 40 | :class:`~betamax.decorator.use_cassette` you can pass the 41 | ``cassette_library_dir`` option. For example, 42 | 43 | .. code-block:: python 44 | 45 | import betamax 46 | import requests 47 | 48 | session = requests.Session() 49 | recorder = betamax.Betamax(session, cassette_library_dir='cassettes') 50 | with recorder.use_cassette('introduction'): 51 | # ... 52 | 53 | 2. You can do it once, globally, for your test suite. 54 | 55 | .. code-block:: python 56 | 57 | import betamax 58 | 59 | with betamax.Betamax.configure() as config: 60 | config.cassette_library_dir = 'cassettes' 61 | 62 | .. note:: 63 | 64 | If you don't set a cassette directory, Betamax won't save cassettes to 65 | disk 66 | 67 | There are other configuration options that *can* be provided, but this is the 68 | only one that is *required*. 69 | 70 | Recording Your First Cassette 71 | ----------------------------- 72 | 73 | Let's make a file named ``our_first_recorded_session.py``. Let's add the 74 | following to our file: 75 | 76 | .. literalinclude:: ../../examples/our_first_recorded_session.py 77 | :language: python 78 | 79 | If we then run our script, we'll see that a new file is created in our 80 | specified cassette directory. It should look something like: 81 | 82 | .. literalinclude:: ../../examples/cassettes/our-first-recorded-session.json 83 | :language: javascript 84 | 85 | Now, each subsequent time that we run that script, we will use the recorded 86 | interaction instead of talking to the internet over and over again. 87 | 88 | .. note:: 89 | 90 | There is no need to write any other code to replay your cassettes. Each 91 | time you run that session with the cassette in place, Betamax does all the 92 | heavy lifting for you. 93 | 94 | Recording More Complex Cassettes 95 | -------------------------------- 96 | 97 | Most times we cannot isolate our tests to a single request at a time, so we'll 98 | have cassettes that make multiple requests. Betamax can handle these with 99 | ease, let's take a look at an example. 100 | 101 | .. literalinclude:: ../../examples/more_complicated_cassettes.py 102 | :language: python 103 | 104 | Before we run this example, we have to install a new package: 105 | ``betamax-serializers``, e.g., ``pip install betamax-serializers``. 106 | 107 | If we now run our new example, we'll see a new file appear in our 108 | :file:`examples/cassettes/` directory named 109 | :file:`more-complicated-cassettes.json`. This cassette will be much larger as 110 | a result of making 3 requests and receiving 3 responses. You'll also notice 111 | that we imported :mod:`betamax_serializers.pretty_json` and called 112 | :meth:`~betamax.Betamax.register_serializer` with 113 | :class:`~betamax_serializers.pretty_json.PrettyJSONSerializer`. Then we added 114 | a keyword argument to our invocation of :meth:`~betamax.Betamax.use_cassette`, 115 | ``serialize_with='prettyjson'``. 116 | :class:`~betamax_serializers.pretty_json.PrettyJSONSerializer` is a class 117 | provided by the ``betamax-serializers`` package on PyPI that can serialize and 118 | deserialize cassette data into JSON while allowing it to be easily human 119 | readable and pretty. Let's see the results: 120 | 121 | .. literalinclude:: ../../examples/cassettes/more-complicated-cassettes.json 122 | :language: javascript 123 | 124 | This makes the cassette easy to read and helps us recognize that requests and 125 | responses are paired together. We'll explore cassettes more a bit later. 126 | 127 | .. links 128 | 129 | .. _Requests: 130 | http://docs.python-requests.org/ 131 | .. _VCRpy: 132 | https://github.com/kevin1024/vcrpy 133 | .. _py.test: 134 | http://pytest.org/ 135 | .. _unittest: 136 | https://docs.python.org/3/library/unittest.html 137 | -------------------------------------------------------------------------------- /docs/source/long_term_usage.rst: -------------------------------------------------------------------------------- 1 | Long Term Usage Patterns 2 | ======================== 3 | 4 | Now that we've covered the basics in :ref:`getting_started`, let's look at 5 | some patterns and problems we might encounter when using Betamax over a period 6 | of months instead of minutes. 7 | 8 | Adding New Requests to a Cassette 9 | --------------------------------- 10 | 11 | Let's reuse an example. Specifically let's reuse our 12 | :file:`examples/more_complicated_cassettes.py` example. 13 | 14 | .. literalinclude:: ../../examples/more_complicated_cassettes.py 15 | :language: python 16 | 17 | Let's add a new ``POST`` request in there: 18 | 19 | .. code-block:: python 20 | 21 | session.post('https://httpbin.org/post', 22 | params={'id': '20'}, 23 | json={'some-other-attribute': 'some-other-value'}) 24 | 25 | If we run this cassette now, we should expect to see that there was an 26 | exception because Betamax couldn't find a matching request for it. We expect 27 | this because the post requests have two completely different bodies, right? 28 | Right. The problem you'll find is that by default Betamax **only** matches on 29 | the URI and the Method. So Betamax will find a matching request/response pair 30 | for ``("POST", "https://httpbin.org/post?id=20")`` and reuse it. So now we 31 | need to update how we use Betamax so it will match using the ``body`` as well: 32 | 33 | .. literalinclude:: ../../examples/more_complicated_cassettes_2.py 34 | :language: python 35 | 36 | Now when we run that we should see something like this: 37 | 38 | .. literalinclude:: ../../examples/more_complicated_cassettes_2.traceback 39 | :language: pytb 40 | 41 | This is what we do expect to see. So, how do we fix it? 42 | 43 | We have a few options to fix it. 44 | 45 | Option 1: Re-recording the Cassette 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | One of the easiest ways to fix this situation is to simply remove the cassette 49 | that was recorded and run the script again. This will recreate the cassette 50 | and subsequent runs will work just fine. 51 | 52 | To be clear, we're advocating for this option that the user do: 53 | 54 | .. code:: 55 | 56 | $ rm examples/cassettes/{{ cassette-name }} 57 | 58 | This is the favorable option if you don't foresee yourself needing to add new 59 | interactions often. 60 | 61 | Option 2: Changing the Record Mode 62 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 63 | 64 | A different way would be to update the recording mode used by Betamax. We 65 | would update the line in our file that currently reads: 66 | 67 | .. code-block:: python 68 | 69 | with recorder.use_cassette('more-complicated-cassettes', 70 | serialize_with='prettyjson', 71 | match_requests_on=matchers): 72 | 73 | to add one more parameter to the call to :meth:`~betamax.Betamax.use_cassette`. 74 | We want to use the ``record`` parameter to tell Betamax to use either the 75 | ``new_episodes`` or ``all`` modes. Which you choose depends on your use case. 76 | 77 | ``new_episodes`` will only record new request/response interactions that 78 | Betamax sees. ``all`` will just re-record every interaction every time. In our 79 | example, we'll use ``new_episodes`` so our code now looks like: 80 | 81 | .. code-block:: python 82 | 83 | with recorder.use_cassette('more-complicated-cassettes', 84 | serialize_with='prettyjson', 85 | match_requests_on=matchers, 86 | record='new_episodes'): 87 | 88 | Known Issues 89 | ------------ 90 | 91 | Tests Periodically Slow Down 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 94 | **Description:** 95 | 96 | Requests checks if it should use or bypass proxies using the standard library 97 | function ``proxy_bypass``. This has been known to cause slow downs when using 98 | Requests and can cause your recorded requests to slow down as well. 99 | 100 | Betamax presently has no way to prevent this from being called as it operates 101 | at a lower level in Requests than is necessary. 102 | 103 | **Workarounds:** 104 | 105 | - Mock gethostbyname method from socket library, to force a localhost setting, 106 | e.g., 107 | 108 | .. code-block:: python 109 | 110 | import socket 111 | socket.gethostbyname = lambda x: '127.0.0.1' 112 | 113 | - Set ``trust_env`` to ``False`` on the session used with Betamax. This will 114 | prevent Requests from checking for proxies and whether it needs bypass them. 115 | 116 | **Related bugs:** 117 | 118 | - https://github.com/sigmavirus24/betamax/issues/96 119 | 120 | - https://github.com/kennethreitz/requests/issues/2988 121 | 122 | .. 123 | Template for known issues 124 | 125 | Descriptive Title 126 | ~~~~~~~~~~~~~~~~~ 127 | 128 | **Description:** 129 | 130 | 131 | 132 | **Workaround(s):** 133 | 134 | - List 135 | 136 | - of 137 | 138 | - workarounds 139 | 140 | **Related bug(s):** 141 | 142 | - List 143 | 144 | - of 145 | 146 | - bug 147 | 148 | - links 149 | -------------------------------------------------------------------------------- /docs/source/matchers.rst: -------------------------------------------------------------------------------- 1 | Matchers 2 | ======== 3 | 4 | You can specify how you would like Betamax to match requests you are making 5 | with the recorded requests. You have the following options for default 6 | (built-in) matchers: 7 | 8 | ======= ========= 9 | Matcher Behaviour 10 | ======= ========= 11 | body This matches by checking the equality of the request bodies. 12 | headers This matches by checking the equality of all of the request headers 13 | host This matches based on the host of the URI 14 | method This matches based on the method, e.g., ``GET``, ``POST``, etc. 15 | path This matches on the path of the URI 16 | query This matches on the query part of the URI 17 | uri This matches on the entirety of the URI 18 | ======= ========= 19 | 20 | Default Matchers 21 | ---------------- 22 | 23 | By default, Betamax matches on ``uri`` and ``method``. 24 | 25 | Specifying Matchers 26 | ------------------- 27 | 28 | You can specify the matchers to be used in the entire library by configuring 29 | Betamax like so: 30 | 31 | .. code-block:: python 32 | 33 | import betamax 34 | 35 | with betamax.Betamax.configure() as config: 36 | config.default_cassette_options['match_requests_on'].extend([ 37 | 'headers', 'body' 38 | ]) 39 | 40 | Instead of configuring global state, though, you can set it per cassette. For 41 | example: 42 | 43 | .. code-block:: python 44 | 45 | import betamax 46 | import requests 47 | 48 | 49 | session = requests.Session() 50 | recorder = betamax.Betamax(session) 51 | match_on = ['uri', 'method', 'headers', 'body'] 52 | with recorder.use_cassette('example', match_requests_on=match_on): 53 | # ... 54 | 55 | 56 | Making Your Own Matcher 57 | ----------------------- 58 | 59 | So long as you are matching requests, you can define your own way of matching. 60 | Each request matcher has to inherit from ``betamax.BaseMatcher`` and implement 61 | ``match``. 62 | 63 | .. autoclass:: betamax.BaseMatcher 64 | :members: 65 | 66 | Some examples of matchers are in the source reproduced here: 67 | 68 | .. literalinclude:: ../../src/betamax/matchers/headers.py 69 | :language: python 70 | 71 | .. literalinclude:: ../../src/betamax/matchers/host.py 72 | :language: python 73 | 74 | .. literalinclude:: ../../src/betamax/matchers/method.py 75 | :language: python 76 | 77 | .. literalinclude:: ../../src/betamax/matchers/path.py 78 | :language: python 79 | 80 | .. literalinclude:: ../../src/betamax/matchers/path.py 81 | :language: python 82 | 83 | .. literalinclude:: ../../src/betamax/matchers/uri.py 84 | :language: python 85 | 86 | When you have finished writing your own matcher, you can instruct betamax to 87 | use it like so: 88 | 89 | .. code-block:: python 90 | 91 | import betamax 92 | 93 | class MyMatcher(betamax.BaseMatcher): 94 | name = 'my' 95 | 96 | def match(self, request, recorded_request): 97 | return True 98 | 99 | betamax.Betamax.register_request_matcher(MyMatcher) 100 | 101 | To use it, you simply use the name you set like you use the name of the 102 | default matchers, e.g.: 103 | 104 | .. code-block:: python 105 | 106 | with Betamax(s).use_cassette('example', match_requests_on=['uri', 'my']): 107 | # ... 108 | 109 | 110 | ``on_init`` 111 | ~~~~~~~~~~~ 112 | 113 | As you can see in the code for ``URIMatcher``, we use ``on_init`` to 114 | initialize an attribute on the ``URIMatcher`` instance. This method serves to 115 | provide the matcher author with a different way of initializing the object 116 | outside of the ``match`` method. This also means that the author does not have 117 | to override the base class' ``__init__`` method. 118 | -------------------------------------------------------------------------------- /docs/source/record_modes.rst: -------------------------------------------------------------------------------- 1 | Record Modes 2 | ============ 3 | 4 | Betamax, like `VCR`_, has four modes that it can use to record cassettes: 5 | 6 | - ``'all'`` 7 | - ``'new_episodes'`` 8 | - ``'none'`` 9 | - ``'once'`` 10 | 11 | You can only ever use one record mode. Below are explanations and examples of 12 | each record mode. The explanations are blatantly taken from VCR's own `Record 13 | Modes documentation`_. 14 | 15 | All 16 | --- 17 | 18 | The ``'all'`` record mode will: 19 | 20 | - Record new interactions. 21 | - Never replay previously recorded interactions. 22 | 23 | This can be temporarily used to force VCR to re-record a cassette (i.e., to 24 | ensure the responses are not out of date) or can be used when you simply want 25 | to log all HTTP requests. 26 | 27 | Given our file, ``examples/record_modes/all/example.py``, 28 | 29 | .. literalinclude:: ../../examples/record_modes/all/example.py 30 | :language: python 31 | 32 | Every time we run it, our cassette 33 | (``examples/record_modes/all/all-example.json``) will be updated with new 34 | values. 35 | 36 | New Episodes 37 | ------------ 38 | 39 | The ``'new_episodes'`` record mode will: 40 | 41 | - Record new interactions. 42 | - Replay previously recorded interactions. 43 | 44 | It is similar to the ``'once'`` record mode, but will always record new 45 | interactions, even if you have an existing recorded one that is similar 46 | (but not identical, based on the :match_request_on option). 47 | 48 | Given our file, ``examples/record_modes/new_episodes/example_original.py``, 49 | with which we have already recorded 50 | ``examples/record_modes/new_episodes/new-episodes-example.json`` 51 | 52 | .. literalinclude:: ../../examples/record_modes/new_episodes/example_original.py 53 | :language: python 54 | 55 | If we then run ``examples/record_modes/new_episodes/example_updated.py`` 56 | 57 | .. literalinclude:: ../../examples/record_modes/new_episodes/example_updated.py 58 | :language: python 59 | 60 | The new request at the end of the file will be added to the cassette without 61 | updating the other interactions that were already recorded. 62 | 63 | None 64 | ---- 65 | 66 | The ``'none'`` record mode will: 67 | 68 | - Replay previously recorded interactions. 69 | - Cause an error to be raised for any new requests. 70 | 71 | This is useful when your code makes potentially dangerous HTTP requests. The 72 | ``'none'`` record mode guarantees that no new HTTP requests will be made. 73 | 74 | Given our file, ``examples/record_modes/none/example_original.py``, with a 75 | cassette that already has interactions recorded in 76 | ``examples/record_modes/none/none-example.json`` 77 | 78 | .. literalinclude:: ../../examples/record_modes/none/example_original.py 79 | :language: python 80 | 81 | If we then run ``examples/record_modes/none/example_updated.py`` 82 | 83 | .. literalinclude:: ../../examples/record_modes/none/example_updated.py 84 | :language: python 85 | 86 | We'll see an exception indicating that new interactions were prevented: 87 | 88 | .. literalinclude:: ../../examples/record_modes/none/example_updated.traceback 89 | :language: pytb 90 | 91 | Once 92 | ---- 93 | 94 | The ``'once'`` record mode will: 95 | 96 | - Replay previously recorded interactions. 97 | - Record new interactions if there is no cassette file. 98 | - Cause an error to be raised for new requests if there is a cassette file. 99 | 100 | It is similar to the ``'new_episodes'`` record mode, but will prevent new, 101 | unexpected requests from being made (i.e. because the request URI changed 102 | or whatever). 103 | 104 | ``'once'`` is the default record mode, used when you do not set one. 105 | 106 | If we have a file, ``examples/record_modes/once/example_original.py``, 107 | 108 | .. literalinclude:: ../../examples/record_modes/once/example_original.py 109 | :language: python 110 | 111 | And we run it, we'll see a cassette named 112 | ``examples/record_modes/once/once-example.json`` has been created. 113 | 114 | If we then run ``examples/record_modes/once/example_updated.py``, 115 | 116 | .. literalinclude:: ../../examples/record_modes/once/example_updated.py 117 | :language: python 118 | 119 | We'll see an exception similar to the one we see when using the ``'none'`` 120 | record mode. 121 | 122 | .. literalinclude:: ../../examples/record_modes/once/example_updated.traceback 123 | :language: pytb 124 | 125 | 126 | 127 | .. _VCR: https://relishapp.com/vcr/vcr 128 | .. _Record Modes documentation: 129 | https://relishapp.com/vcr/vcr/v/2-9-3/docs/record-modes/ 130 | -------------------------------------------------------------------------------- /docs/source/serializers.rst: -------------------------------------------------------------------------------- 1 | Serializers 2 | =========== 3 | 4 | You can tell Betamax how you would like it to serialize the cassettes when 5 | saving them to a file. By default Betamax will serialize your cassettes to 6 | JSON. The only default serializer is the JSON serializer, but writing your own 7 | is very easy. 8 | 9 | Creating Your Own Serializer 10 | ---------------------------- 11 | 12 | Betamax handles the structuring of the cassette and writing to a file, your 13 | serializer simply takes a :ref:`dictionary ` and returns a string. 14 | 15 | Every Serializer has to inherit from :class:`betamax.BaseSerializer` and 16 | implement three methods: 17 | 18 | - ``betamax.BaseSerializer.generate_cassette_name`` which is a static method. 19 | This will take the directory the user (you) wants to store the cassettes in 20 | and the name of the cassette and generate the file name. 21 | 22 | - :py:meth:`betamax.BaseSerializer.serialize` is a method that takes the 23 | dictionary and returns the dictionary serialized as a string 24 | 25 | - :py:meth:`betamax.BaseSerializer.deserialize` is a method that takes a 26 | string and returns the data serialized in it as a dictionary. 27 | 28 | .. versionadded:: 0.9.0 29 | 30 | Allow Serializers to indicate their format is a binary format via 31 | ``stored_as_binary``. 32 | 33 | Additionally, if your Serializer is utilizing a binary format, you will want 34 | to set the ``stored_as_binary`` attribute to ``True`` on your class. 35 | 36 | .. autoclass:: betamax.BaseSerializer 37 | :members: 38 | 39 | Here's the default (JSON) serializer as an example: 40 | 41 | .. literalinclude:: ../../src/betamax/serializers/json_serializer.py 42 | :language: python 43 | 44 | 45 | This is incredibly simple. We take advantage of the :mod:`os.path` to properly 46 | join the directory name and the file name. Betamax uses this method to find an 47 | existing cassette or create a new one. 48 | 49 | Next we have the :py:meth:`betamax.serializers.JSONSerializer.serialize` which 50 | takes the cassette dictionary and turns it into a string for us. Here we are 51 | just leveraging the :mod:`json` module and its ability to dump any valid 52 | dictionary to a string. 53 | 54 | Finally, there is the 55 | :py:meth:`betamax.serializers.JSONSerializer.deserialize` method which takes a 56 | string and turns it into the dictionary that betamax needs to function. 57 | -------------------------------------------------------------------------------- /docs/source/third_party_packages.rst: -------------------------------------------------------------------------------- 1 | Third-Party Packages 2 | ==================== 3 | 4 | Betamax was created to be a very close imitation of `VCR`_. As such, it has 5 | the default set of request matchers and a subset of the supported cassette 6 | serializers for VCR. 7 | 8 | As part of my own usage of Betamax, and supporting other people's usage of 9 | Betamax, I've created (and maintain) two third party packages that provide 10 | extra request matchers and cassette serializers. 11 | 12 | - `betamax-matchers`_ 13 | - `betamax-serializers`_ 14 | 15 | For simplicity, those modules will be documented here instead of on their own 16 | documentation sites. 17 | 18 | Request Matchers 19 | ---------------- 20 | 21 | There are three third-party request matchers provided by the 22 | `betamax-matchers`_ package: 23 | 24 | - :class:`~betamax_matchers.form_urlencoded.URLEncodedBodyMatcher`, 25 | ``'form-urlencoded-body'`` 26 | - :class:`~betamax_matchers.json_body.JSONBodyMatcher`, ``'json-body'`` 27 | - :class:`~betamax_matchers.multipart.MultipartFormDataBodyMatcher`, 28 | ``'multipart-form-data-body'`` 29 | 30 | In order to use any of these we have to register them with Betamax. Below we 31 | will register all three but you do not need to do that if you only need to use 32 | one: 33 | 34 | .. code-block:: python 35 | 36 | import betamax 37 | from betamax_matchers import form_urlencoded 38 | from betamax_matchers import json_body 39 | from betamax_matchers import multipart 40 | 41 | betamax.Betamax.register_request_matcher( 42 | form_urlencoded.URLEncodedBodyMatcher 43 | ) 44 | betamax.Betamax.register_request_matcher( 45 | json_body.JSONBodyMatcher 46 | ) 47 | betamax.Betamax.register_request_matcher( 48 | multipart.MultipartFormDataBodyMatcher 49 | ) 50 | 51 | All of these classes inherit from :class:`betamax.BaseMatcher` which means 52 | that each needs a name that will be used when specifying what matchers to use 53 | with Betamax. I have noted those next to the class name for each matcher 54 | above. Let's use the JSON body matcher in an example though: 55 | 56 | .. code-block:: python 57 | 58 | import betamax 59 | from betamax_matchers import json_body 60 | # This example requires at least requests 2.5.0 61 | import requests 62 | 63 | betamax.Betamax.register_request_matcher( 64 | json_body.JSONBodyMatcher 65 | ) 66 | 67 | 68 | def main(): 69 | session = requests.Session() 70 | recorder = betamax.Betamax(session, cassette_library_dir='.') 71 | url = 'https://httpbin.org/post' 72 | json_data = {'key': 'value', 73 | 'other-key': 'other-value', 74 | 'yet-another-key': 'yet-another-value'} 75 | matchers = ['method', 'uri', 'json-body'] 76 | 77 | with recorder.use_cassette('json-body-example', match_requests_on=matchers): 78 | r = session.post(url, json=json_data) 79 | 80 | 81 | if __name__ == '__main__': 82 | main() 83 | 84 | If we ran that request without those matcher with hash seed randomization, 85 | then we would occasionally receive exceptions that a request could not be 86 | matched. That is because dictionaries are not inherently ordered so the body 87 | string of the request can change and be any of the following: 88 | 89 | .. code-block:: js 90 | 91 | {"key": "value", "other-key": "other-value", "yet-another-key": 92 | "yet-another-value"} 93 | 94 | .. code-block:: js 95 | 96 | {"key": "value", "yet-another-key": "yet-another-value", "other-key": 97 | "other-value"} 98 | 99 | .. code-block:: js 100 | 101 | {"other-key": "other-value", "yet-another-key": "yet-another-value", 102 | "key": "value"} 103 | 104 | 105 | .. code-block:: js 106 | 107 | {"yet-another-key": "yet-another-value", "key": "value", "other-key": 108 | "other-value"} 109 | 110 | .. code-block:: js 111 | 112 | {"yet-another-key": "yet-another-value", "other-key": "other-value", 113 | "key": "value"} 114 | 115 | .. code-block:: js 116 | 117 | {"other-key": "other-value", "key": "value", "yet-another-key": 118 | "yet-another-value"} 119 | 120 | But using the ``'json-body'`` matcher, the matcher will parse the request and 121 | compare python dictionaries instead of python strings. That will completely 122 | bypass the issues introduced by hash randomization. I use this matcher 123 | extensively in `github3.py`_\ 's tests. 124 | 125 | Cassette Serializers 126 | -------------------- 127 | 128 | By default, Betamax only comes with the JSON serializer. 129 | `betamax-serializers`_ provides extra serializer classes that users have 130 | contributed. 131 | 132 | For example, as we've seen elsewhere in our documentation, the default JSON 133 | serializer does not create beautiful or easy to read cassettes. As a 134 | substitute for that, we have the 135 | :class:`~betamax_serializers.pretty_json.PrettyJSONSerializer` that does that 136 | for you. 137 | 138 | .. code-block:: python 139 | 140 | from betamax import Betamax 141 | from betamax_serializers import pretty_json 142 | 143 | import requests 144 | 145 | Betamax.register_serializer(pretty_json.PrettyJSONSerializer) 146 | 147 | session = requests.Session() 148 | recorder = Betamax(session) 149 | with recorder.use_cassette('testpretty', serialize_with='prettyjson'): 150 | session.request(method=method, url=url, ...) 151 | 152 | 153 | This will give us a pretty-printed cassette like: 154 | 155 | .. literalinclude:: ../../examples/cassettes/more-complicated-cassettes.json 156 | :language: js 157 | 158 | .. links 159 | 160 | .. _VCR: 161 | https://relishapp.com/vcr/vcr 162 | .. _betamax-matchers: 163 | https://pypi.python.org/pypi/betamax-matchers 164 | .. _betamax-serializers: 165 | https://pypi.python.org/pypi/betamax-serializers 166 | .. _github3.py: 167 | https://github.com/sigmavirus24/github3.py 168 | -------------------------------------------------------------------------------- /docs/source/usage_patterns.rst: -------------------------------------------------------------------------------- 1 | Usage Patterns 2 | ============== 3 | 4 | Below are suggested patterns for using Betamax efficiently. 5 | 6 | Configuring Betamax in py.test's conftest.py 7 | -------------------------------------------- 8 | 9 | Betamax and github3.py (the project which instigated the creation of Betamax) 10 | both utilize py.test_ and its feature of configuring how the tests run with 11 | ``conftest.py`` [#]_. One pattern that I have found useful is to include this 12 | in your ``conftest.py`` file: 13 | 14 | .. code-block:: python 15 | 16 | import betamax 17 | 18 | with betamax.Betamax.configure() as config: 19 | config.cassette_library_dir = 'tests/cassettes/' 20 | 21 | This configures your cassette directory for all of your tests. If you do not 22 | check your cassettes into your version control system, then you can also add: 23 | 24 | .. code-block:: python 25 | 26 | import os 27 | 28 | if not os.path.exists('tests/cassettes'): 29 | os.makedirs('tests/cassettes') 30 | 31 | An Example from github3.py 32 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 33 | 34 | You can configure other aspects of Betamax via the ``conftest.py`` file. For 35 | example, in github3.py, I do the following: 36 | 37 | .. code-block:: python 38 | 39 | import os 40 | 41 | record_mode = 'none' if os.environ.get('TRAVIS_GH3') else 'once' 42 | 43 | with betamax.Betamax.configure() as config: 44 | config.cassette_library_dir = 'tests/cassettes/' 45 | config.default_cassette_options['record_mode'] = record_mode 46 | config.define_cassette_placeholder( 47 | '', 48 | os.environ.get('GH_AUTH', 'x' * 20) 49 | ) 50 | 51 | In essence, if the tests are being run on `Travis CI`_, then we want to make 52 | sure to not try to record new cassettes or interactions. We also, want to 53 | ensure we're authenticated when possible but that we do not leave our 54 | placeholder in the cassettes when they're replayed. 55 | 56 | Using Human Readable JSON Cassettes 57 | ----------------------------------- 58 | 59 | Using the ``PrettyJSONSerializer`` provided by the ``betamax_serializers`` 60 | package provides human readable JSON cassettes. Cassettes output in this way 61 | make it easy to compare modifications to cassettes to ensure only expected 62 | changes are introduced. 63 | 64 | While you can use the ``serialize_with`` option when creating each individual 65 | cassette, it is simpler to provide this setting globally. The following example 66 | demonstrates how to configure Betamax to use the ``PrettyJSONSerializer`` for 67 | all newly created cassettes: 68 | 69 | .. code-block:: python 70 | 71 | from betamax_serializers import pretty_json 72 | betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer) 73 | # ... 74 | config.default_cassette_options['serialize_with'] = 'prettyjson' 75 | 76 | Updating Existing Betamax Cassettes to be Human Readable 77 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 78 | 79 | If you already have a library of cassettes when applying the previous 80 | configuration update, then you will probably want to also update all your 81 | existing cassettes into the new human readable format. The following script 82 | will help you transform your existing cassettes: 83 | 84 | .. code-block:: python 85 | 86 | import os 87 | import glob 88 | import json 89 | import sys 90 | 91 | try: 92 | cassette_dir = sys.argv[1] 93 | cassettes = glob.glob(os.path.join(cassette_dir, '*.json')) 94 | except: 95 | print('Usage: {0} CASSETTE_DIRECTORY'.format(sys.argv[0])) 96 | sys.exit(1) 97 | 98 | for cassette_path in cassettes: 99 | with open(cassette_path, 'r') as fp: 100 | data = json.load(fp) 101 | with open(cassette_path, 'w') as fp: 102 | json.dump(data, fp, sort_keys=True, indent=2, 103 | separators=(',', ': ')) 104 | print('Updated {0} cassette{1}.'.format( 105 | len(cassettes), '' if len(cassettes) == 1 else 's')) 106 | 107 | Copy and save the above script as ``fix_cassettes.py`` and then run it like: 108 | 109 | .. code-block:: bash 110 | 111 | python fix_cassettes.py PATH_TO_CASSETTE_DIRECTORY 112 | 113 | If you're not already using a version control system (e.g., git, svn) then it 114 | is recommended you make a backup of your cassettes first in the event something 115 | goes wrong. 116 | 117 | .. [#] http://pytest.org/latest/plugins.html 118 | 119 | .. _py.test: http://pytest.org/latest/ 120 | .. _Travis CI: https://travis-ci.org/ 121 | -------------------------------------------------------------------------------- /examples/cassettes/more-complicated-cassettes.json: -------------------------------------------------------------------------------- 1 | { 2 | "http_interactions": [ 3 | { 4 | "recorded_at": "2015-06-21T19:22:54", 5 | "request": { 6 | "body": { 7 | "encoding": "utf-8", 8 | "string": "" 9 | }, 10 | "headers": { 11 | "Accept": [ 12 | "*/*" 13 | ], 14 | "Accept-Encoding": [ 15 | "gzip, deflate" 16 | ], 17 | "Connection": [ 18 | "keep-alive" 19 | ], 20 | "User-Agent": [ 21 | "python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0" 22 | ] 23 | }, 24 | "method": "GET", 25 | "uri": "https://httpbin.org/get" 26 | }, 27 | "response": { 28 | "body": { 29 | "encoding": null, 30 | "string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get\"\n}\n" 31 | }, 32 | "headers": { 33 | "access-control-allow-credentials": [ 34 | "true" 35 | ], 36 | "access-control-allow-origin": [ 37 | "*" 38 | ], 39 | "connection": [ 40 | "keep-alive" 41 | ], 42 | "content-length": [ 43 | "265" 44 | ], 45 | "content-type": [ 46 | "application/json" 47 | ], 48 | "date": [ 49 | "Sun, 21 Jun 2015 19:22:54 GMT" 50 | ], 51 | "server": [ 52 | "nginx" 53 | ] 54 | }, 55 | "status": { 56 | "code": 200, 57 | "message": "OK" 58 | }, 59 | "url": "https://httpbin.org/get" 60 | } 61 | }, 62 | { 63 | "recorded_at": "2015-06-21T19:22:54", 64 | "request": { 65 | "body": { 66 | "encoding": "utf-8", 67 | "string": "{\"some-attribute\": \"some-value\"}" 68 | }, 69 | "headers": { 70 | "Accept": [ 71 | "*/*" 72 | ], 73 | "Accept-Encoding": [ 74 | "gzip, deflate" 75 | ], 76 | "Connection": [ 77 | "keep-alive" 78 | ], 79 | "Content-Length": [ 80 | "32" 81 | ], 82 | "Content-Type": [ 83 | "application/json" 84 | ], 85 | "User-Agent": [ 86 | "python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0" 87 | ] 88 | }, 89 | "method": "POST", 90 | "uri": "https://httpbin.org/post?id=20" 91 | }, 92 | "response": { 93 | "body": { 94 | "encoding": null, 95 | "string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"data\": \"{\\\"some-attribute\\\": \\\"some-value\\\"}\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Content-Length\": \"32\", \n \"Content-Type\": \"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"json\": {\n \"some-attribute\": \"some-value\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/post?id=20\"\n}\n" 96 | }, 97 | "headers": { 98 | "access-control-allow-credentials": [ 99 | "true" 100 | ], 101 | "access-control-allow-origin": [ 102 | "*" 103 | ], 104 | "connection": [ 105 | "keep-alive" 106 | ], 107 | "content-length": [ 108 | "495" 109 | ], 110 | "content-type": [ 111 | "application/json" 112 | ], 113 | "date": [ 114 | "Sun, 21 Jun 2015 19:22:54 GMT" 115 | ], 116 | "server": [ 117 | "nginx" 118 | ] 119 | }, 120 | "status": { 121 | "code": 200, 122 | "message": "OK" 123 | }, 124 | "url": "https://httpbin.org/post?id=20" 125 | } 126 | }, 127 | { 128 | "recorded_at": "2015-06-21T19:22:54", 129 | "request": { 130 | "body": { 131 | "encoding": "utf-8", 132 | "string": "" 133 | }, 134 | "headers": { 135 | "Accept": [ 136 | "*/*" 137 | ], 138 | "Accept-Encoding": [ 139 | "gzip, deflate" 140 | ], 141 | "Connection": [ 142 | "keep-alive" 143 | ], 144 | "User-Agent": [ 145 | "python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0" 146 | ] 147 | }, 148 | "method": "GET", 149 | "uri": "https://httpbin.org/get?id=20" 150 | }, 151 | "response": { 152 | "body": { 153 | "encoding": null, 154 | "string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get?id=20\"\n}\n" 155 | }, 156 | "headers": { 157 | "access-control-allow-credentials": [ 158 | "true" 159 | ], 160 | "access-control-allow-origin": [ 161 | "*" 162 | ], 163 | "connection": [ 164 | "keep-alive" 165 | ], 166 | "content-length": [ 167 | "289" 168 | ], 169 | "content-type": [ 170 | "application/json" 171 | ], 172 | "date": [ 173 | "Sun, 21 Jun 2015 19:22:54 GMT" 174 | ], 175 | "server": [ 176 | "nginx" 177 | ] 178 | }, 179 | "status": { 180 | "code": 200, 181 | "message": "OK" 182 | }, 183 | "url": "https://httpbin.org/get?id=20" 184 | } 185 | } 186 | ], 187 | "recorded_with": "betamax/0.4.2" 188 | } 189 | -------------------------------------------------------------------------------- /examples/cassettes/our-first-recorded-session.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get"}, "response": {"body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"content-length": ["265"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Fri, 19 Jun 2015 04:10:33 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get"}, "recorded_at": "2015-06-19T04:10:33"}], "recorded_with": "betamax/0.4.1"} 2 | -------------------------------------------------------------------------------- /examples/more_complicated_cassettes.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | from betamax_serializers import pretty_json 3 | import requests 4 | 5 | CASSETTE_LIBRARY_DIR = 'examples/cassettes/' 6 | 7 | 8 | def main(): 9 | session = requests.Session() 10 | betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer) 11 | recorder = betamax.Betamax( 12 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 13 | ) 14 | 15 | with recorder.use_cassette('more-complicated-cassettes', 16 | serialize_with='prettyjson'): 17 | session.get('https://httpbin.org/get') 18 | session.post('https://httpbin.org/post', 19 | params={'id': '20'}, 20 | json={'some-attribute': 'some-value'}) 21 | session.get('https://httpbin.org/get', params={'id': '20'}) 22 | 23 | 24 | if __name__ == '__main__': 25 | main() 26 | -------------------------------------------------------------------------------- /examples/more_complicated_cassettes_2.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | from betamax_serializers import pretty_json 3 | import requests 4 | 5 | CASSETTE_LIBRARY_DIR = 'examples/cassettes/' 6 | 7 | 8 | def main(): 9 | session = requests.Session() 10 | betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer) 11 | recorder = betamax.Betamax( 12 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 13 | ) 14 | matchers = ['method', 'uri', 'body'] 15 | 16 | with recorder.use_cassette('more-complicated-cassettes', 17 | serialize_with='prettyjson', 18 | match_requests_on=matchers): 19 | session.get('https://httpbin.org/get') 20 | session.post('https://httpbin.org/post', 21 | params={'id': '20'}, 22 | json={'some-attribute': 'some-value'}) 23 | session.get('https://httpbin.org/get', params={'id': '20'}) 24 | session.post('https://httpbin.org/post', 25 | params={'id': '20'}, 26 | json={'some-other-attribute': 'some-other-value'}) 27 | 28 | 29 | if __name__ == '__main__': 30 | main() 31 | -------------------------------------------------------------------------------- /examples/more_complicated_cassettes_2.traceback: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "examples/more_complicated_cassettes_2.py", line 30, in 3 | main() 4 | File "examples/more_complicated_cassettes_2.py", line 26, in main 5 | json={'some-other-attribute': 'some-other-value'}) 6 | File ".../lib/python2.7/site-packages/requests/sessions.py", line 508, in post 7 | return self.request('POST', url, data=data, json=json, **kwargs) 8 | File ".../lib/python2.7/site-packages/requests/sessions.py", line 465, in request 9 | resp = self.send(prep, **send_kwargs) 10 | File ".../lib/python2.7/site-packages/requests/sessions.py", line 573, in send 11 | r = adapter.send(request, **kwargs) 12 | File ".../lib/python2.7/site-packages/betamax/adapter.py", line 91, in send 13 | self.cassette)) 14 | betamax.exceptions.BetamaxError: A request was made that could not be handled. 15 | 16 | A request was made to https://httpbin.org/post?id=20 that could not be found in more-complicated-cassettes. 17 | 18 | The settings on the cassette are: 19 | 20 | - record_mode: once 21 | - match_options ['method', 'uri', 'body']. 22 | -------------------------------------------------------------------------------- /examples/our_first_recorded_session.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/cassettes/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('our-first-recorded-session'): 14 | session.get('https://httpbin.org/get') 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /examples/record_modes/all/all-example.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get"}, "response": {"body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"content-length": ["267"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:24:24 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get"}, "recorded_at": "2015-07-09T03:24:24"}, {"request": {"body": {"string": "{\"some-attribute\": \"some-value\"}", "encoding": "utf-8"}, "headers": {"Content-Length": ["32"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"], "Connection": ["keep-alive"], "Content-Type": ["application/json"]}, "method": "POST", "uri": "https://httpbin.org/post?id=20"}, "response": {"body": {"string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"data\": \"{\\\"some-attribute\\\": \\\"some-value\\\"}\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Content-Length\": \"32\", \n \"Content-Type\": \"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"json\": {\n \"some-attribute\": \"some-value\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/post?id=20\"\n}\n", "encoding": null}, "headers": {"content-length": ["497"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:24:24 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/post?id=20"}, "recorded_at": "2015-07-09T03:24:24"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get?id=20"}, "response": {"body": {"string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get?id=20\"\n}\n", "encoding": null}, "headers": {"content-length": ["291"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:24:24 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get?id=20"}, "recorded_at": "2015-07-09T03:24:24"}], "recorded_with": "betamax/0.4.1"} 2 | -------------------------------------------------------------------------------- /examples/record_modes/all/example.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/all/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('all-example', record='all'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /examples/record_modes/new_episodes/example_original.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/new_episodes/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('new-episodes-example', record='new_episodes'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /examples/record_modes/new_episodes/example_updated.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/new_episodes/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('new-episodes-example', record='new_episodes'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | session.get('https://httpbin.org/get', params={'id': '40'}) 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /examples/record_modes/new_episodes/new-episodes-example.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get"}, "response": {"body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"content-length": ["267"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:32:35 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get"}, "recorded_at": "2015-07-09T03:32:35"}, {"request": {"body": {"string": "{\"some-attribute\": \"some-value\"}", "encoding": "utf-8"}, "headers": {"Content-Length": ["32"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"], "Connection": ["keep-alive"], "Content-Type": ["application/json"]}, "method": "POST", "uri": "https://httpbin.org/post?id=20"}, "response": {"body": {"string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"data\": \"{\\\"some-attribute\\\": \\\"some-value\\\"}\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Content-Length\": \"32\", \n \"Content-Type\": \"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"json\": {\n \"some-attribute\": \"some-value\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/post?id=20\"\n}\n", "encoding": null}, "headers": {"content-length": ["497"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:32:35 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/post?id=20"}, "recorded_at": "2015-07-09T03:32:35"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get?id=20"}, "response": {"body": {"string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get?id=20\"\n}\n", "encoding": null}, "headers": {"content-length": ["291"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:32:35 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get?id=20"}, "recorded_at": "2015-07-09T03:32:35"}], "recorded_with": "betamax/0.4.1"} 2 | -------------------------------------------------------------------------------- /examples/record_modes/none/example_original.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/none/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('none-example', record='none'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /examples/record_modes/none/example_updated.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/none/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('none-example', record='none'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | session.get('https://httpbin.org/get', params={'id': '40'}) 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /examples/record_modes/none/example_updated.traceback: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "examples/record_modes/none/example_updated.py", line 23, in 3 | main() 4 | File "examples/record_modes/none/example_updated.py", line 19, in main 5 | session.get('https://httpbin.org/get', params={'id': '40'}) 6 | File "/usr/local/lib/python2.7/site-packages/requests/sessions.py", line 477, in get 7 | return self.request('GET', url, **kwargs) 8 | File "/usr/local/lib/python2.7/site-packages/requests/sessions.py", line 465, in request 9 | resp = self.send(prep, **send_kwargs) 10 | File "/usr/local/lib/python2.7/site-packages/requests/sessions.py", line 573, in send 11 | r = adapter.send(request, **kwargs) 12 | File "/usr/local/lib/python2.7/site-packages/betamax/adapter.py", line 91, in send 13 | self.cassette)) 14 | betamax.exceptions.BetamaxError: A request was made that could not be handled. 15 | 16 | A request was made to https://httpbin.org/get?id=40 that could not be found in none-example. 17 | 18 | The settings on the cassette are: 19 | 20 | - record_mode: none 21 | - match_options ['method', 'uri']. 22 | 23 | -------------------------------------------------------------------------------- /examples/record_modes/none/none-example.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get"}, "response": {"body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"content-length": ["267"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:32:35 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get"}, "recorded_at": "2015-07-09T03:32:35"}, {"request": {"body": {"string": "{\"some-attribute\": \"some-value\"}", "encoding": "utf-8"}, "headers": {"Content-Length": ["32"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"], "Connection": ["keep-alive"], "Content-Type": ["application/json"]}, "method": "POST", "uri": "https://httpbin.org/post?id=20"}, "response": {"body": {"string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"data\": \"{\\\"some-attribute\\\": \\\"some-value\\\"}\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Content-Length\": \"32\", \n \"Content-Type\": \"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"json\": {\n \"some-attribute\": \"some-value\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/post?id=20\"\n}\n", "encoding": null}, "headers": {"content-length": ["497"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:32:35 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/post?id=20"}, "recorded_at": "2015-07-09T03:32:35"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get?id=20"}, "response": {"body": {"string": "{\n \"args\": {\n \"id\": \"20\"\n }, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.7.0 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"127.0.0.1\", \n \"url\": \"https://httpbin.org/get?id=20\"\n}\n", "encoding": null}, "headers": {"content-length": ["291"], "server": ["nginx"], "connection": ["keep-alive"], "access-control-allow-credentials": ["true"], "date": ["Thu, 09 Jul 2015 03:32:35 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get?id=20"}, "recorded_at": "2015-07-09T03:32:35"}], "recorded_with": "betamax/0.4.1"} 2 | -------------------------------------------------------------------------------- /examples/record_modes/once/example_original.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/once/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('once-example', record='once'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /examples/record_modes/once/example_updated.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import requests 3 | 4 | CASSETTE_LIBRARY_DIR = 'examples/record_modes/once/' 5 | 6 | 7 | def main(): 8 | session = requests.Session() 9 | recorder = betamax.Betamax( 10 | session, cassette_library_dir=CASSETTE_LIBRARY_DIR 11 | ) 12 | 13 | with recorder.use_cassette('once-example', record='once'): 14 | session.get('https://httpbin.org/get') 15 | session.post('https://httpbin.org/post', 16 | params={'id': '20'}, 17 | json={'some-attribute': 'some-value'}) 18 | session.get('https://httpbin.org/get', params={'id': '20'}) 19 | session.get('https://httpbin.org/get', params={'id': '40'}) 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /examples/record_modes/once/example_updated.traceback: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "examples/record_modes/once/example_updated.py", line 23, in 3 | main() 4 | File "examples/record_modes/once/example_updated.py", line 19, in main 5 | session.get('https://httpbin.org/get', params={'id': '40'}) 6 | File "/usr/local/lib/python2.7/site-packages/requests/sessions.py", line 477, in get 7 | return self.request('GET', url, **kwargs) 8 | File "/usr/local/lib/python2.7/site-packages/requests/sessions.py", line 465, in request 9 | resp = self.send(prep, **send_kwargs) 10 | File "/usr/local/lib/python2.7/site-packages/requests/sessions.py", line 573, in send 11 | r = adapter.send(request, **kwargs) 12 | File "/usr/local/lib/python2.7/site-packages/betamax/adapter.py", line 91, in send 13 | self.cassette)) 14 | betamax.exceptions.BetamaxError: A request was made that could not be handled. 15 | 16 | A request was made to https://httpbin.org/get?id=40 that could not be found in none-example. 17 | 18 | The settings on the cassette are: 19 | 20 | - record_mode: once 21 | - match_options ['method', 'uri']. 22 | 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = betamax 3 | version = attr: betamax.__version__ 4 | description = A VCR imitation for python-requests 5 | long_description = file: README.rst 6 | long_description_content_type = text/x-rst 7 | url = https://github.com/sigmavirus24/betamax 8 | author = Ian Stapleton Cordasco 9 | author_email = graffatcolmingov@gmail.com 10 | license = Apache-2.0 11 | license_files = LICENSE 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | Intended Audience :: Developers 15 | License :: OSI Approved :: Apache Software License 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3 :: Only 19 | Programming Language :: Python :: 3.8 20 | Programming Language :: Python :: 3.9 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: Implementation :: CPython 24 | Programming Language :: Python :: Implementation :: PyPy 25 | Topic :: Software Development :: Libraries :: Python Modules 26 | Topic :: Software Development :: Quality Assurance 27 | 28 | [options] 29 | packages = find: 30 | include_package_data = True 31 | install_requires = 32 | requests >= 2.0 33 | python_requires = >=3.8.1 34 | package_dir = 35 | =src 36 | 37 | [options.packages.find] 38 | where = src 39 | exclude = 40 | tests 41 | tests.integration 42 | 43 | [options.package_data] 44 | * = LICENSE AUTHORS.rst HISTORY.rst README.rst 45 | 46 | [options.entry_points] 47 | pytest11 = 48 | pytest-betamax = betamax.fixtures.pytest 49 | 50 | [bdist_wheel] 51 | universal = 1 52 | 53 | [coverage:run] 54 | source = 55 | betamax 56 | tests 57 | plugins = covdefaults 58 | 59 | #[coverage:report] 60 | #fail_under = 70 61 | 62 | #[mypy] 63 | #check_untyped_defs = true 64 | #disallow_any_generics = true 65 | #disallow_incomplete_defs = true 66 | #disallow_untyped_defs = true 67 | #no_implicit_optional = true 68 | #warn_unused_ignores = true 69 | # 70 | #[mypy-tests.*] 71 | #disallow_untyped_defs = false 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Packaging logic for betamax.""" 2 | import setuptools 3 | 4 | setuptools.setup() 5 | -------------------------------------------------------------------------------- /src/betamax/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | betamax. 3 | 4 | ======= 5 | 6 | See https://betamax.readthedocs.io/ for documentation. 7 | 8 | :copyright: (c) 2013-2018 by Ian Stapleton Cordasco 9 | :license: Apache 2.0, see LICENSE for more details 10 | 11 | """ 12 | 13 | from .decorator import use_cassette 14 | from .exceptions import BetamaxError 15 | from .matchers import BaseMatcher 16 | from .recorder import Betamax 17 | from .serializers import BaseSerializer 18 | 19 | __all__ = ('BetamaxError', 'Betamax', 'BaseMatcher', 'BaseSerializer', 20 | 'use_cassette') 21 | __author__ = 'Ian Stapleton Cordasco' 22 | __copyright__ = 'Copyright 2013- Ian Stapleton Cordasco' 23 | __license__ = 'Apache 2.0' 24 | __title__ = 'betamax' 25 | __version__ = '0.9.0' 26 | __version_info__ = tuple(int(i) for i in __version__.split('.')) 27 | -------------------------------------------------------------------------------- /src/betamax/cassette/__init__.py: -------------------------------------------------------------------------------- 1 | from .cassette import Cassette, dispatch_hooks 2 | from .interaction import Interaction 3 | 4 | __all__ = ('Cassette', 'Interaction', 'dispatch_hooks') 5 | -------------------------------------------------------------------------------- /src/betamax/cassette/interaction.py: -------------------------------------------------------------------------------- 1 | from requests.cookies import extract_cookies_to_jar 2 | from datetime import datetime 3 | 4 | from betamax import util 5 | 6 | 7 | class Interaction(object): 8 | 9 | """The Interaction object represents the entirety of a single interaction. 10 | 11 | The interaction includes the date it was recorded, its JSON 12 | representation, and the ``requests.Response`` object complete with its 13 | ``request`` attribute. 14 | 15 | This object also handles the filtering of sensitive data. 16 | 17 | No methods or attributes on this object are considered public or part of 18 | the public API. As such they are entirely considered implementation 19 | details and subject to change. Using or relying on them is not wise or 20 | advised. 21 | 22 | """ 23 | 24 | def __init__(self, interaction, response=None): 25 | self.data = interaction 26 | self.orig_response = response 27 | self.recorded_response = self.deserialize() 28 | self.used = False 29 | self.ignored = False 30 | 31 | def ignore(self): 32 | """Ignore this interaction. 33 | 34 | This is only to be used from a before_record or a before_playback 35 | callback. 36 | """ 37 | self.ignored = True 38 | 39 | def as_response(self): 40 | """Return the Interaction as a Response object.""" 41 | self.recorded_response = self.deserialize() 42 | return self.recorded_response 43 | 44 | @property 45 | def recorded_at(self): 46 | return datetime.strptime(self.data['recorded_at'], '%Y-%m-%dT%H:%M:%S') 47 | 48 | def deserialize(self): 49 | """Turn a serialized interaction into a Response.""" 50 | r = util.deserialize_response(self.data['response']) 51 | r.request = util.deserialize_prepared_request(self.data['request']) 52 | extract_cookies_to_jar(r.cookies, r.request, r.raw) 53 | return r 54 | 55 | def match(self, matchers): 56 | """Return whether this interaction is a match.""" 57 | request = self.data['request'] 58 | return all(m(request) for m in matchers) 59 | 60 | def replace(self, text_to_replace, placeholder): 61 | """Replace sensitive data in this interaction.""" 62 | self.replace_in_headers(text_to_replace, placeholder) 63 | self.replace_in_body(text_to_replace, placeholder) 64 | self.replace_in_uri(text_to_replace, placeholder) 65 | 66 | def replace_all(self, replacements, serializing): 67 | """Easy way to accept all placeholders registered.""" 68 | for placeholder in replacements: 69 | self.replace(*placeholder.unpack(serializing)) 70 | 71 | def replace_in_headers(self, text_to_replace, placeholder): 72 | if text_to_replace == '': 73 | return 74 | for obj in ('request', 'response'): 75 | headers = self.data[obj]['headers'] 76 | for k, v in list(headers.items()): 77 | if isinstance(v, list): 78 | headers[k] = [hv.replace(text_to_replace, placeholder) 79 | for hv in v] 80 | else: 81 | headers[k] = v.replace(text_to_replace, placeholder) 82 | 83 | def replace_in_body(self, text_to_replace, placeholder): 84 | if text_to_replace == '': 85 | return 86 | for obj in ('request', 'response'): 87 | body = self.data[obj]['body'] 88 | old_style = hasattr(body, 'replace') 89 | if not old_style: 90 | body = body.get('string', '') 91 | 92 | if text_to_replace in body: 93 | body = body.replace(text_to_replace, placeholder) 94 | if old_style: 95 | self.data[obj]['body'] = body 96 | else: 97 | self.data[obj]['body']['string'] = body 98 | 99 | def replace_in_uri(self, text_to_replace, placeholder): 100 | if text_to_replace == '': 101 | return 102 | for (obj, key) in (('request', 'uri'), ('response', 'url')): 103 | uri = self.data[obj][key] 104 | if text_to_replace in uri: 105 | self.data[obj][key] = uri.replace( 106 | text_to_replace, placeholder 107 | ) 108 | -------------------------------------------------------------------------------- /src/betamax/configure.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from .cassette import Cassette 4 | 5 | 6 | class Configuration(object): 7 | """This object acts as a proxy to configure different parts of Betamax. 8 | 9 | You should only ever encounter this object when configuring the library as 10 | a whole. For example: 11 | 12 | .. code:: 13 | 14 | with Betamax.configure() as config: 15 | config.cassette_library_dir = 'tests/cassettes/' 16 | config.default_cassette_options['record_mode'] = 'once' 17 | config.default_cassette_options['match_requests_on'] = ['uri'] 18 | config.define_cassette_placeholder('', 'http://httpbin.org') 19 | config.preserve_exact_body_bytes = True 20 | 21 | """ 22 | 23 | CASSETTE_LIBRARY_DIR = 'vcr/cassettes' 24 | recording_hooks = defaultdict(list) 25 | 26 | def __enter__(self): 27 | return self 28 | 29 | def __exit__(self, *args): 30 | pass 31 | 32 | def __setattr__(self, prop, value): 33 | if prop == 'preserve_exact_body_bytes': 34 | self.default_cassette_options[prop] = True 35 | else: 36 | super(Configuration, self).__setattr__(prop, value) 37 | 38 | def after_start(self, callback=None): 39 | """Register a function to call after Betamax is started. 40 | 41 | Example usage: 42 | 43 | .. code-block:: python 44 | 45 | def on_betamax_start(cassette): 46 | if cassette.is_recording(): 47 | print("Setting up authentication...") 48 | 49 | with Betamax.configure() as config: 50 | config.cassette_load(callback=on_cassette_load) 51 | 52 | :param callable callback: 53 | The function which accepts a cassette and might mutate 54 | it before returning. 55 | """ 56 | self.recording_hooks['after_start'].append(callback) 57 | 58 | def before_playback(self, tag=None, callback=None): 59 | """Register a function to call before playing back an interaction. 60 | 61 | Example usage: 62 | 63 | .. code-block:: python 64 | 65 | def before_playback(interaction, cassette): 66 | pass 67 | 68 | with Betamax.configure() as config: 69 | config.before_playback(callback=before_playback) 70 | 71 | :param str tag: 72 | Limits the interactions passed to the function based on the 73 | interaction's tag (currently unsupported). 74 | :param callable callback: 75 | The function which either accepts just an interaction or an 76 | interaction and a cassette and mutates the interaction before 77 | returning. 78 | """ 79 | Cassette.hooks['before_playback'].append(callback) 80 | 81 | def before_record(self, tag=None, callback=None): 82 | """Register a function to call before recording an interaction. 83 | 84 | Example usage: 85 | 86 | .. code-block:: python 87 | 88 | def before_record(interaction, cassette): 89 | pass 90 | 91 | with Betamax.configure() as config: 92 | config.before_record(callback=before_record) 93 | 94 | :param str tag: 95 | Limits the interactions passed to the function based on the 96 | interaction's tag (currently unsupported). 97 | :param callable callback: 98 | The function which either accepts just an interaction or an 99 | interaction and a cassette and mutates the interaction before 100 | returning. 101 | """ 102 | Cassette.hooks['before_record'].append(callback) 103 | 104 | def before_stop(self, callback=None): 105 | """Register a function to call before Betamax stops. 106 | 107 | Example usage: 108 | 109 | .. code-block:: python 110 | 111 | def on_betamax_stop(cassette): 112 | if not cassette.is_recording(): 113 | print("Playback completed.") 114 | 115 | with Betamax.configure() as config: 116 | config.cassette_eject(callback=on_betamax_stop) 117 | 118 | :param callable callback: 119 | The function which accepts a cassette and might mutate 120 | it before returning. 121 | """ 122 | self.recording_hooks['before_stop'].append(callback) 123 | 124 | @property 125 | def cassette_library_dir(self): 126 | """Retrieve and set the directory to store the cassettes in.""" 127 | return Configuration.CASSETTE_LIBRARY_DIR 128 | 129 | @cassette_library_dir.setter 130 | def cassette_library_dir(self, value): 131 | Configuration.CASSETTE_LIBRARY_DIR = value 132 | 133 | @property 134 | def default_cassette_options(self): 135 | """Retrieve and set the default cassette options. 136 | 137 | The options include: 138 | 139 | - ``match_requests_on`` 140 | - ``placeholders`` 141 | - ``re_record_interval`` 142 | - ``record_mode`` 143 | - ``preserve_exact_body_bytes`` 144 | 145 | Other options will be ignored. 146 | """ 147 | return Cassette.default_cassette_options 148 | 149 | @default_cassette_options.setter 150 | def default_cassette_options(self, value): 151 | Cassette.default_cassette_options = value 152 | 153 | def define_cassette_placeholder(self, placeholder, replace): 154 | """Define a placeholder value for some text. 155 | 156 | This also will replace the placeholder text with the text you wish it 157 | to use when replaying interactions from cassettes. 158 | 159 | :param str placeholder: (required), text to be used as a placeholder 160 | :param str replace: (required), text to be replaced or replacing the 161 | placeholder 162 | """ 163 | self.default_cassette_options['placeholders'].append({ 164 | 'placeholder': placeholder, 165 | 'replace': replace, 166 | }) 167 | -------------------------------------------------------------------------------- /src/betamax/decorator.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import unittest 3 | 4 | import requests 5 | 6 | from . import recorder 7 | 8 | 9 | def use_cassette(cassette_name, cassette_library_dir=None, 10 | default_cassette_options={}, **use_cassette_kwargs): 11 | r"""Provide a Betamax-wrapped Session for convenience. 12 | 13 | .. versionadded:: 0.5.0 14 | 15 | This decorator can be used to get a plain Session that has been wrapped in 16 | Betamax. For example, 17 | 18 | .. code-block:: python 19 | 20 | from betamax.decorator import use_cassette 21 | 22 | @use_cassette('example-decorator', cassette_library_dir='.') 23 | def test_get(session): 24 | # do things with session 25 | 26 | :param str cassette_name: 27 | Name of the cassette file in which interactions will be stored. 28 | :param str cassette_library_dir: 29 | Directory in which cassette files will be stored. 30 | :param dict default_cassette_options: 31 | Dictionary of default cassette options to set for the cassette used 32 | when recording these interactions. 33 | :param \*\*use_cassette_kwargs: 34 | Keyword arguments passed to :meth:`~betamax.Betamax.use_cassette` 35 | """ 36 | def actual_decorator(func): 37 | @functools.wraps(func) 38 | def test_wrapper(*args, **kwargs): 39 | session = requests.Session() 40 | recr = recorder.Betamax( 41 | session=session, 42 | cassette_library_dir=cassette_library_dir, 43 | default_cassette_options=default_cassette_options 44 | ) 45 | 46 | if args: 47 | fst, args = args[0], args[1:] 48 | if isinstance(fst, unittest.TestCase): 49 | args = (fst, session) + args 50 | else: 51 | args = (session, fst) + args 52 | else: 53 | args = (session,) 54 | 55 | with recr.use_cassette(cassette_name, **use_cassette_kwargs): 56 | func(*args, **kwargs) 57 | 58 | return test_wrapper 59 | return actual_decorator 60 | -------------------------------------------------------------------------------- /src/betamax/exceptions.py: -------------------------------------------------------------------------------- 1 | class BetamaxError(Exception): 2 | def __init__(self, message): 3 | super(BetamaxError, self).__init__(message) 4 | 5 | 6 | class MissingDirectoryError(BetamaxError): 7 | pass 8 | 9 | 10 | class ValidationError(BetamaxError): 11 | pass 12 | 13 | 14 | class InvalidOption(ValidationError): 15 | pass 16 | 17 | 18 | class BodyBytesValidationError(ValidationError): 19 | pass 20 | 21 | 22 | class MatchersValidationError(ValidationError): 23 | pass 24 | 25 | 26 | class RecordValidationError(ValidationError): 27 | pass 28 | 29 | 30 | class RecordIntervalValidationError(ValidationError): 31 | pass 32 | 33 | 34 | class PlaceholdersValidationError(ValidationError): 35 | pass 36 | 37 | 38 | class PlaybackRepeatsValidationError(ValidationError): 39 | pass 40 | 41 | 42 | class SerializerValidationError(ValidationError): 43 | pass 44 | 45 | 46 | validation_error_map = { 47 | 'allow_playback_repeats': PlaybackRepeatsValidationError, 48 | 'match_requests_on': MatchersValidationError, 49 | 'record': RecordValidationError, 50 | 'placeholders': PlaceholdersValidationError, 51 | 'preserve_exact_body_bytes': BodyBytesValidationError, 52 | 're_record_interval': RecordIntervalValidationError, 53 | 'serialize': SerializerValidationError, # TODO: Remove this 54 | 'serialize_with': SerializerValidationError 55 | } 56 | -------------------------------------------------------------------------------- /src/betamax/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betamaxpy/betamax/8f3d284103676a43d1481b5cffae96f3a601e0be/src/betamax/fixtures/__init__.py -------------------------------------------------------------------------------- /src/betamax/fixtures/pytest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A set of fixtures to integrate Betamax with py.test. 3 | 4 | .. autofunction:: betamax_session 5 | 6 | """ 7 | 8 | from __future__ import absolute_import 9 | 10 | import re 11 | import warnings 12 | 13 | import pytest 14 | import requests 15 | 16 | from .. import recorder as betamax 17 | 18 | 19 | def _sanitize(name): 20 | """ 21 | Replace certain characters (which might be problematic when contained in 22 | strings which will be used as file names) by '-'s. """ 23 | return re.sub(r'[\s/<>:\\"|?*]', '-', name) 24 | 25 | 26 | def _cassette_name(request, parametrized): 27 | """Determine a cassette name from request. 28 | 29 | :param request: 30 | A request object from pytest giving us context information for the 31 | fixture. 32 | :param parametrized: 33 | Whether the name should consider parametrized tests. 34 | :returns: 35 | A cassette name. 36 | """ 37 | cassette_name = '' 38 | 39 | if request.module is not None: 40 | cassette_name += request.module.__name__ + '.' 41 | 42 | if request.cls is not None: 43 | cassette_name += request.cls.__name__ + '.' 44 | 45 | if parametrized: 46 | cassette_name += _sanitize(request.node.name) 47 | else: 48 | cassette_name += request.function.__name__ 49 | if request.node.name != request.function.__name__: 50 | warnings.warn( 51 | "betamax_recorder and betamax_session currently don't include " 52 | "parameters in the cassette name. " 53 | "Use betamax_parametrized_recorder/_session to include " 54 | "parameters. " 55 | "This behavior will be the default in betamax 1.0", 56 | FutureWarning, stacklevel=3) 57 | 58 | return cassette_name 59 | 60 | 61 | def _betamax_recorder(request, parametrized=True): 62 | cassette_name = _cassette_name(request, parametrized=parametrized) 63 | session = requests.Session() 64 | recorder = betamax.Betamax(session) 65 | recorder.use_cassette(cassette_name) 66 | recorder.start() 67 | request.addfinalizer(recorder.stop) 68 | return recorder 69 | 70 | 71 | @pytest.fixture 72 | def betamax_recorder(request): 73 | """Generate a recorder with a session that has Betamax already installed. 74 | 75 | This will create a new Betamax instance with a generated cassette name. 76 | The cassette name is generated by first using the module name from where 77 | the test is collected, then the class name (if it exists), and then the 78 | test function name. For example, if your test is in ``test_stuff.py`` and 79 | is the method ``TestStuffClass.test_stuff`` then your cassette name will be 80 | ``test_stuff_TestStuffClass_test_stuff``. If the test is parametrized, 81 | the parameters will not be included in the name. In case you need that, 82 | use betamax_parametrized_recorder instead. This will change in 1.0.0, 83 | where parameters will be included by default. 84 | 85 | :param request: 86 | A request object from pytest giving us context information for the 87 | fixture. 88 | :returns: 89 | An instantiated recorder. 90 | """ 91 | return _betamax_recorder(request, parametrized=False) 92 | 93 | 94 | @pytest.fixture 95 | def betamax_session(betamax_recorder): 96 | """Generate a session that has Betamax already installed. 97 | 98 | See `betamax_recorder` fixture. 99 | 100 | :param betamax_recorder: 101 | A recorder fixture with a configured request session. 102 | :returns: 103 | An instantiated requests Session wrapped by Betamax. 104 | """ 105 | 106 | return betamax_recorder.session 107 | 108 | 109 | @pytest.fixture 110 | def betamax_parametrized_recorder(request): 111 | """Generate a recorder with a session that has Betamax already installed. 112 | 113 | This will create a new Betamax instance with a generated cassette name. 114 | The cassette name is generated by first using the module name from where 115 | the test is collected, then the class name (if it exists), and then the 116 | test function name with parameters if parametrized. 117 | For example, if your test is in ``test_stuff.py`` and 118 | the method is ``TestStuffClass.test_stuff`` with parameter ``True`` then 119 | your cassette name will be 120 | ``test_stuff_TestStuffClass_test_stuff[True]``. 121 | 122 | :param request: 123 | A request object from pytest giving us context information for the 124 | fixture. 125 | :returns: 126 | An instantiated recorder. 127 | """ 128 | warnings.warn( 129 | "betamax_parametrized_recorder and betamax_parametrized_session " 130 | "will be removed in betamax 1.0. Their behavior will be the " 131 | "default.", 132 | DeprecationWarning) 133 | return _betamax_recorder(request, parametrized=True) 134 | 135 | 136 | @pytest.fixture 137 | def betamax_parametrized_session(betamax_parametrized_recorder): 138 | """Generate a session that has Betamax already installed. 139 | 140 | See `betamax_parametrized_recorder` fixture. 141 | 142 | :param betamax_parametrized_recorder: 143 | A recorder fixture with a configured request session. 144 | :returns: 145 | An instantiated requests Session wrapped by Betamax. 146 | """ 147 | 148 | return betamax_parametrized_recorder.session 149 | -------------------------------------------------------------------------------- /src/betamax/fixtures/unittest.py: -------------------------------------------------------------------------------- 1 | """Minimal :class:`unittest.TestCase` subclass adding Betamax integration. 2 | 3 | .. autoclass:: betamax.fixtures.unittest.BetamaxTestCase 4 | :members: 5 | 6 | When using Betamax with unittest, you can use the traditional style of Betamax 7 | covered in the documentation thoroughly, or you can use your fixture methods, 8 | :meth:`unittest.TestCase.setUp` and :meth:`unittest.TestCase.tearDown` to wrap 9 | entire tests in Betamax. 10 | 11 | Here's how you might use it: 12 | 13 | .. code-block:: python 14 | 15 | from betamax.fixtures import unittest 16 | 17 | from myapi import SessionManager 18 | 19 | 20 | class TestMyApi(unittest.BetamaxTestCase): 21 | def setUp(self): 22 | # Call BetamaxTestCase's setUp first to get a session 23 | super(TestMyApi, self).setUp() 24 | 25 | self.manager = SessionManager(self.session) 26 | 27 | def test_all_users(self): 28 | \"\"\"Retrieve all users from the API.\"\"\" 29 | for user in self.manager: 30 | # Make assertions or something 31 | 32 | Alternatively, if you are subclassing a :class:`requests.Session` to provide 33 | extra functionality, you can do something like this: 34 | 35 | .. code-block:: python 36 | 37 | from betamax.fixtures import unittest 38 | 39 | from myapi import Session, SessionManager 40 | 41 | 42 | class TestMyApi(unittest.BetamaxTestCase): 43 | SESSION_CLASS = Session 44 | 45 | # See above ... 46 | 47 | """ 48 | # NOTE(sigmavirus24): absolute_import is required to make import unittest work 49 | from __future__ import absolute_import 50 | 51 | try: 52 | import unittest2 as unittest 53 | except ImportError: 54 | import unittest 55 | 56 | import requests 57 | 58 | from .. import recorder 59 | 60 | 61 | __all__ = ('BetamaxTestCase',) 62 | 63 | 64 | class BetamaxTestCase(unittest.TestCase): 65 | 66 | """Betamax integration for unittest. 67 | 68 | .. versionadded:: 0.5.0 69 | """ 70 | 71 | #: Class that is a subclass of :class:`requests.Session` 72 | SESSION_CLASS = requests.Session 73 | 74 | #: Custom path to save cassette. 75 | CASSETTE_LIBRARY_DIR = None 76 | 77 | def generate_cassette_name(self): 78 | """Generates a cassette name for the current test. 79 | 80 | The default format is "%(classname)s.%(testMethodName)s" 81 | 82 | To change the default cassette format, override this method in a 83 | subclass. 84 | 85 | :returns: Cassette name for the current test. 86 | :rtype: str 87 | """ 88 | cls = getattr(self, '__class__') 89 | test = self._testMethodName 90 | return '{0}.{1}'.format(cls.__name__, test) 91 | 92 | def setUp(self): 93 | """Betamax-ified setUp fixture. 94 | 95 | This will call the superclass' setUp method *first* and then it will 96 | create a new :class:`requests.Session` and wrap that in a Betamax 97 | object to record it. At the end of ``setUp``, it will start recording. 98 | """ 99 | # Bail out early if the SESSION_CLASS isn't a subclass of 100 | # requests.Session 101 | self.assertTrue(issubclass(self.SESSION_CLASS, requests.Session)) 102 | # Make sure if the user is multiply inheriting that all setUps are 103 | # called. (If that confuses you, see: https://youtu.be/EiOglTERPEo) 104 | super(BetamaxTestCase, self).setUp() 105 | 106 | cassette_name = self.generate_cassette_name() 107 | 108 | self.session = self.SESSION_CLASS() 109 | self.recorder = recorder.Betamax( 110 | session=self.session, 111 | cassette_library_dir=self.CASSETTE_LIBRARY_DIR) 112 | 113 | self.recorder.use_cassette(cassette_name) 114 | self.recorder.start() 115 | 116 | def tearDown(self): 117 | """Betamax-ified tearDown fixture. 118 | 119 | This will call the superclass' tearDown method *first* and then it 120 | will stop recording interactions. 121 | """ 122 | super(BetamaxTestCase, self).tearDown() 123 | self.recorder.stop() 124 | -------------------------------------------------------------------------------- /src/betamax/matchers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseMatcher 2 | from .body import BodyMatcher 3 | from .digest_auth import DigestAuthMatcher 4 | from .headers import HeadersMatcher 5 | from .host import HostMatcher 6 | from .method import MethodMatcher 7 | from .path import PathMatcher 8 | from .query import QueryMatcher 9 | from .uri import URIMatcher 10 | 11 | matcher_registry = {} 12 | 13 | __all__ = ('BaseMatcher', 'BodyMatcher', 'DigestAuthMatcher', 14 | 'HeadersMatcher', 'HostMatcher', 'MethodMatcher', 'PathMatcher', 15 | 'QueryMatcher', 'URIMatcher', 'matcher_registry') 16 | 17 | 18 | _matchers = [BodyMatcher, DigestAuthMatcher, HeadersMatcher, HostMatcher, 19 | MethodMatcher, PathMatcher, QueryMatcher, URIMatcher] 20 | matcher_registry.update(dict((m.name, m()) for m in _matchers)) 21 | del _matchers 22 | -------------------------------------------------------------------------------- /src/betamax/matchers/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class BaseMatcher(object): 5 | 6 | """ 7 | Base class that ensures sub-classes that implement custom matchers can be 8 | registered and have the only method that is required. 9 | 10 | Usage: 11 | 12 | .. code-block:: python 13 | 14 | from betamax import Betamax, BaseMatcher 15 | 16 | class MyMatcher(BaseMatcher): 17 | name = 'my' 18 | 19 | def match(self, request, recorded_request): 20 | # My fancy matching algorithm 21 | 22 | Betamax.register_request_matcher(MyMatcher) 23 | 24 | The last line is absolutely necessary. 25 | 26 | The `match` method will be given a `requests.PreparedRequest` object and a 27 | dictionary. The dictionary always has the following keys: 28 | 29 | - url 30 | - method 31 | - body 32 | - headers 33 | 34 | """ 35 | 36 | name = None 37 | 38 | def __init__(self): 39 | if not self.name: 40 | raise ValueError('Matchers require names') 41 | self.on_init() 42 | 43 | def on_init(self): 44 | """Method to implement if you wish something to happen in ``__init__``. 45 | 46 | The return value is not checked and this is called at the end of 47 | ``__init__``. It is meant to provide the matcher author a way to 48 | perform things during initialization of the instance that would 49 | otherwise require them to override ``BaseMatcher.__init__``. 50 | """ 51 | return None 52 | 53 | def match(self, request, recorded_request): 54 | """A method that must be implemented by the user. 55 | 56 | :param PreparedRequest request: A requests PreparedRequest object 57 | :param dict recorded_request: A dictionary containing the serialized 58 | request in the cassette 59 | :returns bool: True if they match else False 60 | """ 61 | raise NotImplementedError('The match method must be implemented on' 62 | ' %s' % self.__class__.__name__) 63 | -------------------------------------------------------------------------------- /src/betamax/matchers/body.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | 4 | from betamax import util 5 | 6 | 7 | class BodyMatcher(BaseMatcher): 8 | # Matches based on the body of the request 9 | name = 'body' 10 | 11 | def match(self, request, recorded_request): 12 | recorded_request = util.deserialize_prepared_request(recorded_request) 13 | 14 | request_body = b'' 15 | if request.body: 16 | request_body = util.coerce_content(request.body) 17 | 18 | recorded_body = b'' 19 | if recorded_request.body: 20 | recorded_body = util.coerce_content(recorded_request.body) 21 | 22 | return recorded_body == request_body 23 | -------------------------------------------------------------------------------- /src/betamax/matchers/digest_auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | from betamax.util import from_list 4 | 5 | 6 | class DigestAuthMatcher(BaseMatcher): 7 | 8 | """This matcher is provided to help those who need to use Digest Auth. 9 | 10 | .. note:: 11 | 12 | The code requests 2.0.1 uses to generate this header is different from 13 | the code that every requests version after it uses. Specifically, in 14 | 2.0.1 one of the parameters is ``qop=auth`` and every other version is 15 | ``qop="auth"``. Given that there's also an unsupported type of ``qop`` 16 | in requests, I've chosen not to ignore ore sanitize this. All 17 | cassettes recorded on 2.0.1 will need to be re-recorded for any 18 | requests version after it. 19 | 20 | This matcher also ignores the ``cnonce`` and ``response`` parameters. 21 | These parameters require the system time to be monkey-patched and 22 | that is out of the scope of betamax 23 | 24 | """ 25 | 26 | name = 'digest-auth' 27 | 28 | def match(self, request, recorded_request): 29 | request_digest = self.digest_parts(request.headers) 30 | recorded_digest = self.digest_parts(recorded_request['headers']) 31 | return request_digest == recorded_digest 32 | 33 | def digest_parts(self, headers): 34 | auth = headers.get('Authorization') or headers.get('authorization') 35 | if not auth: 36 | return None 37 | auth = from_list(auth).strip('Digest ') 38 | # cnonce and response will be based on the system time, which I will 39 | # not monkey-patch. 40 | excludes = ('cnonce', 'response') 41 | return [p for p in auth.split(', ') if not p.startswith(excludes)] 42 | -------------------------------------------------------------------------------- /src/betamax/matchers/headers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | 4 | 5 | class HeadersMatcher(BaseMatcher): 6 | # Matches based on the headers of the request 7 | name = 'headers' 8 | 9 | def match(self, request, recorded_request): 10 | return dict(request.headers) == self.flatten_headers(recorded_request) 11 | 12 | def flatten_headers(self, request): 13 | from betamax.util import from_list 14 | headers = request['headers'].items() 15 | return dict((k, from_list(v)) for (k, v) in headers) 16 | -------------------------------------------------------------------------------- /src/betamax/matchers/host.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | from requests.compat import urlparse 4 | 5 | 6 | class HostMatcher(BaseMatcher): 7 | # Matches based on the host of the request 8 | name = 'host' 9 | 10 | def match(self, request, recorded_request): 11 | request_host = urlparse(request.url).netloc 12 | recorded_host = urlparse(recorded_request['uri']).netloc 13 | return request_host == recorded_host 14 | -------------------------------------------------------------------------------- /src/betamax/matchers/method.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | 4 | 5 | class MethodMatcher(BaseMatcher): 6 | # Matches based on the method of the request 7 | name = 'method' 8 | 9 | def match(self, request, recorded_request): 10 | return request.method == recorded_request['method'] 11 | -------------------------------------------------------------------------------- /src/betamax/matchers/path.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | from requests.compat import urlparse 4 | 5 | 6 | class PathMatcher(BaseMatcher): 7 | # Matches based on the path of the request 8 | name = 'path' 9 | 10 | def match(self, request, recorded_request): 11 | request_path = urlparse(request.url).path 12 | recorded_path = urlparse(recorded_request['uri']).path 13 | return request_path == recorded_path 14 | -------------------------------------------------------------------------------- /src/betamax/matchers/query.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | from .base import BaseMatcher 5 | 6 | try: 7 | from urlparse import parse_qs, urlparse 8 | except ImportError: 9 | from urllib.parse import parse_qs, urlparse 10 | 11 | 12 | isPY2 = (2, 6) <= sys.version_info < (3, 0) 13 | 14 | 15 | class QueryMatcher(BaseMatcher): 16 | # Matches based on the query of the request 17 | name = 'query' 18 | 19 | def to_dict(self, query): 20 | """Turn the query string into a dictionary.""" 21 | return parse_qs( 22 | query or '', # Protect against None 23 | keep_blank_values=True, 24 | ) 25 | 26 | def match(self, request, recorded_request): 27 | request_query_dict = self.to_dict(urlparse(request.url).query) 28 | recorded_query = urlparse(recorded_request['uri']).query 29 | if recorded_query and isPY2: 30 | # NOTE(sigmavirus24): If we're on Python 2, the request.url will 31 | # be str/bytes and the recorded_request['uri'] will be unicode. 32 | # For the comparison to work for high unicode characters, we need 33 | # to encode the recorded query string before parsing it. See also 34 | # GitHub bug #43. 35 | recorded_query = recorded_query.encode('utf-8') 36 | recorded_query_dict = self.to_dict(recorded_query) 37 | return request_query_dict == recorded_query_dict 38 | -------------------------------------------------------------------------------- /src/betamax/matchers/uri.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseMatcher 3 | from .query import QueryMatcher 4 | from requests.compat import urlparse 5 | 6 | 7 | class URIMatcher(BaseMatcher): 8 | # Matches based on the uri of the request 9 | name = 'uri' 10 | 11 | def on_init(self): 12 | # Get something we can use to match query strings with 13 | self.query_matcher = QueryMatcher().match 14 | 15 | def match(self, request, recorded_request): 16 | queries_match = self.query_matcher(request, recorded_request) 17 | request_url, recorded_url = request.url, recorded_request['uri'] 18 | return self.all_equal(request_url, recorded_url) and queries_match 19 | 20 | def parse(self, uri): 21 | parsed = urlparse(uri) 22 | return { 23 | 'scheme': parsed.scheme, 24 | 'netloc': parsed.netloc, 25 | 'path': parsed.path, 26 | 'fragment': parsed.fragment 27 | } 28 | 29 | def all_equal(self, new_uri, recorded_uri): 30 | new_parsed = self.parse(new_uri) 31 | recorded_parsed = self.parse(recorded_uri) 32 | return (new_parsed == recorded_parsed) 33 | -------------------------------------------------------------------------------- /src/betamax/mock_response.py: -------------------------------------------------------------------------------- 1 | from email import parser, message 2 | import sys 3 | 4 | 5 | class MockHTTPResponse(object): 6 | def __init__(self, headers): 7 | from betamax.util import coerce_content 8 | 9 | h = ["%s: %s" % (k, v) for k in headers for v in headers.getlist(k)] 10 | h = map(coerce_content, h) 11 | h = '\r\n'.join(h) 12 | if sys.version_info < (2, 7): 13 | h = h.encode() 14 | p = parser.Parser(EmailMessage) 15 | # Thanks to Python 3, we have to use the slightly more awful API below 16 | # mimetools was deprecated so we have to use email.message.Message 17 | # which takes no arguments in its initializer. 18 | self.msg = p.parsestr(h) 19 | self.msg.set_payload(h) 20 | 21 | self._closed = False 22 | 23 | def isclosed(self): 24 | return self._closed 25 | 26 | def close(self): 27 | self._closed = True 28 | 29 | 30 | class EmailMessage(message.Message): 31 | def getheaders(self, value, *args): 32 | return self.get_all(value, []) 33 | -------------------------------------------------------------------------------- /src/betamax/options.py: -------------------------------------------------------------------------------- 1 | from .cassette import Cassette 2 | from .exceptions import InvalidOption, validation_error_map 3 | 4 | 5 | def validate_record(record): 6 | return record in ['all', 'new_episodes', 'none', 'once'] 7 | 8 | 9 | def validate_matchers(matchers): 10 | from betamax.matchers import matcher_registry 11 | available_matchers = list(matcher_registry.keys()) 12 | return all(m in available_matchers for m in matchers) 13 | 14 | 15 | def validate_serializer(serializer): 16 | from betamax.serializers import serializer_registry 17 | return serializer in list(serializer_registry.keys()) 18 | 19 | 20 | def validate_placeholders(placeholders): 21 | """Validate placeholders is a dict-like structure""" 22 | keys = ['placeholder', 'replace'] 23 | try: 24 | return all(sorted(list(p.keys())) == keys for p in placeholders) 25 | except TypeError: 26 | return False 27 | 28 | 29 | def translate_cassette_options(): 30 | for (k, v) in Cassette.default_cassette_options.items(): 31 | yield (k, v) if k != 'record_mode' else ('record', v) 32 | 33 | 34 | def isboolean(value): 35 | return value in [True, False] 36 | 37 | 38 | class Options(object): 39 | valid_options = { 40 | 'match_requests_on': validate_matchers, 41 | 're_record_interval': lambda x: x is None or x > 0, 42 | 'record': validate_record, 43 | 'serialize': validate_serializer, # TODO: Remove this 44 | 'serialize_with': validate_serializer, 45 | 'preserve_exact_body_bytes': isboolean, 46 | 'placeholders': validate_placeholders, 47 | 'allow_playback_repeats': isboolean, 48 | } 49 | 50 | defaults = { 51 | 'match_requests_on': ['method', 'uri'], 52 | 're_record_interval': None, 53 | 'record': 'once', 54 | 'serialize': None, # TODO: Remove this 55 | 'serialize_with': 'json', 56 | 'preserve_exact_body_bytes': False, 57 | 'placeholders': [], 58 | 'allow_playback_repeats': False, 59 | } 60 | 61 | def __init__(self, data=None): 62 | self.data = data or {} 63 | self.validate() 64 | self.defaults = Options.defaults.copy() 65 | self.defaults.update(translate_cassette_options()) 66 | 67 | def __repr__(self): 68 | return 'Options(%s)' % self.data 69 | 70 | def __getitem__(self, key): 71 | return self.data.get(key, self.defaults.get(key)) 72 | 73 | def __setitem__(self, key, value): 74 | self.data[key] = value 75 | return value 76 | 77 | def __delitem__(self, key): 78 | del self.data[key] 79 | 80 | def __contains__(self, key): 81 | return key in self.data 82 | 83 | def items(self): 84 | return self.data.items() 85 | 86 | def validate(self): 87 | for key, value in list(self.data.items()): 88 | if key not in Options.valid_options: 89 | raise InvalidOption('{0} is not a valid option'.format(key)) 90 | else: 91 | is_valid = Options.valid_options[key] 92 | if not is_valid(value): 93 | raise validation_error_map[key]('{0!r} is not valid' 94 | .format(value)) 95 | -------------------------------------------------------------------------------- /src/betamax/recorder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import matchers, serializers 3 | from .adapter import BetamaxAdapter 4 | from .cassette import Cassette 5 | from .configure import Configuration 6 | from .options import Options 7 | 8 | 9 | class Betamax(object): 10 | 11 | """This object contains the main API of the request-vcr library. 12 | 13 | This object is entirely a context manager so all you have to do is: 14 | 15 | .. code:: 16 | 17 | s = requests.Session() 18 | with Betamax(s) as vcr: 19 | vcr.use_cassette('example') 20 | r = s.get('https://httpbin.org/get') 21 | 22 | Or more concisely, you can do: 23 | 24 | .. code:: 25 | 26 | s = requests.Session() 27 | with Betamax(s).use_cassette('example') as vcr: 28 | r = s.get('https://httpbin.org/get') 29 | 30 | This object allows for the user to specify the cassette library directory 31 | and default cassette options. 32 | 33 | .. code:: 34 | 35 | s = requests.Session() 36 | with Betamax(s, cassette_library_dir='tests/cassettes') as vcr: 37 | vcr.use_cassette('example') 38 | r = s.get('https://httpbin.org/get') 39 | 40 | with Betamax(s, default_cassette_options={ 41 | 're_record_interval': 1000 42 | }) as vcr: 43 | vcr.use_cassette('example') 44 | r = s.get('https://httpbin.org/get') 45 | 46 | """ 47 | 48 | def __init__(self, session, cassette_library_dir=None, 49 | default_cassette_options={}): 50 | #: Store the requests.Session object being wrapped. 51 | self.session = session 52 | #: Store the session's original adapters. 53 | self.http_adapters = session.adapters.copy() 54 | #: Create a new adapter to replace the existing ones 55 | self.betamax_adapter = BetamaxAdapter(old_adapters=self.http_adapters) 56 | # We need a configuration instance to make life easier 57 | self.config = Configuration() 58 | # Merge the new cassette options with the default ones 59 | self.config.default_cassette_options.update( 60 | default_cassette_options or {} 61 | ) 62 | 63 | # If it was passed in, use that instead. 64 | if cassette_library_dir: 65 | self.config.cassette_library_dir = cassette_library_dir 66 | 67 | def __enter__(self): 68 | self.start() 69 | return self 70 | 71 | def __exit__(self, *ex_args): 72 | self.stop() 73 | # ex_args comes through as the exception type, exception value and 74 | # exception traceback. If any of them are not None, we should probably 75 | # try to raise the exception and not muffle anything. 76 | if any(ex_args): 77 | # If you return False, Python will re-raise the exception for you 78 | return False 79 | 80 | @staticmethod 81 | def configure(): 82 | """Help to configure the library as a whole. 83 | 84 | .. code:: 85 | 86 | with Betamax.configure() as config: 87 | config.cassette_library_dir = 'tests/cassettes/' 88 | config.default_cassette_options['match_options'] = [ 89 | 'method', 'uri', 'headers' 90 | ] 91 | """ 92 | return Configuration() 93 | 94 | @property 95 | def current_cassette(self): 96 | """Return the cassette that is currently in use. 97 | 98 | :returns: :class:`Cassette ` 99 | """ 100 | return self.betamax_adapter.cassette 101 | 102 | @staticmethod 103 | def register_request_matcher(matcher_class): 104 | """Register a new request matcher. 105 | 106 | :param matcher_class: (required), this must sub-class 107 | :class:`BaseMatcher ` 108 | """ 109 | matchers.matcher_registry[matcher_class.name] = matcher_class() 110 | 111 | @staticmethod 112 | def register_serializer(serializer_class): 113 | """Register a new serializer. 114 | 115 | :param matcher_class: (required), this must sub-class 116 | :class:`BaseSerializer ` 117 | """ 118 | name = serializer_class.name 119 | serializers.serializer_registry[name] = serializer_class() 120 | 121 | # ▶ 122 | def start(self): 123 | """Start recording or replaying interactions.""" 124 | for k in self.http_adapters: 125 | self.session.mount(k, self.betamax_adapter) 126 | dispatch_hooks('after_start', self.betamax_adapter.cassette) 127 | 128 | # ■ 129 | def stop(self): 130 | """Stop recording or replaying interactions.""" 131 | dispatch_hooks('before_stop', self.betamax_adapter.cassette) 132 | 133 | # No need to keep the cassette in memory any longer. 134 | self.betamax_adapter.eject_cassette() 135 | # On exit, we no longer wish to use our adapter and we want the 136 | # session to behave normally! Woooo! 137 | self.betamax_adapter.close() 138 | for (k, v) in self.http_adapters.items(): 139 | self.session.mount(k, v) 140 | 141 | def use_cassette(self, cassette_name, **kwargs): 142 | """Tell Betamax which cassette you wish to use for the context. 143 | 144 | :param str cassette_name: relative name, without the serialization 145 | format, of the cassette you wish Betamax would use 146 | :param str serialize_with: the format you want Betamax to serialize 147 | the cassette with 148 | :param str serialize: DEPRECATED the format you want Betamax to 149 | serialize the request and response data to and from 150 | """ 151 | kwargs = Options(kwargs) 152 | serialize = kwargs['serialize'] or kwargs['serialize_with'] 153 | kwargs['cassette_library_dir'] = self.config.cassette_library_dir 154 | 155 | can_load = Cassette.can_be_loaded( 156 | self.config.cassette_library_dir, 157 | cassette_name, 158 | serialize, 159 | kwargs['record'] 160 | ) 161 | 162 | if can_load: 163 | self.betamax_adapter.load_cassette(cassette_name, serialize, 164 | kwargs) 165 | else: 166 | # If we're not recording or replaying an existing cassette, we 167 | # should tell the user/developer that there is no cassette, only 168 | # Zuul 169 | raise ValueError('Cassette must have a valid name and may not be' 170 | ' None.') 171 | return self 172 | 173 | 174 | def dispatch_hooks(hook_name, *args): 175 | """Dispatch registered hooks.""" 176 | hooks = Configuration.recording_hooks[hook_name] 177 | for hook in hooks: 178 | hook(*args) 179 | -------------------------------------------------------------------------------- /src/betamax/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseSerializer 3 | from .json_serializer import JSONSerializer 4 | from .proxy import SerializerProxy 5 | 6 | serializer_registry = {} 7 | 8 | _serializers = [JSONSerializer] 9 | serializer_registry.update(dict((s.name, s()) for s in _serializers)) 10 | del _serializers 11 | 12 | __all__ = ('BaseSerializer', 'JSONSerializer', 'SerializerProxy') 13 | -------------------------------------------------------------------------------- /src/betamax/serializers/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | NOT_IMPLEMENTED_ERROR_MSG = ('This method must be implemented by classes' 3 | ' inheriting from BaseSerializer.') 4 | 5 | 6 | class BaseSerializer(object): 7 | """ 8 | Base Serializer class that provides an interface for other serializers. 9 | 10 | Usage: 11 | 12 | .. code-block:: python 13 | 14 | from betamax import Betamax, BaseSerializer 15 | 16 | 17 | class MySerializer(BaseSerializer): 18 | name = 'my' 19 | 20 | @staticmethod 21 | def generate_cassette_name(cassette_library_dir, cassette_name): 22 | # Generate a string that will give the relative path of a 23 | # cassette 24 | 25 | def serialize(self, cassette_data): 26 | # Take a dictionary and convert it to whatever 27 | 28 | def deserialize(self, cassette_data): 29 | # Uses a cassette file to return a dictionary with the 30 | # cassette information 31 | 32 | Betamax.register_serializer(MySerializer) 33 | 34 | The last line is absolutely necessary. 35 | 36 | """ 37 | 38 | name = None 39 | stored_as_binary = False 40 | 41 | @staticmethod 42 | def generate_cassette_name(cassette_library_dir, cassette_name): 43 | raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MSG) 44 | 45 | def __init__(self): 46 | if not self.name: 47 | raise ValueError("Serializer's name attribute must be a string" 48 | " value, not None.") 49 | 50 | self.on_init() 51 | 52 | def on_init(self): 53 | """Method to implement if you wish something to happen in ``__init__``. 54 | 55 | The return value is not checked and this is called at the end of 56 | ``__init__``. It is meant to provide the matcher author a way to 57 | perform things during initialization of the instance that would 58 | otherwise require them to override ``BaseSerializer.__init__``. 59 | """ 60 | return None 61 | 62 | def serialize(self, cassette_data): 63 | """A method that must be implemented by the Serializer author. 64 | 65 | :param dict cassette_data: A dictionary with two keys: 66 | ``http_interactions``, ``recorded_with``. 67 | :returns: Serialized data as a string. 68 | """ 69 | raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MSG) 70 | 71 | def deserialize(self, cassette_data): 72 | """A method that must be implemented by the Serializer author. 73 | 74 | The return value is extremely important. If it is not empty, the 75 | dictionary returned must have the following structure:: 76 | 77 | { 78 | 'http_interactions': [{ 79 | # Interaction 80 | }, 81 | { 82 | # Interaction 83 | }], 84 | 'recorded_with': 'name of recorder' 85 | } 86 | 87 | :params str cassette_data: The data serialized as a string which needs 88 | to be deserialized. 89 | :returns: dictionary 90 | """ 91 | raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MSG) 92 | -------------------------------------------------------------------------------- /src/betamax/serializers/json_serializer.py: -------------------------------------------------------------------------------- 1 | from .base import BaseSerializer 2 | 3 | import json 4 | import os 5 | 6 | 7 | class JSONSerializer(BaseSerializer): 8 | # Serializes and deserializes a cassette to JSON 9 | name = 'json' 10 | stored_as_binary = False 11 | 12 | @staticmethod 13 | def generate_cassette_name(cassette_library_dir, cassette_name): 14 | return os.path.join(cassette_library_dir, 15 | '{0}.{1}'.format(cassette_name, 'json')) 16 | 17 | def serialize(self, cassette_data): 18 | return json.dumps(cassette_data) 19 | 20 | def deserialize(self, cassette_data): 21 | try: 22 | deserialized_data = json.loads(cassette_data) 23 | except ValueError: 24 | deserialized_data = {} 25 | 26 | return deserialized_data 27 | -------------------------------------------------------------------------------- /src/betamax/serializers/proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseSerializer 3 | from betamax.exceptions import MissingDirectoryError 4 | 5 | import os 6 | 7 | 8 | class SerializerProxy(BaseSerializer): 9 | 10 | """ 11 | This is an internal implementation detail of the betamax library. 12 | 13 | No users implementing a serializer should be using this. Developers 14 | working on betamax need only understand that this handles the logic 15 | surrounding whether a cassette should be updated, overwritten, or created. 16 | 17 | It provides one consistent way for betamax to be confident in how it 18 | serializes the data it receives. It allows authors of Serializer classes 19 | to not have to duplicate how files are handled. It delegates the 20 | responsibility of actually serializing the data to those classes and 21 | handles the rest. 22 | 23 | """ 24 | 25 | def __init__(self, serializer, cassette_path, allow_serialization=False): 26 | self.proxied_serializer = serializer 27 | self.allow_serialization = allow_serialization 28 | self.cassette_path = cassette_path 29 | 30 | def _ensure_path_exists(self): 31 | directory, _ = os.path.split(self.cassette_path) 32 | if not (directory == '' or os.path.isdir(directory)): 33 | raise MissingDirectoryError( 34 | 'Configured cassette directory \'{0}\' does not exist - try ' 35 | 'creating it'.format(directory) 36 | ) 37 | if not os.path.exists(self.cassette_path): 38 | open(self.cassette_path, 'w+').close() 39 | 40 | def corrected_file_mode(self, base_mode): 41 | storing_binary_data = getattr(self.proxied_serializer, 42 | 'stored_as_binary', 43 | False) 44 | if storing_binary_data: 45 | return '{}b'.format(base_mode) 46 | return base_mode 47 | 48 | @classmethod 49 | def find(cls, serialize_with, cassette_library_dir, cassette_name): 50 | from . import serializer_registry 51 | serializer = serializer_registry.get(serialize_with) 52 | if serializer is None: 53 | raise ValueError( 54 | 'No serializer registered for {0}'.format(serialize_with) 55 | ) 56 | 57 | cassette_path = cls.generate_cassette_name( 58 | serializer, cassette_library_dir, cassette_name 59 | ) 60 | return cls(serializer, cassette_path) 61 | 62 | @staticmethod 63 | def generate_cassette_name(serializer, cassette_library_dir, 64 | cassette_name): 65 | return serializer.generate_cassette_name( 66 | cassette_library_dir, cassette_name 67 | ) 68 | 69 | def serialize(self, cassette_data): 70 | if not self.allow_serialization: 71 | return 72 | 73 | self._ensure_path_exists() 74 | mode = self.corrected_file_mode('w') 75 | 76 | with open(self.cassette_path, mode) as fd: 77 | fd.write(self.proxied_serializer.serialize(cassette_data)) 78 | 79 | def deserialize(self): 80 | self._ensure_path_exists() 81 | 82 | data = {} 83 | mode = self.corrected_file_mode('r') 84 | 85 | with open(self.cassette_path, mode) as fd: 86 | data = self.proxied_serializer.deserialize(fd.read()) 87 | 88 | return data 89 | -------------------------------------------------------------------------------- /src/betamax/util.py: -------------------------------------------------------------------------------- 1 | from .mock_response import MockHTTPResponse 2 | from datetime import datetime, timezone 3 | from requests.models import PreparedRequest, Response 4 | from requests.packages.urllib3 import HTTPResponse 5 | from requests.structures import CaseInsensitiveDict 6 | from requests.status_codes import _codes 7 | from requests.cookies import RequestsCookieJar 8 | 9 | try: 10 | from requests.packages.urllib3._collections import HTTPHeaderDict 11 | except ImportError: 12 | from .headers import HTTPHeaderDict 13 | 14 | import base64 15 | import io 16 | import sys 17 | 18 | 19 | def coerce_content(content, encoding=None): 20 | if hasattr(content, 'decode'): 21 | content = content.decode(encoding or 'utf-8', 'replace') 22 | return content 23 | 24 | 25 | def body_io(string, encoding=None): 26 | if hasattr(string, 'encode'): 27 | string = string.encode(encoding or 'utf-8') 28 | return io.BytesIO(string) 29 | 30 | 31 | def from_list(value): 32 | if isinstance(value, list): 33 | return value[0] 34 | return value 35 | 36 | 37 | def add_body(r, preserve_exact_body_bytes, body_dict): 38 | """Simple function which takes a response or request and coerces the body. 39 | 40 | This function adds either ``'string'`` or ``'base64_string'`` to 41 | ``body_dict``. If ``preserve_exact_body_bytes`` is ``True`` then it 42 | encodes the body as a base64 string and saves it like that. Otherwise, 43 | it saves the plain string. 44 | 45 | :param r: This is either a PreparedRequest instance or a Response 46 | instance. 47 | :param preserve_exact_body_bytes bool: Either True or False. 48 | :param body_dict dict: A dictionary already containing the encoding to be 49 | used. 50 | """ 51 | body = getattr(r, 'raw', getattr(r, 'body', None)) 52 | if hasattr(body, 'read'): 53 | body = body.read() 54 | 55 | if not body: 56 | body = '' 57 | 58 | if (preserve_exact_body_bytes or 59 | 'gzip' in r.headers.get('Content-Encoding', '')): 60 | if sys.version_info >= (3, 0) and hasattr(body, 'encode'): 61 | body = body.encode(body_dict['encoding'] or 'utf-8') 62 | 63 | body_dict['base64_string'] = base64.b64encode(body).decode() 64 | else: 65 | body_dict['string'] = coerce_content(body, body_dict['encoding']) 66 | 67 | 68 | def serialize_prepared_request(request, preserve_exact_body_bytes): 69 | headers = request.headers 70 | body = {'encoding': 'utf-8'} 71 | add_body(request, preserve_exact_body_bytes, body) 72 | return { 73 | 'body': body, 74 | 'headers': dict( 75 | (coerce_content(k, 'utf-8'), [v]) for (k, v) in headers.items() 76 | ), 77 | 'method': request.method, 78 | 'uri': request.url, 79 | } 80 | 81 | 82 | def deserialize_prepared_request(serialized): 83 | p = PreparedRequest() 84 | p._cookies = RequestsCookieJar() 85 | body = serialized['body'] 86 | if isinstance(body, dict): 87 | original_body = body.get('string') 88 | p.body = original_body or base64.b64decode( 89 | body.get('base64_string', '').encode()) 90 | else: 91 | p.body = body 92 | h = [(k, from_list(v)) for k, v in serialized['headers'].items()] 93 | p.headers = CaseInsensitiveDict(h) 94 | p.method = serialized['method'] 95 | p.url = serialized['uri'] 96 | return p 97 | 98 | 99 | def serialize_response(response, preserve_exact_body_bytes): 100 | body = {'encoding': response.encoding} 101 | add_body(response, preserve_exact_body_bytes, body) 102 | header_map = HTTPHeaderDict(response.raw.headers) 103 | headers = {} 104 | for header_name in header_map.keys(): 105 | headers[header_name] = header_map.getlist(header_name) 106 | 107 | return { 108 | 'body': body, 109 | 'headers': headers, 110 | 'status': {'code': response.status_code, 'message': response.reason}, 111 | 'url': response.url, 112 | } 113 | 114 | 115 | def deserialize_response(serialized): 116 | r = Response() 117 | r.encoding = serialized['body']['encoding'] 118 | header_dict = HTTPHeaderDict() 119 | 120 | for header_name, header_list in serialized['headers'].items(): 121 | if isinstance(header_list, list): 122 | for header_value in header_list: 123 | header_dict.add(header_name, header_value) 124 | else: 125 | header_dict.add(header_name, header_list) 126 | r.headers = CaseInsensitiveDict(header_dict) 127 | 128 | r.url = serialized.get('url', '') 129 | if 'status' in serialized: 130 | r.status_code = serialized['status']['code'] 131 | r.reason = serialized['status']['message'] 132 | else: 133 | r.status_code = serialized['status_code'] 134 | r.reason = _codes[r.status_code][0].upper() 135 | add_urllib3_response(serialized, r, header_dict) 136 | return r 137 | 138 | 139 | def add_urllib3_response(serialized, response, headers): 140 | if 'base64_string' in serialized['body']: 141 | body = io.BytesIO( 142 | base64.b64decode(serialized['body']['base64_string'].encode()) 143 | ) 144 | else: 145 | body = body_io(**serialized['body']) 146 | 147 | h = HTTPResponse( 148 | body, 149 | status=response.status_code, 150 | reason=response.reason, 151 | headers=headers, 152 | preload_content=False, 153 | original_response=MockHTTPResponse(headers) 154 | ) 155 | # NOTE(sigmavirus24): 156 | # urllib3 updated it's chunked encoding handling which breaks on recorded 157 | # responses. Since a recorded response cannot be streamed appropriately 158 | # for this handling to work, we can preserve the integrity of the data in 159 | # the response by forcing the chunked attribute to always be False. 160 | # This isn't pretty, but it is much better than munging a response. 161 | h.chunked = False 162 | response.raw = h 163 | 164 | 165 | def timestamp(): 166 | stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") 167 | try: 168 | i = stamp.rindex('.') 169 | except ValueError: 170 | return stamp 171 | else: 172 | return stamp[:i] 173 | 174 | 175 | _SENTINEL = object() 176 | 177 | 178 | def _option_from(option, kwargs, defaults): 179 | value = kwargs.get(option, _SENTINEL) 180 | if value is _SENTINEL: 181 | value = defaults.get(option) 182 | return value 183 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betamaxpy/betamax/8f3d284103676a43d1481b5cffae96f3a601e0be/tests/__init__.py -------------------------------------------------------------------------------- /tests/cassettes/FakeBetamaxTestCase.test_fake.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [], "recorded_with": "betamax/0.8.2"} -------------------------------------------------------------------------------- /tests/cassettes/global_preserve_exact_body_bytes.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"base64_string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["gzip, deflate, compress"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.0.0 CPython/2.7.5 Darwin/13.1.0"]}, "method": "GET", "uri": "https://httpbin.org/get"}, "response": {"body": {"base64_string": "ewogICJhcmdzIjoge30sCiAgInVybCI6ICJodHRwOi8vaHR0cGJpbi5vcmcvZ2V0IiwKICAiaGVhZGVycyI6IHsKICAgICJYLVJlcXVlc3QtSWQiOiAiZTJlNDAwYjgtNTVjNC00NTVkLWIwMmYtYjcwZTYzYmI4ZGYyIiwKICAgICJDb25uZWN0aW9uIjogImNsb3NlIiwKICAgICJBY2NlcHQtRW5jb2RpbmciOiAiZ3ppcCwgZGVmbGF0ZSwgY29tcHJlc3MiLAogICAgIkFjY2VwdCI6ICIqLyoiLAogICAgIlVzZXItQWdlbnQiOiAicHl0aG9uLXJlcXVlc3RzLzIuMC4wIENQeXRob24vMi43LjUgRGFyd2luLzEzLjEuMCIsCiAgICAiSG9zdCI6ICJodHRwYmluLm9yZyIKICB9LAogICJvcmlnaW4iOiAiNjYuMTcxLjE3My4yNTAiCn0=", "encoding": null}, "headers": {"content-length": ["356"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Fri, 11 Apr 2014 20:30:30 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get"}, "recorded_at": "2014-04-11T20:30:30"}], "recorded_with": "betamax/{version}"} -------------------------------------------------------------------------------- /tests/cassettes/handles_digest_auth.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.3.0 CPython/2.7.5 Darwin/13.1.0"]}, "method": "GET", "uri": "https://httpbin.org/digest-auth/auth/user/passwd"}, "response": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"content-length": ["0"], "set-cookie": ["fake=fake_value"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Fri, 02 May 2014 13:13:26 GMT"], "access-control-allow-origin": ["*"], "content-type": ["text/html; charset=utf-8"], "www-authenticate": ["Digest opaque=\"8202f396ae9e04ba77f99a024a8cf5eb\", qop=auth, nonce=\"ad49767e1b450af7af35d21da282cbee\", realm=\"me@kennethreitz.com\""]}, "status": {"message": "UNAUTHORIZED", "code": 401}, "url": "https://httpbin.org/digest-auth/auth/user/passwd"}, "recorded_at": "2014-05-02T13:13:26"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept": ["*/*"], "Cookie": ["fake=fake_value"], "Accept-Encoding": ["gzip, deflate"], "Authorization": ["Digest username=\"user\", realm=\"me@kennethreitz.com\", nonce=\"ad49767e1b450af7af35d21da282cbee\", uri=\"/digest-auth/auth/user/passwd\", response=\"af73a28fe4b2ee57c87b73f4c523e0d4\", opaque=\"8202f396ae9e04ba77f99a024a8cf5eb\", qop=\"auth\", nc=00000001, cnonce=\"75bdd2263bb91c0a\""], "User-Agent": ["python-requests/2.3.0 CPython/2.7.5 Darwin/13.1.0"]}, "method": "GET", "uri": "https://httpbin.org/digest-auth/auth/user/passwd"}, "response": {"body": {"string": "{\n \"user\": \"user\",\n \"authenticated\": true\n}", "encoding": null}, "headers": {"content-length": ["45"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Fri, 02 May 2014 13:13:26 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/digest-auth/auth/user/passwd"}, "recorded_at": "2014-05-02T13:13:26"}], "recorded_with": "betamax/{version}"} -------------------------------------------------------------------------------- /tests/cassettes/once_record_mode.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"recorded_at": "2013-12-22T16:30:30", "response": {"headers": {"server": "gunicorn/0.17.4", "access-control-allow-origin": "*", "date": "Sun, 22 Dec 2013 16:31:13 GMT", "connection": "keep-alive", "content-type": "application/json", "content-length": "295"}, "status_code": 200, "body": {"string": "{\n \"headers\": {\n \"Accept-Encoding\": \"gzip, deflate, compress\",\n \"Accept\": \"*/*\",\n \"Host\": \"httpbin.org\",\n \"User-Agent\": \"python-requests/2.0.0 CPython/3.3.2 Linux/3.2.29\",\n \"Connection\": \"close\"\n },\n \"origin\": \"72.160.214.132\",\n \"args\": {},\n \"url\": \"http://httpbin.org/get\"\n}", "encoding": null}, "url": "http://httpbin.org/get"}, "request": {"method": "GET", "headers": {"Accept-Encoding": "gzip, deflate, compress", "Accept": "*/*", "User-Agent": "python-requests/2.0.0 CPython/3.3.2 Linux/3.2.29"}, "uri": "http://httpbin.org/get", "body": ""}}], "recorded_with": "betamax"} -------------------------------------------------------------------------------- /tests/cassettes/preserve_exact_bytes.json: -------------------------------------------------------------------------------- 1 | {"recorded_with": "betamax/0.4.2", "http_interactions": [{"recorded_at": "2015-06-27T15:39:25", "request": {"method": "POST", "uri": "https://httpbin.org/post", "headers": {"Content-Length": ["3"], "User-Agent": ["python-requests/2.7.0 CPython/3.4.3 Darwin/14.3.0"], "Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Content-Type": ["application/x-www-form-urlencoded"], "Accept": ["*/*"]}, "body": {"base64_string": "YT0x", "encoding": "utf-8"}}, "response": {"url": "https://httpbin.org/post", "headers": {"access-control-allow-origin": ["*"], "content-type": ["application/json"], "server": ["nginx"], "date": ["Sat, 27 Jun 2015 15:39:25 GMT"], "content-length": ["430"], "access-control-allow-credentials": ["true"], "connection": ["keep-alive"]}, "status": {"code": 200, "message": "OK"}, "body": {"base64_string": "ewogICJhcmdzIjoge30sIAogICJkYXRhIjogIiIsIAogICJmaWxlcyI6IHt9LCAKICAiZm9ybSI6IHsKICAgICJhIjogIjEiCiAgfSwgCiAgImhlYWRlcnMiOiB7CiAgICAiQWNjZXB0IjogIiovKiIsIAogICAgIkFjY2VwdC1FbmNvZGluZyI6ICJnemlwLCBkZWZsYXRlIiwgCiAgICAiQ29udGVudC1MZW5ndGgiOiAiMyIsIAogICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQiLCAKICAgICJIb3N0IjogImh0dHBiaW4ub3JnIiwgCiAgICAiVXNlci1BZ2VudCI6ICJweXRob24tcmVxdWVzdHMvMi43LjAgQ1B5dGhvbi8zLjQuMyBEYXJ3aW4vMTQuMy4wIgogIH0sIAogICJqc29uIjogbnVsbCwgCiAgIm9yaWdpbiI6ICI2OC42LjkxLjIzOSIsIAogICJ1cmwiOiAiaHR0cHM6Ly9odHRwYmluLm9yZy9wb3N0Igp9Cg==", "encoding": null}}}]} -------------------------------------------------------------------------------- /tests/cassettes/replay_interactions.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"response": {"url": "http://httpbin.org/get", "status": {"message": "OK", "code": 200}, "body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.9.1\"\n }, \n \"origin\": \"96.37.91.79\", \n \"url\": \"http://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"Access-Control-Allow-Origin": ["*"], "Access-Control-Allow-Credentials": ["true"], "Connection": ["keep-alive"], "Content-Type": ["application/json"], "Date": ["Thu, 07 Apr 2016 13:22:08 GMT"], "Server": ["nginx"], "Content-Length": ["235"]}}, "recorded_at": "2016-04-07T13:22:13", "request": {"uri": "http://httpbin.org/get", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept": ["*/*"], "Connection": ["keep-alive"], "User-Agent": ["python-requests/2.9.1"], "Accept-Encoding": ["gzip, deflate"]}}}], "recorded_with": "betamax/0.5.1"} -------------------------------------------------------------------------------- /tests/cassettes/replay_multiple_times.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"]}, "method": "GET", "uri": "http://httpbin.org/stream/5"}, "response": {"body": {"string": "{\"url\": \"http://httpbin.org/stream/5\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 0, \"origin\": \"188.39.80.202\"}\n{\"url\": \"http://httpbin.org/stream/5\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 1, \"origin\": \"188.39.80.202\"}\n{\"url\": \"http://httpbin.org/stream/5\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 2, \"origin\": \"188.39.80.202\"}\n{\"url\": \"http://httpbin.org/stream/5\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 3, \"origin\": \"188.39.80.202\"}\n{\"url\": \"http://httpbin.org/stream/5\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 4, \"origin\": \"188.39.80.202\"}\n", "encoding": null}, "headers": {"Transfer-Encoding": ["chunked"], "Server": ["nginx"], "Connection": ["keep-alive"], "Access-Control-Allow-Credentials": ["true"], "Date": ["Fri, 25 Mar 2016 17:06:34 GMT"], "Access-Control-Allow-Origin": ["*"], "Content-Type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "http://httpbin.org/stream/5"}, "recorded_at": "2016-03-25T17:06:34"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"]}, "method": "GET", "uri": "http://httpbin.org/stream/1"}, "response": {"body": {"string": "{\"url\": \"http://httpbin.org/stream/1\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 0, \"origin\": \"188.39.80.202\"}\n", "encoding": null}, "headers": {"Transfer-Encoding": ["chunked"], "Server": ["nginx"], "Connection": ["keep-alive"], "Access-Control-Allow-Credentials": ["true"], "Date": ["Fri, 25 Mar 2016 17:20:38 GMT"], "Access-Control-Allow-Origin": ["*"], "Content-Type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "http://httpbin.org/stream/1"}, "recorded_at": "2016-03-25T17:20:38"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"]}, "method": "GET", "uri": "http://httpbin.org/stream/3"}, "response": {"body": {"string": "{\"url\": \"http://httpbin.org/stream/3\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 0, \"origin\": \"188.39.80.202\"}\n{\"url\": \"http://httpbin.org/stream/3\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 1, \"origin\": \"188.39.80.202\"}\n{\"url\": \"http://httpbin.org/stream/3\", \"headers\": {\"Host\": \"httpbin.org\", \"Accept-Encoding\": \"gzip, deflate\", \"Accept\": \"*/*\", \"User-Agent\": \"python-requests/2.9.1\"}, \"args\": {}, \"id\": 2, \"origin\": \"188.39.80.202\"}\n", "encoding": null}, "headers": {"Transfer-Encoding": ["chunked"], "Server": ["nginx"], "Connection": ["keep-alive"], "Access-Control-Allow-Credentials": ["true"], "Date": ["Fri, 25 Mar 2016 17:37:06 GMT"], "Access-Control-Allow-Origin": ["*"], "Content-Type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "http://httpbin.org/stream/3"}, "recorded_at": "2016-03-25T17:37:06"}], "recorded_with": "betamax/0.5.1"} -------------------------------------------------------------------------------- /tests/cassettes/test-multiple-cookies-regression.json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": ""}, "method": "GET", "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "User-Agent": ["python-requests/2.6.0 CPython/3.4.2 Darwin/14.1.0"], "Accept": ["*/*"]}, "uri": "https://httpbin.org/cookies/set?cookie2=value2&cookie1=value1&cookie3=value3&cookie0=value0"}, "recorded_at": "2015-04-18T16:05:26", "response": {"body": {"encoding": "utf-8", "string": "\nRedirecting...\n

Redirecting...

\n

You should be redirected automatically to target URL: /cookies. If not click the link."}, "url": "https://httpbin.org/cookies/set?cookie2=value2&cookie1=value1&cookie3=value3&cookie0=value0", "headers": {"date": ["Sat, 18 Apr 2015 16:05:26 GMT"], "content-length": ["223"], "set-cookie": ["cookie1=value1; Path=/", "cookie0=value0; Path=/", "cookie3=value3; Path=/", "cookie2=value2; Path=/"], "location": ["/cookies"], "connection": ["keep-alive"], "access-control-allow-origin": ["*"], "content-type": ["text/html; charset=utf-8"], "access-control-allow-credentials": ["true"], "server": ["nginx"]}, "status": {"message": "FOUND", "code": 302}}}, {"request": {"body": {"encoding": "utf-8", "string": ""}, "method": "GET", "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "User-Agent": ["python-requests/2.6.0 CPython/3.4.2 Darwin/14.1.0"], "Accept": ["*/*"], "Cookie": ["cookie2=value2; cookie1=value1; cookie3=value3; cookie0=value0"]}, "uri": "https://httpbin.org/cookies"}, "recorded_at": "2015-04-18T16:05:26", "response": {"body": {"encoding": null, "string": "{\n \"cookies\": {\n \"cookie0\": \"value0\", \n \"cookie1\": \"value1\", \n \"cookie2\": \"value2\", \n \"cookie3\": \"value3\"\n }\n}\n"}, "url": "https://httpbin.org/cookies", "headers": {"date": ["Sat, 18 Apr 2015 16:05:26 GMT"], "content-length": ["125"], "access-control-allow-credentials": ["true"], "connection": ["keep-alive"], "access-control-allow-origin": ["*"], "content-type": ["application/json"], "server": ["nginx"]}, "status": {"message": "OK", "code": 200}}}], "recorded_with": "betamax/0.4.1"} -------------------------------------------------------------------------------- /tests/cassettes/test.json: -------------------------------------------------------------------------------- 1 | {"recorded_with": "betamax", "http_interactions": []} -------------------------------------------------------------------------------- /tests/cassettes/test_replays_response_on_right_order.json: -------------------------------------------------------------------------------- 1 | { 2 | "http_interactions": [ 3 | { 4 | "recorded_at": "2013-12-22T16:30:30", 5 | "response": { 6 | "headers": { 7 | "server": "gunicorn/0.17.4", 8 | "access-control-allow-origin": "*", 9 | "date": "Sun, 22 Dec 2013 16:31:13 GMT", 10 | "connection": "keep-alive", 11 | "content-type": "application/json", 12 | "content-length": "295" 13 | }, 14 | "status_code": 200, 15 | "body": { 16 | "string": "{\n \"headers\": {\n \"Accept-Encoding\": \"gzip, deflate, compress\",\n \"Accept\": \"*/*\",\n \"Host\": \"httpbin.org\",\n \"User-Agent\": \"python-requests/2.0.0 CPython/3.3.2 Linux/3.2.29\",\n \"Connection\": \"close\"\n },\n \"origin\": \"72.160.214.132\",\n \"args\": {},\n \"url\": \"http://httpbin.org/get\"\n}", 17 | "encoding": null 18 | }, 19 | "url": "http://httpbin.org/get" 20 | }, 21 | "request": { 22 | "method": "GET", 23 | "headers": { 24 | "Accept-Encoding": "gzip, deflate, compress", 25 | "Accept": "*/*", 26 | "User-Agent": "python-requests/2.0.0 CPython/3.3.2 Linux/3.2.29" 27 | }, 28 | "uri": "http://httpbin.org/get", 29 | "body": "" 30 | } 31 | }, 32 | { 33 | "recorded_at": "2013-12-22T16:30:30", 34 | "response": { 35 | "headers": { 36 | "server": "gunicorn/0.17.4", 37 | "access-control-allow-origin": "*", 38 | "date": "Sun, 22 Dec 2013 16:31:13 GMT", 39 | "connection": "keep-alive", 40 | "content-type": "application/json", 41 | "content-length": "295" 42 | }, 43 | "status_code": 200, 44 | "body": { 45 | "string": "{\n \"headers\": {\n \"Accept-Encoding\": \"gzip, deflate, compress\",\n \"Accept\": \"*/*\",\n \"Host\": \"httpbin.org\",\n \"User-Agent\": \"python-requests/2.0.0 CPython/3.3.2 Linux/3.2.29\",\n \"Connection\": \"close\"\n },\n \"origin\": \"72.160.214.133\",\n \"args\": {},\n \"url\": \"http://httpbin.org/get\"\n}", 46 | "encoding": null 47 | }, 48 | "url": "http://httpbin.org/get" 49 | }, 50 | "request": { 51 | "method": "GET", 52 | "headers": { 53 | "Accept-Encoding": "gzip, deflate, compress", 54 | "Accept": "*/*", 55 | "User-Agent": "python-requests/2.0.0 CPython/3.3.2 Linux/3.2.29" 56 | }, 57 | "uri": "http://httpbin.org/get", 58 | "body": "" 59 | } 60 | } 61 | ], 62 | "recorded_with": "betamax" 63 | } 64 | -------------------------------------------------------------------------------- /tests/cassettes/tests.integration.test_fixtures.TestPyTestFixtures.test_pytest_fixture.json: -------------------------------------------------------------------------------- 1 | {"recorded_with": "betamax/0.4.2", "http_interactions": [{"recorded_at": "2015-05-25T00:46:42", "response": {"body": {"encoding": null, "string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.6.0 CPython/3.4.2 Darwin/14.1.0\"\n }, \n \"origin\": \"72.160.201.47\", \n \"url\": \"https://httpbin.org/get\"\n}\n"}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get", "headers": {"connection": ["keep-alive"], "content-type": ["application/json"], "content-length": ["266"], "date": ["Mon, 25 May 2015 00:46:42 GMT"], "access-control-allow-origin": ["*"], "access-control-allow-credentials": ["true"], "server": ["nginx"]}}, "request": {"method": "GET", "body": {"encoding": "utf-8", "string": ""}, "uri": "https://httpbin.org/get", "headers": {"Connection": ["keep-alive"], "User-Agent": ["python-requests/2.6.0 CPython/3.4.2 Darwin/14.1.0"], "Accept": ["*/*"], "Accept-Encoding": ["gzip, deflate"]}}}]} -------------------------------------------------------------------------------- /tests/cassettes/tests.integration.test_fixtures.TestPyTestParametrizedFixtures.test_pytest_fixture[https---httpbin.org-get].json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.13.0"]}, "method": "GET", "uri": "https://httpbin.org/get"}, "response": {"body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.13.0\"\n }, \n \"origin\": \"216.98.56.20\", \n \"url\": \"https://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"Content-Length": ["238"], "Server": ["nginx"], "Connection": ["keep-alive"], "Access-Control-Allow-Credentials": ["true"], "Date": ["Fri, 10 Mar 2017 16:58:21 GMT"], "Access-Control-Allow-Origin": ["*"], "Content-Type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "https://httpbin.org/get"}, "recorded_at": "2017-03-10T16:58:21"}], "recorded_with": "betamax/0.8.0"} -------------------------------------------------------------------------------- /tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[aaa-bbb].json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [], "recorded_with": "betamax/0.8.0"} -------------------------------------------------------------------------------- /tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[ccc-ddd].json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [], "recorded_with": "betamax/0.8.0"} -------------------------------------------------------------------------------- /tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[eee-fff].json: -------------------------------------------------------------------------------- 1 | {"http_interactions": [], "recorded_with": "betamax/0.8.0"} -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import betamax 4 | 5 | sys.path.insert(0, os.path.abspath('.')) 6 | 7 | with betamax.Betamax.configure() as config: 8 | config.cassette_library_dir = 'tests/cassettes/' 9 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betamaxpy/betamax/8f3d284103676a43d1481b5cffae96f3a601e0be/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from requests import Session 5 | 6 | 7 | class IntegrationHelper(unittest.TestCase): 8 | cassette_created = True 9 | 10 | def setUp(self): 11 | self.cassette_path = None 12 | self.session = Session() 13 | 14 | def tearDown(self): 15 | if self.cassette_created: 16 | assert self.cassette_path is not None 17 | os.unlink(self.cassette_path) 18 | -------------------------------------------------------------------------------- /tests/integration/test_allow_playback_repeats.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | 3 | from tests.integration import helper 4 | 5 | 6 | class TestPlaybackRepeatInteractions(helper.IntegrationHelper): 7 | def test_will_replay_the_same_interaction(self): 8 | self.cassette_created = False 9 | s = self.session 10 | recorder = betamax.Betamax(s) 11 | # NOTE(sigmavirus24): Ensure the cassette is recorded 12 | with recorder.use_cassette('replay_interactions'): 13 | cassette = recorder.current_cassette 14 | r = s.get('http://httpbin.org/get') 15 | assert r.status_code == 200 16 | assert len(cassette.interactions) == 1 17 | 18 | with recorder.use_cassette('replay_interactions', 19 | allow_playback_repeats=True): 20 | cassette = recorder.current_cassette 21 | r = s.get('http://httpbin.org/get') 22 | assert r.status_code == 200 23 | assert len(cassette.interactions) == 1 24 | r = s.get('http://httpbin.org/get') 25 | assert r.status_code == 200 26 | assert len(cassette.interactions) == 1 27 | assert cassette.interactions[0].used is False 28 | -------------------------------------------------------------------------------- /tests/integration/test_backwards_compat.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | import copy 3 | from .helper import IntegrationHelper 4 | 5 | 6 | class TestBackwardsCompatibleSerialization(IntegrationHelper): 7 | def setUp(self): 8 | super(TestBackwardsCompatibleSerialization, self).setUp() 9 | self.cassette_created = False 10 | opts = betamax.cassette.Cassette.default_cassette_options 11 | self.original_defaults = copy.deepcopy(opts) 12 | 13 | with betamax.Betamax.configure() as config: 14 | config.define_cassette_placeholder('', 'nothing to replace') 15 | 16 | def tearDown(self): 17 | super(TestBackwardsCompatibleSerialization, self).setUp() 18 | Cassette = betamax.cassette.Cassette 19 | Cassette.default_cassette_options = self.original_defaults 20 | 21 | def test_can_deserialize_an_old_cassette(self): 22 | with betamax.Betamax(self.session).use_cassette('GitHub_emojis') as b: 23 | assert b.current_cassette is not None 24 | cassette = b.current_cassette 25 | assert len(cassette.interactions) > -1 26 | 27 | def test_matches_old_request_data(self): 28 | with betamax.Betamax(self.session).use_cassette('GitHub_emojis'): 29 | r = self.session.get('https://api.github.com/emojis') 30 | assert r is not None 31 | 32 | def tests_populates_correct_fields_with_missing_data(self): 33 | with betamax.Betamax(self.session).use_cassette('GitHub_emojis'): 34 | r = self.session.get('https://api.github.com/emojis') 35 | assert r.reason == 'OK' 36 | assert r.status_code == 200 37 | 38 | def tests_deserializes_old_cassette_headers(self): 39 | with betamax.Betamax(self.session).use_cassette('GitHub_emojis') as b: 40 | self.session.get('https://api.github.com/emojis') 41 | interaction = b.current_cassette.interactions[0].data 42 | header = interaction['request']['headers']['Accept'] 43 | assert not isinstance(header, list) 44 | -------------------------------------------------------------------------------- /tests/integration/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.usefixtures('betamax_session') 7 | class TestPyTestFixtures: 8 | @pytest.fixture(autouse=True) 9 | def setup(self, request): 10 | """After test hook to assert everything.""" 11 | def finalizer(): 12 | test_dir = os.path.abspath('.') 13 | cassette_name = ('tests.integration.test_fixtures.' # Module name 14 | 'TestPyTestFixtures.' # Class name 15 | 'test_pytest_fixture' # Test function name 16 | '.json') 17 | file_name = os.path.join(test_dir, 'tests', 'cassettes', 18 | cassette_name) 19 | assert os.path.exists(file_name) is True 20 | 21 | request.addfinalizer(finalizer) 22 | 23 | def test_pytest_fixture(self, betamax_session): 24 | """Exercise the fixture itself.""" 25 | resp = betamax_session.get('https://httpbin.org/get') 26 | assert resp.ok 27 | 28 | 29 | @pytest.mark.usefixtures('betamax_parametrized_session') 30 | class TestPyTestParametrizedFixtures: 31 | @pytest.fixture(autouse=True) 32 | def setup(self, request): 33 | """After test hook to assert everything.""" 34 | def finalizer(): 35 | test_dir = os.path.abspath('.') 36 | cassette_name = ('tests.integration.test_fixtures.' # Module name 37 | 'TestPyTestParametrizedFixtures.' # Class name 38 | 'test_pytest_fixture' # Test function name 39 | '[https---httpbin.org-get]' # Parameter 40 | '.json') 41 | file_name = os.path.join(test_dir, 'tests', 'cassettes', 42 | cassette_name) 43 | assert os.path.exists(file_name) is True 44 | 45 | request.addfinalizer(finalizer) 46 | 47 | @pytest.mark.parametrize('url', ('https://httpbin.org/get',)) 48 | def test_pytest_fixture(self, betamax_parametrized_session, url): 49 | """Exercise the fixture itself.""" 50 | resp = betamax_parametrized_session.get(url) 51 | assert resp.ok 52 | 53 | 54 | @pytest.mark.parametrize('problematic_arg', [r'aaa\bbb', 'ccc:ddd', 'eee*fff']) 55 | def test_pytest_parametrize_with_filesystem_problematic_chars( 56 | betamax_parametrized_session, problematic_arg): 57 | """ 58 | Exercice parametrized args containing characters which might cause 59 | problems when getting translated into file names. """ 60 | assert True 61 | -------------------------------------------------------------------------------- /tests/integration/test_hooks.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | 3 | from . import helper 4 | 5 | 6 | def prerecord_hook(interaction, cassette): 7 | assert cassette.interactions == [] 8 | interaction.data['response']['headers']['Betamax-Fake-Header'] = 'success' 9 | 10 | 11 | def ignoring_hook(interaction, cassette): 12 | interaction.ignore() 13 | 14 | 15 | def preplayback_hook(interaction, cassette): 16 | assert cassette.interactions != [] 17 | interaction.data['response']['headers']['Betamax-Fake-Header'] = 'temp' 18 | 19 | 20 | class Counter(object): 21 | def __init__(self): 22 | self.value = 0 23 | 24 | def increment(self, cassette): 25 | self.value += 1 26 | 27 | 28 | class TestHooks(helper.IntegrationHelper): 29 | def tearDown(self): 30 | super(TestHooks, self).tearDown() 31 | # Clear out the hooks 32 | betamax.configure.Configuration.recording_hooks.pop('after_start', None) 33 | betamax.cassette.Cassette.hooks.pop('before_record', None) 34 | betamax.cassette.Cassette.hooks.pop('before_playback', None) 35 | betamax.configure.Configuration.recording_hooks.pop('before_stop', None) 36 | 37 | def test_post_start_hook(self): 38 | start_count = Counter() 39 | with betamax.Betamax.configure() as config: 40 | config.after_start(callback=start_count.increment) 41 | 42 | recorder = betamax.Betamax(self.session) 43 | 44 | assert start_count.value == 0 45 | with recorder.use_cassette('after_start_hook'): 46 | assert start_count.value == 1 47 | self.cassette_path = recorder.current_cassette.cassette_path 48 | self.session.get('https://httpbin.org/get') 49 | 50 | assert start_count.value == 1 51 | with recorder.use_cassette('after_start_hook', record='none'): 52 | assert start_count.value == 2 53 | self.session.get('https://httpbin.org/get') 54 | assert start_count.value == 2 55 | 56 | def test_pre_stop_hook(self): 57 | stop_count = Counter() 58 | with betamax.Betamax.configure() as config: 59 | config.before_stop(callback=stop_count.increment) 60 | 61 | recorder = betamax.Betamax(self.session) 62 | 63 | assert stop_count.value == 0 64 | with recorder.use_cassette('before_stop_hook'): 65 | self.cassette_path = recorder.current_cassette.cassette_path 66 | self.session.get('https://httpbin.org/get') 67 | assert stop_count.value == 0 68 | assert stop_count.value == 1 69 | 70 | with recorder.use_cassette('before_stop_hook', record='none'): 71 | self.session.get('https://httpbin.org/get') 72 | assert stop_count.value == 1 73 | assert stop_count.value == 2 74 | 75 | def test_prerecord_hook(self): 76 | with betamax.Betamax.configure() as config: 77 | config.before_record(callback=prerecord_hook) 78 | 79 | recorder = betamax.Betamax(self.session) 80 | with recorder.use_cassette('prerecord_hook'): 81 | self.cassette_path = recorder.current_cassette.cassette_path 82 | response = self.session.get('https://httpbin.org/get') 83 | assert response.headers['Betamax-Fake-Header'] == 'success' 84 | 85 | with recorder.use_cassette('prerecord_hook', record='none'): 86 | response = self.session.get('https://httpbin.org/get') 87 | assert response.headers['Betamax-Fake-Header'] == 'success' 88 | 89 | def test_preplayback_hook(self): 90 | with betamax.Betamax.configure() as config: 91 | config.before_playback(callback=preplayback_hook) 92 | 93 | recorder = betamax.Betamax(self.session) 94 | with recorder.use_cassette('preplayback_hook'): 95 | self.cassette_path = recorder.current_cassette.cassette_path 96 | self.session.get('https://httpbin.org/get') 97 | 98 | with recorder.use_cassette('preplayback_hook', record='none'): 99 | response = self.session.get('https://httpbin.org/get') 100 | assert response.headers['Betamax-Fake-Header'] == 'temp' 101 | 102 | def test_prerecord_ignoring_hook(self): 103 | with betamax.Betamax.configure() as config: 104 | config.before_record(callback=ignoring_hook) 105 | 106 | recorder = betamax.Betamax(self.session) 107 | with recorder.use_cassette('ignore_hook'): 108 | self.cassette_path = recorder.current_cassette.cassette_path 109 | self.session.get('https://httpbin.org/get') 110 | assert recorder.current_cassette.interactions == [] 111 | -------------------------------------------------------------------------------- /tests/integration/test_multiple_cookies.py: -------------------------------------------------------------------------------- 1 | import betamax 2 | 3 | from .helper import IntegrationHelper 4 | 5 | 6 | class TestMultipleCookies(IntegrationHelper): 7 | """Previously our handling of multiple instances of cookies was wrong. 8 | 9 | This set of tests is here to ensure that we properly serialize/deserialize 10 | the case where the client receives and betamax serializes multiple 11 | Set-Cookie headers. 12 | 13 | See the following for more information: 14 | 15 | - https://github.com/sigmavirus24/betamax/pull/60 16 | - https://github.com/sigmavirus24/betamax/pull/59 17 | - https://github.com/sigmavirus24/betamax/issues/58 18 | """ 19 | def setUp(self): 20 | super(TestMultipleCookies, self).setUp() 21 | self.cassette_created = False 22 | 23 | def test_multiple_cookies(self): 24 | """Make a request to httpbin.org and verify we serialize it correctly. 25 | 26 | We should be able to see that the cookiejar on the session has the 27 | cookies properly parsed and distinguished. 28 | """ 29 | recorder = betamax.Betamax(self.session) 30 | cassette_name = 'test-multiple-cookies-regression' 31 | url = 'https://httpbin.org/cookies/set' 32 | cookies = { 33 | 'cookie0': 'value0', 34 | 'cookie1': 'value1', 35 | 'cookie2': 'value2', 36 | 'cookie3': 'value3', 37 | } 38 | with recorder.use_cassette(cassette_name): 39 | self.session.get(url, params=cookies) 40 | 41 | for name, value in cookies.items(): 42 | assert self.session.cookies[name] == value 43 | -------------------------------------------------------------------------------- /tests/integration/test_placeholders.py: -------------------------------------------------------------------------------- 1 | from betamax import Betamax 2 | from betamax.cassette import Cassette 3 | 4 | from copy import deepcopy 5 | from tests.integration.helper import IntegrationHelper 6 | 7 | original_cassette_options = deepcopy(Cassette.default_cassette_options) 8 | b64_foobar = 'Zm9vOmJhcg==' # base64.b64encode('foo:bar') 9 | 10 | 11 | class TestPlaceholders(IntegrationHelper): 12 | def setUp(self): 13 | super(TestPlaceholders, self).setUp() 14 | config = Betamax.configure() 15 | config.define_cassette_placeholder('', b64_foobar) 16 | 17 | def tearDown(self): 18 | super(TestPlaceholders, self).tearDown() 19 | Cassette.default_cassette_options = original_cassette_options 20 | 21 | def test_placeholders_work(self): 22 | placeholders = Cassette.default_cassette_options['placeholders'] 23 | assert placeholders == [{ 24 | 'placeholder': '', 25 | 'replace': b64_foobar, 26 | }] 27 | 28 | s = self.session 29 | cassette = None 30 | with Betamax(s).use_cassette('test_placeholders') as recorder: 31 | r = s.get('http://httpbin.org/get', auth=('foo', 'bar')) 32 | cassette = recorder.current_cassette 33 | self.cassette_path = cassette.cassette_path 34 | assert r.status_code == 200 35 | auth = r.json()['headers']['Authorization'] 36 | assert b64_foobar in auth 37 | 38 | self.cassette_path = cassette.cassette_path 39 | i = cassette.interactions[0] 40 | auth = i.data['request']['headers']['Authorization'] 41 | assert '' in auth[0] 42 | -------------------------------------------------------------------------------- /tests/integration/test_preserve_exact_body_bytes.py: -------------------------------------------------------------------------------- 1 | from .helper import IntegrationHelper 2 | from betamax import Betamax 3 | from betamax.cassette import Cassette 4 | 5 | import copy 6 | 7 | 8 | class TestPreserveExactBodyBytes(IntegrationHelper): 9 | def test_preserve_exact_body_bytes_does_not_munge_response_content(self): 10 | # Do not delete this cassette after the test 11 | self.cassette_created = False 12 | 13 | with Betamax(self.session) as b: 14 | b.use_cassette('preserve_exact_bytes', 15 | preserve_exact_body_bytes=True, 16 | match_requests_on=['uri', 'method', 'body']) 17 | r = self.session.post('https://httpbin.org/post', 18 | data={'a': 1}) 19 | assert 'headers' in r.json() 20 | 21 | interaction = b.current_cassette.interactions[0].data 22 | assert 'base64_string' in interaction['request']['body'] 23 | assert 'base64_string' in interaction['response']['body'] 24 | 25 | 26 | class TestPreserveExactBodyBytesForAllCassettes(IntegrationHelper): 27 | def setUp(self): 28 | super(TestPreserveExactBodyBytesForAllCassettes, self).setUp() 29 | self.orig = copy.deepcopy(Cassette.default_cassette_options) 30 | self.cassette_created = False 31 | 32 | def tearDown(self): 33 | super(TestPreserveExactBodyBytesForAllCassettes, self).tearDown() 34 | Cassette.default_cassette_options = self.orig 35 | 36 | def test_preserve_exact_body_bytes(self): 37 | with Betamax.configure() as config: 38 | config.preserve_exact_body_bytes = True 39 | 40 | with Betamax(self.session) as b: 41 | b.use_cassette('global_preserve_exact_body_bytes') 42 | r = self.session.get('https://httpbin.org/get') 43 | assert 'headers' in r.json() 44 | 45 | interaction = b.current_cassette.interactions[0].data 46 | assert 'base64_string' in interaction['response']['body'] 47 | -------------------------------------------------------------------------------- /tests/integration/test_unicode.py: -------------------------------------------------------------------------------- 1 | from betamax import Betamax 2 | from tests.integration.helper import IntegrationHelper 3 | 4 | 5 | class TestUnicode(IntegrationHelper): 6 | def test_unicode_is_saved_properly(self): 7 | s = self.session 8 | # https://github.com/kanzure/python-requestions/issues/4 9 | url = 'http://www.amazon.com/review/RAYTXRF3122TO' 10 | 11 | with Betamax(s).use_cassette('test_unicode') as beta: 12 | self.cassette_path = beta.current_cassette.cassette_path 13 | s.get(url) 14 | -------------------------------------------------------------------------------- /tests/regression/test_can_replay_interactions_multiple_times.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from betamax import Betamax 4 | from requests import Session 5 | 6 | 7 | class TestReplayInteractionMultipleTimes(unittest.TestCase): 8 | """ 9 | Test that an Interaction can be replayed multiple times within the same 10 | betamax session. 11 | """ 12 | def test_replay_interaction_more_than_once(self): 13 | s = Session() 14 | 15 | with Betamax(s).use_cassette('replay_multiple_times', record='once', 16 | allow_playback_repeats=True): 17 | for k in range(1, 5): 18 | r = s.get('http://httpbin.org/stream/3', stream=True) 19 | assert r.raw.read(1028), "Stream already consumed. Try: %d" % k 20 | -------------------------------------------------------------------------------- /tests/regression/test_cassettes_retain_global_configuration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import unittest 3 | 4 | from betamax import Betamax, cassette 5 | from requests import Session 6 | 7 | 8 | class TestCassetteRecordMode(unittest.TestCase): 9 | def setUp(self): 10 | with Betamax.configure() as config: 11 | config.default_cassette_options['record_mode'] = 'none' 12 | 13 | def tearDown(self): 14 | with Betamax.configure() as config: 15 | config.default_cassette_options['record_mode'] = 'once' 16 | 17 | def test_record_mode_is_none(self): 18 | s = Session() 19 | with pytest.raises(ValueError): 20 | with Betamax(s) as recorder: 21 | recorder.use_cassette('regression_record_mode') 22 | assert recorder.current_cassette is None 23 | 24 | def test_class_variables_retain_their_value(self): 25 | opts = cassette.Cassette.default_cassette_options 26 | assert opts['record_mode'] == 'none' 27 | -------------------------------------------------------------------------------- /tests/regression/test_gzip_compression.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from betamax import Betamax 5 | from requests import Session 6 | 7 | 8 | class TestGZIPRegression(unittest.TestCase): 9 | def tearDown(self): 10 | os.unlink('tests/cassettes/gzip_regression.json') 11 | 12 | def test_saves_content_as_gzip(self): 13 | s = Session() 14 | with Betamax(s).use_cassette('gzip_regression'): 15 | r = s.get( 16 | 'https://api.github.com/repos/github3py/fork_this/issues/1', 17 | headers={'Accept-Encoding': 'gzip, deflate, compress'} 18 | ) 19 | assert r.headers.get('Content-Encoding') == 'gzip' 20 | assert r.json() is not None 21 | 22 | r2 = s.get( 23 | 'https://api.github.com/repos/github3py/fork_this/issues/1', 24 | headers={'Accept-Encoding': 'gzip, deflate, compress'} 25 | ) 26 | assert r2.headers.get('Content-Encoding') == 'gzip' 27 | assert r2.json() is not None 28 | assert r2.json() == r.json() 29 | 30 | s = Session() 31 | with Betamax(s).use_cassette('gzip_regression'): 32 | r = s.get( 33 | 'https://api.github.com/repos/github3py/fork_this/issues/1' 34 | ) 35 | assert r.json() is not None 36 | -------------------------------------------------------------------------------- /tests/regression/test_once_prevents_new_interactions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import unittest 3 | 4 | from betamax import Betamax, BetamaxError 5 | from requests import Session 6 | 7 | 8 | class TestOncePreventsNewInteractions(unittest.TestCase): 9 | 10 | """Test that using a cassette with once record mode prevents new requests. 11 | 12 | """ 13 | 14 | def test_once_prevents_new_requests(self): 15 | s = Session() 16 | with Betamax(s).use_cassette('once_record_mode'): 17 | with pytest.raises(BetamaxError): 18 | s.get('http://example.com') 19 | -------------------------------------------------------------------------------- /tests/regression/test_requests_2_11_body_matcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import pytest 5 | import requests 6 | 7 | from betamax import Betamax 8 | 9 | 10 | class TestRequests211BodyMatcher(unittest.TestCase): 11 | def tearDown(self): 12 | os.unlink('tests/cassettes/requests_2_11_body_matcher.json') 13 | 14 | @pytest.mark.skipif(requests.__build__ < 0x020401, 15 | reason="No json keyword.") 16 | def test_requests_with_json_body(self): 17 | s = requests.Session() 18 | with Betamax(s).use_cassette('requests_2_11_body_matcher', 19 | match_requests_on=['body']): 20 | r = s.post('https://httpbin.org/post', json={'a': 2}) 21 | assert r.json() is not None 22 | 23 | s = requests.Session() 24 | with Betamax(s).use_cassette('requests_2_11_body_matcher', 25 | match_requests_on=['body']): 26 | r = s.post('https://httpbin.org/post', json={'a': 2}) 27 | assert r.json() is not None 28 | -------------------------------------------------------------------------------- /tests/regression/test_works_with_digest_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from betamax import Betamax 4 | from requests import Session 5 | from requests.auth import HTTPDigestAuth 6 | 7 | 8 | class TestDigestAuth(unittest.TestCase): 9 | def test_saves_content_as_gzip(self): 10 | s = Session() 11 | cassette_name = 'handles_digest_auth' 12 | match = ['method', 'uri', 'digest-auth'] 13 | with Betamax(s).use_cassette(cassette_name, match_requests_on=match): 14 | r = s.get('https://httpbin.org/digest-auth/auth/user/passwd', 15 | auth=HTTPDigestAuth('user', 'passwd')) 16 | assert r.ok 17 | assert r.history[0].status_code == 401 18 | 19 | s = Session() 20 | with Betamax(s).use_cassette(cassette_name, match_requests_on=match): 21 | r = s.get('https://httpbin.org/digest-auth/auth/user/passwd', 22 | auth=HTTPDigestAuth('user', 'passwd')) 23 | assert r.json() is not None 24 | -------------------------------------------------------------------------------- /tests/unit/test_adapter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | try: 4 | from unittest import mock 5 | except ImportError: 6 | import mock 7 | 8 | from betamax.adapter import BetamaxAdapter 9 | from requests.adapters import HTTPAdapter 10 | 11 | 12 | class TestBetamaxAdapter(unittest.TestCase): 13 | def setUp(self): 14 | http_adapter = mock.Mock() 15 | self.adapters_dict = {'http://': http_adapter} 16 | self.adapter = BetamaxAdapter(old_adapters=self.adapters_dict) 17 | 18 | def tearDown(self): 19 | self.adapter.eject_cassette() 20 | 21 | def test_has_http_adatper(self): 22 | assert self.adapter.http_adapter is not None 23 | assert isinstance(self.adapter.http_adapter, HTTPAdapter) 24 | 25 | def test_empty_initial_state(self): 26 | assert self.adapter.cassette is None 27 | assert self.adapter.cassette_name is None 28 | assert self.adapter.serialize is None 29 | 30 | def test_load_cassette(self): 31 | filename = 'test' 32 | self.adapter.load_cassette(filename, 'json', { 33 | 'record': 'none', 34 | 'cassette_library_dir': 'tests/cassettes/' 35 | }) 36 | assert self.adapter.cassette is not None 37 | assert self.adapter.cassette_name == filename 38 | -------------------------------------------------------------------------------- /tests/unit/test_betamax.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from betamax import Betamax, matchers 4 | from betamax.adapter import BetamaxAdapter 5 | from betamax.cassette import Cassette 6 | from requests import Session 7 | from requests.adapters import HTTPAdapter 8 | 9 | 10 | class TestBetamax(unittest.TestCase): 11 | def setUp(self): 12 | self.session = Session() 13 | self.vcr = Betamax(self.session) 14 | 15 | def test_initialization_does_alter_the_session(self): 16 | for v in self.session.adapters.values(): 17 | assert not isinstance(v, BetamaxAdapter) 18 | assert isinstance(v, HTTPAdapter) 19 | 20 | def test_entering_context_alters_adapters(self): 21 | with self.vcr: 22 | for v in self.session.adapters.values(): 23 | assert isinstance(v, BetamaxAdapter) 24 | 25 | def test_exiting_resets_the_adapters(self): 26 | with self.vcr: 27 | pass 28 | for v in self.session.adapters.values(): 29 | assert not isinstance(v, BetamaxAdapter) 30 | 31 | def test_current_cassette(self): 32 | assert self.vcr.current_cassette is None 33 | self.vcr.use_cassette('test') 34 | assert isinstance(self.vcr.current_cassette, Cassette) 35 | 36 | def test_use_cassette_returns_cassette_object(self): 37 | assert self.vcr.use_cassette('test') is self.vcr 38 | 39 | def test_register_request_matcher(self): 40 | class FakeMatcher(object): 41 | name = 'fake' 42 | 43 | Betamax.register_request_matcher(FakeMatcher) 44 | assert 'fake' in matchers.matcher_registry 45 | assert isinstance(matchers.matcher_registry['fake'], FakeMatcher) 46 | 47 | def test_stores_the_session_instance(self): 48 | assert self.session is self.vcr.session 49 | 50 | def test_replaces_all_adapters(self): 51 | mount_point = 'fake_protocol://' 52 | s = Session() 53 | s.mount(mount_point, HTTPAdapter()) 54 | with Betamax(s): 55 | adapter = s.adapters.get(mount_point) 56 | assert adapter is not None 57 | assert isinstance(adapter, BetamaxAdapter) 58 | -------------------------------------------------------------------------------- /tests/unit/test_configure.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import copy 3 | import unittest 4 | 5 | from betamax.configure import Configuration 6 | from betamax.cassette import Cassette 7 | from betamax.recorder import Betamax 8 | 9 | 10 | class TestConfiguration(unittest.TestCase): 11 | def setUp(self): 12 | self.cassette_options = copy.deepcopy( 13 | Cassette.default_cassette_options 14 | ) 15 | self.cassette_dir = Configuration.CASSETTE_LIBRARY_DIR 16 | 17 | def tearDown(self): 18 | Configuration.recording_hooks = collections.defaultdict(list) 19 | Cassette.default_cassette_options = self.cassette_options 20 | Cassette.hooks = collections.defaultdict(list) 21 | Configuration.CASSETTE_LIBRARY_DIR = self.cassette_dir 22 | 23 | def test_acts_as_pass_through(self): 24 | c = Configuration() 25 | c.default_cassette_options['foo'] = 'bar' 26 | assert 'foo' in Cassette.default_cassette_options 27 | assert Cassette.default_cassette_options.get('foo') == 'bar' 28 | 29 | def test_sets_cassette_library(self): 30 | c = Configuration() 31 | c.cassette_library_dir = 'foo' 32 | assert Configuration.CASSETTE_LIBRARY_DIR == 'foo' 33 | 34 | def test_is_a_context_manager(self): 35 | with Configuration() as c: 36 | assert isinstance(c, Configuration) 37 | 38 | def test_allows_registration_of_placeholders(self): 39 | opts = copy.deepcopy(Cassette.default_cassette_options) 40 | c = Configuration() 41 | c.define_cassette_placeholder('', 'test') 42 | 43 | assert opts != Cassette.default_cassette_options 44 | placeholders = Cassette.default_cassette_options['placeholders'] 45 | assert placeholders[0]['placeholder'] == '' 46 | assert placeholders[0]['replace'] == 'test' 47 | 48 | def test_registers_post_start_hooks(self): 49 | c = Configuration() 50 | assert Configuration.recording_hooks['after_start'] == [] 51 | c.after_start(callback=lambda: None) 52 | assert Configuration.recording_hooks['after_start'] != [] 53 | assert len(Configuration.recording_hooks['after_start']) == 1 54 | assert callable(Configuration.recording_hooks['after_start'][0]) 55 | 56 | def test_registers_pre_record_hooks(self): 57 | c = Configuration() 58 | assert Cassette.hooks['before_record'] == [] 59 | c.before_record(callback=lambda: None) 60 | assert Cassette.hooks['before_record'] != [] 61 | assert len(Cassette.hooks['before_record']) == 1 62 | assert callable(Cassette.hooks['before_record'][0]) 63 | 64 | def test_registers_pre_playback_hooks(self): 65 | c = Configuration() 66 | assert Cassette.hooks['before_playback'] == [] 67 | c.before_playback(callback=lambda: None) 68 | assert Cassette.hooks['before_playback'] != [] 69 | assert len(Cassette.hooks['before_playback']) == 1 70 | assert callable(Cassette.hooks['before_playback'][0]) 71 | 72 | def test_registers_pre_stop_hooks(self): 73 | c = Configuration() 74 | assert Configuration.recording_hooks['before_stop'] == [] 75 | c.before_stop(callback=lambda: None) 76 | assert Configuration.recording_hooks['before_stop'] != [] 77 | assert len(Configuration.recording_hooks['before_stop']) == 1 78 | assert callable(Configuration.recording_hooks['before_stop'][0]) 79 | -------------------------------------------------------------------------------- /tests/unit/test_decorator.py: -------------------------------------------------------------------------------- 1 | try: 2 | from unittest import mock 3 | except ImportError: 4 | import mock 5 | 6 | import betamax 7 | from betamax.decorator import use_cassette 8 | 9 | 10 | @mock.patch('betamax.recorder.Betamax', autospec=True) 11 | def test_wraps_session(Betamax): 12 | # This needs to be a magic mock so it will mock __exit__ 13 | recorder = mock.MagicMock(spec=betamax.Betamax) 14 | recorder.use_cassette.return_value = recorder 15 | Betamax.return_value = recorder 16 | 17 | @use_cassette('foo', cassette_library_dir='fizbarbogus') 18 | def _test(session): 19 | pass 20 | 21 | _test() 22 | Betamax.assert_called_once_with( 23 | session=mock.ANY, 24 | cassette_library_dir='fizbarbogus', 25 | default_cassette_options={} 26 | ) 27 | recorder.use_cassette.assert_called_once_with('foo') 28 | 29 | 30 | @mock.patch('betamax.recorder.Betamax', autospec=True) 31 | @mock.patch('requests.Session') 32 | def test_creates_a_new_session(Session, Betamax): 33 | @use_cassette('foo', cassette_library_dir='dir') 34 | def _test(session): 35 | pass 36 | 37 | _test() 38 | 39 | assert Session.call_count == 1 40 | -------------------------------------------------------------------------------- /tests/unit/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import inspect 3 | 4 | from betamax import exceptions 5 | 6 | 7 | def exception_classes(): 8 | for _, module_object in inspect.getmembers(exceptions): 9 | if inspect.isclass(module_object): 10 | yield module_object 11 | 12 | 13 | class TestExceptions(unittest.TestCase): 14 | def test_all_exceptions_are_betamax_errors(self): 15 | for exception_class in exception_classes(): 16 | assert isinstance(exception_class('msg'), exceptions.BetamaxError) 17 | 18 | def test_all_validation_errors_are_in_validation_error_map(self): 19 | validation_error_map_values = exceptions.validation_error_map.values() 20 | for exception_class in exception_classes(): 21 | if exception_class.__name__ == 'ValidationError' or \ 22 | not exception_class.__name__.endswith('ValidationError'): 23 | continue 24 | assert exception_class in validation_error_map_values 25 | 26 | def test_all_validation_errors_are_validation_errors(self): 27 | for exception_class in exception_classes(): 28 | if not exception_class.__name__.endswith('ValidationError'): 29 | continue 30 | assert isinstance(exception_class('msg'), 31 | exceptions.ValidationError) 32 | 33 | def test_invalid_option_is_validation_error(self): 34 | assert isinstance(exceptions.InvalidOption('msg'), 35 | exceptions.ValidationError) 36 | 37 | def test_betamaxerror_repr(self): 38 | """Ensure errors don't raise exceptions in their __repr__. 39 | 40 | This should protect against regression. If this test starts failing, 41 | heavily modify it to not be flakey. 42 | """ 43 | assert "BetamaxError" in repr(exceptions.BetamaxError('test')) 44 | -------------------------------------------------------------------------------- /tests/unit/test_fixtures.py: -------------------------------------------------------------------------------- 1 | try: 2 | import unittest.mock as mock 3 | except ImportError: 4 | import mock 5 | 6 | import pytest 7 | import unittest 8 | 9 | import requests 10 | 11 | import betamax 12 | from betamax.fixtures import pytest as pytest_fixture 13 | from betamax.fixtures import unittest as unittest_fixture 14 | 15 | 16 | class TestPyTestFixture(unittest.TestCase): 17 | def setUp(self): 18 | self.mocked_betamax = mock.MagicMock() 19 | self.patched_betamax = mock.patch.object( 20 | betamax.recorder, 'Betamax', return_value=self.mocked_betamax) 21 | self.patched_betamax.start() 22 | 23 | def tearDown(self): 24 | self.patched_betamax.stop() 25 | 26 | def test_adds_stop_as_a_finalizer(self): 27 | # Mock a pytest request object 28 | request = mock.MagicMock() 29 | request.cls = request.module = None 30 | request.node.name = request.function.__name__ = 'test' 31 | 32 | pytest_fixture._betamax_recorder(request) 33 | assert request.addfinalizer.called is True 34 | request.addfinalizer.assert_called_once_with(self.mocked_betamax.stop) 35 | 36 | def test_auto_starts_the_recorder(self): 37 | # Mock a pytest request object 38 | request = mock.MagicMock() 39 | request.cls = request.module = None 40 | request.node.name = request.function.__name__ = 'test' 41 | 42 | pytest_fixture._betamax_recorder(request) 43 | self.mocked_betamax.start.assert_called_once_with() 44 | 45 | 46 | class FakeBetamaxTestCase(unittest_fixture.BetamaxTestCase): 47 | def test_fake(self): 48 | pass 49 | 50 | 51 | class TestUnittestFixture(unittest.TestCase): 52 | def setUp(self): 53 | self.mocked_betamax = mock.MagicMock() 54 | self.patched_betamax = mock.patch.object( 55 | betamax.recorder, 'Betamax', return_value=self.mocked_betamax) 56 | self.betamax = self.patched_betamax.start() 57 | self.fixture = FakeBetamaxTestCase(methodName='test_fake') 58 | 59 | def tearDown(self): 60 | self.patched_betamax.stop() 61 | 62 | def test_setUp(self): 63 | self.fixture.setUp() 64 | 65 | self.mocked_betamax.use_cassette.assert_called_once_with( 66 | 'FakeBetamaxTestCase.test_fake' 67 | ) 68 | self.mocked_betamax.start.assert_called_once_with() 69 | 70 | def test_setUp_rejects_arbitrary_session_classes(self): 71 | self.fixture.SESSION_CLASS = object 72 | 73 | with pytest.raises(AssertionError): 74 | self.fixture.setUp() 75 | 76 | def test_setUp_accepts_session_subclasses(self): 77 | class TestSession(requests.Session): 78 | pass 79 | 80 | self.fixture.SESSION_CLASS = TestSession 81 | 82 | self.fixture.setUp() 83 | 84 | assert self.betamax.called is True 85 | call_kwargs = self.betamax.call_args[-1] 86 | assert isinstance(call_kwargs['session'], TestSession) 87 | 88 | def test_tearDown_calls_stop(self): 89 | recorder = mock.Mock() 90 | self.fixture.recorder = recorder 91 | 92 | self.fixture.tearDown() 93 | 94 | recorder.stop.assert_called_once_with() 95 | -------------------------------------------------------------------------------- /tests/unit/test_options.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from itertools import permutations 3 | 4 | import pytest 5 | 6 | from betamax import exceptions 7 | from betamax.options import Options, validate_record, validate_matchers 8 | 9 | 10 | class TestValidators(unittest.TestCase): 11 | def test_validate_record(self): 12 | for mode in ['once', 'none', 'all', 'new_episodes']: 13 | assert validate_record(mode) is True 14 | 15 | def test_validate_matchers(self): 16 | matchers = ['method', 'uri', 'query', 'host', 'body'] 17 | for i in range(1, len(matchers)): 18 | for l in permutations(matchers, i): 19 | assert validate_matchers(l) is True 20 | 21 | matchers.append('foobar') 22 | assert validate_matchers(matchers) is False 23 | 24 | 25 | class TestOptions(unittest.TestCase): 26 | def setUp(self): 27 | self.data = { 28 | 're_record_interval': 10000, 29 | 'match_requests_on': ['method'], 30 | 'serialize': 'json' 31 | } 32 | self.options = Options(self.data) 33 | 34 | def test_data_is_valid(self): 35 | for key in self.data: 36 | assert key in self.options 37 | 38 | def test_raise_on_unknown_option(self): 39 | data = self.data.copy() 40 | data['fake'] = 'value' 41 | with pytest.raises(exceptions.InvalidOption): 42 | Options(data) 43 | 44 | def test_raise_on_invalid_body_bytes(self): 45 | data = self.data.copy() 46 | data['preserve_exact_body_bytes'] = None 47 | with pytest.raises(exceptions.BodyBytesValidationError): 48 | Options(data) 49 | 50 | def test_raise_on_invalid_matchers(self): 51 | data = self.data.copy() 52 | data['match_requests_on'] = ['foo', 'bar', 'bogus'] 53 | with pytest.raises(exceptions.MatchersValidationError): 54 | Options(data) 55 | 56 | def test_raise_on_invalid_placeholders(self): 57 | data = self.data.copy() 58 | data['placeholders'] = None 59 | with pytest.raises(exceptions.PlaceholdersValidationError): 60 | Options(data) 61 | 62 | def test_raise_on_invalid_playback_repeats(self): 63 | data = self.data.copy() 64 | data['allow_playback_repeats'] = None 65 | with pytest.raises(exceptions.PlaybackRepeatsValidationError): 66 | Options(data) 67 | 68 | def test_raise_on_invalid_record(self): 69 | data = self.data.copy() 70 | data['record'] = None 71 | with pytest.raises(exceptions.RecordValidationError): 72 | Options(data) 73 | 74 | def test_raise_on_invalid_record_interval(self): 75 | data = self.data.copy() 76 | data['re_record_interval'] = -1 77 | with pytest.raises(exceptions.RecordIntervalValidationError): 78 | Options(data) 79 | 80 | def test_raise_on_invalid_serializer(self): 81 | data = self.data.copy() 82 | data['serialize_with'] = None 83 | with pytest.raises(exceptions.SerializerValidationError): 84 | Options(data) 85 | -------------------------------------------------------------------------------- /tests/unit/test_recorder.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from betamax import matchers, serializers 4 | from betamax.adapter import BetamaxAdapter 5 | from betamax.cassette import cassette 6 | from betamax.recorder import Betamax 7 | from requests import Session 8 | from requests.adapters import HTTPAdapter 9 | 10 | 11 | class TestBetamax(unittest.TestCase): 12 | def setUp(self): 13 | self.session = Session() 14 | self.vcr = Betamax(self.session) 15 | 16 | def test_initialization_does_not_alter_the_session(self): 17 | for v in self.session.adapters.values(): 18 | assert not isinstance(v, BetamaxAdapter) 19 | assert isinstance(v, HTTPAdapter) 20 | 21 | def test_initialization_converts_placeholders(self): 22 | placeholders = [{'placeholder': '', 'replace': 'replace-with'}] 23 | default_cassette_options = {'placeholders': placeholders} 24 | self.vcr = Betamax(self.session, 25 | default_cassette_options=default_cassette_options) 26 | assert self.vcr.config.default_cassette_options['placeholders'] == [{ 27 | 'placeholder': '', 28 | 'replace': 'replace-with', 29 | }] 30 | 31 | def test_entering_context_alters_adapters(self): 32 | with self.vcr: 33 | for v in self.session.adapters.values(): 34 | assert isinstance(v, BetamaxAdapter) 35 | 36 | def test_exiting_resets_the_adapters(self): 37 | with self.vcr: 38 | pass 39 | for v in self.session.adapters.values(): 40 | assert not isinstance(v, BetamaxAdapter) 41 | 42 | def test_current_cassette(self): 43 | assert self.vcr.current_cassette is None 44 | self.vcr.use_cassette('test') 45 | assert isinstance(self.vcr.current_cassette, cassette.Cassette) 46 | 47 | def test_use_cassette_returns_cassette_object(self): 48 | assert self.vcr.use_cassette('test') is self.vcr 49 | 50 | def test_register_request_matcher(self): 51 | class FakeMatcher(object): 52 | name = 'fake_matcher' 53 | 54 | Betamax.register_request_matcher(FakeMatcher) 55 | assert 'fake_matcher' in matchers.matcher_registry 56 | assert isinstance(matchers.matcher_registry['fake_matcher'], 57 | FakeMatcher) 58 | 59 | def test_register_serializer(self): 60 | class FakeSerializer(object): 61 | name = 'fake_serializer' 62 | 63 | Betamax.register_serializer(FakeSerializer) 64 | assert 'fake_serializer' in serializers.serializer_registry 65 | assert isinstance(serializers.serializer_registry['fake_serializer'], 66 | FakeSerializer) 67 | 68 | def test_stores_the_session_instance(self): 69 | assert self.session is self.vcr.session 70 | 71 | def test_use_cassette_passes_along_placeholders(self): 72 | placeholders = [{'placeholder': '', 'replace': 'replace-with'}] 73 | self.vcr.use_cassette('test', placeholders=placeholders) 74 | assert self.vcr.current_cassette.placeholders == [ 75 | cassette.Placeholder.from_dict(p) for p in placeholders 76 | ] 77 | -------------------------------------------------------------------------------- /tests/unit/test_replays.py: -------------------------------------------------------------------------------- 1 | from betamax import Betamax, BetamaxError 2 | from requests import Session 3 | 4 | import unittest 5 | 6 | 7 | class TestReplays(unittest.TestCase): 8 | def setUp(self): 9 | self.session = Session() 10 | 11 | def test_replays_response_on_right_order(self): 12 | s = self.session 13 | opts = {'record': 'none'} 14 | with Betamax(s).use_cassette('test_replays_response_on_right_order', **opts) as betamax: 15 | self.cassette_path = betamax.current_cassette.cassette_path 16 | r0 = s.get('http://httpbin.org/get') 17 | r1 = s.get('http://httpbin.org/get') 18 | r0_found = (b'72.160.214.132' in r0.content) 19 | assert r0_found == True 20 | r1_found = (b'72.160.214.133' in r1.content) 21 | assert r1_found == True 22 | -------------------------------------------------------------------------------- /tests/unit/test_serializers.py: -------------------------------------------------------------------------------- 1 | """Tests for serializers.""" 2 | import os 3 | import unittest 4 | 5 | import pytest 6 | 7 | from betamax.serializers import base 8 | from betamax.serializers import json_serializer 9 | from betamax.serializers import proxy 10 | 11 | 12 | class TestJSONSerializer(unittest.TestCase): 13 | """Tests around the JSONSerializer default.""" 14 | 15 | def setUp(self): 16 | """Fixture setup.""" 17 | self.cassette_dir = 'fake_dir' 18 | self.cassette_name = 'cassette_name' 19 | 20 | def test_generate_cassette_name(self): 21 | """Verify the behaviour of generate_cassette_name.""" 22 | assert (os.path.join('fake_dir', 'cassette_name.json') == 23 | json_serializer.JSONSerializer.generate_cassette_name( 24 | self.cassette_dir, 25 | self.cassette_name, 26 | )) 27 | 28 | def test_generate_cassette_name_with_instance(self): 29 | """Verify generate_cassette_name works on an instance too.""" 30 | serializer = json_serializer.JSONSerializer() 31 | assert (os.path.join('fake_dir', 'cassette_name.json') == 32 | serializer.generate_cassette_name(self.cassette_dir, 33 | self.cassette_name)) 34 | 35 | 36 | class Serializer(base.BaseSerializer): 37 | """Serializer to test NotImplementedError exceptions.""" 38 | 39 | name = 'test' 40 | 41 | 42 | class BytesSerializer(base.BaseSerializer): 43 | """Serializer to test stored_as_binary.""" 44 | 45 | name = 'bytes-test' 46 | stored_as_binary = True 47 | 48 | # NOTE(sigmavirus24): These bytes, when decoded, result in a 49 | # UnicodeDecodeError 50 | serialized_bytes = b"hi \xAD" 51 | 52 | def serialize(self, *args): 53 | """Return the problematic bytes.""" 54 | return self.serialized_bytes 55 | 56 | def deserialize(self, *args): 57 | """Return the problematic bytes.""" 58 | return self.serialized_bytes 59 | 60 | 61 | class TestBaseSerializer(unittest.TestCase): 62 | """Tests around BaseSerializer behaviour.""" 63 | 64 | def test_serialize_is_an_interface(self): 65 | """Verify we handle unimplemented methods.""" 66 | serializer = Serializer() 67 | with pytest.raises(NotImplementedError): 68 | serializer.serialize({}) 69 | 70 | def test_deserialize_is_an_interface(self): 71 | """Verify we handle unimplemented methods.""" 72 | serializer = Serializer() 73 | with pytest.raises(NotImplementedError): 74 | serializer.deserialize('path') 75 | 76 | def test_requires_a_name(self): 77 | """Verify we handle unimplemented methods.""" 78 | with pytest.raises(ValueError): 79 | base.BaseSerializer() 80 | 81 | 82 | class TestBinarySerializers(unittest.TestCase): 83 | """Verify the behaviour of stored_as_binary=True.""" 84 | 85 | @pytest.fixture(autouse=True) 86 | def _setup(self): 87 | serializer = BytesSerializer() 88 | self.cassette_path = 'test_cassette.test' 89 | self.proxy = proxy.SerializerProxy( 90 | serializer, 91 | self.cassette_path, 92 | allow_serialization=True, 93 | ) 94 | 95 | def test_serialize(self): 96 | """Verify we use the right mode with open().""" 97 | mode = self.proxy.corrected_file_mode('w') 98 | assert mode == 'wb' 99 | 100 | def test_deserialize(self): 101 | """Verify we use the right mode with open().""" 102 | mode = self.proxy.corrected_file_mode('r') 103 | assert mode == 'rb' 104 | 105 | 106 | class TestTextSerializer(unittest.TestCase): 107 | """Verify the default behaviour of stored_as_binary.""" 108 | 109 | @pytest.fixture(autouse=True) 110 | def _setup(self): 111 | serializer = Serializer() 112 | self.cassette_path = 'test_cassette.test' 113 | self.proxy = proxy.SerializerProxy( 114 | serializer, 115 | self.cassette_path, 116 | allow_serialization=True, 117 | ) 118 | 119 | def test_serialize(self): 120 | """Verify we use the right mode with open().""" 121 | mode = self.proxy.corrected_file_mode('w') 122 | assert mode == 'w' 123 | 124 | def test_deserialize(self): 125 | """Verify we use the right mode with open().""" 126 | mode = self.proxy.corrected_file_mode('r') 127 | assert mode == 'r' 128 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,pypy,py38-flake8,docstrings 3 | 4 | [testenv] 5 | pip_pre = False 6 | deps = 7 | requests{env:REQUESTS_VERSION: >= 2.0} 8 | pytest 9 | mock 10 | commands = py.test {posargs} 11 | 12 | [testenv:py38-flake8] 13 | basepython = python3.8 14 | deps = 15 | flake8 16 | commands = flake8 {posargs} src/betamax 17 | 18 | [testenv:docstrings] 19 | deps = 20 | flake8 21 | flake8-docstrings 22 | commands = flake8 {posargs} src/betamax 23 | 24 | [testenv:build] 25 | deps = 26 | build 27 | commands = 28 | python -m build 29 | 30 | [testenv:release] 31 | deps = 32 | twine >= 1.5.0 33 | {[testenv:build]deps} 34 | commands = 35 | {[testenv:build]commands} 36 | twine upload --skip-existing {posargs} dist/* 37 | 38 | [testenv:docs] 39 | deps = 40 | -rdocs/requirements.txt 41 | . 42 | commands = 43 | sphinx-build -E -W -c docs/source/ -b html docs/source/ docs/build/html 44 | 45 | [testenv:readme] 46 | deps = 47 | readme 48 | commands = 49 | python setup.py check -r -s 50 | 51 | [pytest] 52 | addopts = -q 53 | norecursedirs = *.egg .git .* _* 54 | --------------------------------------------------------------------------------