././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.514656 betamax-0.9.0/0000755000175100001770000000000014561150460012550 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/AUTHORS.rst0000644000175100001770000000055114561150445014433 0ustar00runnerdockerDevelopment Lead ---------------- - Ian Cordasco Requests ```````` - Kenneth Reitz Design Advice ------------- - Cory Benfield Contributors ------------ - Marc Abramowitz (@msabramo) - Bryce Boe (@bboe) - Alex Richard-Hoyling <@arhoyling) - Joey RH (@jarhill0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/HISTORY.rst0000644000175100001770000001537714561150445014463 0ustar00runnerdockerHistory ======= 0.9.0 - 2024-02-06 ------------------ - Add support for urllib3 2.0 - Fix documentation - Add support for binary serializer storage; useful with custom serializers (such as pickle based), however all builtin betamax serializers remain text based. - Fix bug with ``new_episodes`` always trying to record. - Improved documentation. - This release drops support for Python 3.3; if you are still using Python 3.3 update your Python or don't update to betamax 0.8.2. - This release is the first release that declares support for Python 3.7, however previous versions most likely worked with 3.7 as well. 0.8.1 - 2018-03-13 ------------------ - Previous attempts to sanitize cassette names were incomplete. Sanitization has become more thorough which could have some affects on existing cassette files. **This may cause new cassettes to be generated.** - Fix bug where there may be an exception raised in a ``betamax.exceptions.BetamaxError`` repr. 0.8.0 - 2016-08-16 ------------------ - Add ``betamax_parametrized_recorder`` and ``betamax_parametrized_session`` to our list of pytest fixtures so that users will have parametrized cassette names when writing parametrized tests with our fixtures. (I wonder if I can mention parametrization a bunch more times so I can say parametrize a lot in this bullet note.) - Add ``ValidationError`` and a set of subclasses for each possible validation error. - Raise ``InvalidOption`` on unknown cassette options rather than silently ignoring extra options. - Raise a subclass of ``ValidationError`` when a particular cassette option is invalid, rather than silently ignoring the validation failure. 0.7.2 - 2016-08-04 ------------------ - Fix bug with query string matcher where query-strings without values (e.g., ``?foo&bar`` as opposed to ``?foo=1&bar=2``) were treated as if there were no query string. 0.7.1 - 2016-06-14 ------------------ - Fix issue #108 by effectively copying the items in the match_requests_on list into the match_options set on a Cassette instance 0.7.0 - 2016-04-29 ------------------ - Add ``before_record`` and ``before_playback`` hooks - Allow per-cassette placeholders to be merged and override global placeholders - Fix bug where the ``QueryMatcher`` failed matching on high Unicode points 0.6.0 - 2016-04-12 ------------------ - Add ``betamax_recorder`` pytest fixture - Change default behaviour to allow duplicate interactions to be recorded in single cassette - Add ``allow_playback_repeats`` to allow an interaction to be used more than once from a single cassette - Always return a new ``Response`` object from an Interaction to allow for a streaming response to be usable multiple times - Remove CI support for Pythons 2.6 and 3.2 0.5.1 - 2015-10-24 ------------------ - Fix bugs with requests 2.8.x integration - Fix bugs with older versions of requests that were missing an HTTPHeaderDict implementation 0.5.0 - 2015-07-15 ------------------ - Add unittest integration in ``betamax.fixtures.unittest`` - Add pytest integration in ``betamax.fixtures.pytest`` - Add a decorator as a short cut for ``use_cassette`` - Fix bug where body bytes were not always encoded on Python 3.2+ Fixed by @bboe 0.4.2 - 2015-04-18 ------------------ - Fix issue #58 reported by @bboe Multiple cookies were not being properly stored or replayed after being recorded. - @leighlondon converted ``__all__`` to a tuple 0.4.1 - 2014-09-24 ------------------ - Fix issue #39 reported by @buttscicles This bug did not properly parse the Set-Cookie header with multiple cookies when replaying a recorded response. 0.4.0 - 2014-07-29 ------------------ - Allow the user to pass placeholders to ``Betamax#use_cassette``. - Include Betamax's version number in cassettes 0.3.2 - 2014-06-05 ------------------ - Fix request and response bodies courtesy of @dgouldin 0.3.1 - 2014-05-28 ------------------ - Fix GitHub Issue #35 - Placeholders were not being properly applied to request bodies. This release fixes that so placeholders are now behave as expected with recorded request bodies. 0.3.0 - 2014-05-23 ------------------ - Add ``Betamax#start`` and ``Betamax#stop`` to allow users to start recording and stop without using a context-manager. - Add ``digest-auth`` matcher to help users match the right request when using requests' ``HTTPDigestAuth``. - Reorganize and refactor the cassettes, matchers, and serializers modules. - Refactor some portions of code a bit. - ``Cassette.cassette_name`` no longer is the relative path to the file in which the cassette is saved. To access that information use ``Cassette.cassette_path``. The ``cassette_name`` attribute is now the name that you pass to ``Betamax#use_cassette``. 0.2.0 - 2014-04-12 ------------------ - Fix bug where new interactions recorded under ``new_episodes`` or ``all`` were not actually saved to disk. - Match URIs in a far more intelligent way. - Use the Session's original adapters when making new requests In the event the Session has a custom adapter mounted, e.g., the SSLAdapter in requests-toolbelt, then we should probably use that. - Add ``on_init`` hook to ``BaseMatcher`` so matcher authors can customize initialization - Add support for custom Serialization formats. See the docs for more info. - Add support for preserving exact body bytes. - Deprecate ``serialize`` keyword to ``Betamax#use_cassette`` in preference for ``serialize_with`` (to be more similar to VCR). 0.1.6 - 2013-12-07 ------------------ - Fix how global settings and per-invocation options are persisted and honored. (#10) - Support ``match_requests_on`` as a parameter sent to ``Betamax#use_cassette``. (No issue) 0.1.5 - 2013-09-27 ------------------ - Make sure what we pass to ``base64.b64decode`` is a bytes object 0.1.4 - 2013-09-27 ------------------ - Do not try to sanitize something that may not exist. 0.1.3 - 2013-09-27 ------------------ - Fix issue when response has a Content-Encoding of gzip and we need to preserve the original bytes of the message. 0.1.2 - 2013-09-21 ------------------ - Fix issues with how requests parses cookies out of responses - Fix unicode issues with ``Response#text`` (trying to use ``Response#json`` raises exception because it cannot use string decoding on a unicode string) 0.1.1 - 2013-09-19 ------------------ - Fix issue where there is a unicode character not in ``range(128)`` 0.1.0 - 2013-09-17 ------------------ - Initial Release - Support for VCR generated cassettes (JSON only) - Support for ``re_record_interval`` - Support for the ``once``, ``all``, ``new_episodes``, ``all`` cassette modes - Support for filtering sensitive data - Support for the following methods of request matching: - Method - URI - Host - Path - Query String - Body - Headers ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/LICENSE0000644000175100001770000000110414561150445013554 0ustar00runnerdockerCopyright 2013 Ian Cordasco Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/MANIFEST.in0000644000175100001770000000027014561150445014310 0ustar00runnerdockerinclude README.rst include LICENSE include HISTORY.rst include AUTHORS.rst recursive-include docs Makefile *.py *.rst recursive-include tests *.json *.py prune *.pyc prune docs/_build ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.514656 betamax-0.9.0/PKG-INFO0000644000175100001770000000774314561150460013660 0ustar00runnerdockerMetadata-Version: 2.1 Name: betamax Version: 0.9.0 Summary: A VCR imitation for python-requests Home-page: https://github.com/sigmavirus24/betamax Author: Ian Stapleton Cordasco Author-email: graffatcolmingov@gmail.com License: Apache-2.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Software Development :: Quality Assurance Requires-Python: >=3.8.1 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: requests>=2.0 betamax ======= Betamax is a VCR_ imitation for requests. This will make mocking out requests much easier. It is tested on `Travis CI`_. Put in a more humorous way: "Betamax records your HTTP interactions so the NSA does not have to." Example Use ----------- .. code-block:: python from betamax import Betamax from requests import Session from unittest import TestCase with Betamax.configure() as config: config.cassette_library_dir = 'tests/fixtures/cassettes' class TestGitHubAPI(TestCase): def setUp(self): self.session = Session() self.headers.update(...) # Set the cassette in a line other than the context declaration def test_user(self): with Betamax(self.session) as vcr: vcr.use_cassette('user') resp = self.session.get('https://api.github.com/user', auth=('user', 'pass')) assert resp.json()['login'] is not None # Set the cassette in line with the context declaration def test_repo(self): with Betamax(self.session).use_cassette('repo'): resp = self.session.get( 'https://api.github.com/repos/sigmavirus24/github3.py' ) assert resp.json()['owner'] != {} What does it even do? --------------------- If you are unfamiliar with VCR_, you might need a better explanation of what Betamax does. Betamax intercepts every request you make and attempts to find a matching request that has already been intercepted and recorded. Two things can then happen: 1. If there is a matching request, it will return the response that is associated with it. 2. If there is **not** a matching request and it is allowed to record new responses, it will make the request, record the response and return the response. Recorded requests and corresponding responses - also known as interactions - are stored in files called cassettes. (An example cassette can be seen in the `examples section of the documentation`_.) The directory you store your cassettes in is called your library, or your `cassette library`_. VCR Cassette Compatibility -------------------------- Betamax can use any VCR-recorded cassette as of this point in time. The only caveat is that python-requests returns a URL on each response. VCR does not store that in a cassette now but we will. Any VCR-recorded cassette used to playback a response will unfortunately not have a URL attribute on responses that are returned. This is a minor annoyance but not something that can be fixed. .. _VCR: https://github.com/vcr/vcr .. _Travis CI: https://travis-ci.org/sigmavirus24/betamax .. _examples section of the documentation: http://betamax.readthedocs.org/en/latest/api.html#examples .. _cassette library: http://betamax.readthedocs.org/en/latest/cassettes.html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/README.rst0000644000175100001770000000555514561150445014254 0ustar00runnerdockerbetamax ======= Betamax is a VCR_ imitation for requests. This will make mocking out requests much easier. It is tested on `Travis CI`_. Put in a more humorous way: "Betamax records your HTTP interactions so the NSA does not have to." Example Use ----------- .. code-block:: python from betamax import Betamax from requests import Session from unittest import TestCase with Betamax.configure() as config: config.cassette_library_dir = 'tests/fixtures/cassettes' class TestGitHubAPI(TestCase): def setUp(self): self.session = Session() self.headers.update(...) # Set the cassette in a line other than the context declaration def test_user(self): with Betamax(self.session) as vcr: vcr.use_cassette('user') resp = self.session.get('https://api.github.com/user', auth=('user', 'pass')) assert resp.json()['login'] is not None # Set the cassette in line with the context declaration def test_repo(self): with Betamax(self.session).use_cassette('repo'): resp = self.session.get( 'https://api.github.com/repos/sigmavirus24/github3.py' ) assert resp.json()['owner'] != {} What does it even do? --------------------- If you are unfamiliar with VCR_, you might need a better explanation of what Betamax does. Betamax intercepts every request you make and attempts to find a matching request that has already been intercepted and recorded. Two things can then happen: 1. If there is a matching request, it will return the response that is associated with it. 2. If there is **not** a matching request and it is allowed to record new responses, it will make the request, record the response and return the response. Recorded requests and corresponding responses - also known as interactions - are stored in files called cassettes. (An example cassette can be seen in the `examples section of the documentation`_.) The directory you store your cassettes in is called your library, or your `cassette library`_. VCR Cassette Compatibility -------------------------- Betamax can use any VCR-recorded cassette as of this point in time. The only caveat is that python-requests returns a URL on each response. VCR does not store that in a cassette now but we will. Any VCR-recorded cassette used to playback a response will unfortunately not have a URL attribute on responses that are returned. This is a minor annoyance but not something that can be fixed. .. _VCR: https://github.com/vcr/vcr .. _Travis CI: https://travis-ci.org/sigmavirus24/betamax .. _examples section of the documentation: http://betamax.readthedocs.org/en/latest/api.html#examples .. _cassette library: http://betamax.readthedocs.org/en/latest/cassettes.html ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.498656 betamax-0.9.0/docs/0000755000175100001770000000000014561150457013506 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.502656 betamax-0.9.0/docs/source/0000755000175100001770000000000014561150460015000 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/api.rst0000644000175100001770000001111614561150445016306 0ustar00runnerdockerAPI === .. module:: betamax .. autoclass:: Betamax :members: .. autofunction:: betamax.decorator.use_cassette .. autoclass:: betamax.configure.Configuration :members: .. automodule:: betamax.fixtures.pytest .. automodule:: betamax.fixtures.unittest Examples -------- Basic Usage ^^^^^^^^^^^ Let `example.json` be a file in a directory called `cassettes` with the content: .. code-block:: javascript { "http_interactions": [ { "request": { "body": { "string": "", "encoding": "utf-8" }, "headers": { "User-Agent": ["python-requests/v1.2.3"] }, "method": "GET", "uri": "https://httpbin.org/get" }, "response": { "body": { "string": "example body", "encoding": "utf-8" }, "headers": {}, "status": { "code": 200, "message": "OK" }, "url": "https://httpbin.org/get" } } ], "recorded_with": "betamax" } The following snippet will not raise any exceptions .. code-block:: python from betamax import Betamax from requests import Session s = Session() with Betamax(s, cassette_library_dir='cassettes') as betamax: betamax.use_cassette('example', record='none') r = s.get("https://httpbin.org/get") On the other hand, this will raise an exception: .. code-block:: python from betamax import Betamax from requests import Session s = Session() with Betamax(s, cassette_library_dir='cassettes') as betamax: betamax.use_cassette('example', record='none') r = s.post("https://httpbin.org/post", data={"key": "value"}) Finally, we can also use a decorator in order to simplify things: .. code-block:: python import unittest from betamax.decorator import use_cassette class TestExample(unittest.TestCase): @use_cassette('example', cassette_library_dir='cassettes') def test_example(self, session): session.get('https://httpbin.org/get') # Or if you're using something like py.test @use_cassette('example', cassette_library_dir='cassettes') def test_example_pytest(session): session.get('https://httpbin.org/get') .. _opinions: Opinions at Work ---------------- If you use ``requests``'s default ``Accept-Encoding`` header, servers that support gzip content encoding will return responses that Betamax cannot serialize in a human-readable format. In this event, the cassette will look like this: .. code-block:: javascript :emphasize-lines: 17 { "http_interactions": [ { "request": { "body": { "base64_string": "", "encoding": "utf-8" }, "headers": { "User-Agent": ["python-requests/v1.2.3"] }, "method": "GET", "uri": "https://httpbin.org/get" }, "response": { "body": { "base64_string": "Zm9vIGJhcgo=", "encoding": "utf-8" }, "headers": { "Content-Encoding": ["gzip"] }, "status": { "code": 200, "message": "OK" }, "url": "https://httpbin.org/get" } } ], "recorded_with": "betamax" } Forcing bytes to be preserved ----------------------------- You may want to force betamax to preserve the exact bytes in the body of a response (or request) instead of relying on the :ref:`opinions held by the library `. In this case you have two ways of telling betamax to do this. The first, is on a per-cassette basis, like so: .. code-block:: python from betamax import Betamax import requests session = Session() with Betamax.configure() as config: c.cassette_library_dir = '.' with Betamax(session).use_cassette('some_cassette', preserve_exact_body_bytes=True): r = session.get('http://example.com') On the other hand, you may want to preserve exact body bytes for all cassettes. In this case, you can do: .. code-block:: python from betamax import Betamax import requests session = Session() with Betamax.configure() as config: config.cassette_library_dir = '.' config.preserve_exact_body_bytes = True with Betamax(session).use_cassette('some_cassette'): r = session.get('http://example.com') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/cassettes.rst0000644000175100001770000000675714561150445017552 0ustar00runnerdockerWhat is a cassette? =================== A cassette is a set of recorded interactions serialized to a specific format. Currently the only supported format is JSON_. A cassette has a list (or array) of interactions and information about the library that recorded it. This means that the cassette's structure (using JSON) is .. code:: javascript { "http_interactions": [ // ... ], "recorded_with": "betamax" } Each interaction is the object representing the request and response as well as the date it was recorded. The structure of an interaction is .. code:: javascript { "request": { // ... }, "response": { // ... }, "recorded_at": "2013-09-28T01:25:38" } Each request has the body, method, uri, and an object representing the headers. A serialized request looks like: .. code:: javascript { "body": { "string": "...", "encoding": "utf-8" }, "method": "GET", "uri": "http://example.com", "headers": { // ... } } A serialized response has the status_code, url, and objects representing the headers and the body. A serialized response looks like: .. code:: javascript { "body": { "encoding": "utf-8", "string": "..." }, "url": "http://example.com", "status": { "code": 200, "message": "OK" }, "headers": { // ... } } If you put everything together, you get: .. _cassette-dict: .. code:: javascript { "http_interactions": [ { "request": { { "body": { "string": "...", "encoding": "utf-8" }, "method": "GET", "uri": "http://example.com", "headers": { // ... } } }, "response": { { "body": { "encoding": "utf-8", "string": "..." }, "url": "http://example.com", "status": { "code": 200, "message": "OK" }, "headers": { // ... } } }, "recorded_at": "2013-09-28T01:25:38" } ], "recorded_with": "betamax" } If you were to pretty-print a cassette, this is vaguely what you would see. Keep in mind that since Python does not keep dictionaries ordered, the items may not be in the same order as this example. .. note:: **Pro-tip** You can pretty print a cassette like so: ``python -m json.tool cassette.json``. What is a cassette library? =========================== When configuring Betamax, you can choose your own cassette library directory. This is the directory available from the current directory in which you want to store your cassettes. For example, let's say that you set your cassette library to be ``tests/cassettes/``. In that case, when you record a cassette, it will be saved there. To continue the example, let's say you use the following code: .. code:: python from requests import Session from betamax import Betamax s = Session() with Betamax(s, cassette_library_dir='tests/cassettes').use_cassette('example'): r = s.get('https://httpbin.org/get') You would then have the following directory structure:: . `-- tests `-- cassettes `-- example.json .. _JSON: http://json.org ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/conf.py0000644000175100001770000001746714561150445016321 0ustar00runnerdocker# -*- coding: utf-8 -*- # # Requests documentation build configuration file, created by # sphinx-quickstart on Sun Feb 13 23:54:25 2011. # # This file is execfile()d with the current directory set to its containing # dir # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../../src')) import betamax # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Betamax' copyright = u'2013-2018 - Ian Stapleton Cordasco' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = betamax.__version__ # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['build'] # The reST default role (used for this markup: `text`) to use for all # documents #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. # pygments_style = 'flask_theme_support.FlaskyStyle' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- Options for HTML output ----------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'nature' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". #html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'betamax.doc' # -- Options for LaTeX output ---------------------------------------------- # The paper size ('letter' or 'a4'). #latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). #latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass # [howto/manual]). latex_documents = [ ('index', 'betamax.tex', u'Betamax Documentation', u'Ian Stapleton Cordasco', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Additional stuff for the LaTeX preamble. #latex_preamble = '' # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output ---------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'betamax', u'Betamax Documentation', [u'Ian Stapleton Cordasco'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output -------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'betamax', u'Betamax Documentation', u'Ian Cordasco', 'Betamax', "Python imitation of Ruby's VCR", 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. texinfo_appendices = [] # Intersphinx configuration intersphinx_mapping = { 'python': ('https://docs.python.org/3.6', (None, 'python-inv.txt')), 'requests': ('http://docs.python-requests.org/en/latest', (None, 'requests-inv.txt')), } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/configuring.rst0000644000175100001770000003024014561150445020046 0ustar00runnerdockerConfiguring Betamax =================== By now you've seen examples where we pass a great deal of keyword arguments to :meth:`~betamax.Betamax.use_cassette`. You have also seen that we used :meth:`betamax.Betamax.configure`. In this section, we'll go into a deep description of the different approaches and why you might pick one over the other. Global Configuration -------------------- Admittedly, I am not too proud of my decision to borrow this design from `VCR`_, but I did and I use it and it isn't entirely terrible. (Note: I do hope to come up with an elegant way to redesign it for v1.0.0 but that's a long way off.) The best way to configure Betamax globally is by using :meth:`betamax.Betamax.configure`. This returns a :class:`betamax.configure.Configuration` instance. This instance can be used as a context manager in order to make the usage look more like `VCR`_'s way of configuring the library. For example, in `VCR`_, you might do .. code-block:: ruby VCR.configure do |config| config.cassette_library_dir = 'examples/cassettes' config.default_cassette_options[:record] = :none # ... end Where as with Betamax you might do .. code-block:: python from betamax import Betamax with Betamax.configure() as config: config.cassette_library_dir = 'examples/cassettes' config.default_cassette_options['record_mode'] = 'none' Alternatively, since the object returned is really just an object and does not do anything special as a context manager, you could just as easily do .. code-block:: python from betamax import Betamax config = Betamax.configure() config.cassette_library_dir = 'examples/cassettes' config.default_cassette_options['record_mode'] = 'none' We'll now move on to specific use-cases when configuring Betamax. We'll exclude the portion of each example where we create a :class:`~betamax.configure.Configuration` instance. Setting the Directory in which Betamax Should Store Cassette Files `````````````````````````````````````````````````````````````````` Each and every time we use Betamax we need to tell it where to store (and discover) cassette files. By default we do this by setting the ``cassette_library_dir`` attribute on our ``config`` object, e.g., .. code-block:: python config.cassette_library_dir = 'tests/integration/cassettes' Note that these paths are relative to what Python thinks is the current working directory. Wherever you run your tests from, write the path to be relative to that directory. Setting Default Cassette Options ```````````````````````````````` Cassettes have default options used by Betamax if none are set. For example, - The default record mode is ``once``. - The default matchers used are ``method`` and ``uri``. - Cassettes do **not** preserve the exact body bytes by default. These can all be configured as you please. For example, if you want to change the default matchers and preserve exact body bytes, you would do .. code-block:: python config.default_cassette_options['match_requests_on'] = [ 'method', 'uri', 'headers', ] config.preserve_exact_body_bytes = True Filtering Sensitive Data ```````````````````````` It's unlikely that you'll want to record an interaction that will not require authentication. For this we can define placeholders in our cassettes. Let's use a very real example. Let's say that you want to get your user data from GitHub using Requests. You might have code that looks like this: .. code-block:: python def me(username, password, session): r = session.get('https://api.github.com/user', auth=(username, password)) r.raise_for_status() return r.json() You would test this something like: .. code-block:: python import os import betamax import requests from my_module import me session = requests.Session() recorder = betamax.Betamax(session) username = os.environ.get('USERNAME', 'testuser') password = os.environ.get('PASSWORD', 'testpassword') with recorder.use_cassette('test-me'): json = me(username, password, session) # assertions about the JSON returned The problem is that now your username and password will be recorded in the cassette which you don't then want to push to your version control. How can we prevent that from happening? .. code-block:: python import base64 username = os.environ.get('USERNAME', 'testuser') password = os.environ.get('PASSWORD', 'testpassword') config.define_cassette_placeholder( '', base64.b64encode( '{0}:{1}'.format(username, password).encode('utf-8') ) ) .. note:: Obviously you can refactor this a bit so you can pull those environment variables out in only one place, but I'd rather be clear than not here. The first time you run the test script you would invoke your tests like so: .. code-block:: sh $ USERNAME='my-real-username' PASSWORD='supersecretep@55w0rd' \ python test_script.py Future runs of the script could simply be run without those environment variables, e.g., .. code-block:: sh $ python test_script.py This means that you can run these tests on a service like Travis-CI without providing credentials. In the event that you can not anticipate what you will need to filter out, version 0.7.0 of Betamax adds ``before_record`` and ``before_playback`` hooks. These two hooks both will pass the :class:`~betamax.cassette.interaction.Interaction` and :class:`~betamax.cassette.cassette.Cassette` to the function provided. An example callback would look like: .. code-block:: python def hook(interaction, cassette): pass You would then register this callback: .. code-block:: python # Either config.before_record(callback=hook) # Or config.before_playback(callback=hook) You can register callables for both hooks. If you wish to ignore an interaction and prevent it from being recorded or replayed, you can call the :meth:`~betamax.cassette.interaction.Interaction.ignore`. You also have full access to all of the methods and attributes on an instance of an Interaction. This will allow you to inspect the response produced by the interaction and then modify it. Let's say, for example, that you are talking to an API that grants authorization tokens on a specific request. In this example, you might authenticate initially using a username and password and then use a token after authenticating. You want, however, for the token to be kept secret. In that case you might configure Betamax to replace the username and password, e.g., .. code-block:: python config.define_cassette_placeholder('', username) config.define_cassette_placeholder('', password) And you would also write a function that, prior to recording, finds the token, saves it, and obscures it from the recorded version of the cassette: .. code-block:: python from betamax.cassette import cassette def sanitize_token(interaction, current_cassette): # Exit early if the request did not return 200 OK because that's the # only time we want to look for Authorization-Token headers if interaction.data['response']['status']['code'] != 200: return headers = interaction.data['response']['headers'] token = headers.get('Authorization-Token') # If there was no token header in the response, exit if token is None: return # Otherwise, create a new placeholder so that when cassette is saved, # Betamax will replace the token with our placeholder. current_cassette.placeholders.append( cassette.Placeholder(placeholder='', replace=token) ) This will dynamically create a placeholder for that cassette only. Once we have our hook, we need merely register it like so: .. code-block:: python config.before_record(callback=sanitize_token) And we no longer need to worry about leaking sensitive data. In addition to the ``before_record`` and ``before_playback`` hooks, version 0.9.0 of Betamax adds :meth:`.after_start` and :meth:`.before_stop` hooks. These two hooks both will pass the current :class:`~betamax.cassette.cassette.Cassette` to the callback function provided. Register these hooks like so: .. code-block:: python def hook(cassette): if cassette.is_recording(): print("This cassette is recording!") # Either config.after_start(callback=hook) # Or config.before_stop(callback=hook) These hooks are useful for performing configuration actions external to Betamax at the time Betamax is invoked, such as setting up correct authentication to an API so that the recording will not encounter any errors. Setting default serializer `````````````````````````` If you want to use a specific serializer for every cassette, you can set ``serialize_with`` as a default cassette option. For example, if you wanted to use the ``prettyjson`` serializer for every cassette you would do: .. code-block:: python config.default_cassette_options['serialize_with'] = 'prettyjson' Per-Use Configuration --------------------- Each time you create a :class:`~betamax.Betamax` instance or use :meth:`~betamax.Betamax.use_cassette`, you can pass some of the options from above. Setting the Directory in which Betamax Should Store Cassette Files `````````````````````````````````````````````````````````````````` When using per-use configuration of Betamax, you can specify the cassette directory when you instantiate a :class:`~betamax.Betamax` object: .. code-block:: python session = requests.Session() recorder = betamax.Betamax(session, cassette_library_dir='tests/cassettes/') Setting Default Cassette Options ```````````````````````````````` You can also set default cassette options when instantiating a :class:`~betamax.Betamax` object: .. code-block:: python session = requests.Session() recorder = betamax.Betamax(session, default_cassette_options={ 'record_mode': 'once', 'match_requests_on': ['method', 'uri', 'headers'], 'preserve_exact_body_bytes': True }) You can also set the above when calling :meth:`~betamax.Betamax.use_cassette`: .. code-block:: python session = requests.Session() recorder = betamax.Betamax(session) with recorder.use_cassette('cassette-name', preserve_exact_body_bytes=True, match_requests_on=['method', 'uri', 'headers'], record='once'): session.get('https://httpbin.org/get') Filtering Sensitive Data ```````````````````````` Filtering sensitive data on a per-usage basis is the only difficult (or perhaps, less convenient) case. Cassette placeholders are part of the default cassette options, so we'll set this value similarly to how we set the other default cassette options, the catch is that placeholders have a specific structure. Placeholders are stored as a list of dictionaries. Let's use our example above and convert it. .. code-block:: python import base64 username = os.environ.get('USERNAME', 'testuser') password = os.environ.get('PASSWORD', 'testpassword') session = requests.Session() recorder = betamax.Betamax(session, default_cassette_options={ 'placeholders': [{ 'placeholder': '', 'replace': base64.b64encode( '{0}:{1}'.format(username, password).encode('utf-8') ), }] }) Note that what we passed as our first argument is assigned to the ``'placeholder'`` key while the value we're replacing is assigned to the ``'replace'`` key. This isn't the typical way that people filter sensitive data because they tend to want to do it globally. Mixing and Matching ------------------- It's not uncommon to mix and match configuration methodologies. I do this in `github3.py`_. I use global configuration to filter sensitive data and set defaults based on the environment the tests are running in. On Travis-CI, the record mode is set to ``'none'``. I also set how we match requests and when we preserve exact body bytes on a per-use basis. .. links .. _VCR: https://relishapp.com/vcr/vcr .. _github3.py: https://github.com/sigmavirus24/github3.py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/implementation_details.rst0000644000175100001770000000306514561150445022273 0ustar00runnerdockerImplementation Details ====================== Everything here is an implementation detail and subject to volatile change. I would not rely on anything here for any mission critical code. Gzip Content-Encoding --------------------- By default, requests sets an ``Accept-Encoding`` header value that includes ``gzip`` (specifically, unless overridden, requests always sends ``Accept-Encoding: gzip, deflate, compress``). When a server supports this and responds with a response that has the ``Content-Encoding`` header set to ``gzip``, ``urllib3`` automatically decompresses the body for requests. This can only be prevented in the case where the ``stream`` parameter is set to ``True``. Since Betamax refuses to alter the headers on the response object in any way, we force ``stream`` to be ``True`` so we can capture the compressed data before it is decompressed. We then properly repopulate the response object so you perceive no difference in the interaction. To preserve the response exactly as is, we then must ``base64`` encode the body of the response before saving it to the file object. In other words, whenever a server responds with a compressed body, you will not have a human readable response body. There is, at the present moment, no way to configure this so that this does not happen and because of the way that Betamax works, you can not remove the ``Content-Encoding`` header to prevent this from happening. Class Details ------------- .. autoclass:: betamax.cassette.Cassette :members: .. autoclass:: betamax.cassette.Interaction :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/index.rst0000644000175100001770000000101214561150445016636 0ustar00runnerdocker.. include:: ../../README.rst Contents of Betamax's Documentation =================================== .. toctree:: :caption: Narrative Documentation :maxdepth: 3 introduction long_term_usage configuring record_modes third_party_packages usage_patterns integrations .. toctree:: :caption: API Documentation :maxdepth: 2 api cassettes implementation_details matchers serializers Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/integrations.rst0000644000175100001770000001121514561150445020243 0ustar00runnerdockerIntegrating Betamax with Test Frameworks ======================================== It's nice to have a way to integrate libraries you use for testing into your testing frameworks. Having considered this, the authors of and contributors to Betamax have included integrations in the package. Betamax comes with integrations for py.test and unittest. (If you need an integration for another framework, please suggest it and send a patch!) PyTest Integration ------------------ .. versionadded:: 0.5.0 .. versionchanged:: 0.6.0 When you install Betamax, it now installs two `py.test`_ fixtures by default. To use it in your tests you need only follow the `instructions`_ on pytest's documentation. To use the ``betamax_session`` fixture for an entire class of tests you would do: .. code-block:: python # tests/test_http_integration.py import pytest @pytest.mark.usefixtures('betamax_session') class TestMyHttpClient: def test_get(self, betamax_session): betamax_session.get('https://httpbin.org/get') This will generate a cassette name for you, e.g., ``tests.test_http_integration.TestMyHttpClient.test_get``. After running this test you would have a cassette file stored in your cassette library directory named ``tests.test_http_integration.TestMyHttpClient.test_get.json``. To use this fixture at the module level, you need only do .. code-block:: python # tests/test_http_integration.py import pytest pytest.mark.usefixtures('betamax_session') class TestMyHttpClient: def test_get(self, betamax_session): betamax_session.get('https://httpbin.org/get') class TestMyOtherHttpClient: def test_post(self, betamax_session): betamax_session.post('https://httpbin.org/post') If you need to customize the recorder object, however, you can instead use the ``betamax_recorder`` fixture: .. code-block:: python # tests/test_http_integration.py import pytest pytest.mark.usefixtures('betamax_recorder') class TestMyHttpClient: def test_post(self, betamax_recorder): betamax_recorder.current_cassette.match_options.add('json-body') session = betamax_recorder.session session.post('https://httpbin.org/post', json={'foo': 'bar'}) Unittest Integration -------------------- .. versionadded:: 0.5.0 When writing tests with unittest, a common pattern is to either import :class:`unittest.TestCase` or subclass that and use that subclass in your tests. When integrating Betamax with your unittest testsuite, you should do the following: .. code-block:: python from betamax.fixtures import unittest class IntegrationTestCase(unittest.BetamaxTestCase): # Add the rest of the helper methods you want for your # integration tests class SpecificTestCase(IntegrationTestCase): def test_something(self): # Test something The unittest integration provides the following attributes on the test case instance: - ``session`` the instance of ``BetamaxTestCase.SESSION_CLASS`` created for that test. - ``recorder`` the instance of :class:`betamax.Betamax` created. The integration also generates a cassette name from the test case class name and test method. So the cassette generated for the above example would be named ``SpecificTestCase.test_something``. To override that behaviour, you need to override the :meth:`~betamax.fixtures.BetamaxTestCase.generate_cassette_name` method in your subclass. The default path to save cassette is `./vcr/cassettes`. To override the path uses the follow code at the top of file. .. code-block:: python with betamax.Betamax.configure() as config: config.cassette_library_dir = 'your/path/here' If you are subclassing :class:`requests.Session` in your application, then it follows that you will want to use that in your tests. To facilitate this, you can set the ``SESSION_CLASS`` attribute. To give a fuller example, let's say you're changing the default cassette name and you're providing your own session class, your code might look like: .. code-block:: python from betamax.fixtures import unittest from myapi import session class IntegrationTestCase(unittest.BetamaxTestCase): # Add the rest of the helper methods you want for your # integration tests SESSION_CLASS = session.MyApiSession def generate_cassette_name(self): classname = self.__class__.__name__ method = self._testMethodName return 'integration_{0}_{1}'.format(classname, method) .. _py.test: http://pytest.org/latest/ .. _instructions: http://pytest.org/latest/fixture.html#using-fixtures-from-classes-modules-or-projects ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/introduction.rst0000644000175100001770000001037014561150445020257 0ustar00runnerdocker.. _getting_started: Getting Started =============== The first step is to make sure Betamax is right for you. Let's start by answering the following questions - Are you using `Requests`_? If you're not using Requests, Betamax is not for you. You should checkout `VCRpy`_. - Are you using Sessions or are you using the functional API (e.g., ``requests.get``)? If you're using the functional API, and aren't willing to use Sessions, Betamax is not *yet* for you. So if you're using Requests and you're using Sessions, you're in the right place. Betamax officially supports `py.test`_ and `unittest`_ but it should integrate well with nose as well. Installation ------------ .. code-block:: bash $ pip install betamax Configuration ------------- When starting with Betamax, you need to tell it where to store the cassettes that it creates. There's two ways to do this: 1. If you're using :class:`~betamax.recorder.Betamax` or :class:`~betamax.decorator.use_cassette` you can pass the ``cassette_library_dir`` option. For example, .. code-block:: python import betamax import requests session = requests.Session() recorder = betamax.Betamax(session, cassette_library_dir='cassettes') with recorder.use_cassette('introduction'): # ... 2. You can do it once, globally, for your test suite. .. code-block:: python import betamax with betamax.Betamax.configure() as config: config.cassette_library_dir = 'cassettes' .. note:: If you don't set a cassette directory, Betamax won't save cassettes to disk There are other configuration options that *can* be provided, but this is the only one that is *required*. Recording Your First Cassette ----------------------------- Let's make a file named ``our_first_recorded_session.py``. Let's add the following to our file: .. literalinclude:: ../../examples/our_first_recorded_session.py :language: python If we then run our script, we'll see that a new file is created in our specified cassette directory. It should look something like: .. literalinclude:: ../../examples/cassettes/our-first-recorded-session.json :language: javascript Now, each subsequent time that we run that script, we will use the recorded interaction instead of talking to the internet over and over again. .. note:: There is no need to write any other code to replay your cassettes. Each time you run that session with the cassette in place, Betamax does all the heavy lifting for you. Recording More Complex Cassettes -------------------------------- Most times we cannot isolate our tests to a single request at a time, so we'll have cassettes that make multiple requests. Betamax can handle these with ease, let's take a look at an example. .. literalinclude:: ../../examples/more_complicated_cassettes.py :language: python Before we run this example, we have to install a new package: ``betamax-serializers``, e.g., ``pip install betamax-serializers``. If we now run our new example, we'll see a new file appear in our :file:`examples/cassettes/` directory named :file:`more-complicated-cassettes.json`. This cassette will be much larger as a result of making 3 requests and receiving 3 responses. You'll also notice that we imported :mod:`betamax_serializers.pretty_json` and called :meth:`~betamax.Betamax.register_serializer` with :class:`~betamax_serializers.pretty_json.PrettyJSONSerializer`. Then we added a keyword argument to our invocation of :meth:`~betamax.Betamax.use_cassette`, ``serialize_with='prettyjson'``. :class:`~betamax_serializers.pretty_json.PrettyJSONSerializer` is a class provided by the ``betamax-serializers`` package on PyPI that can serialize and deserialize cassette data into JSON while allowing it to be easily human readable and pretty. Let's see the results: .. literalinclude:: ../../examples/cassettes/more-complicated-cassettes.json :language: javascript This makes the cassette easy to read and helps us recognize that requests and responses are paired together. We'll explore cassettes more a bit later. .. links .. _Requests: http://docs.python-requests.org/ .. _VCRpy: https://github.com/kevin1024/vcrpy .. _py.test: http://pytest.org/ .. _unittest: https://docs.python.org/3/library/unittest.html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/long_term_usage.rst0000644000175100001770000001051014561150445020704 0ustar00runnerdockerLong Term Usage Patterns ======================== Now that we've covered the basics in :ref:`getting_started`, let's look at some patterns and problems we might encounter when using Betamax over a period of months instead of minutes. Adding New Requests to a Cassette --------------------------------- Let's reuse an example. Specifically let's reuse our :file:`examples/more_complicated_cassettes.py` example. .. literalinclude:: ../../examples/more_complicated_cassettes.py :language: python Let's add a new ``POST`` request in there: .. code-block:: python session.post('https://httpbin.org/post', params={'id': '20'}, json={'some-other-attribute': 'some-other-value'}) If we run this cassette now, we should expect to see that there was an exception because Betamax couldn't find a matching request for it. We expect this because the post requests have two completely different bodies, right? Right. The problem you'll find is that by default Betamax **only** matches on the URI and the Method. So Betamax will find a matching request/response pair for ``("POST", "https://httpbin.org/post?id=20")`` and reuse it. So now we need to update how we use Betamax so it will match using the ``body`` as well: .. literalinclude:: ../../examples/more_complicated_cassettes_2.py :language: python Now when we run that we should see something like this: .. literalinclude:: ../../examples/more_complicated_cassettes_2.traceback :language: pytb This is what we do expect to see. So, how do we fix it? We have a few options to fix it. Option 1: Re-recording the Cassette ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One of the easiest ways to fix this situation is to simply remove the cassette that was recorded and run the script again. This will recreate the cassette and subsequent runs will work just fine. To be clear, we're advocating for this option that the user do: .. code:: $ rm examples/cassettes/{{ cassette-name }} This is the favorable option if you don't foresee yourself needing to add new interactions often. Option 2: Changing the Record Mode ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A different way would be to update the recording mode used by Betamax. We would update the line in our file that currently reads: .. code-block:: python with recorder.use_cassette('more-complicated-cassettes', serialize_with='prettyjson', match_requests_on=matchers): to add one more parameter to the call to :meth:`~betamax.Betamax.use_cassette`. We want to use the ``record`` parameter to tell Betamax to use either the ``new_episodes`` or ``all`` modes. Which you choose depends on your use case. ``new_episodes`` will only record new request/response interactions that Betamax sees. ``all`` will just re-record every interaction every time. In our example, we'll use ``new_episodes`` so our code now looks like: .. code-block:: python with recorder.use_cassette('more-complicated-cassettes', serialize_with='prettyjson', match_requests_on=matchers, record='new_episodes'): Known Issues ------------ Tests Periodically Slow Down ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Description:** Requests checks if it should use or bypass proxies using the standard library function ``proxy_bypass``. This has been known to cause slow downs when using Requests and can cause your recorded requests to slow down as well. Betamax presently has no way to prevent this from being called as it operates at a lower level in Requests than is necessary. **Workarounds:** - Mock gethostbyname method from socket library, to force a localhost setting, e.g., .. code-block:: python import socket socket.gethostbyname = lambda x: '127.0.0.1' - Set ``trust_env`` to ``False`` on the session used with Betamax. This will prevent Requests from checking for proxies and whether it needs bypass them. **Related bugs:** - https://github.com/sigmavirus24/betamax/issues/96 - https://github.com/kennethreitz/requests/issues/2988 .. Template for known issues Descriptive Title ~~~~~~~~~~~~~~~~~ **Description:** **Workaround(s):** - List - of - workarounds **Related bug(s):** - List - of - bug - links ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/matchers.rst0000644000175100001770000000620714561150445017350 0ustar00runnerdockerMatchers ======== You can specify how you would like Betamax to match requests you are making with the recorded requests. You have the following options for default (built-in) matchers: ======= ========= Matcher Behaviour ======= ========= body This matches by checking the equality of the request bodies. headers This matches by checking the equality of all of the request headers host This matches based on the host of the URI method This matches based on the method, e.g., ``GET``, ``POST``, etc. path This matches on the path of the URI query This matches on the query part of the URI uri This matches on the entirety of the URI ======= ========= Default Matchers ---------------- By default, Betamax matches on ``uri`` and ``method``. Specifying Matchers ------------------- You can specify the matchers to be used in the entire library by configuring Betamax like so: .. code-block:: python import betamax with betamax.Betamax.configure() as config: config.default_cassette_options['match_requests_on'].extend([ 'headers', 'body' ]) Instead of configuring global state, though, you can set it per cassette. For example: .. code-block:: python import betamax import requests session = requests.Session() recorder = betamax.Betamax(session) match_on = ['uri', 'method', 'headers', 'body'] with recorder.use_cassette('example', match_requests_on=match_on): # ... Making Your Own Matcher ----------------------- So long as you are matching requests, you can define your own way of matching. Each request matcher has to inherit from ``betamax.BaseMatcher`` and implement ``match``. .. autoclass:: betamax.BaseMatcher :members: Some examples of matchers are in the source reproduced here: .. literalinclude:: ../../src/betamax/matchers/headers.py :language: python .. literalinclude:: ../../src/betamax/matchers/host.py :language: python .. literalinclude:: ../../src/betamax/matchers/method.py :language: python .. literalinclude:: ../../src/betamax/matchers/path.py :language: python .. literalinclude:: ../../src/betamax/matchers/path.py :language: python .. literalinclude:: ../../src/betamax/matchers/uri.py :language: python When you have finished writing your own matcher, you can instruct betamax to use it like so: .. code-block:: python import betamax class MyMatcher(betamax.BaseMatcher): name = 'my' def match(self, request, recorded_request): return True betamax.Betamax.register_request_matcher(MyMatcher) To use it, you simply use the name you set like you use the name of the default matchers, e.g.: .. code-block:: python with Betamax(s).use_cassette('example', match_requests_on=['uri', 'my']): # ... ``on_init`` ~~~~~~~~~~~ As you can see in the code for ``URIMatcher``, we use ``on_init`` to initialize an attribute on the ``URIMatcher`` instance. This method serves to provide the matcher author with a different way of initializing the object outside of the ``match`` method. This also means that the author does not have to override the base class' ``__init__`` method. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/record_modes.rst0000644000175100001770000000760514561150445020212 0ustar00runnerdockerRecord Modes ============ Betamax, like `VCR`_, has four modes that it can use to record cassettes: - ``'all'`` - ``'new_episodes'`` - ``'none'`` - ``'once'`` You can only ever use one record mode. Below are explanations and examples of each record mode. The explanations are blatantly taken from VCR's own `Record Modes documentation`_. All --- The ``'all'`` record mode will: - Record new interactions. - Never replay previously recorded interactions. This can be temporarily used to force VCR to re-record a cassette (i.e., to ensure the responses are not out of date) or can be used when you simply want to log all HTTP requests. Given our file, ``examples/record_modes/all/example.py``, .. literalinclude:: ../../examples/record_modes/all/example.py :language: python Every time we run it, our cassette (``examples/record_modes/all/all-example.json``) will be updated with new values. New Episodes ------------ The ``'new_episodes'`` record mode will: - Record new interactions. - Replay previously recorded interactions. It is similar to the ``'once'`` record mode, but will always record new interactions, even if you have an existing recorded one that is similar (but not identical, based on the :match_request_on option). Given our file, ``examples/record_modes/new_episodes/example_original.py``, with which we have already recorded ``examples/record_modes/new_episodes/new-episodes-example.json`` .. literalinclude:: ../../examples/record_modes/new_episodes/example_original.py :language: python If we then run ``examples/record_modes/new_episodes/example_updated.py`` .. literalinclude:: ../../examples/record_modes/new_episodes/example_updated.py :language: python The new request at the end of the file will be added to the cassette without updating the other interactions that were already recorded. None ---- The ``'none'`` record mode will: - Replay previously recorded interactions. - Cause an error to be raised for any new requests. This is useful when your code makes potentially dangerous HTTP requests. The ``'none'`` record mode guarantees that no new HTTP requests will be made. Given our file, ``examples/record_modes/none/example_original.py``, with a cassette that already has interactions recorded in ``examples/record_modes/none/none-example.json`` .. literalinclude:: ../../examples/record_modes/none/example_original.py :language: python If we then run ``examples/record_modes/none/example_updated.py`` .. literalinclude:: ../../examples/record_modes/none/example_updated.py :language: python We'll see an exception indicating that new interactions were prevented: .. literalinclude:: ../../examples/record_modes/none/example_updated.traceback :language: pytb Once ---- The ``'once'`` record mode will: - Replay previously recorded interactions. - Record new interactions if there is no cassette file. - Cause an error to be raised for new requests if there is a cassette file. It is similar to the ``'new_episodes'`` record mode, but will prevent new, unexpected requests from being made (i.e. because the request URI changed or whatever). ``'once'`` is the default record mode, used when you do not set one. If we have a file, ``examples/record_modes/once/example_original.py``, .. literalinclude:: ../../examples/record_modes/once/example_original.py :language: python And we run it, we'll see a cassette named ``examples/record_modes/once/once-example.json`` has been created. If we then run ``examples/record_modes/once/example_updated.py``, .. literalinclude:: ../../examples/record_modes/once/example_updated.py :language: python We'll see an exception similar to the one we see when using the ``'none'`` record mode. .. literalinclude:: ../../examples/record_modes/once/example_updated.traceback :language: pytb .. _VCR: https://relishapp.com/vcr/vcr .. _Record Modes documentation: https://relishapp.com/vcr/vcr/v/2-9-3/docs/record-modes/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/serializers.rst0000644000175100001770000000421314561150445020071 0ustar00runnerdockerSerializers =========== You can tell Betamax how you would like it to serialize the cassettes when saving them to a file. By default Betamax will serialize your cassettes to JSON. The only default serializer is the JSON serializer, but writing your own is very easy. Creating Your Own Serializer ---------------------------- Betamax handles the structuring of the cassette and writing to a file, your serializer simply takes a :ref:`dictionary ` and returns a string. Every Serializer has to inherit from :class:`betamax.BaseSerializer` and implement three methods: - ``betamax.BaseSerializer.generate_cassette_name`` which is a static method. This will take the directory the user (you) wants to store the cassettes in and the name of the cassette and generate the file name. - :py:meth:`betamax.BaseSerializer.serialize` is a method that takes the dictionary and returns the dictionary serialized as a string - :py:meth:`betamax.BaseSerializer.deserialize` is a method that takes a string and returns the data serialized in it as a dictionary. .. versionadded:: 0.9.0 Allow Serializers to indicate their format is a binary format via ``stored_as_binary``. Additionally, if your Serializer is utilizing a binary format, you will want to set the ``stored_as_binary`` attribute to ``True`` on your class. .. autoclass:: betamax.BaseSerializer :members: Here's the default (JSON) serializer as an example: .. literalinclude:: ../../src/betamax/serializers/json_serializer.py :language: python This is incredibly simple. We take advantage of the :mod:`os.path` to properly join the directory name and the file name. Betamax uses this method to find an existing cassette or create a new one. Next we have the :py:meth:`betamax.serializers.JSONSerializer.serialize` which takes the cassette dictionary and turns it into a string for us. Here we are just leveraging the :mod:`json` module and its ability to dump any valid dictionary to a string. Finally, there is the :py:meth:`betamax.serializers.JSONSerializer.deserialize` method which takes a string and turns it into the dictionary that betamax needs to function. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/third_party_packages.rst0000644000175100001770000001213414561150445021725 0ustar00runnerdockerThird-Party Packages ==================== Betamax was created to be a very close imitation of `VCR`_. As such, it has the default set of request matchers and a subset of the supported cassette serializers for VCR. As part of my own usage of Betamax, and supporting other people's usage of Betamax, I've created (and maintain) two third party packages that provide extra request matchers and cassette serializers. - `betamax-matchers`_ - `betamax-serializers`_ For simplicity, those modules will be documented here instead of on their own documentation sites. Request Matchers ---------------- There are three third-party request matchers provided by the `betamax-matchers`_ package: - :class:`~betamax_matchers.form_urlencoded.URLEncodedBodyMatcher`, ``'form-urlencoded-body'`` - :class:`~betamax_matchers.json_body.JSONBodyMatcher`, ``'json-body'`` - :class:`~betamax_matchers.multipart.MultipartFormDataBodyMatcher`, ``'multipart-form-data-body'`` In order to use any of these we have to register them with Betamax. Below we will register all three but you do not need to do that if you only need to use one: .. code-block:: python import betamax from betamax_matchers import form_urlencoded from betamax_matchers import json_body from betamax_matchers import multipart betamax.Betamax.register_request_matcher( form_urlencoded.URLEncodedBodyMatcher ) betamax.Betamax.register_request_matcher( json_body.JSONBodyMatcher ) betamax.Betamax.register_request_matcher( multipart.MultipartFormDataBodyMatcher ) All of these classes inherit from :class:`betamax.BaseMatcher` which means that each needs a name that will be used when specifying what matchers to use with Betamax. I have noted those next to the class name for each matcher above. Let's use the JSON body matcher in an example though: .. code-block:: python import betamax from betamax_matchers import json_body # This example requires at least requests 2.5.0 import requests betamax.Betamax.register_request_matcher( json_body.JSONBodyMatcher ) def main(): session = requests.Session() recorder = betamax.Betamax(session, cassette_library_dir='.') url = 'https://httpbin.org/post' json_data = {'key': 'value', 'other-key': 'other-value', 'yet-another-key': 'yet-another-value'} matchers = ['method', 'uri', 'json-body'] with recorder.use_cassette('json-body-example', match_requests_on=matchers): r = session.post(url, json=json_data) if __name__ == '__main__': main() If we ran that request without those matcher with hash seed randomization, then we would occasionally receive exceptions that a request could not be matched. That is because dictionaries are not inherently ordered so the body string of the request can change and be any of the following: .. code-block:: js {"key": "value", "other-key": "other-value", "yet-another-key": "yet-another-value"} .. code-block:: js {"key": "value", "yet-another-key": "yet-another-value", "other-key": "other-value"} .. code-block:: js {"other-key": "other-value", "yet-another-key": "yet-another-value", "key": "value"} .. code-block:: js {"yet-another-key": "yet-another-value", "key": "value", "other-key": "other-value"} .. code-block:: js {"yet-another-key": "yet-another-value", "other-key": "other-value", "key": "value"} .. code-block:: js {"other-key": "other-value", "key": "value", "yet-another-key": "yet-another-value"} But using the ``'json-body'`` matcher, the matcher will parse the request and compare python dictionaries instead of python strings. That will completely bypass the issues introduced by hash randomization. I use this matcher extensively in `github3.py`_\ 's tests. Cassette Serializers -------------------- By default, Betamax only comes with the JSON serializer. `betamax-serializers`_ provides extra serializer classes that users have contributed. For example, as we've seen elsewhere in our documentation, the default JSON serializer does not create beautiful or easy to read cassettes. As a substitute for that, we have the :class:`~betamax_serializers.pretty_json.PrettyJSONSerializer` that does that for you. .. code-block:: python from betamax import Betamax from betamax_serializers import pretty_json import requests Betamax.register_serializer(pretty_json.PrettyJSONSerializer) session = requests.Session() recorder = Betamax(session) with recorder.use_cassette('testpretty', serialize_with='prettyjson'): session.request(method=method, url=url, ...) This will give us a pretty-printed cassette like: .. literalinclude:: ../../examples/cassettes/more-complicated-cassettes.json :language: js .. links .. _VCR: https://relishapp.com/vcr/vcr .. _betamax-matchers: https://pypi.python.org/pypi/betamax-matchers .. _betamax-serializers: https://pypi.python.org/pypi/betamax-serializers .. _github3.py: https://github.com/sigmavirus24/github3.py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/docs/source/usage_patterns.rst0000644000175100001770000000773614561150445020576 0ustar00runnerdockerUsage Patterns ============== Below are suggested patterns for using Betamax efficiently. Configuring Betamax in py.test's conftest.py -------------------------------------------- Betamax and github3.py (the project which instigated the creation of Betamax) both utilize py.test_ and its feature of configuring how the tests run with ``conftest.py`` [#]_. One pattern that I have found useful is to include this in your ``conftest.py`` file: .. code-block:: python import betamax with betamax.Betamax.configure() as config: config.cassette_library_dir = 'tests/cassettes/' This configures your cassette directory for all of your tests. If you do not check your cassettes into your version control system, then you can also add: .. code-block:: python import os if not os.path.exists('tests/cassettes'): os.makedirs('tests/cassettes') An Example from github3.py ^^^^^^^^^^^^^^^^^^^^^^^^^^ You can configure other aspects of Betamax via the ``conftest.py`` file. For example, in github3.py, I do the following: .. code-block:: python import os record_mode = 'none' if os.environ.get('TRAVIS_GH3') else 'once' with betamax.Betamax.configure() as config: config.cassette_library_dir = 'tests/cassettes/' config.default_cassette_options['record_mode'] = record_mode config.define_cassette_placeholder( '', os.environ.get('GH_AUTH', 'x' * 20) ) In essence, if the tests are being run on `Travis CI`_, then we want to make sure to not try to record new cassettes or interactions. We also, want to ensure we're authenticated when possible but that we do not leave our placeholder in the cassettes when they're replayed. Using Human Readable JSON Cassettes ----------------------------------- Using the ``PrettyJSONSerializer`` provided by the ``betamax_serializers`` package provides human readable JSON cassettes. Cassettes output in this way make it easy to compare modifications to cassettes to ensure only expected changes are introduced. While you can use the ``serialize_with`` option when creating each individual cassette, it is simpler to provide this setting globally. The following example demonstrates how to configure Betamax to use the ``PrettyJSONSerializer`` for all newly created cassettes: .. code-block:: python from betamax_serializers import pretty_json betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer) # ... config.default_cassette_options['serialize_with'] = 'prettyjson' Updating Existing Betamax Cassettes to be Human Readable ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If you already have a library of cassettes when applying the previous configuration update, then you will probably want to also update all your existing cassettes into the new human readable format. The following script will help you transform your existing cassettes: .. code-block:: python import os import glob import json import sys try: cassette_dir = sys.argv[1] cassettes = glob.glob(os.path.join(cassette_dir, '*.json')) except: print('Usage: {0} CASSETTE_DIRECTORY'.format(sys.argv[0])) sys.exit(1) for cassette_path in cassettes: with open(cassette_path, 'r') as fp: data = json.load(fp) with open(cassette_path, 'w') as fp: json.dump(data, fp, sort_keys=True, indent=2, separators=(',', ': ')) print('Updated {0} cassette{1}.'.format( len(cassettes), '' if len(cassettes) == 1 else 's')) Copy and save the above script as ``fix_cassettes.py`` and then run it like: .. code-block:: bash python fix_cassettes.py PATH_TO_CASSETTE_DIRECTORY If you're not already using a version control system (e.g., git, svn) then it is recommended you make a backup of your cassettes first in the event something goes wrong. .. [#] http://pytest.org/latest/plugins.html .. _py.test: http://pytest.org/latest/ .. _Travis CI: https://travis-ci.org/ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.518656 betamax-0.9.0/setup.cfg0000644000175100001770000000273514561150460014400 0ustar00runnerdocker[metadata] name = betamax version = attr: betamax.__version__ description = A VCR imitation for python-requests long_description = file: README.rst long_description_content_type = text/x-rst url = https://github.com/sigmavirus24/betamax author = Ian Stapleton Cordasco author_email = graffatcolmingov@gmail.com license = Apache-2.0 license_files = LICENSE classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: Apache Software License Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Topic :: Software Development :: Libraries :: Python Modules Topic :: Software Development :: Quality Assurance [options] packages = find: include_package_data = True install_requires = requests >= 2.0 python_requires = >=3.8.1 package_dir = =src [options.packages.find] where = src exclude = tests tests.integration [options.package_data] * = LICENSE AUTHORS.rst HISTORY.rst README.rst [options.entry_points] pytest11 = pytest-betamax = betamax.fixtures.pytest [bdist_wheel] universal = 1 [coverage:run] source = betamax tests plugins = covdefaults [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/setup.py0000644000175100001770000000011114561150445014256 0ustar00runnerdocker"""Packaging logic for betamax.""" import setuptools setuptools.setup() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.498656 betamax-0.9.0/src/0000755000175100001770000000000014561150457013345 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.502656 betamax-0.9.0/src/betamax/0000755000175100001770000000000014561150460014760 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/__init__.py0000644000175100001770000000126714561150445017102 0ustar00runnerdocker""" betamax. ======= See https://betamax.readthedocs.io/ for documentation. :copyright: (c) 2013-2018 by Ian Stapleton Cordasco :license: Apache 2.0, see LICENSE for more details """ from .decorator import use_cassette from .exceptions import BetamaxError from .matchers import BaseMatcher from .recorder import Betamax from .serializers import BaseSerializer __all__ = ('BetamaxError', 'Betamax', 'BaseMatcher', 'BaseSerializer', 'use_cassette') __author__ = 'Ian Stapleton Cordasco' __copyright__ = 'Copyright 2013- Ian Stapleton Cordasco' __license__ = 'Apache 2.0' __title__ = 'betamax' __version__ = '0.9.0' __version_info__ = tuple(int(i) for i in __version__.split('.')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/adapter.py0000644000175100001770000001447614561150445016771 0ustar00runnerdocker""" betamax.adapter. ============== adapter for betamax """ import os from . import cassette from .exceptions import BetamaxError from datetime import datetime, timedelta from requests.adapters import BaseAdapter, HTTPAdapter _SENTINEL = object() class BetamaxAdapter(BaseAdapter): """This object is an implementation detail of the library. It is not meant to be a public API and is not exported as such. """ def __init__(self, **kwargs): super(BetamaxAdapter, self).__init__() self.cassette = None self.cassette_name = None self.old_adapters = kwargs.pop('old_adapters', {}) self.http_adapter = HTTPAdapter(**kwargs) self.serialize = None self.options = {} def cassette_exists(self): """Check if cassette exists on file system. :returns: bool -- True if exists, False otherwise """ if self.cassette_name and os.path.exists(self.cassette_name): return True return False def close(self): """Propagate close to underlying adapter.""" self.http_adapter.close() def eject_cassette(self): """Eject currently loaded cassette.""" if self.cassette: self.cassette.eject() self.cassette = None # Allow self.cassette to be garbage-collected def load_cassette(self, cassette_name, serialize, options): """Load cassette. Loads a previously serialized http response as a cassette :param str cassette_name: (required), name of cassette :param str serialize: (required), type of serialization i.e 'json' :options dict options: (required), options for cassette """ self.cassette_name = cassette_name self.serialize = serialize self.options.update(options.items()) placeholders = self.options.get('placeholders', {}) cassette_options = {} default_options = cassette.Cassette.default_cassette_options match_requests_on = self.options.get( 'match_requests_on', default_options['match_requests_on'] ) cassette_options['preserve_exact_body_bytes'] = self.options.get( 'preserve_exact_body_bytes', ) cassette_options['allow_playback_repeats'] = self.options.get( 'allow_playback_repeats' ) cassette_options['record_mode'] = self.options.get('record') for option, value in list(cassette_options.items()): if value is None: cassette_options.pop(option) self.cassette = cassette.Cassette( cassette_name, serialize, placeholders=placeholders, cassette_library_dir=self.options.get('cassette_library_dir'), **cassette_options ) if 'record' in self.options: self.cassette.record_mode = self.options['record'] # NOTE(sigmavirus24): Cassette.match_options is a set, might as well # use that instead of overriding it. self.cassette.match_options.update(match_requests_on) re_record_interval = timedelta.max if self.options.get('re_record_interval'): re_record_interval = timedelta(self.options['re_record_interval']) now = datetime.utcnow() if re_record_interval < (now - self.cassette.earliest_recorded_date): self.cassette.clear() def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None): """Send request. :param request request: request :returns: A Response object """ interaction = None current_cassette = self.cassette if not current_cassette: raise BetamaxError('No cassette was specified or found.') if current_cassette.interactions: interaction = current_cassette.find_match(request) if not interaction and current_cassette.is_recording(): interaction = self.send_and_record( request, stream, timeout, verify, cert, proxies ) if not interaction: raise BetamaxError(unhandled_request_message(request, current_cassette)) resp = interaction.as_response() resp.connection = self return resp def send_and_record(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None): """Send request and record response. The response will be serialized and saved to a cassette which can be replayed in the future. :param request request: request :param bool stream: (optional) defer download until content is accessed :param float timeout: (optional) time to wait for a response :param bool verify: (optional) verify SSL certificate :param str cert: (optional) path to SSL client :param proxies dict: (optional) mapping protocol to URL of the proxy :return: Interaction :rtype: class:`betamax.cassette.Interaction` """ adapter = self.find_adapter(request.url) response = adapter.send( request, stream=True, timeout=timeout, verify=verify, cert=cert, proxies=proxies ) return self.cassette.save_interaction(response, request) def find_adapter(self, url): """Find adapter. Searches for an existing adapter where the url and prefix match. :param url str: (required) url of the adapter :returns: betamax adapter """ for (prefix, adapter) in self.old_adapters.items(): if url.lower().startswith(prefix): return adapter # Unlike in requests, we cannot possibly get this far. UNHANDLED_REQUEST_EXCEPTION = """A request was made that could not be handled. A request was made to {url} that could not be found in {cassette_file_path}. The settings on the cassette are: - record_mode: {cassette_record_mode} - match_options {cassette_match_options}. """ def unhandled_request_message(request, cassette): """Generate exception for unhandled requests.""" return UNHANDLED_REQUEST_EXCEPTION.format( url=request.url, cassette_file_path=cassette.cassette_name, cassette_record_mode=cassette.record_mode, cassette_match_options=cassette.match_options ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.506656 betamax-0.9.0/src/betamax/cassette/0000755000175100001770000000000014561150460016573 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/cassette/__init__.py0000644000175100001770000000021514561150445020705 0ustar00runnerdockerfrom .cassette import Cassette, dispatch_hooks from .interaction import Interaction __all__ = ('Cassette', 'Interaction', 'dispatch_hooks') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/cassette/cassette.py0000644000175100001770000002045714561150445020773 0ustar00runnerdocker# -*- coding: utf-8 -*- import collections from datetime import datetime from functools import partial import os.path from .interaction import Interaction from .. import matchers from .. import serializers from betamax.util import (_option_from, serialize_prepared_request, serialize_response, timestamp) class Cassette(object): default_cassette_options = { 'record_mode': 'once', 'match_requests_on': ['method', 'uri'], 're_record_interval': None, 'placeholders': [], 'preserve_exact_body_bytes': False, 'allow_playback_repeats': False, } hooks = collections.defaultdict(list) def __init__(self, cassette_name, serialization_format, **kwargs): #: Short name of the cassette self.cassette_name = cassette_name self.serialized = None defaults = Cassette.default_cassette_options # Determine the record mode self.record_mode = _option_from('record_mode', kwargs, defaults) # Retrieve the serializer for this cassette self.serializer = serializers.SerializerProxy.find( serialization_format, kwargs.get('cassette_library_dir'), cassette_name ) self.cassette_path = self.serializer.cassette_path # Determine which placeholders to use default_placeholders = defaults['placeholders'][:] cassette_placeholders = kwargs.get('placeholders', []) self.placeholders = merge_placeholder_lists(default_placeholders, cassette_placeholders) # Determine whether to preserve exact body bytes self.preserve_exact_body_bytes = _option_from( 'preserve_exact_body_bytes', kwargs, defaults ) self.allow_playback_repeats = _option_from( 'allow_playback_repeats', kwargs, defaults ) # Initialize the interactions self.interactions = [] # Initialize the match options self.match_options = set() self.load_interactions() self.serializer.allow_serialization = self.is_recording() @staticmethod def can_be_loaded(cassette_library_dir, cassette_name, serialize_with, record_mode): # If we want to record a cassette we don't care if the file exists # yet recording = False if record_mode in ['once', 'all', 'new_episodes']: recording = True serializer = serializers.serializer_registry.get( serialize_with ) if not serializer: raise ValueError( 'Serializer {0} is not registered with Betamax'.format( serialize_with )) cassette_path = serializer.generate_cassette_name( cassette_library_dir, cassette_name ) # Otherwise if we're only replaying responses, we should probably # have the cassette the user expects us to load and raise. return os.path.exists(cassette_path) or recording def clear(self): # Clear out the interactions self.interactions = [] # Serialize to the cassette file self._save_cassette() @property def earliest_recorded_date(self): """The earliest date of all of the interactions this cassette.""" if self.interactions: i = sorted(self.interactions, key=lambda i: i.recorded_at)[0] return i.recorded_at return datetime.now() def eject(self): self._save_cassette() def find_match(self, request): """Find a matching interaction based on the matchers and request. This uses all of the matchers selected via configuration or ``use_cassette`` and passes in the request currently in progress. :param request: ``requests.PreparedRequest`` :returns: :class:`~betamax.cassette.Interaction` """ # if we are recording, do not filter by match if self.is_recording(): if ((self.record_mode == 'new_episodes' and all(i.used is True for i in self.interactions)) or self.record_mode in ('once', 'none')): return None opts = self.match_options # Curry those matchers curried_matchers = [ partial(matchers.matcher_registry[o].match, request) for o in opts ] for interaction in self.interactions: if not interaction.match(curried_matchers): continue if interaction.used or interaction.ignored: continue # If the interaction matches everything if self.record_mode == 'all': # If we're recording everything and there's a matching # interaction we want to overwrite it, so we remove it. self.interactions.remove(interaction) break # set interaction as used before returning if not self.allow_playback_repeats: interaction.used = True return interaction # No matches. So sad. return None def is_empty(self): """Determine if the cassette was empty when loaded.""" return not self.serialized def is_recording(self): """Return whether the cassette is recording.""" values = { 'none': False, 'once': self.is_empty(), } return values.get(self.record_mode, True) def load_interactions(self): if self.serialized is None: self.serialized = self.serializer.deserialize() interactions = self.serialized.get('http_interactions', []) self.interactions = [Interaction(i) for i in interactions] for i in self.interactions: dispatch_hooks('before_playback', i, self) i.replace_all(self.placeholders, False) def sanitize_interactions(self): for i in self.interactions: i.replace_all(self.placeholders, True) def save_interaction(self, response, request): serialized_data = self.serialize_interaction(response, request) interaction = Interaction(serialized_data, response) dispatch_hooks('before_record', interaction, self) if not interaction.ignored: # If a hook caused this to be ignored self.interactions.append(interaction) return interaction def serialize_interaction(self, response, request): return { 'request': serialize_prepared_request( request, self.preserve_exact_body_bytes ), 'response': serialize_response( response, self.preserve_exact_body_bytes ), 'recorded_at': timestamp(), } # Private methods def _save_cassette(self): from .. import __version__ self.sanitize_interactions() cassette_data = { 'http_interactions': [i.data for i in self.interactions], 'recorded_with': 'betamax/{0}'.format(__version__) } self.serializer.serialize(cassette_data) class Placeholder(collections.namedtuple('Placeholder', 'placeholder replace')): """Encapsulate some logic about Placeholders.""" @classmethod def from_dict(cls, dictionary): return cls(**dictionary) def unpack(self, serializing): if serializing: return self.replace, self.placeholder else: return self.placeholder, self.replace def merge_placeholder_lists(defaults, overrides): overrides = [Placeholder.from_dict(override) for override in overrides] overrides_dict = dict((p.placeholder, p) for p in overrides) placeholders = [overrides_dict.pop(p.placeholder, p) for p in map(Placeholder.from_dict, defaults)] return placeholders + [p for p in overrides if p.placeholder in overrides_dict] def dispatch_hooks(hook_name, *args): """Dispatch registered hooks.""" # Cassette.hooks is a dictionary that defaults to an empty list, # we neither need to check for the presence of hook_name in it, nor # need to worry about whether the return value will be iterable hooks = Cassette.hooks[hook_name] for hook in hooks: hook(*args) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/cassette/interaction.py0000644000175100001770000000744314561150445021477 0ustar00runnerdockerfrom requests.cookies import extract_cookies_to_jar from datetime import datetime from betamax import util class Interaction(object): """The Interaction object represents the entirety of a single interaction. The interaction includes the date it was recorded, its JSON representation, and the ``requests.Response`` object complete with its ``request`` attribute. This object also handles the filtering of sensitive data. No methods or attributes on this object are considered public or part of the public API. As such they are entirely considered implementation details and subject to change. Using or relying on them is not wise or advised. """ def __init__(self, interaction, response=None): self.data = interaction self.orig_response = response self.recorded_response = self.deserialize() self.used = False self.ignored = False def ignore(self): """Ignore this interaction. This is only to be used from a before_record or a before_playback callback. """ self.ignored = True def as_response(self): """Return the Interaction as a Response object.""" self.recorded_response = self.deserialize() return self.recorded_response @property def recorded_at(self): return datetime.strptime(self.data['recorded_at'], '%Y-%m-%dT%H:%M:%S') def deserialize(self): """Turn a serialized interaction into a Response.""" r = util.deserialize_response(self.data['response']) r.request = util.deserialize_prepared_request(self.data['request']) extract_cookies_to_jar(r.cookies, r.request, r.raw) return r def match(self, matchers): """Return whether this interaction is a match.""" request = self.data['request'] return all(m(request) for m in matchers) def replace(self, text_to_replace, placeholder): """Replace sensitive data in this interaction.""" self.replace_in_headers(text_to_replace, placeholder) self.replace_in_body(text_to_replace, placeholder) self.replace_in_uri(text_to_replace, placeholder) def replace_all(self, replacements, serializing): """Easy way to accept all placeholders registered.""" for placeholder in replacements: self.replace(*placeholder.unpack(serializing)) def replace_in_headers(self, text_to_replace, placeholder): if text_to_replace == '': return for obj in ('request', 'response'): headers = self.data[obj]['headers'] for k, v in list(headers.items()): if isinstance(v, list): headers[k] = [hv.replace(text_to_replace, placeholder) for hv in v] else: headers[k] = v.replace(text_to_replace, placeholder) def replace_in_body(self, text_to_replace, placeholder): if text_to_replace == '': return for obj in ('request', 'response'): body = self.data[obj]['body'] old_style = hasattr(body, 'replace') if not old_style: body = body.get('string', '') if text_to_replace in body: body = body.replace(text_to_replace, placeholder) if old_style: self.data[obj]['body'] = body else: self.data[obj]['body']['string'] = body def replace_in_uri(self, text_to_replace, placeholder): if text_to_replace == '': return for (obj, key) in (('request', 'uri'), ('response', 'url')): uri = self.data[obj][key] if text_to_replace in uri: self.data[obj][key] = uri.replace( text_to_replace, placeholder ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/configure.py0000644000175100001770000001236714561150445017327 0ustar00runnerdockerfrom collections import defaultdict from .cassette import Cassette class Configuration(object): """This object acts as a proxy to configure different parts of Betamax. You should only ever encounter this object when configuring the library as a whole. For example: .. code:: with Betamax.configure() as config: config.cassette_library_dir = 'tests/cassettes/' config.default_cassette_options['record_mode'] = 'once' config.default_cassette_options['match_requests_on'] = ['uri'] config.define_cassette_placeholder('', 'http://httpbin.org') config.preserve_exact_body_bytes = True """ CASSETTE_LIBRARY_DIR = 'vcr/cassettes' recording_hooks = defaultdict(list) def __enter__(self): return self def __exit__(self, *args): pass def __setattr__(self, prop, value): if prop == 'preserve_exact_body_bytes': self.default_cassette_options[prop] = True else: super(Configuration, self).__setattr__(prop, value) def after_start(self, callback=None): """Register a function to call after Betamax is started. Example usage: .. code-block:: python def on_betamax_start(cassette): if cassette.is_recording(): print("Setting up authentication...") with Betamax.configure() as config: config.cassette_load(callback=on_cassette_load) :param callable callback: The function which accepts a cassette and might mutate it before returning. """ self.recording_hooks['after_start'].append(callback) def before_playback(self, tag=None, callback=None): """Register a function to call before playing back an interaction. Example usage: .. code-block:: python def before_playback(interaction, cassette): pass with Betamax.configure() as config: config.before_playback(callback=before_playback) :param str tag: Limits the interactions passed to the function based on the interaction's tag (currently unsupported). :param callable callback: The function which either accepts just an interaction or an interaction and a cassette and mutates the interaction before returning. """ Cassette.hooks['before_playback'].append(callback) def before_record(self, tag=None, callback=None): """Register a function to call before recording an interaction. Example usage: .. code-block:: python def before_record(interaction, cassette): pass with Betamax.configure() as config: config.before_record(callback=before_record) :param str tag: Limits the interactions passed to the function based on the interaction's tag (currently unsupported). :param callable callback: The function which either accepts just an interaction or an interaction and a cassette and mutates the interaction before returning. """ Cassette.hooks['before_record'].append(callback) def before_stop(self, callback=None): """Register a function to call before Betamax stops. Example usage: .. code-block:: python def on_betamax_stop(cassette): if not cassette.is_recording(): print("Playback completed.") with Betamax.configure() as config: config.cassette_eject(callback=on_betamax_stop) :param callable callback: The function which accepts a cassette and might mutate it before returning. """ self.recording_hooks['before_stop'].append(callback) @property def cassette_library_dir(self): """Retrieve and set the directory to store the cassettes in.""" return Configuration.CASSETTE_LIBRARY_DIR @cassette_library_dir.setter def cassette_library_dir(self, value): Configuration.CASSETTE_LIBRARY_DIR = value @property def default_cassette_options(self): """Retrieve and set the default cassette options. The options include: - ``match_requests_on`` - ``placeholders`` - ``re_record_interval`` - ``record_mode`` - ``preserve_exact_body_bytes`` Other options will be ignored. """ return Cassette.default_cassette_options @default_cassette_options.setter def default_cassette_options(self, value): Cassette.default_cassette_options = value def define_cassette_placeholder(self, placeholder, replace): """Define a placeholder value for some text. This also will replace the placeholder text with the text you wish it to use when replaying interactions from cassettes. :param str placeholder: (required), text to be used as a placeholder :param str replace: (required), text to be replaced or replacing the placeholder """ self.default_cassette_options['placeholders'].append({ 'placeholder': placeholder, 'replace': replace, }) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/decorator.py0000644000175100001770000000357314561150445017327 0ustar00runnerdockerimport functools import unittest import requests from . import recorder def use_cassette(cassette_name, cassette_library_dir=None, default_cassette_options={}, **use_cassette_kwargs): r"""Provide a Betamax-wrapped Session for convenience. .. versionadded:: 0.5.0 This decorator can be used to get a plain Session that has been wrapped in Betamax. For example, .. code-block:: python from betamax.decorator import use_cassette @use_cassette('example-decorator', cassette_library_dir='.') def test_get(session): # do things with session :param str cassette_name: Name of the cassette file in which interactions will be stored. :param str cassette_library_dir: Directory in which cassette files will be stored. :param dict default_cassette_options: Dictionary of default cassette options to set for the cassette used when recording these interactions. :param \*\*use_cassette_kwargs: Keyword arguments passed to :meth:`~betamax.Betamax.use_cassette` """ def actual_decorator(func): @functools.wraps(func) def test_wrapper(*args, **kwargs): session = requests.Session() recr = recorder.Betamax( session=session, cassette_library_dir=cassette_library_dir, default_cassette_options=default_cassette_options ) if args: fst, args = args[0], args[1:] if isinstance(fst, unittest.TestCase): args = (fst, session) + args else: args = (session, fst) + args else: args = (session,) with recr.use_cassette(cassette_name, **use_cassette_kwargs): func(*args, **kwargs) return test_wrapper return actual_decorator ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/exceptions.py0000644000175100001770000000220214561150445017512 0ustar00runnerdockerclass BetamaxError(Exception): def __init__(self, message): super(BetamaxError, self).__init__(message) class MissingDirectoryError(BetamaxError): pass class ValidationError(BetamaxError): pass class InvalidOption(ValidationError): pass class BodyBytesValidationError(ValidationError): pass class MatchersValidationError(ValidationError): pass class RecordValidationError(ValidationError): pass class RecordIntervalValidationError(ValidationError): pass class PlaceholdersValidationError(ValidationError): pass class PlaybackRepeatsValidationError(ValidationError): pass class SerializerValidationError(ValidationError): pass validation_error_map = { 'allow_playback_repeats': PlaybackRepeatsValidationError, 'match_requests_on': MatchersValidationError, 'record': RecordValidationError, 'placeholders': PlaceholdersValidationError, 'preserve_exact_body_bytes': BodyBytesValidationError, 're_record_interval': RecordIntervalValidationError, 'serialize': SerializerValidationError, # TODO: Remove this 'serialize_with': SerializerValidationError } ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.506656 betamax-0.9.0/src/betamax/fixtures/0000755000175100001770000000000014561150460016631 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/fixtures/__init__.py0000644000175100001770000000000014561150445020733 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/fixtures/pytest.py0000644000175100001770000001132614561150445020541 0ustar00runnerdocker# -*- coding: utf-8 -*- """A set of fixtures to integrate Betamax with py.test. .. autofunction:: betamax_session """ from __future__ import absolute_import import re import warnings import pytest import requests from .. import recorder as betamax def _sanitize(name): """ Replace certain characters (which might be problematic when contained in strings which will be used as file names) by '-'s. """ return re.sub(r'[\s/<>:\\"|?*]', '-', name) def _cassette_name(request, parametrized): """Determine a cassette name from request. :param request: A request object from pytest giving us context information for the fixture. :param parametrized: Whether the name should consider parametrized tests. :returns: A cassette name. """ cassette_name = '' if request.module is not None: cassette_name += request.module.__name__ + '.' if request.cls is not None: cassette_name += request.cls.__name__ + '.' if parametrized: cassette_name += _sanitize(request.node.name) else: cassette_name += request.function.__name__ if request.node.name != request.function.__name__: warnings.warn( "betamax_recorder and betamax_session currently don't include " "parameters in the cassette name. " "Use betamax_parametrized_recorder/_session to include " "parameters. " "This behavior will be the default in betamax 1.0", FutureWarning, stacklevel=3) return cassette_name def _betamax_recorder(request, parametrized=True): cassette_name = _cassette_name(request, parametrized=parametrized) session = requests.Session() recorder = betamax.Betamax(session) recorder.use_cassette(cassette_name) recorder.start() request.addfinalizer(recorder.stop) return recorder @pytest.fixture def betamax_recorder(request): """Generate a recorder with a session that has Betamax already installed. This will create a new Betamax instance with a generated cassette name. The cassette name is generated by first using the module name from where the test is collected, then the class name (if it exists), and then the test function name. For example, if your test is in ``test_stuff.py`` and is the method ``TestStuffClass.test_stuff`` then your cassette name will be ``test_stuff_TestStuffClass_test_stuff``. If the test is parametrized, the parameters will not be included in the name. In case you need that, use betamax_parametrized_recorder instead. This will change in 1.0.0, where parameters will be included by default. :param request: A request object from pytest giving us context information for the fixture. :returns: An instantiated recorder. """ return _betamax_recorder(request, parametrized=False) @pytest.fixture def betamax_session(betamax_recorder): """Generate a session that has Betamax already installed. See `betamax_recorder` fixture. :param betamax_recorder: A recorder fixture with a configured request session. :returns: An instantiated requests Session wrapped by Betamax. """ return betamax_recorder.session @pytest.fixture def betamax_parametrized_recorder(request): """Generate a recorder with a session that has Betamax already installed. This will create a new Betamax instance with a generated cassette name. The cassette name is generated by first using the module name from where the test is collected, then the class name (if it exists), and then the test function name with parameters if parametrized. For example, if your test is in ``test_stuff.py`` and the method is ``TestStuffClass.test_stuff`` with parameter ``True`` then your cassette name will be ``test_stuff_TestStuffClass_test_stuff[True]``. :param request: A request object from pytest giving us context information for the fixture. :returns: An instantiated recorder. """ warnings.warn( "betamax_parametrized_recorder and betamax_parametrized_session " "will be removed in betamax 1.0. Their behavior will be the " "default.", DeprecationWarning) return _betamax_recorder(request, parametrized=True) @pytest.fixture def betamax_parametrized_session(betamax_parametrized_recorder): """Generate a session that has Betamax already installed. See `betamax_parametrized_recorder` fixture. :param betamax_parametrized_recorder: A recorder fixture with a configured request session. :returns: An instantiated requests Session wrapped by Betamax. """ return betamax_parametrized_recorder.session ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/fixtures/unittest.py0000644000175100001770000000703514561150445021072 0ustar00runnerdocker"""Minimal :class:`unittest.TestCase` subclass adding Betamax integration. .. autoclass:: betamax.fixtures.unittest.BetamaxTestCase :members: When using Betamax with unittest, you can use the traditional style of Betamax covered in the documentation thoroughly, or you can use your fixture methods, :meth:`unittest.TestCase.setUp` and :meth:`unittest.TestCase.tearDown` to wrap entire tests in Betamax. Here's how you might use it: .. code-block:: python from betamax.fixtures import unittest from myapi import SessionManager class TestMyApi(unittest.BetamaxTestCase): def setUp(self): # Call BetamaxTestCase's setUp first to get a session super(TestMyApi, self).setUp() self.manager = SessionManager(self.session) def test_all_users(self): \"\"\"Retrieve all users from the API.\"\"\" for user in self.manager: # Make assertions or something Alternatively, if you are subclassing a :class:`requests.Session` to provide extra functionality, you can do something like this: .. code-block:: python from betamax.fixtures import unittest from myapi import Session, SessionManager class TestMyApi(unittest.BetamaxTestCase): SESSION_CLASS = Session # See above ... """ # NOTE(sigmavirus24): absolute_import is required to make import unittest work from __future__ import absolute_import try: import unittest2 as unittest except ImportError: import unittest import requests from .. import recorder __all__ = ('BetamaxTestCase',) class BetamaxTestCase(unittest.TestCase): """Betamax integration for unittest. .. versionadded:: 0.5.0 """ #: Class that is a subclass of :class:`requests.Session` SESSION_CLASS = requests.Session #: Custom path to save cassette. CASSETTE_LIBRARY_DIR = None def generate_cassette_name(self): """Generates a cassette name for the current test. The default format is "%(classname)s.%(testMethodName)s" To change the default cassette format, override this method in a subclass. :returns: Cassette name for the current test. :rtype: str """ cls = getattr(self, '__class__') test = self._testMethodName return '{0}.{1}'.format(cls.__name__, test) def setUp(self): """Betamax-ified setUp fixture. This will call the superclass' setUp method *first* and then it will create a new :class:`requests.Session` and wrap that in a Betamax object to record it. At the end of ``setUp``, it will start recording. """ # Bail out early if the SESSION_CLASS isn't a subclass of # requests.Session self.assertTrue(issubclass(self.SESSION_CLASS, requests.Session)) # Make sure if the user is multiply inheriting that all setUps are # called. (If that confuses you, see: https://youtu.be/EiOglTERPEo) super(BetamaxTestCase, self).setUp() cassette_name = self.generate_cassette_name() self.session = self.SESSION_CLASS() self.recorder = recorder.Betamax( session=self.session, cassette_library_dir=self.CASSETTE_LIBRARY_DIR) self.recorder.use_cassette(cassette_name) self.recorder.start() def tearDown(self): """Betamax-ified tearDown fixture. This will call the superclass' tearDown method *first* and then it will stop recording interactions. """ super(BetamaxTestCase, self).tearDown() self.recorder.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/headers.py0000644000175100001770000001757714561150445016771 0ustar00runnerdocker"""Backport of urllib3's HTTPHeaderDict for older versions of requests. This code was originally licensed under the MIT License and is copyrighted by Andrey Petrov and contributors to urllib3. This version was imported from: https://github.com/shazow/urllib3/blob/3bd63406bef7c16d007c17563b6af14582567d4b/urllib3/_collections.py """ import sys from collections import Mapping, MutableMapping __all__ = ('HTTPHeaderDict',) PY3 = sys.version_info >= (3, 0) class HTTPHeaderDict(MutableMapping): """ :param headers: An iterable of field-value pairs. Must not contain multiple field names when compared case-insensitively. :param kwargs: Additional field-value pairs to pass in to ``dict.update``. A ``dict`` like container for storing HTTP Headers. Field names are stored and compared case-insensitively in compliance with RFC 7230. Iteration provides the first case-sensitive key seen for each case-insensitive pair. Using ``__setitem__`` syntax overwrites fields that compare equal case-insensitively in order to maintain ``dict``'s api. For fields that compare equal, instead create a new ``HTTPHeaderDict`` and use ``.add`` in a loop. If multiple fields that are equal case-insensitively are passed to the constructor or ``.update``, the behavior is undefined and some will be lost. >>> headers = HTTPHeaderDict() >>> headers.add('Set-Cookie', 'foo=bar') >>> headers.add('set-cookie', 'baz=quxx') >>> headers['content-length'] = '7' >>> headers['SET-cookie'] 'foo=bar, baz=quxx' >>> headers['Content-Length'] '7' """ def __init__(self, headers=None, **kwargs): super(HTTPHeaderDict, self).__init__() self._container = {} if headers is not None: if isinstance(headers, HTTPHeaderDict): self._copy_from(headers) else: self.extend(headers) if kwargs: self.extend(kwargs) def __setitem__(self, key, val): self._container[key.lower()] = (key, val) return self._container[key.lower()] def __getitem__(self, key): val = self._container[key.lower()] return ', '.join(val[1:]) def __delitem__(self, key): del self._container[key.lower()] def __contains__(self, key): return key.lower() in self._container def __eq__(self, other): if not isinstance(other, Mapping) and not hasattr(other, 'keys'): return False if not isinstance(other, type(self)): other = type(self)(other) return (dict((k.lower(), v) for k, v in self.itermerged()) == dict((k.lower(), v) for k, v in other.itermerged())) def __ne__(self, other): return not self.__eq__(other) if not PY3: # Python 2 iterkeys = MutableMapping.iterkeys itervalues = MutableMapping.itervalues __marker = object() def __len__(self): return len(self._container) def __iter__(self): # Only provide the originally cased names for vals in self._container.values(): yield vals[0] def pop(self, key, default=__marker): """D.pop(k[,d]) -> v, remove specified key and return the value. If key is not found, d is returned if given, otherwise KeyError is raised. """ # Using the MutableMapping function directly fails due to the private # marker. # Using ordinary dict.pop would expose the internal structures. # So let's reinvent the wheel. try: value = self[key] except KeyError: if default is self.__marker: raise return default else: del self[key] return value def discard(self, key): try: del self[key] except KeyError: pass def add(self, key, val): """Adds a (name, value) pair, doesn't overwrite the value if it already exists. >>> headers = HTTPHeaderDict(foo='bar') >>> headers.add('Foo', 'baz') >>> headers['foo'] 'bar, baz' """ key_lower = key.lower() new_vals = key, val # Keep the common case aka no item present as fast as possible vals = self._container.setdefault(key_lower, new_vals) if new_vals is not vals: # new_vals was not inserted, as there was a previous one if isinstance(vals, list): # If already several items got inserted, we have a list vals.append(val) else: # vals should be a tuple then, i.e. only one item so far # Need to convert the tuple to list for further extension self._container[key_lower] = [vals[0], vals[1], val] def extend(self, *args, **kwargs): """Generic import function for any type of header-like object. Adapted version of MutableMapping.update in order to insert items with self.add instead of self.__setitem__ """ if len(args) > 1: raise TypeError("extend() takes at most 1 positional " "arguments ({0} given)".format(len(args))) other = args[0] if len(args) >= 1 else () if isinstance(other, HTTPHeaderDict): for key, val in other.iteritems(): self.add(key, val) elif isinstance(other, Mapping): for key in other: self.add(key, other[key]) elif hasattr(other, "keys"): for key in other.keys(): self.add(key, other[key]) else: for key, value in other: self.add(key, value) for key, value in kwargs.items(): self.add(key, value) def getlist(self, key): """Returns a list of all the values for the named field. Returns an empty list if the key doesn't exist.""" try: vals = self._container[key.lower()] except KeyError: return [] else: if isinstance(vals, tuple): return [vals[1]] else: return vals[1:] # Backwards compatibility for httplib getheaders = getlist getallmatchingheaders = getlist iget = getlist def __repr__(self): return "%s(%s)" % (type(self).__name__, dict(self.itermerged())) def _copy_from(self, other): for key in other: val = other.getlist(key) if isinstance(val, list): # Don't need to convert tuples val = list(val) self._container[key.lower()] = [key] + val def copy(self): clone = type(self)() clone._copy_from(self) return clone def iteritems(self): """Iterate over all header lines, including duplicate ones.""" for key in self: vals = self._container[key.lower()] for val in vals[1:]: yield vals[0], val def itermerged(self): """Iterate over all headers, merging duplicate ones together.""" for key in self: val = self._container[key.lower()] yield val[0], ', '.join(val[1:]) def items(self): return list(self.iteritems()) @classmethod def from_httplib(cls, message): # Python 2 """Read headers from a Python 2 httplib message object.""" # python2.7 does not expose a proper API for exporting multiheaders # efficiently. This function re-reads raw lines from the message # object and extracts the multiheaders properly. headers = [] for line in message.headers: if line.startswith((' ', '\t')): key, value = headers[-1] headers[-1] = (key, value + '\r\n' + line.rstrip()) continue key, value = line.split(':', 1) headers.append((key, value.strip())) return cls(headers) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.506656 betamax-0.9.0/src/betamax/matchers/0000755000175100001770000000000014561150460016566 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/__init__.py0000644000175100001770000000134114561150445020701 0ustar00runnerdockerfrom .base import BaseMatcher from .body import BodyMatcher from .digest_auth import DigestAuthMatcher from .headers import HeadersMatcher from .host import HostMatcher from .method import MethodMatcher from .path import PathMatcher from .query import QueryMatcher from .uri import URIMatcher matcher_registry = {} __all__ = ('BaseMatcher', 'BodyMatcher', 'DigestAuthMatcher', 'HeadersMatcher', 'HostMatcher', 'MethodMatcher', 'PathMatcher', 'QueryMatcher', 'URIMatcher', 'matcher_registry') _matchers = [BodyMatcher, DigestAuthMatcher, HeadersMatcher, HostMatcher, MethodMatcher, PathMatcher, QueryMatcher, URIMatcher] matcher_registry.update(dict((m.name, m()) for m in _matchers)) del _matchers ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/base.py0000644000175100001770000000344014561150445020056 0ustar00runnerdocker# -*- coding: utf-8 -*- class BaseMatcher(object): """ Base class that ensures sub-classes that implement custom matchers can be registered and have the only method that is required. Usage: .. code-block:: python from betamax import Betamax, BaseMatcher class MyMatcher(BaseMatcher): name = 'my' def match(self, request, recorded_request): # My fancy matching algorithm Betamax.register_request_matcher(MyMatcher) The last line is absolutely necessary. The `match` method will be given a `requests.PreparedRequest` object and a dictionary. The dictionary always has the following keys: - url - method - body - headers """ name = None def __init__(self): if not self.name: raise ValueError('Matchers require names') self.on_init() def on_init(self): """Method to implement if you wish something to happen in ``__init__``. The return value is not checked and this is called at the end of ``__init__``. It is meant to provide the matcher author a way to perform things during initialization of the instance that would otherwise require them to override ``BaseMatcher.__init__``. """ return None def match(self, request, recorded_request): """A method that must be implemented by the user. :param PreparedRequest request: A requests PreparedRequest object :param dict recorded_request: A dictionary containing the serialized request in the cassette :returns bool: True if they match else False """ raise NotImplementedError('The match method must be implemented on' ' %s' % self.__class__.__name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/body.py0000644000175100001770000000113114561150445020074 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher from betamax import util class BodyMatcher(BaseMatcher): # Matches based on the body of the request name = 'body' def match(self, request, recorded_request): recorded_request = util.deserialize_prepared_request(recorded_request) request_body = b'' if request.body: request_body = util.coerce_content(request.body) recorded_body = b'' if recorded_request.body: recorded_body = util.coerce_content(recorded_request.body) return recorded_body == request_body ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/digest_auth.py0000644000175100001770000000310414561150445021441 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher from betamax.util import from_list class DigestAuthMatcher(BaseMatcher): """This matcher is provided to help those who need to use Digest Auth. .. note:: The code requests 2.0.1 uses to generate this header is different from the code that every requests version after it uses. Specifically, in 2.0.1 one of the parameters is ``qop=auth`` and every other version is ``qop="auth"``. Given that there's also an unsupported type of ``qop`` in requests, I've chosen not to ignore ore sanitize this. All cassettes recorded on 2.0.1 will need to be re-recorded for any requests version after it. This matcher also ignores the ``cnonce`` and ``response`` parameters. These parameters require the system time to be monkey-patched and that is out of the scope of betamax """ name = 'digest-auth' def match(self, request, recorded_request): request_digest = self.digest_parts(request.headers) recorded_digest = self.digest_parts(recorded_request['headers']) return request_digest == recorded_digest def digest_parts(self, headers): auth = headers.get('Authorization') or headers.get('authorization') if not auth: return None auth = from_list(auth).strip('Digest ') # cnonce and response will be based on the system time, which I will # not monkey-patch. excludes = ('cnonce', 'response') return [p for p in auth.split(', ') if not p.startswith(excludes)] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/headers.py0000644000175100001770000000074014561150445020557 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher class HeadersMatcher(BaseMatcher): # Matches based on the headers of the request name = 'headers' def match(self, request, recorded_request): return dict(request.headers) == self.flatten_headers(recorded_request) def flatten_headers(self, request): from betamax.util import from_list headers = request['headers'].items() return dict((k, from_list(v)) for (k, v) in headers) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/host.py0000644000175100001770000000062114561150445020117 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher from requests.compat import urlparse class HostMatcher(BaseMatcher): # Matches based on the host of the request name = 'host' def match(self, request, recorded_request): request_host = urlparse(request.url).netloc recorded_host = urlparse(recorded_request['uri']).netloc return request_host == recorded_host ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/method.py0000644000175100001770000000041414561150445020422 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher class MethodMatcher(BaseMatcher): # Matches based on the method of the request name = 'method' def match(self, request, recorded_request): return request.method == recorded_request['method'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/path.py0000644000175100001770000000061514561150445020101 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher from requests.compat import urlparse class PathMatcher(BaseMatcher): # Matches based on the path of the request name = 'path' def match(self, request, recorded_request): request_path = urlparse(request.url).path recorded_path = urlparse(recorded_request['uri']).path return request_path == recorded_path ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/query.py0000644000175100001770000000237314561150445020315 0ustar00runnerdocker# -*- coding: utf-8 -*- import sys from .base import BaseMatcher try: from urlparse import parse_qs, urlparse except ImportError: from urllib.parse import parse_qs, urlparse isPY2 = (2, 6) <= sys.version_info < (3, 0) class QueryMatcher(BaseMatcher): # Matches based on the query of the request name = 'query' def to_dict(self, query): """Turn the query string into a dictionary.""" return parse_qs( query or '', # Protect against None keep_blank_values=True, ) def match(self, request, recorded_request): request_query_dict = self.to_dict(urlparse(request.url).query) recorded_query = urlparse(recorded_request['uri']).query if recorded_query and isPY2: # NOTE(sigmavirus24): If we're on Python 2, the request.url will # be str/bytes and the recorded_request['uri'] will be unicode. # For the comparison to work for high unicode characters, we need # to encode the recorded query string before parsing it. See also # GitHub bug #43. recorded_query = recorded_query.encode('utf-8') recorded_query_dict = self.to_dict(recorded_query) return request_query_dict == recorded_query_dict ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/matchers/uri.py0000644000175100001770000000202714561150445017743 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseMatcher from .query import QueryMatcher from requests.compat import urlparse class URIMatcher(BaseMatcher): # Matches based on the uri of the request name = 'uri' def on_init(self): # Get something we can use to match query strings with self.query_matcher = QueryMatcher().match def match(self, request, recorded_request): queries_match = self.query_matcher(request, recorded_request) request_url, recorded_url = request.url, recorded_request['uri'] return self.all_equal(request_url, recorded_url) and queries_match def parse(self, uri): parsed = urlparse(uri) return { 'scheme': parsed.scheme, 'netloc': parsed.netloc, 'path': parsed.path, 'fragment': parsed.fragment } def all_equal(self, new_uri, recorded_uri): new_parsed = self.parse(new_uri) recorded_parsed = self.parse(recorded_uri) return (new_parsed == recorded_parsed) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/mock_response.py0000644000175100001770000000164214561150445020207 0ustar00runnerdockerfrom email import parser, message import sys class MockHTTPResponse(object): def __init__(self, headers): from betamax.util import coerce_content h = ["%s: %s" % (k, v) for k in headers for v in headers.getlist(k)] h = map(coerce_content, h) h = '\r\n'.join(h) if sys.version_info < (2, 7): h = h.encode() p = parser.Parser(EmailMessage) # Thanks to Python 3, we have to use the slightly more awful API below # mimetools was deprecated so we have to use email.message.Message # which takes no arguments in its initializer. self.msg = p.parsestr(h) self.msg.set_payload(h) self._closed = False def isclosed(self): return self._closed def close(self): self._closed = True class EmailMessage(message.Message): def getheaders(self, value, *args): return self.get_all(value, []) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/options.py0000644000175100001770000000547014561150445017036 0ustar00runnerdockerfrom .cassette import Cassette from .exceptions import InvalidOption, validation_error_map def validate_record(record): return record in ['all', 'new_episodes', 'none', 'once'] def validate_matchers(matchers): from betamax.matchers import matcher_registry available_matchers = list(matcher_registry.keys()) return all(m in available_matchers for m in matchers) def validate_serializer(serializer): from betamax.serializers import serializer_registry return serializer in list(serializer_registry.keys()) def validate_placeholders(placeholders): """Validate placeholders is a dict-like structure""" keys = ['placeholder', 'replace'] try: return all(sorted(list(p.keys())) == keys for p in placeholders) except TypeError: return False def translate_cassette_options(): for (k, v) in Cassette.default_cassette_options.items(): yield (k, v) if k != 'record_mode' else ('record', v) def isboolean(value): return value in [True, False] class Options(object): valid_options = { 'match_requests_on': validate_matchers, 're_record_interval': lambda x: x is None or x > 0, 'record': validate_record, 'serialize': validate_serializer, # TODO: Remove this 'serialize_with': validate_serializer, 'preserve_exact_body_bytes': isboolean, 'placeholders': validate_placeholders, 'allow_playback_repeats': isboolean, } defaults = { 'match_requests_on': ['method', 'uri'], 're_record_interval': None, 'record': 'once', 'serialize': None, # TODO: Remove this 'serialize_with': 'json', 'preserve_exact_body_bytes': False, 'placeholders': [], 'allow_playback_repeats': False, } def __init__(self, data=None): self.data = data or {} self.validate() self.defaults = Options.defaults.copy() self.defaults.update(translate_cassette_options()) def __repr__(self): return 'Options(%s)' % self.data def __getitem__(self, key): return self.data.get(key, self.defaults.get(key)) def __setitem__(self, key, value): self.data[key] = value return value def __delitem__(self, key): del self.data[key] def __contains__(self, key): return key in self.data def items(self): return self.data.items() def validate(self): for key, value in list(self.data.items()): if key not in Options.valid_options: raise InvalidOption('{0} is not a valid option'.format(key)) else: is_valid = Options.valid_options[key] if not is_valid(value): raise validation_error_map[key]('{0!r} is not valid' .format(value)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/recorder.py0000644000175100001770000001403714561150445017147 0ustar00runnerdocker# -*- coding: utf-8 -*- from . import matchers, serializers from .adapter import BetamaxAdapter from .cassette import Cassette from .configure import Configuration from .options import Options class Betamax(object): """This object contains the main API of the request-vcr library. This object is entirely a context manager so all you have to do is: .. code:: s = requests.Session() with Betamax(s) as vcr: vcr.use_cassette('example') r = s.get('https://httpbin.org/get') Or more concisely, you can do: .. code:: s = requests.Session() with Betamax(s).use_cassette('example') as vcr: r = s.get('https://httpbin.org/get') This object allows for the user to specify the cassette library directory and default cassette options. .. code:: s = requests.Session() with Betamax(s, cassette_library_dir='tests/cassettes') as vcr: vcr.use_cassette('example') r = s.get('https://httpbin.org/get') with Betamax(s, default_cassette_options={ 're_record_interval': 1000 }) as vcr: vcr.use_cassette('example') r = s.get('https://httpbin.org/get') """ def __init__(self, session, cassette_library_dir=None, default_cassette_options={}): #: Store the requests.Session object being wrapped. self.session = session #: Store the session's original adapters. self.http_adapters = session.adapters.copy() #: Create a new adapter to replace the existing ones self.betamax_adapter = BetamaxAdapter(old_adapters=self.http_adapters) # We need a configuration instance to make life easier self.config = Configuration() # Merge the new cassette options with the default ones self.config.default_cassette_options.update( default_cassette_options or {} ) # If it was passed in, use that instead. if cassette_library_dir: self.config.cassette_library_dir = cassette_library_dir def __enter__(self): self.start() return self def __exit__(self, *ex_args): self.stop() # ex_args comes through as the exception type, exception value and # exception traceback. If any of them are not None, we should probably # try to raise the exception and not muffle anything. if any(ex_args): # If you return False, Python will re-raise the exception for you return False @staticmethod def configure(): """Help to configure the library as a whole. .. code:: with Betamax.configure() as config: config.cassette_library_dir = 'tests/cassettes/' config.default_cassette_options['match_options'] = [ 'method', 'uri', 'headers' ] """ return Configuration() @property def current_cassette(self): """Return the cassette that is currently in use. :returns: :class:`Cassette ` """ return self.betamax_adapter.cassette @staticmethod def register_request_matcher(matcher_class): """Register a new request matcher. :param matcher_class: (required), this must sub-class :class:`BaseMatcher ` """ matchers.matcher_registry[matcher_class.name] = matcher_class() @staticmethod def register_serializer(serializer_class): """Register a new serializer. :param matcher_class: (required), this must sub-class :class:`BaseSerializer ` """ name = serializer_class.name serializers.serializer_registry[name] = serializer_class() # â–¶ def start(self): """Start recording or replaying interactions.""" for k in self.http_adapters: self.session.mount(k, self.betamax_adapter) dispatch_hooks('after_start', self.betamax_adapter.cassette) # â–  def stop(self): """Stop recording or replaying interactions.""" dispatch_hooks('before_stop', self.betamax_adapter.cassette) # No need to keep the cassette in memory any longer. self.betamax_adapter.eject_cassette() # On exit, we no longer wish to use our adapter and we want the # session to behave normally! Woooo! self.betamax_adapter.close() for (k, v) in self.http_adapters.items(): self.session.mount(k, v) def use_cassette(self, cassette_name, **kwargs): """Tell Betamax which cassette you wish to use for the context. :param str cassette_name: relative name, without the serialization format, of the cassette you wish Betamax would use :param str serialize_with: the format you want Betamax to serialize the cassette with :param str serialize: DEPRECATED the format you want Betamax to serialize the request and response data to and from """ kwargs = Options(kwargs) serialize = kwargs['serialize'] or kwargs['serialize_with'] kwargs['cassette_library_dir'] = self.config.cassette_library_dir can_load = Cassette.can_be_loaded( self.config.cassette_library_dir, cassette_name, serialize, kwargs['record'] ) if can_load: self.betamax_adapter.load_cassette(cassette_name, serialize, kwargs) else: # If we're not recording or replaying an existing cassette, we # should tell the user/developer that there is no cassette, only # Zuul raise ValueError('Cassette must have a valid name and may not be' ' None.') return self def dispatch_hooks(hook_name, *args): """Dispatch registered hooks.""" hooks = Configuration.recording_hooks[hook_name] for hook in hooks: hook(*args) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.506656 betamax-0.9.0/src/betamax/serializers/0000755000175100001770000000000014561150460017314 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/serializers/__init__.py0000644000175100001770000000053514561150445021433 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseSerializer from .json_serializer import JSONSerializer from .proxy import SerializerProxy serializer_registry = {} _serializers = [JSONSerializer] serializer_registry.update(dict((s.name, s()) for s in _serializers)) del _serializers __all__ = ('BaseSerializer', 'JSONSerializer', 'SerializerProxy') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/serializers/base.py0000644000175100001770000000553514561150445020613 0ustar00runnerdocker# -*- coding: utf-8 -*- NOT_IMPLEMENTED_ERROR_MSG = ('This method must be implemented by classes' ' inheriting from BaseSerializer.') class BaseSerializer(object): """ Base Serializer class that provides an interface for other serializers. Usage: .. code-block:: python from betamax import Betamax, BaseSerializer class MySerializer(BaseSerializer): name = 'my' @staticmethod def generate_cassette_name(cassette_library_dir, cassette_name): # Generate a string that will give the relative path of a # cassette def serialize(self, cassette_data): # Take a dictionary and convert it to whatever def deserialize(self, cassette_data): # Uses a cassette file to return a dictionary with the # cassette information Betamax.register_serializer(MySerializer) The last line is absolutely necessary. """ name = None stored_as_binary = False @staticmethod def generate_cassette_name(cassette_library_dir, cassette_name): raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MSG) def __init__(self): if not self.name: raise ValueError("Serializer's name attribute must be a string" " value, not None.") self.on_init() def on_init(self): """Method to implement if you wish something to happen in ``__init__``. The return value is not checked and this is called at the end of ``__init__``. It is meant to provide the matcher author a way to perform things during initialization of the instance that would otherwise require them to override ``BaseSerializer.__init__``. """ return None def serialize(self, cassette_data): """A method that must be implemented by the Serializer author. :param dict cassette_data: A dictionary with two keys: ``http_interactions``, ``recorded_with``. :returns: Serialized data as a string. """ raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MSG) def deserialize(self, cassette_data): """A method that must be implemented by the Serializer author. The return value is extremely important. If it is not empty, the dictionary returned must have the following structure:: { 'http_interactions': [{ # Interaction }, { # Interaction }], 'recorded_with': 'name of recorder' } :params str cassette_data: The data serialized as a string which needs to be deserialized. :returns: dictionary """ raise NotImplementedError(NOT_IMPLEMENTED_ERROR_MSG) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/serializers/json_serializer.py0000644000175100001770000000126714561150445023101 0ustar00runnerdockerfrom .base import BaseSerializer import json import os class JSONSerializer(BaseSerializer): # Serializes and deserializes a cassette to JSON name = 'json' stored_as_binary = False @staticmethod def generate_cassette_name(cassette_library_dir, cassette_name): return os.path.join(cassette_library_dir, '{0}.{1}'.format(cassette_name, 'json')) def serialize(self, cassette_data): return json.dumps(cassette_data) def deserialize(self, cassette_data): try: deserialized_data = json.loads(cassette_data) except ValueError: deserialized_data = {} return deserialized_data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/serializers/proxy.py0000644000175100001770000000603314561150445021054 0ustar00runnerdocker# -*- coding: utf-8 -*- from .base import BaseSerializer from betamax.exceptions import MissingDirectoryError import os class SerializerProxy(BaseSerializer): """ This is an internal implementation detail of the betamax library. No users implementing a serializer should be using this. Developers working on betamax need only understand that this handles the logic surrounding whether a cassette should be updated, overwritten, or created. It provides one consistent way for betamax to be confident in how it serializes the data it receives. It allows authors of Serializer classes to not have to duplicate how files are handled. It delegates the responsibility of actually serializing the data to those classes and handles the rest. """ def __init__(self, serializer, cassette_path, allow_serialization=False): self.proxied_serializer = serializer self.allow_serialization = allow_serialization self.cassette_path = cassette_path def _ensure_path_exists(self): directory, _ = os.path.split(self.cassette_path) if not (directory == '' or os.path.isdir(directory)): raise MissingDirectoryError( 'Configured cassette directory \'{0}\' does not exist - try ' 'creating it'.format(directory) ) if not os.path.exists(self.cassette_path): open(self.cassette_path, 'w+').close() def corrected_file_mode(self, base_mode): storing_binary_data = getattr(self.proxied_serializer, 'stored_as_binary', False) if storing_binary_data: return '{}b'.format(base_mode) return base_mode @classmethod def find(cls, serialize_with, cassette_library_dir, cassette_name): from . import serializer_registry serializer = serializer_registry.get(serialize_with) if serializer is None: raise ValueError( 'No serializer registered for {0}'.format(serialize_with) ) cassette_path = cls.generate_cassette_name( serializer, cassette_library_dir, cassette_name ) return cls(serializer, cassette_path) @staticmethod def generate_cassette_name(serializer, cassette_library_dir, cassette_name): return serializer.generate_cassette_name( cassette_library_dir, cassette_name ) def serialize(self, cassette_data): if not self.allow_serialization: return self._ensure_path_exists() mode = self.corrected_file_mode('w') with open(self.cassette_path, mode) as fd: fd.write(self.proxied_serializer.serialize(cassette_data)) def deserialize(self): self._ensure_path_exists() data = {} mode = self.corrected_file_mode('r') with open(self.cassette_path, mode) as fd: data = self.proxied_serializer.deserialize(fd.read()) return data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/src/betamax/util.py0000644000175100001770000001304514561150445016315 0ustar00runnerdockerfrom .mock_response import MockHTTPResponse from datetime import datetime from requests.models import PreparedRequest, Response from requests.packages.urllib3 import HTTPResponse from requests.structures import CaseInsensitiveDict from requests.status_codes import _codes from requests.cookies import RequestsCookieJar try: from requests.packages.urllib3._collections import HTTPHeaderDict except ImportError: from .headers import HTTPHeaderDict import base64 import io import sys def coerce_content(content, encoding=None): if hasattr(content, 'decode'): content = content.decode(encoding or 'utf-8', 'replace') return content def body_io(string, encoding=None): if hasattr(string, 'encode'): string = string.encode(encoding or 'utf-8') return io.BytesIO(string) def from_list(value): if isinstance(value, list): return value[0] return value def add_body(r, preserve_exact_body_bytes, body_dict): """Simple function which takes a response or request and coerces the body. This function adds either ``'string'`` or ``'base64_string'`` to ``body_dict``. If ``preserve_exact_body_bytes`` is ``True`` then it encodes the body as a base64 string and saves it like that. Otherwise, it saves the plain string. :param r: This is either a PreparedRequest instance or a Response instance. :param preserve_exact_body_bytes bool: Either True or False. :param body_dict dict: A dictionary already containing the encoding to be used. """ body = getattr(r, 'raw', getattr(r, 'body', None)) if hasattr(body, 'read'): body = body.read() if not body: body = '' if (preserve_exact_body_bytes or 'gzip' in r.headers.get('Content-Encoding', '')): if sys.version_info >= (3, 0) and hasattr(body, 'encode'): body = body.encode(body_dict['encoding'] or 'utf-8') body_dict['base64_string'] = base64.b64encode(body).decode() else: body_dict['string'] = coerce_content(body, body_dict['encoding']) def serialize_prepared_request(request, preserve_exact_body_bytes): headers = request.headers body = {'encoding': 'utf-8'} add_body(request, preserve_exact_body_bytes, body) return { 'body': body, 'headers': dict( (coerce_content(k, 'utf-8'), [v]) for (k, v) in headers.items() ), 'method': request.method, 'uri': request.url, } def deserialize_prepared_request(serialized): p = PreparedRequest() p._cookies = RequestsCookieJar() body = serialized['body'] if isinstance(body, dict): original_body = body.get('string') p.body = original_body or base64.b64decode( body.get('base64_string', '').encode()) else: p.body = body h = [(k, from_list(v)) for k, v in serialized['headers'].items()] p.headers = CaseInsensitiveDict(h) p.method = serialized['method'] p.url = serialized['uri'] return p def serialize_response(response, preserve_exact_body_bytes): body = {'encoding': response.encoding} add_body(response, preserve_exact_body_bytes, body) header_map = HTTPHeaderDict(response.raw.headers) headers = {} for header_name in header_map.keys(): headers[header_name] = header_map.getlist(header_name) return { 'body': body, 'headers': headers, 'status': {'code': response.status_code, 'message': response.reason}, 'url': response.url, } def deserialize_response(serialized): r = Response() r.encoding = serialized['body']['encoding'] header_dict = HTTPHeaderDict() for header_name, header_list in serialized['headers'].items(): if isinstance(header_list, list): for header_value in header_list: header_dict.add(header_name, header_value) else: header_dict.add(header_name, header_list) r.headers = CaseInsensitiveDict(header_dict) r.url = serialized.get('url', '') if 'status' in serialized: r.status_code = serialized['status']['code'] r.reason = serialized['status']['message'] else: r.status_code = serialized['status_code'] r.reason = _codes[r.status_code][0].upper() add_urllib3_response(serialized, r, header_dict) return r def add_urllib3_response(serialized, response, headers): if 'base64_string' in serialized['body']: body = io.BytesIO( base64.b64decode(serialized['body']['base64_string'].encode()) ) else: body = body_io(**serialized['body']) h = HTTPResponse( body, status=response.status_code, reason=response.reason, headers=headers, preload_content=False, original_response=MockHTTPResponse(headers) ) # NOTE(sigmavirus24): # urllib3 updated it's chunked encoding handling which breaks on recorded # responses. Since a recorded response cannot be streamed appropriately # for this handling to work, we can preserve the integrity of the data in # the response by forcing the chunked attribute to always be False. # This isn't pretty, but it is much better than munging a response. h.chunked = False response.raw = h def timestamp(): stamp = datetime.utcnow().isoformat() try: i = stamp.rindex('.') except ValueError: return stamp else: return stamp[:i] _SENTINEL = object() def _option_from(option, kwargs, defaults): value = kwargs.get(option, _SENTINEL) if value is _SENTINEL: value = defaults.get(option) return value ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.514656 betamax-0.9.0/src/betamax.egg-info/0000755000175100001770000000000014561150460016452 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397423.0 betamax-0.9.0/src/betamax.egg-info/PKG-INFO0000644000175100001770000000774314561150457017570 0ustar00runnerdockerMetadata-Version: 2.1 Name: betamax Version: 0.9.0 Summary: A VCR imitation for python-requests Home-page: https://github.com/sigmavirus24/betamax Author: Ian Stapleton Cordasco Author-email: graffatcolmingov@gmail.com License: Apache-2.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Software Development :: Quality Assurance Requires-Python: >=3.8.1 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: requests>=2.0 betamax ======= Betamax is a VCR_ imitation for requests. This will make mocking out requests much easier. It is tested on `Travis CI`_. Put in a more humorous way: "Betamax records your HTTP interactions so the NSA does not have to." Example Use ----------- .. code-block:: python from betamax import Betamax from requests import Session from unittest import TestCase with Betamax.configure() as config: config.cassette_library_dir = 'tests/fixtures/cassettes' class TestGitHubAPI(TestCase): def setUp(self): self.session = Session() self.headers.update(...) # Set the cassette in a line other than the context declaration def test_user(self): with Betamax(self.session) as vcr: vcr.use_cassette('user') resp = self.session.get('https://api.github.com/user', auth=('user', 'pass')) assert resp.json()['login'] is not None # Set the cassette in line with the context declaration def test_repo(self): with Betamax(self.session).use_cassette('repo'): resp = self.session.get( 'https://api.github.com/repos/sigmavirus24/github3.py' ) assert resp.json()['owner'] != {} What does it even do? --------------------- If you are unfamiliar with VCR_, you might need a better explanation of what Betamax does. Betamax intercepts every request you make and attempts to find a matching request that has already been intercepted and recorded. Two things can then happen: 1. If there is a matching request, it will return the response that is associated with it. 2. If there is **not** a matching request and it is allowed to record new responses, it will make the request, record the response and return the response. Recorded requests and corresponding responses - also known as interactions - are stored in files called cassettes. (An example cassette can be seen in the `examples section of the documentation`_.) The directory you store your cassettes in is called your library, or your `cassette library`_. VCR Cassette Compatibility -------------------------- Betamax can use any VCR-recorded cassette as of this point in time. The only caveat is that python-requests returns a URL on each response. VCR does not store that in a cassette now but we will. Any VCR-recorded cassette used to playback a response will unfortunately not have a URL attribute on responses that are returned. This is a minor annoyance but not something that can be fixed. .. _VCR: https://github.com/vcr/vcr .. _Travis CI: https://travis-ci.org/sigmavirus24/betamax .. _examples section of the documentation: http://betamax.readthedocs.org/en/latest/api.html#examples .. _cassette library: http://betamax.readthedocs.org/en/latest/cassettes.html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397423.0 betamax-0.9.0/src/betamax.egg-info/SOURCES.txt0000644000175100001770000000737614561150457020361 0ustar00runnerdockerAUTHORS.rst HISTORY.rst LICENSE MANIFEST.in README.rst setup.cfg setup.py docs/source/api.rst docs/source/cassettes.rst docs/source/conf.py docs/source/configuring.rst docs/source/implementation_details.rst docs/source/index.rst docs/source/integrations.rst docs/source/introduction.rst docs/source/long_term_usage.rst docs/source/matchers.rst docs/source/record_modes.rst docs/source/serializers.rst docs/source/third_party_packages.rst docs/source/usage_patterns.rst src/betamax/__init__.py src/betamax/adapter.py src/betamax/configure.py src/betamax/decorator.py src/betamax/exceptions.py src/betamax/headers.py src/betamax/mock_response.py src/betamax/options.py src/betamax/recorder.py src/betamax/util.py src/betamax.egg-info/PKG-INFO src/betamax.egg-info/SOURCES.txt src/betamax.egg-info/dependency_links.txt src/betamax.egg-info/entry_points.txt src/betamax.egg-info/requires.txt src/betamax.egg-info/top_level.txt src/betamax/cassette/__init__.py src/betamax/cassette/cassette.py src/betamax/cassette/interaction.py src/betamax/fixtures/__init__.py src/betamax/fixtures/pytest.py src/betamax/fixtures/unittest.py src/betamax/matchers/__init__.py src/betamax/matchers/base.py src/betamax/matchers/body.py src/betamax/matchers/digest_auth.py src/betamax/matchers/headers.py src/betamax/matchers/host.py src/betamax/matchers/method.py src/betamax/matchers/path.py src/betamax/matchers/query.py src/betamax/matchers/uri.py src/betamax/serializers/__init__.py src/betamax/serializers/base.py src/betamax/serializers/json_serializer.py src/betamax/serializers/proxy.py tests/__init__.py tests/conftest.py tests/cassettes/FakeBetamaxTestCase.test_fake.json tests/cassettes/GitHub_create_issue.json tests/cassettes/GitHub_emojis.json tests/cassettes/global_preserve_exact_body_bytes.json tests/cassettes/handles_digest_auth.json tests/cassettes/once_record_mode.json tests/cassettes/preserve_exact_bytes.json tests/cassettes/replay_interactions.json tests/cassettes/replay_multiple_times.json tests/cassettes/test-multiple-cookies-regression.json tests/cassettes/test.json tests/cassettes/test_record_once.json tests/cassettes/test_replays_response_on_right_order.json tests/cassettes/tests.integration.test_fixtures.TestPyTestFixtures.test_pytest_fixture.json tests/cassettes/tests.integration.test_fixtures.TestPyTestParametrizedFixtures.test_pytest_fixture[https---httpbin.org-get].json tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[aaa-bbb].json tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[ccc-ddd].json tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[eee-fff].json tests/integration/__init__.py tests/integration/helper.py tests/integration/test_allow_playback_repeats.py tests/integration/test_backwards_compat.py tests/integration/test_fixtures.py tests/integration/test_hooks.py tests/integration/test_multiple_cookies.py tests/integration/test_placeholders.py tests/integration/test_preserve_exact_body_bytes.py tests/integration/test_record_modes.py tests/integration/test_unicode.py tests/regression/test_can_replay_interactions_multiple_times.py tests/regression/test_cassettes_retain_global_configuration.py tests/regression/test_gzip_compression.py tests/regression/test_once_prevents_new_interactions.py tests/regression/test_requests_2_11_body_matcher.py tests/regression/test_works_with_digest_auth.py tests/unit/test_adapter.py tests/unit/test_betamax.py tests/unit/test_cassette.py tests/unit/test_configure.py tests/unit/test_decorator.py tests/unit/test_exceptions.py tests/unit/test_fixtures.py tests/unit/test_matchers.py tests/unit/test_options.py tests/unit/test_recorder.py tests/unit/test_replays.py tests/unit/test_serializers.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397423.0 betamax-0.9.0/src/betamax.egg-info/dependency_links.txt0000644000175100001770000000000114561150457022526 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397423.0 betamax-0.9.0/src/betamax.egg-info/entry_points.txt0000644000175100001770000000006414561150457021756 0ustar00runnerdocker[pytest11] pytest-betamax = betamax.fixtures.pytest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397423.0 betamax-0.9.0/src/betamax.egg-info/requires.txt0000644000175100001770000000001614561150457021055 0ustar00runnerdockerrequests>=2.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397423.0 betamax-0.9.0/src/betamax.egg-info/top_level.txt0000644000175100001770000000001014561150457021201 0ustar00runnerdockerbetamax ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1707397423.5106559 betamax-0.9.0/tests/0000755000175100001770000000000014561150460013712 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/__init__.py0000644000175100001770000000000014561150445016014 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1707397423.5106559 betamax-0.9.0/tests/cassettes/0000755000175100001770000000000014561150460015710 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/FakeBetamaxTestCase.test_fake.json0000644000175100001770000000007314561150445024356 0ustar00runnerdocker{"http_interactions": [], "recorded_with": "betamax/0.8.2"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/GitHub_create_issue.json0000644000175100001770000002571614561150445022536 0ustar00runnerdocker{"http_interactions": [{"request": {"body": "", "headers": {"Accept-Encoding": "gzip, deflate, compress", "Accept": "application/vnd.github.v3.full+json", "User-Agent": "github3.py/0.7.0", "Accept-Charset": "utf-8", "Content-Type": "application/json", "Authorization": "token "}, "method": "GET", "uri": "https://api.github.com/repos/github3py/fork_this"}, "response": {"body": {"string": "{\"id\":5816984,\"name\":\"fork_this\",\"full_name\":\"github3py/fork_this\",\"owner\":{\"login\":\"github3py\",\"id\":1782156,\"avatar_url\":\"https://0.gravatar.com/avatar/396e3de53320abf9855d912cd3d9431f?d=https%3A%2F%2Fidenticons.github.com%2Ff5a7e12a02816f3c90b0da74492d7b73.png\",\"gravatar_id\":\"396e3de53320abf9855d912cd3d9431f\",\"url\":\"https://api.github.com/users/github3py\",\"html_url\":\"https://github.com/github3py\",\"followers_url\":\"https://api.github.com/users/github3py/followers\",\"following_url\":\"https://api.github.com/users/github3py/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/github3py/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/github3py/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/github3py/subscriptions\",\"organizations_url\":\"https://api.github.com/users/github3py/orgs\",\"repos_url\":\"https://api.github.com/users/github3py/repos\",\"events_url\":\"https://api.github.com/users/github3py/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/github3py/received_events\",\"type\":\"Organization\"},\"private\":false,\"html_url\":\"https://github.com/github3py/fork_this\",\"description\":\"A repository to test forking of\",\"fork\":false,\"url\":\"https://api.github.com/repos/github3py/fork_this\",\"forks_url\":\"https://api.github.com/repos/github3py/fork_this/forks\",\"keys_url\":\"https://api.github.com/repos/github3py/fork_this/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/github3py/fork_this/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/github3py/fork_this/teams\",\"hooks_url\":\"https://api.github.com/repos/github3py/fork_this/hooks\",\"issue_events_url\":\"https://api.github.com/repos/github3py/fork_this/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/github3py/fork_this/events\",\"assignees_url\":\"https://api.github.com/repos/github3py/fork_this/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/github3py/fork_this/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/github3py/fork_this/tags\",\"blobs_url\":\"https://api.github.com/repos/github3py/fork_this/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/github3py/fork_this/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/github3py/fork_this/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/github3py/fork_this/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/github3py/fork_this/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/github3py/fork_this/languages\",\"stargazers_url\":\"https://api.github.com/repos/github3py/fork_this/stargazers\",\"contributors_url\":\"https://api.github.com/repos/github3py/fork_this/contributors\",\"subscribers_url\":\"https://api.github.com/repos/github3py/fork_this/subscribers\",\"subscription_url\":\"https://api.github.com/repos/github3py/fork_this/subscription\",\"commits_url\":\"https://api.github.com/repos/github3py/fork_this/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/github3py/fork_this/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/github3py/fork_this/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/github3py/fork_this/issues/comments/{number}\",\"contents_url\":\"https://api.github.com/repos/github3py/fork_this/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/github3py/fork_this/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/github3py/fork_this/merges\",\"archive_url\":\"https://api.github.com/repos/github3py/fork_this/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/github3py/fork_this/downloads\",\"issues_url\":\"https://api.github.com/repos/github3py/fork_this/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/github3py/fork_this/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/github3py/fork_this/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/github3py/fork_this/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/github3py/fork_this/labels{/name}\",\"created_at\":\"2012-09-15T02:40:33Z\",\"updated_at\":\"2013-01-12T06:17:23Z\",\"pushed_at\":\"2012-09-15T02:40:33Z\",\"git_url\":\"git://github.com/github3py/fork_this.git\",\"ssh_url\":\"git@github.com:github3py/fork_this.git\",\"clone_url\":\"https://github.com/github3py/fork_this.git\",\"svn_url\":\"https://github.com/github3py/fork_this\",\"homepage\":null,\"size\":124,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_downloads\":true,\"has_wiki\":true,\"forks_count\":0,\"mirror_url\":null,\"open_issues_count\":0,\"forks\":0,\"open_issues\":0,\"watchers\":0,\"default_branch\":\"master\",\"permissions\":{\"admin\":true,\"push\":true,\"pull\":true},\"network_count\":0,\"organization\":{\"login\":\"github3py\",\"id\":1782156,\"avatar_url\":\"https://0.gravatar.com/avatar/396e3de53320abf9855d912cd3d9431f?d=https%3A%2F%2Fidenticons.github.com%2Ff5a7e12a02816f3c90b0da74492d7b73.png\",\"gravatar_id\":\"396e3de53320abf9855d912cd3d9431f\",\"url\":\"https://api.github.com/users/github3py\",\"html_url\":\"https://github.com/github3py\",\"followers_url\":\"https://api.github.com/users/github3py/followers\",\"following_url\":\"https://api.github.com/users/github3py/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/github3py/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/github3py/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/github3py/subscriptions\",\"organizations_url\":\"https://api.github.com/users/github3py/orgs\",\"repos_url\":\"https://api.github.com/users/github3py/repos\",\"events_url\":\"https://api.github.com/users/github3py/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/github3py/received_events\",\"type\":\"Organization\"}}", "encoding": "utf-8"}, "headers": {"status": "200 OK", "x-ratelimit-remaining": "4998", "x-github-media-type": "github.v3; param=full; format=json", "x-content-type-options": "nosniff", "access-control-expose-headers": "ETag, Link, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes", "transfer-encoding": "chunked", "x-github-request-id": "4B79B77B:4BAE:94DA28:523E624C", "content-encoding": "gzip", "vary": "Accept, Authorization, Cookie, Accept-Encoding", "x-accepted-oauth-scopes": "repo, public_repo, repo:status, repo:deployment, delete_repo, site_admin", "server": "GitHub.com", "cache-control": "private, max-age=60, s-maxage=60", "last-modified": "Sat, 12 Jan 2013 06:17:23 GMT", "x-ratelimit-limit": "5000", "etag": "\"987cea7f315ccf1f18a912a19ac5a8eb\"", "access-control-allow-credentials": "true", "date": "Sun, 22 Sep 2013 03:21:48 GMT", "x-oauth-scopes": "user, repo, gist", "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", "x-ratelimit-reset": "1379822638"}, "url": "https://api.github.com/repos/github3py/fork_this", "status_code": 200}, "recorded_at": "2013-09-22T03:21:04"}, {"request": {"body": "{\"body\": \"Let's see how well this works with Betamax\", \"labels\": [], \"title\": \"Test issue creation\"}", "headers": {"Content-Length": "100", "Accept-Encoding": "gzip, deflate, compress", "Accept": "application/vnd.github.v3.full+json", "User-Agent": "github3.py/0.7.0", "Accept-Charset": "utf-8", "Content-Type": "application/json", "Authorization": "token "}, "method": "POST", "uri": "https://api.github.com/repos/github3py/fork_this/issues"}, "response": {"body": {"string": "{\"url\":\"https://api.github.com/repos/github3py/fork_this/issues/1\",\"labels_url\":\"https://api.github.com/repos/github3py/fork_this/issues/1/labels{/name}\",\"comments_url\":\"https://api.github.com/repos/github3py/fork_this/issues/1/comments\",\"events_url\":\"https://api.github.com/repos/github3py/fork_this/issues/1/events\",\"html_url\":\"https://github.com/github3py/fork_this/issues/1\",\"id\":19867277,\"number\":1,\"title\":\"Test issue creation\",\"user\":{\"login\":\"sigmavirus24\",\"id\":240830,\"avatar_url\":\"https://2.gravatar.com/avatar/c148356d89f925e692178bee1d93acf7?d=https%3A%2F%2Fidenticons.github.com%2F4a71764034cdae877484be72718ba526.png\",\"gravatar_id\":\"c148356d89f925e692178bee1d93acf7\",\"url\":\"https://api.github.com/users/sigmavirus24\",\"html_url\":\"https://github.com/sigmavirus24\",\"followers_url\":\"https://api.github.com/users/sigmavirus24/followers\",\"following_url\":\"https://api.github.com/users/sigmavirus24/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/sigmavirus24/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/sigmavirus24/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/sigmavirus24/subscriptions\",\"organizations_url\":\"https://api.github.com/users/sigmavirus24/orgs\",\"repos_url\":\"https://api.github.com/users/sigmavirus24/repos\",\"events_url\":\"https://api.github.com/users/sigmavirus24/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/sigmavirus24/received_events\",\"type\":\"User\"},\"labels\":[],\"state\":\"open\",\"assignee\":null,\"milestone\":null,\"comments\":0,\"created_at\":\"2013-09-22T03:21:48Z\",\"updated_at\":\"2013-09-22T03:21:48Z\",\"closed_at\":null,\"body_html\":\"

Let's see how well this works with Betamax

\",\"body_text\":\"Let's see how well this works with Betamax\",\"body\":\"Let's see how well this works with Betamax\",\"closed_by\":null}", "encoding": "utf-8"}, "headers": {"status": "201 Created", "x-accepted-oauth-scopes": "repo, public_repo", "x-ratelimit-remaining": "4997", "x-github-media-type": "github.v3; param=full; format=json", "x-content-type-options": "nosniff", "access-control-expose-headers": "ETag, Link, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes", "x-github-request-id": "4B79B77B:4BAE:94DA3B:523E624C", "cache-control": "private, max-age=60, s-maxage=60", "vary": "Accept, Authorization, Cookie", "content-length": "1814", "server": "GitHub.com", "x-ratelimit-limit": "5000", "location": "https://api.github.com/repos/github3py/fork_this/issues/1", "access-control-allow-credentials": "true", "date": "Sun, 22 Sep 2013 03:21:48 GMT", "x-oauth-scopes": "user, repo, gist", "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", "etag": "\"a98254bd24fe3e6cd3acd2e5e115a023\"", "x-ratelimit-reset": "1379822638"}, "url": "https://api.github.com/repos/github3py/fork_this/issues", "status_code": 201}, "recorded_at": "2013-09-22T03:21:05"}], "recorded_with": "betamax"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/GitHub_emojis.json0000644000175100001770000003125114561150445021340 0ustar00runnerdocker{"http_interactions": [{"request": {"body": "", "headers": {"Accept-Charset": "utf-8", "Content-Type": "application/json", "Accept-Encoding": "gzip, deflate, compress", "Accept": "application/vnd.github.v3.full+json", "User-Agent": "github3.py/0.8.0"}, "method": "GET", "uri": "https://api.github.com/emojis"}, "response": {"body": {"base64_string": "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", "encoding": "utf-8"}, "headers": {"status": "200 OK", "x-ratelimit-remaining": "56", "x-github-media-type": "github.v3; param=full; format=json", "x-content-type-options": "nosniff", "access-control-expose-headers": "ETag, Link, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval", "transfer-encoding": "chunked", "x-github-request-id": "48A0C840:6705:C48B6D:52C21872", "content-encoding": "gzip", "vary": "Accept, Accept-Encoding", "server": "GitHub.com", "cache-control": "public, max-age=60, s-maxage=60", "x-ratelimit-limit": "60", "etag": "\"afa229eed862c51b678841bc6936cbd4\"", "access-control-allow-credentials": "true", "date": "Tue, 31 Dec 2013 01:05:54 GMT", "access-control-allow-origin": "*", "content-type": "application/json; charset=utf-8", "x-ratelimit-reset": "1388454935"}, "url": "https://api.github.com/emojis", "status_code": 200}, "recorded_at": "2013-12-31T01:04:59"}], "recorded_with": "betamax"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/global_preserve_exact_body_bytes.json0000644000175100001770000000226114561150445025371 0ustar00runnerdocker{"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}"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/handles_digest_auth.json0000644000175100001770000000400114561150445022577 0ustar00runnerdocker{"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}"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/once_record_mode.json0000644000175100001770000000170314561150445022075 0ustar00runnerdocker{"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"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/preserve_exact_bytes.json0000644000175100001770000000264014561150445023035 0ustar00runnerdocker{"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}}}]}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/replay_interactions.json0000644000175100001770000000175714561150445022676 0ustar00runnerdocker{"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"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/replay_multiple_times.json0000644000175100001770000001037614561150445023225 0ustar00runnerdocker{"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"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/test-multiple-cookies-regression.json0000644000175100001770000000427014561150445025231 0ustar00runnerdocker{"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"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/test.json0000644000175100001770000000006514561150445017566 0ustar00runnerdocker{"recorded_with": "betamax", "http_interactions": []}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/test_record_once.json0000644000175100001770000000007314561150445022127 0ustar00runnerdocker{"http_interactions": [], "recorded_with": "betamax/0.8.2"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/test_replays_response_on_right_order.json0000644000175100001770000000451614561150445026334 0ustar00runnerdocker{ "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_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.133\",\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" } ././@PaxHeader0000000000000000000000000000021100000000000010207 xustar00115 path=betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.TestPyTestFixtures.test_pytest_fixture.json 22 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.TestPyTestFixtures.test_pytest_fixture0000644000175100001770000000205414561150445034207 0ustar00runnerdocker{"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"]}}}]}././@PaxHeader0000000000000000000000000000025600000000000010220 xustar00152 path=betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.TestPyTestParametrizedFixtures.test_pytest_fixture[https---httpbin.org-get].json 22 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.TestPyTestParametrizedFixtures.test_py0000644000175100001770000000176514561150445034121 0ustar00runnerdocker{"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"}././@PaxHeader0000000000000000000000000000024500000000000010216 xustar00143 path=betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[aaa-bbb].json 22 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesyste0000644000175100001770000000007314561150445034344 0ustar00runnerdocker{"http_interactions": [], "recorded_with": "betamax/0.8.0"}././@PaxHeader0000000000000000000000000000024500000000000010216 xustar00143 path=betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[ccc-ddd].json 22 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesyste0000644000175100001770000000007314561150445034344 0ustar00runnerdocker{"http_interactions": [], "recorded_with": "betamax/0.8.0"}././@PaxHeader0000000000000000000000000000024500000000000010216 xustar00143 path=betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesystem_problematic_chars[eee-fff].json 22 mtime=1707397413.0 betamax-0.9.0/tests/cassettes/tests.integration.test_fixtures.test_pytest_parametrize_with_filesyste0000644000175100001770000000007314561150445034344 0ustar00runnerdocker{"http_interactions": [], "recorded_with": "betamax/0.8.0"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/conftest.py0000644000175100001770000000026014561150445016112 0ustar00runnerdockerimport os import sys import betamax sys.path.insert(0, os.path.abspath('.')) with betamax.Betamax.configure() as config: config.cassette_library_dir = 'tests/cassettes/' ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.514656 betamax-0.9.0/tests/integration/0000755000175100001770000000000014561150460016235 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/__init__.py0000644000175100001770000000000014561150445020337 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/helper.py0000644000175100001770000000056214561150445020074 0ustar00runnerdockerimport os import unittest from requests import Session class IntegrationHelper(unittest.TestCase): cassette_created = True def setUp(self): self.cassette_path = None self.session = Session() def tearDown(self): if self.cassette_created: assert self.cassette_path is not None os.unlink(self.cassette_path) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_allow_playback_repeats.py0000644000175100001770000000210314561150445024354 0ustar00runnerdockerimport betamax from tests.integration import helper class TestPlaybackRepeatInteractions(helper.IntegrationHelper): def test_will_replay_the_same_interaction(self): self.cassette_created = False s = self.session recorder = betamax.Betamax(s) # NOTE(sigmavirus24): Ensure the cassette is recorded with recorder.use_cassette('replay_interactions'): cassette = recorder.current_cassette r = s.get('http://httpbin.org/get') assert r.status_code == 200 assert len(cassette.interactions) == 1 with recorder.use_cassette('replay_interactions', allow_playback_repeats=True): cassette = recorder.current_cassette r = s.get('http://httpbin.org/get') assert r.status_code == 200 assert len(cassette.interactions) == 1 r = s.get('http://httpbin.org/get') assert r.status_code == 200 assert len(cassette.interactions) == 1 assert cassette.interactions[0].used is False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_backwards_compat.py0000644000175100001770000000350314561150445023156 0ustar00runnerdockerimport betamax import copy from .helper import IntegrationHelper class TestBackwardsCompatibleSerialization(IntegrationHelper): def setUp(self): super(TestBackwardsCompatibleSerialization, self).setUp() self.cassette_created = False opts = betamax.cassette.Cassette.default_cassette_options self.original_defaults = copy.deepcopy(opts) with betamax.Betamax.configure() as config: config.define_cassette_placeholder('', 'nothing to replace') def tearDown(self): super(TestBackwardsCompatibleSerialization, self).setUp() Cassette = betamax.cassette.Cassette Cassette.default_cassette_options = self.original_defaults def test_can_deserialize_an_old_cassette(self): with betamax.Betamax(self.session).use_cassette('GitHub_emojis') as b: assert b.current_cassette is not None cassette = b.current_cassette assert len(cassette.interactions) > -1 def test_matches_old_request_data(self): with betamax.Betamax(self.session).use_cassette('GitHub_emojis'): r = self.session.get('https://api.github.com/emojis') assert r is not None def tests_populates_correct_fields_with_missing_data(self): with betamax.Betamax(self.session).use_cassette('GitHub_emojis'): r = self.session.get('https://api.github.com/emojis') assert r.reason == 'OK' assert r.status_code == 200 def tests_deserializes_old_cassette_headers(self): with betamax.Betamax(self.session).use_cassette('GitHub_emojis') as b: self.session.get('https://api.github.com/emojis') interaction = b.current_cassette.interactions[0].data header = interaction['request']['headers']['Accept'] assert not isinstance(header, list) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_fixtures.py0000644000175100001770000000451614561150445021530 0ustar00runnerdockerimport os.path import pytest @pytest.mark.usefixtures('betamax_session') class TestPyTestFixtures: @pytest.fixture(autouse=True) def setup(self, request): """After test hook to assert everything.""" def finalizer(): test_dir = os.path.abspath('.') cassette_name = ('tests.integration.test_fixtures.' # Module name 'TestPyTestFixtures.' # Class name 'test_pytest_fixture' # Test function name '.json') file_name = os.path.join(test_dir, 'tests', 'cassettes', cassette_name) assert os.path.exists(file_name) is True request.addfinalizer(finalizer) def test_pytest_fixture(self, betamax_session): """Exercise the fixture itself.""" resp = betamax_session.get('https://httpbin.org/get') assert resp.ok @pytest.mark.usefixtures('betamax_parametrized_session') class TestPyTestParametrizedFixtures: @pytest.fixture(autouse=True) def setup(self, request): """After test hook to assert everything.""" def finalizer(): test_dir = os.path.abspath('.') cassette_name = ('tests.integration.test_fixtures.' # Module name 'TestPyTestParametrizedFixtures.' # Class name 'test_pytest_fixture' # Test function name '[https---httpbin.org-get]' # Parameter '.json') file_name = os.path.join(test_dir, 'tests', 'cassettes', cassette_name) assert os.path.exists(file_name) is True request.addfinalizer(finalizer) @pytest.mark.parametrize('url', ('https://httpbin.org/get',)) def test_pytest_fixture(self, betamax_parametrized_session, url): """Exercise the fixture itself.""" resp = betamax_parametrized_session.get(url) assert resp.ok @pytest.mark.parametrize('problematic_arg', [r'aaa\bbb', 'ccc:ddd', 'eee*fff']) def test_pytest_parametrize_with_filesystem_problematic_chars( betamax_parametrized_session, problematic_arg): """ Exercice parametrized args containing characters which might cause problems when getting translated into file names. """ assert True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_hooks.py0000644000175100001770000001014714561150445020777 0ustar00runnerdockerimport betamax from . import helper def prerecord_hook(interaction, cassette): assert cassette.interactions == [] interaction.data['response']['headers']['Betamax-Fake-Header'] = 'success' def ignoring_hook(interaction, cassette): interaction.ignore() def preplayback_hook(interaction, cassette): assert cassette.interactions != [] interaction.data['response']['headers']['Betamax-Fake-Header'] = 'temp' class Counter(object): def __init__(self): self.value = 0 def increment(self, cassette): self.value += 1 class TestHooks(helper.IntegrationHelper): def tearDown(self): super(TestHooks, self).tearDown() # Clear out the hooks betamax.configure.Configuration.recording_hooks.pop('after_start', None) betamax.cassette.Cassette.hooks.pop('before_record', None) betamax.cassette.Cassette.hooks.pop('before_playback', None) betamax.configure.Configuration.recording_hooks.pop('before_stop', None) def test_post_start_hook(self): start_count = Counter() with betamax.Betamax.configure() as config: config.after_start(callback=start_count.increment) recorder = betamax.Betamax(self.session) assert start_count.value == 0 with recorder.use_cassette('after_start_hook'): assert start_count.value == 1 self.cassette_path = recorder.current_cassette.cassette_path self.session.get('https://httpbin.org/get') assert start_count.value == 1 with recorder.use_cassette('after_start_hook', record='none'): assert start_count.value == 2 self.session.get('https://httpbin.org/get') assert start_count.value == 2 def test_pre_stop_hook(self): stop_count = Counter() with betamax.Betamax.configure() as config: config.before_stop(callback=stop_count.increment) recorder = betamax.Betamax(self.session) assert stop_count.value == 0 with recorder.use_cassette('before_stop_hook'): self.cassette_path = recorder.current_cassette.cassette_path self.session.get('https://httpbin.org/get') assert stop_count.value == 0 assert stop_count.value == 1 with recorder.use_cassette('before_stop_hook', record='none'): self.session.get('https://httpbin.org/get') assert stop_count.value == 1 assert stop_count.value == 2 def test_prerecord_hook(self): with betamax.Betamax.configure() as config: config.before_record(callback=prerecord_hook) recorder = betamax.Betamax(self.session) with recorder.use_cassette('prerecord_hook'): self.cassette_path = recorder.current_cassette.cassette_path response = self.session.get('https://httpbin.org/get') assert response.headers['Betamax-Fake-Header'] == 'success' with recorder.use_cassette('prerecord_hook', record='none'): response = self.session.get('https://httpbin.org/get') assert response.headers['Betamax-Fake-Header'] == 'success' def test_preplayback_hook(self): with betamax.Betamax.configure() as config: config.before_playback(callback=preplayback_hook) recorder = betamax.Betamax(self.session) with recorder.use_cassette('preplayback_hook'): self.cassette_path = recorder.current_cassette.cassette_path self.session.get('https://httpbin.org/get') with recorder.use_cassette('preplayback_hook', record='none'): response = self.session.get('https://httpbin.org/get') assert response.headers['Betamax-Fake-Header'] == 'temp' def test_prerecord_ignoring_hook(self): with betamax.Betamax.configure() as config: config.before_record(callback=ignoring_hook) recorder = betamax.Betamax(self.session) with recorder.use_cassette('ignore_hook'): self.cassette_path = recorder.current_cassette.cassette_path self.session.get('https://httpbin.org/get') assert recorder.current_cassette.interactions == [] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_multiple_cookies.py0000644000175100001770000000265514561150445023230 0ustar00runnerdockerimport betamax from .helper import IntegrationHelper class TestMultipleCookies(IntegrationHelper): """Previously our handling of multiple instances of cookies was wrong. This set of tests is here to ensure that we properly serialize/deserialize the case where the client receives and betamax serializes multiple Set-Cookie headers. See the following for more information: - https://github.com/sigmavirus24/betamax/pull/60 - https://github.com/sigmavirus24/betamax/pull/59 - https://github.com/sigmavirus24/betamax/issues/58 """ def setUp(self): super(TestMultipleCookies, self).setUp() self.cassette_created = False def test_multiple_cookies(self): """Make a request to httpbin.org and verify we serialize it correctly. We should be able to see that the cookiejar on the session has the cookies properly parsed and distinguished. """ recorder = betamax.Betamax(self.session) cassette_name = 'test-multiple-cookies-regression' url = 'https://httpbin.org/cookies/set' cookies = { 'cookie0': 'value0', 'cookie1': 'value1', 'cookie2': 'value2', 'cookie3': 'value3', } with recorder.use_cassette(cassette_name): self.session.get(url, params=cookies) for name, value in cookies.items(): assert self.session.cookies[name] == value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_placeholders.py0000644000175100001770000000274514561150445022326 0ustar00runnerdockerfrom betamax import Betamax from betamax.cassette import Cassette from copy import deepcopy from tests.integration.helper import IntegrationHelper original_cassette_options = deepcopy(Cassette.default_cassette_options) b64_foobar = 'Zm9vOmJhcg==' # base64.b64encode('foo:bar') class TestPlaceholders(IntegrationHelper): def setUp(self): super(TestPlaceholders, self).setUp() config = Betamax.configure() config.define_cassette_placeholder('', b64_foobar) def tearDown(self): super(TestPlaceholders, self).tearDown() Cassette.default_cassette_options = original_cassette_options def test_placeholders_work(self): placeholders = Cassette.default_cassette_options['placeholders'] assert placeholders == [{ 'placeholder': '', 'replace': b64_foobar, }] s = self.session cassette = None with Betamax(s).use_cassette('test_placeholders') as recorder: r = s.get('http://httpbin.org/get', auth=('foo', 'bar')) cassette = recorder.current_cassette self.cassette_path = cassette.cassette_path assert r.status_code == 200 auth = r.json()['headers']['Authorization'] assert b64_foobar in auth self.cassette_path = cassette.cassette_path i = cassette.interactions[0] auth = i.data['request']['headers']['Authorization'] assert '' in auth[0] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_preserve_exact_body_bytes.py0000644000175100001770000000344714561150445025123 0ustar00runnerdockerfrom .helper import IntegrationHelper from betamax import Betamax from betamax.cassette import Cassette import copy class TestPreserveExactBodyBytes(IntegrationHelper): def test_preserve_exact_body_bytes_does_not_munge_response_content(self): # Do not delete this cassette after the test self.cassette_created = False with Betamax(self.session) as b: b.use_cassette('preserve_exact_bytes', preserve_exact_body_bytes=True, match_requests_on=['uri', 'method', 'body']) r = self.session.post('https://httpbin.org/post', data={'a': 1}) assert 'headers' in r.json() interaction = b.current_cassette.interactions[0].data assert 'base64_string' in interaction['request']['body'] assert 'base64_string' in interaction['response']['body'] class TestPreserveExactBodyBytesForAllCassettes(IntegrationHelper): def setUp(self): super(TestPreserveExactBodyBytesForAllCassettes, self).setUp() self.orig = copy.deepcopy(Cassette.default_cassette_options) self.cassette_created = False def tearDown(self): super(TestPreserveExactBodyBytesForAllCassettes, self).tearDown() Cassette.default_cassette_options = self.orig def test_preserve_exact_body_bytes(self): with Betamax.configure() as config: config.preserve_exact_body_bytes = True with Betamax(self.session) as b: b.use_cassette('global_preserve_exact_body_bytes') r = self.session.get('https://httpbin.org/get') assert 'headers' in r.json() interaction = b.current_cassette.interactions[0].data assert 'base64_string' in interaction['response']['body'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_record_modes.py0000644000175100001770000001443314561150445022323 0ustar00runnerdockerimport re from betamax import Betamax, BetamaxError from tests.integration.helper import IntegrationHelper class TestRecordOnce(IntegrationHelper): def test_records_new_interaction(self): s = self.session with Betamax(s).use_cassette('test_record_once') as betamax: self.cassette_path = betamax.current_cassette.cassette_path assert betamax.current_cassette.is_empty() is True r = s.get('http://httpbin.org/get') assert r.status_code == 200 assert betamax.current_cassette.is_empty() is True assert betamax.current_cassette.interactions != [] def test_replays_response_from_cassette(self): s = self.session with Betamax(s).use_cassette('test_replays_response') as betamax: self.cassette_path = betamax.current_cassette.cassette_path assert betamax.current_cassette.is_empty() is True r0 = s.get('http://httpbin.org/get') assert r0.status_code == 200 assert betamax.current_cassette.interactions != [] assert len(betamax.current_cassette.interactions) == 1 r1 = s.get('http://httpbin.org/get') assert len(betamax.current_cassette.interactions) == 2 assert r1.status_code == 200 r0_headers = r0.headers.copy() r0_headers.pop('Date') r0_headers.pop('Age', None) r0_headers.pop('X-Processed-Time', None) r1_headers = r1.headers.copy() r1_headers.pop('Date') r1_headers.pop('Age', None) r1_headers.pop('X-Processed-Time', None) # NOTE(sigmavirus24): This fails if the second request is # technically a second later. Ignoring the Date headers allows # this test to succeed. # NOTE(hroncok): httpbin.org added X-Processed-Time header that # can possibly differ (and often does) r0_content = r0.content.decode(encoding='utf-8', errors='strict') r1_content = r1.content.decode(encoding='utf-8', errors='strict') r0_content = re.sub('"X-Amzn-Trace-Id": "[^"]+"', '"X-Amzn-Trace-Id": ""', r0_content) r1_content = re.sub('"X-Amzn-Trace-Id": "[^"]+"', '"X-Amzn-Trace-Id": ""', r1_content) # NOTE(jhatler): httpbin.org added "X-Amzn-Trace-Id" to their # response, which is a unique ID that will differ between requests. # We remove it from the response body before comparing. assert r0_headers == r1_headers assert r0_content == r1_content class TestRecordNone(IntegrationHelper): def test_raises_exception_when_no_interactions_present(self): s = self.session with Betamax(s) as betamax: betamax.use_cassette('test', record='none') self.cassette_created = False assert betamax.current_cassette is not None self.assertRaises(BetamaxError, s.get, 'http://httpbin.org/get') def test_record_none_does_not_create_cassettes(self): s = self.session with Betamax(s) as betamax: self.assertRaises(ValueError, betamax.use_cassette, 'test_record_none', record='none') self.cassette_created = False class TestRecordNewEpisodes(IntegrationHelper): def setUp(self): super(TestRecordNewEpisodes, self).setUp() with Betamax(self.session).use_cassette('test_record_new'): self.session.get('http://httpbin.org/get') self.session.get('http://httpbin.org/redirect/2') def test_records_new_events_with_existing_cassette(self): s = self.session opts = {'record': 'new_episodes'} with Betamax(s).use_cassette('test_record_new', **opts) as betamax: cassette = betamax.current_cassette self.cassette_path = cassette.cassette_path assert cassette.interactions != [] assert len(cassette.interactions) == 4 assert cassette.is_empty() is False s.get('https://httpbin.org/get') assert len(cassette.interactions) == 5 with Betamax(s).use_cassette('test_record_new') as betamax: cassette = betamax.current_cassette assert len(cassette.interactions) == 5 r = s.get('https://httpbin.org/get') assert r.status_code == 200 class TestRecordNewEpisodesCreatesCassettes(IntegrationHelper): def test_creates_new_cassettes(self): recorder = Betamax(self.session) opts = {'record': 'new_episodes'} cassette_name = 'test_record_new_makes_new_cassettes' with recorder.use_cassette(cassette_name, **opts) as betamax: self.cassette_path = betamax.current_cassette.cassette_path self.session.get('https://httpbin.org/get') class TestRecordAll(IntegrationHelper): def setUp(self): super(TestRecordAll, self).setUp() with Betamax(self.session).use_cassette('test_record_all'): self.session.get('http://httpbin.org/get') self.session.get('http://httpbin.org/redirect/2') self.session.get('http://httpbin.org/get') def test_records_new_interactions(self): s = self.session opts = {'record': 'all'} with Betamax(s).use_cassette('test_record_all', **opts) as betamax: cassette = betamax.current_cassette self.cassette_path = cassette.cassette_path assert cassette.interactions != [] assert len(cassette.interactions) == 5 assert cassette.is_empty() is False s.post('http://httpbin.org/post', data={'foo': 'bar'}) assert len(cassette.interactions) == 6 with Betamax(s).use_cassette('test_record_all') as betamax: assert len(betamax.current_cassette.interactions) == 6 def test_replaces_old_interactions(self): s = self.session opts = {'record': 'all'} with Betamax(s).use_cassette('test_record_all', **opts) as betamax: cassette = betamax.current_cassette self.cassette_path = cassette.cassette_path assert cassette.interactions != [] assert len(cassette.interactions) == 5 assert cassette.is_empty() is False s.get('http://httpbin.org/get') assert len(cassette.interactions) == 5 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/integration/test_unicode.py0000644000175100001770000000073114561150445021300 0ustar00runnerdockerfrom betamax import Betamax from tests.integration.helper import IntegrationHelper class TestUnicode(IntegrationHelper): def test_unicode_is_saved_properly(self): s = self.session # https://github.com/kanzure/python-requestions/issues/4 url = 'http://www.amazon.com/review/RAYTXRF3122TO' with Betamax(s).use_cassette('test_unicode') as beta: self.cassette_path = beta.current_cassette.cassette_path s.get(url) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.514656 betamax-0.9.0/tests/regression/0000755000175100001770000000000014561150460016072 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/regression/test_can_replay_interactions_multiple_times.py0000644000175100001770000000121714561150445027522 0ustar00runnerdockerimport unittest from betamax import Betamax from requests import Session class TestReplayInteractionMultipleTimes(unittest.TestCase): """ Test that an Interaction can be replayed multiple times within the same betamax session. """ def test_replay_interaction_more_than_once(self): s = Session() with Betamax(s).use_cassette('replay_multiple_times', record='once', allow_playback_repeats=True): for k in range(1, 5): r = s.get('http://httpbin.org/stream/3', stream=True) assert r.raw.read(1028), "Stream already consumed. Try: %d" % k ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/regression/test_cassettes_retain_global_configuration.py0000644000175100001770000000151314561150445027315 0ustar00runnerdockerimport pytest import unittest from betamax import Betamax, cassette from requests import Session class TestCassetteRecordMode(unittest.TestCase): def setUp(self): with Betamax.configure() as config: config.default_cassette_options['record_mode'] = 'none' def tearDown(self): with Betamax.configure() as config: config.default_cassette_options['record_mode'] = 'once' def test_record_mode_is_none(self): s = Session() with pytest.raises(ValueError): with Betamax(s) as recorder: recorder.use_cassette('regression_record_mode') assert recorder.current_cassette is None def test_class_variables_retain_their_value(self): opts = cassette.Cassette.default_cassette_options assert opts['record_mode'] == 'none' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/regression/test_gzip_compression.py0000644000175100001770000000226114561150445023101 0ustar00runnerdockerimport os import unittest from betamax import Betamax from requests import Session class TestGZIPRegression(unittest.TestCase): def tearDown(self): os.unlink('tests/cassettes/gzip_regression.json') def test_saves_content_as_gzip(self): s = Session() with Betamax(s).use_cassette('gzip_regression'): r = s.get( 'https://api.github.com/repos/github3py/fork_this/issues/1', headers={'Accept-Encoding': 'gzip, deflate, compress'} ) assert r.headers.get('Content-Encoding') == 'gzip' assert r.json() is not None r2 = s.get( 'https://api.github.com/repos/github3py/fork_this/issues/1', headers={'Accept-Encoding': 'gzip, deflate, compress'} ) assert r2.headers.get('Content-Encoding') == 'gzip' assert r2.json() is not None assert r2.json() == r.json() s = Session() with Betamax(s).use_cassette('gzip_regression'): r = s.get( 'https://api.github.com/repos/github3py/fork_this/issues/1' ) assert r.json() is not None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/regression/test_once_prevents_new_interactions.py0000644000175100001770000000072514561150445026017 0ustar00runnerdockerimport pytest import unittest from betamax import Betamax, BetamaxError from requests import Session class TestOncePreventsNewInteractions(unittest.TestCase): """Test that using a cassette with once record mode prevents new requests. """ def test_once_prevents_new_requests(self): s = Session() with Betamax(s).use_cassette('once_record_mode'): with pytest.raises(BetamaxError): s.get('http://example.com') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/regression/test_requests_2_11_body_matcher.py0000644000175100001770000000163614561150445024631 0ustar00runnerdockerimport os import unittest import pytest import requests from betamax import Betamax class TestRequests211BodyMatcher(unittest.TestCase): def tearDown(self): os.unlink('tests/cassettes/requests_2_11_body_matcher.json') @pytest.mark.skipif(requests.__build__ < 0x020401, reason="No json keyword.") def test_requests_with_json_body(self): s = requests.Session() with Betamax(s).use_cassette('requests_2_11_body_matcher', match_requests_on=['body']): r = s.post('https://httpbin.org/post', json={'a': 2}) assert r.json() is not None s = requests.Session() with Betamax(s).use_cassette('requests_2_11_body_matcher', match_requests_on=['body']): r = s.post('https://httpbin.org/post', json={'a': 2}) assert r.json() is not None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/regression/test_works_with_digest_auth.py0000644000175100001770000000156114561150445024271 0ustar00runnerdockerimport unittest from betamax import Betamax from requests import Session from requests.auth import HTTPDigestAuth class TestDigestAuth(unittest.TestCase): def test_saves_content_as_gzip(self): s = Session() cassette_name = 'handles_digest_auth' match = ['method', 'uri', 'digest-auth'] with Betamax(s).use_cassette(cassette_name, match_requests_on=match): r = s.get('https://httpbin.org/digest-auth/auth/user/passwd', auth=HTTPDigestAuth('user', 'passwd')) assert r.ok assert r.history[0].status_code == 401 s = Session() with Betamax(s).use_cassette(cassette_name, match_requests_on=match): r = s.get('https://httpbin.org/digest-auth/auth/user/passwd', auth=HTTPDigestAuth('user', 'passwd')) assert r.json() is not None ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1707397423.514656 betamax-0.9.0/tests/unit/0000755000175100001770000000000014561150460014671 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_adapter.py0000644000175100001770000000214014561150445017722 0ustar00runnerdockerimport unittest try: from unittest import mock except ImportError: import mock from betamax.adapter import BetamaxAdapter from requests.adapters import HTTPAdapter class TestBetamaxAdapter(unittest.TestCase): def setUp(self): http_adapter = mock.Mock() self.adapters_dict = {'http://': http_adapter} self.adapter = BetamaxAdapter(old_adapters=self.adapters_dict) def tearDown(self): self.adapter.eject_cassette() def test_has_http_adatper(self): assert self.adapter.http_adapter is not None assert isinstance(self.adapter.http_adapter, HTTPAdapter) def test_empty_initial_state(self): assert self.adapter.cassette is None assert self.adapter.cassette_name is None assert self.adapter.serialize is None def test_load_cassette(self): filename = 'test' self.adapter.load_cassette(filename, 'json', { 'record': 'none', 'cassette_library_dir': 'tests/cassettes/' }) assert self.adapter.cassette is not None assert self.adapter.cassette_name == filename ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_betamax.py0000644000175100001770000000361114561150445017727 0ustar00runnerdockerimport unittest from betamax import Betamax, matchers from betamax.adapter import BetamaxAdapter from betamax.cassette import Cassette from requests import Session from requests.adapters import HTTPAdapter class TestBetamax(unittest.TestCase): def setUp(self): self.session = Session() self.vcr = Betamax(self.session) def test_initialization_does_alter_the_session(self): for v in self.session.adapters.values(): assert not isinstance(v, BetamaxAdapter) assert isinstance(v, HTTPAdapter) def test_entering_context_alters_adapters(self): with self.vcr: for v in self.session.adapters.values(): assert isinstance(v, BetamaxAdapter) def test_exiting_resets_the_adapters(self): with self.vcr: pass for v in self.session.adapters.values(): assert not isinstance(v, BetamaxAdapter) def test_current_cassette(self): assert self.vcr.current_cassette is None self.vcr.use_cassette('test') assert isinstance(self.vcr.current_cassette, Cassette) def test_use_cassette_returns_cassette_object(self): assert self.vcr.use_cassette('test') is self.vcr def test_register_request_matcher(self): class FakeMatcher(object): name = 'fake' Betamax.register_request_matcher(FakeMatcher) assert 'fake' in matchers.matcher_registry assert isinstance(matchers.matcher_registry['fake'], FakeMatcher) def test_stores_the_session_instance(self): assert self.session is self.vcr.session def test_replaces_all_adapters(self): mount_point = 'fake_protocol://' s = Session() s.mount(mount_point, HTTPAdapter()) with Betamax(s): adapter = s.adapters.get(mount_point) assert adapter is not None assert isinstance(adapter, BetamaxAdapter) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_cassette.py0000644000175100001770000004233214561150445020124 0ustar00runnerdockerimport email import os import unittest from datetime import datetime import pytest from betamax import __version__ from betamax.cassette import cassette from betamax import mock_response from betamax import recorder from betamax import serializers from betamax import util from requests.models import Response, Request from requests.packages import urllib3 from requests.structures import CaseInsensitiveDict try: from requests.packages.urllib3._collections import HTTPHeaderDict except ImportError: from betamax.headers import HTTPHeaderDict def decode(s): if hasattr(s, 'decode'): return s.decode() return s class Serializer(serializers.BaseSerializer): name = 'test' @staticmethod def generate_cassette_name(cassette_library_dir, cassette_name): return 'test_cassette.test' def on_init(self): self.serialize_calls = [] self.deserialize_calls = [] def serialize(self, data): self.serialize_calls.append(data) return '' def deserialize(self, data): self.deserialize_calls.append(data) return {} class TestSerialization(unittest.TestCase): """Unittests for the serialization and deserialization functions. This tests: - deserialize_prepared_request - deserialize_response - serialize_prepared_request - serialize_response """ def test_serialize_response(self): r = Response() r.status_code = 200 r.reason = 'OK' r.encoding = 'utf-8' r.headers = CaseInsensitiveDict() r.url = 'http://example.com' util.add_urllib3_response({ 'body': { 'string': decode('foo'), 'encoding': 'utf-8' } }, r, HTTPHeaderDict()) serialized = util.serialize_response(r, False) assert serialized is not None assert serialized != {} assert serialized['body']['encoding'] == 'utf-8' assert serialized['body']['string'] == 'foo' assert serialized['headers'] == {} assert serialized['url'] == 'http://example.com' assert serialized['status'] == {'code': 200, 'message': 'OK'} def test_deserialize_response_old(self): """For the previous version of Betamax and backwards compatibility.""" s = { 'body': { 'string': decode('foo'), 'encoding': 'utf-8' }, 'headers': { 'Content-Type': decode('application/json') }, 'url': 'http://example.com/', 'status_code': 200, 'recorded_at': '2013-08-31T00:00:01' } r = util.deserialize_response(s) assert r.content == b'foo' assert r.encoding == 'utf-8' assert r.headers == {'Content-Type': 'application/json'} assert r.url == 'http://example.com/' assert r.status_code == 200 def test_deserialize_response_new(self): """This adheres to the correct cassette specification.""" s = { 'body': { 'string': decode('foo'), 'encoding': 'utf-8' }, 'headers': { 'Content-Type': [decode('application/json')] }, 'url': 'http://example.com/', 'status': {'code': 200, 'message': 'OK'}, 'recorded_at': '2013-08-31T00:00:01' } r = util.deserialize_response(s) assert r.content == b'foo' assert r.encoding == 'utf-8' assert r.headers == {'Content-Type': 'application/json'} assert r.url == 'http://example.com/' assert r.status_code == 200 assert r.reason == 'OK' def test_serialize_prepared_request(self): r = Request() r.method = 'GET' r.url = 'http://example.com' r.headers = {'User-Agent': 'betamax/test header'} r.data = {'key': 'value'} p = r.prepare() serialized = util.serialize_prepared_request(p, False) assert serialized is not None assert serialized != {} assert serialized['method'] == 'GET' assert serialized['uri'] == 'http://example.com/' assert serialized['headers'] == { 'Content-Length': ['9'], 'Content-Type': ['application/x-www-form-urlencoded'], 'User-Agent': ['betamax/test header'], } assert serialized['body']['string'] == 'key=value' def test_deserialize_prepared_request(self): s = { 'body': 'key=value', 'headers': { 'User-Agent': 'betamax/test header', }, 'method': 'GET', 'uri': 'http://example.com/', } p = util.deserialize_prepared_request(s) assert p.body == 'key=value' assert p.headers == CaseInsensitiveDict( {'User-Agent': 'betamax/test header'} ) assert p.method == 'GET' assert p.url == 'http://example.com/' def test_from_list_returns_an_element(self): a = ['value'] assert util.from_list(a) == 'value' def test_from_list_handles_non_lists(self): a = 'value' assert util.from_list(a) == 'value' def test_add_urllib3_response(self): r = Response() r.status_code = 200 r.headers = {} util.add_urllib3_response({ 'body': { 'string': decode('foo'), 'encoding': 'utf-8' } }, r, HTTPHeaderDict()) assert isinstance(r.raw, urllib3.response.HTTPResponse) assert r.content == b'foo' assert isinstance(r.raw._original_response, mock_response.MockHTTPResponse) def test_cassette_initialization(): serializers.serializer_registry['test'] = Serializer() cassette.Cassette.default_cassette_options['placeholders'] = [] with recorder.Betamax.configure() as config: config.define_cassette_placeholder('', 'default') config.define_cassette_placeholder('', 'config') placeholders = [{ 'placeholder': '', 'replace': 'override', }, { 'placeholder': '', 'replace': 'cassette', }] instance = cassette.Cassette( 'test_cassette', 'test', placeholders=placeholders ) expected = [ cassette.Placeholder('', 'override'), cassette.Placeholder('', 'config'), cassette.Placeholder('', 'cassette'), ] assert instance.placeholders == expected cassette.Cassette.default_cassette_options['placeholders'] = [] class TestCassette(unittest.TestCase): cassette_name = 'test_cassette' def setUp(self): # Make a new serializer to test with self.test_serializer = Serializer() serializers.serializer_registry['test'] = self.test_serializer # Instantiate the cassette to test with self.cassette = cassette.Cassette( TestCassette.cassette_name, 'test', record_mode='once' ) # Create a new object to serialize r = Response() r.status_code = 200 r.reason = 'OK' r.encoding = 'utf-8' r.headers = CaseInsensitiveDict({'Content-Type': decode('foo')}) r.url = 'http://example.com' util.add_urllib3_response({ 'body': { 'string': decode('foo'), 'encoding': 'utf-8' } }, r, HTTPHeaderDict({'Content-Type': decode('foo')})) self.response = r # Create an associated request r = Request() r.method = 'GET' r.url = 'http://example.com' r.headers = {} r.data = {'key': 'value'} self.response.request = r.prepare() self.response.request.headers.update( {'User-Agent': 'betamax/test header'} ) # Expected serialized cassette data. self.json = { 'request': { 'body': { 'encoding': 'utf-8', 'string': 'key=value', }, 'headers': { 'User-Agent': ['betamax/test header'], 'Content-Length': ['9'], 'Content-Type': ['application/x-www-form-urlencoded'], }, 'method': 'GET', 'uri': 'http://example.com/', }, 'response': { 'body': { 'string': decode('foo'), 'encoding': 'utf-8', }, 'headers': {'Content-Type': [decode('foo')]}, 'status': {'code': 200, 'message': 'OK'}, 'url': 'http://example.com', }, 'recorded_at': '2013-08-31T00:00:00', } self.date = datetime(2013, 8, 31) self.cassette.save_interaction(self.response, self.response.request) self.interaction = self.cassette.interactions[0] def tearDown(self): try: self.cassette.eject() except: pass if os.path.exists(TestCassette.cassette_name): os.unlink(TestCassette.cassette_name) def test_serialize_interaction(self): serialized = self.interaction.data assert serialized['request'] == self.json['request'] assert serialized['response'] == self.json['response'] assert serialized.get('recorded_at') is not None def test_holds_interactions(self): assert isinstance(self.cassette.interactions, list) assert self.cassette.interactions != [] assert self.interaction in self.cassette.interactions def test_find_match(self): self.cassette.match_options = set(['uri', 'method']) self.cassette.record_mode = 'none' i = self.cassette.find_match(self.response.request) assert i is not None assert self.interaction is i def test_find_match_new_episodes_with_existing_unused_interactions(self): """See bug 153 in GitHub for details. https://github.com/betamaxpy/betamax/issues/153 """ self.cassette.match_options = set(['method', 'uri']) self.cassette.record_mode = 'new_episodes' i = self.cassette.find_match(self.response.request) assert i is not None assert self.interaction is i def test_find_match_new_episodes_with_no_unused_interactions(self): """See bug 153 in GitHub for details. https://github.com/betamaxpy/betamax/issues/153 """ self.cassette.match_options = set(['method', 'uri']) self.cassette.record_mode = 'new_episodes' self.interaction.used = True i = self.cassette.find_match(self.response.request) assert i is None def test_find_match__missing_matcher(self): self.cassette.match_options = set(['uri', 'method', 'invalid']) self.cassette.record_mode = 'none' with pytest.raises(KeyError): self.cassette.find_match(self.response.request) def test_eject(self): serializer = self.test_serializer self.cassette.eject() assert serializer.serialize_calls == [ {'http_interactions': [self.cassette.interactions[0].data], 'recorded_with': 'betamax/{0}'.format(__version__)} ] def test_earliest_recorded_date(self): assert self.interaction.recorded_at is not None assert self.cassette.earliest_recorded_date is not None class TestInteraction(unittest.TestCase): def setUp(self): self.request = { 'body': { 'string': 'key=value&key2=secret_value', 'encoding': 'utf-8' }, 'headers': { 'User-Agent': ['betamax/test header'], 'Content-Length': ['9'], 'Content-Type': ['application/x-www-form-urlencoded'], 'Authorization': ['123456789abcdef'], }, 'method': 'GET', 'uri': 'http://example.com/', } self.response = { 'body': { 'string': decode('foo'), 'encoding': 'utf-8' }, 'headers': { 'Content-Type': [decode('foo')], 'Set-Cookie': ['cookie_name=cookie_value', 'sessionid=deadbeef'] }, 'status_code': 200, 'url': 'http://example.com', } self.json = { 'request': self.request, 'response': self.response, 'recorded_at': '2013-08-31T00:00:00', } self.interaction = cassette.Interaction(self.json) self.date = datetime(2013, 8, 31) def test_as_response(self): r = self.interaction.as_response() assert isinstance(r, Response) def test_as_response_returns_new_instance(self): r1 = self.interaction.as_response() r2 = self.interaction.as_response() assert r1 is not r2 def test_deserialized_response(self): def check_uri(attr): # Necessary since PreparedRequests do not have a uri attr if attr == 'uri': return 'url' return attr r = self.interaction.as_response() for attr in ['status_code', 'url']: assert self.response[attr] == decode(getattr(r, attr)) headers = dict((k, ', '.join(v)) for k, v in self.response['headers'].items()) assert headers == r.headers tested_cookie = False for cookie in r.cookies: cookie_str = "{0}={1}".format(cookie.name, cookie.value) assert cookie_str in r.headers['Set-Cookie'] tested_cookie = True assert tested_cookie assert self.response['body']['string'] == decode(r.content) actual_req = r.request expected_req = self.request for attr in ['method', 'uri']: assert expected_req[attr] == getattr(actual_req, check_uri(attr)) assert self.request['body']['string'] == decode(actual_req.body) headers = dict((k, v[0]) for k, v in expected_req['headers'].items()) assert headers == actual_req.headers assert self.date == self.interaction.recorded_at def test_match(self): matchers = [lambda x: True, lambda x: False, lambda x: True] assert self.interaction.match(matchers) is False matchers[1] = lambda x: True assert self.interaction.match(matchers) is True def test_replace(self): self.interaction.replace('123456789abcdef', '') self.interaction.replace('cookie_value', '') self.interaction.replace('secret_value', '') self.interaction.replace('foo', '') self.interaction.replace('http://example.com', '') self.interaction.replace('', '' header = self.interaction.data['response']['headers']['Set-Cookie'] assert header[0] == 'cookie_name=' assert header[1] == 'sessionid=deadbeef' body = self.interaction.data['request']['body']['string'] assert body == 'key=value&key2=' body = self.interaction.data['response']['body'] assert body == {'encoding': 'utf-8', 'string': ''} uri = self.interaction.data['request']['uri'] assert uri == '/' uri = self.interaction.data['response']['url'] assert uri == '' def test_replace_in_headers(self): self.interaction.replace_in_headers('123456789abcdef', '') self.interaction.replace_in_headers('cookie_value', '') header = ( self.interaction.data['request']['headers']['Authorization'][0]) assert header == '' header = self.interaction.data['response']['headers']['Set-Cookie'][0] assert header == 'cookie_name=' def test_replace_in_body(self): self.interaction.replace_in_body('secret_value', '') self.interaction.replace_in_body('foo', '') body = self.interaction.data['request']['body']['string'] assert body == 'key=value&key2=' body = self.interaction.data['response']['body'] assert body == {'encoding': 'utf-8', 'string': ''} def test_replace_in_uri(self): self.interaction.replace_in_uri('http://example.com', '') uri = self.interaction.data['request']['uri'] assert uri == '/' uri = self.interaction.data['response']['url'] assert uri == '' class TestMockHTTPResponse(unittest.TestCase): def setUp(self): self.resp = mock_response.MockHTTPResponse(HTTPHeaderDict({ decode('Header'): decode('value') })) def test_isclosed(self): assert self.resp.isclosed() is False def test_is_Message(self): assert isinstance(self.resp.msg, email.message.Message) def test_close(self): self.resp.close() assert self.resp.isclosed() is True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_configure.py0000644000175100001770000000613414561150445020272 0ustar00runnerdockerimport collections import copy import unittest from betamax.configure import Configuration from betamax.cassette import Cassette from betamax.recorder import Betamax class TestConfiguration(unittest.TestCase): def setUp(self): self.cassette_options = copy.deepcopy( Cassette.default_cassette_options ) self.cassette_dir = Configuration.CASSETTE_LIBRARY_DIR def tearDown(self): Configuration.recording_hooks = collections.defaultdict(list) Cassette.default_cassette_options = self.cassette_options Cassette.hooks = collections.defaultdict(list) Configuration.CASSETTE_LIBRARY_DIR = self.cassette_dir def test_acts_as_pass_through(self): c = Configuration() c.default_cassette_options['foo'] = 'bar' assert 'foo' in Cassette.default_cassette_options assert Cassette.default_cassette_options.get('foo') == 'bar' def test_sets_cassette_library(self): c = Configuration() c.cassette_library_dir = 'foo' assert Configuration.CASSETTE_LIBRARY_DIR == 'foo' def test_is_a_context_manager(self): with Configuration() as c: assert isinstance(c, Configuration) def test_allows_registration_of_placeholders(self): opts = copy.deepcopy(Cassette.default_cassette_options) c = Configuration() c.define_cassette_placeholder('', 'test') assert opts != Cassette.default_cassette_options placeholders = Cassette.default_cassette_options['placeholders'] assert placeholders[0]['placeholder'] == '' assert placeholders[0]['replace'] == 'test' def test_registers_post_start_hooks(self): c = Configuration() assert Configuration.recording_hooks['after_start'] == [] c.after_start(callback=lambda: None) assert Configuration.recording_hooks['after_start'] != [] assert len(Configuration.recording_hooks['after_start']) == 1 assert callable(Configuration.recording_hooks['after_start'][0]) def test_registers_pre_record_hooks(self): c = Configuration() assert Cassette.hooks['before_record'] == [] c.before_record(callback=lambda: None) assert Cassette.hooks['before_record'] != [] assert len(Cassette.hooks['before_record']) == 1 assert callable(Cassette.hooks['before_record'][0]) def test_registers_pre_playback_hooks(self): c = Configuration() assert Cassette.hooks['before_playback'] == [] c.before_playback(callback=lambda: None) assert Cassette.hooks['before_playback'] != [] assert len(Cassette.hooks['before_playback']) == 1 assert callable(Cassette.hooks['before_playback'][0]) def test_registers_pre_stop_hooks(self): c = Configuration() assert Configuration.recording_hooks['before_stop'] == [] c.before_stop(callback=lambda: None) assert Configuration.recording_hooks['before_stop'] != [] assert len(Configuration.recording_hooks['before_stop']) == 1 assert callable(Configuration.recording_hooks['before_stop'][0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_decorator.py0000644000175100001770000000176714561150445020302 0ustar00runnerdockertry: from unittest import mock except ImportError: import mock import betamax from betamax.decorator import use_cassette @mock.patch('betamax.recorder.Betamax', autospec=True) def test_wraps_session(Betamax): # This needs to be a magic mock so it will mock __exit__ recorder = mock.MagicMock(spec=betamax.Betamax) recorder.use_cassette.return_value = recorder Betamax.return_value = recorder @use_cassette('foo', cassette_library_dir='fizbarbogus') def _test(session): pass _test() Betamax.assert_called_once_with( session=mock.ANY, cassette_library_dir='fizbarbogus', default_cassette_options={} ) recorder.use_cassette.assert_called_once_with('foo') @mock.patch('betamax.recorder.Betamax', autospec=True) @mock.patch('requests.Session') def test_creates_a_new_session(Session, Betamax): @use_cassette('foo', cassette_library_dir='dir') def _test(session): pass _test() assert Session.call_count == 1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_exceptions.py0000644000175100001770000000323414561150445020470 0ustar00runnerdockerimport unittest import inspect from betamax import exceptions def exception_classes(): for _, module_object in inspect.getmembers(exceptions): if inspect.isclass(module_object): yield module_object class TestExceptions(unittest.TestCase): def test_all_exceptions_are_betamax_errors(self): for exception_class in exception_classes(): assert isinstance(exception_class('msg'), exceptions.BetamaxError) def test_all_validation_errors_are_in_validation_error_map(self): validation_error_map_values = exceptions.validation_error_map.values() for exception_class in exception_classes(): if exception_class.__name__ == 'ValidationError' or \ not exception_class.__name__.endswith('ValidationError'): continue assert exception_class in validation_error_map_values def test_all_validation_errors_are_validation_errors(self): for exception_class in exception_classes(): if not exception_class.__name__.endswith('ValidationError'): continue assert isinstance(exception_class('msg'), exceptions.ValidationError) def test_invalid_option_is_validation_error(self): assert isinstance(exceptions.InvalidOption('msg'), exceptions.ValidationError) def test_betamaxerror_repr(self): """Ensure errors don't raise exceptions in their __repr__. This should protect against regression. If this test starts failing, heavily modify it to not be flakey. """ assert "BetamaxError" in repr(exceptions.BetamaxError('test')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_fixtures.py0000644000175100001770000000540614561150445020163 0ustar00runnerdockertry: import unittest.mock as mock except ImportError: import mock import pytest import unittest import requests import betamax from betamax.fixtures import pytest as pytest_fixture from betamax.fixtures import unittest as unittest_fixture class TestPyTestFixture(unittest.TestCase): def setUp(self): self.mocked_betamax = mock.MagicMock() self.patched_betamax = mock.patch.object( betamax.recorder, 'Betamax', return_value=self.mocked_betamax) self.patched_betamax.start() def tearDown(self): self.patched_betamax.stop() def test_adds_stop_as_a_finalizer(self): # Mock a pytest request object request = mock.MagicMock() request.cls = request.module = None request.node.name = request.function.__name__ = 'test' pytest_fixture._betamax_recorder(request) assert request.addfinalizer.called is True request.addfinalizer.assert_called_once_with(self.mocked_betamax.stop) def test_auto_starts_the_recorder(self): # Mock a pytest request object request = mock.MagicMock() request.cls = request.module = None request.node.name = request.function.__name__ = 'test' pytest_fixture._betamax_recorder(request) self.mocked_betamax.start.assert_called_once_with() class FakeBetamaxTestCase(unittest_fixture.BetamaxTestCase): def test_fake(self): pass class TestUnittestFixture(unittest.TestCase): def setUp(self): self.mocked_betamax = mock.MagicMock() self.patched_betamax = mock.patch.object( betamax.recorder, 'Betamax', return_value=self.mocked_betamax) self.betamax = self.patched_betamax.start() self.fixture = FakeBetamaxTestCase(methodName='test_fake') def tearDown(self): self.patched_betamax.stop() def test_setUp(self): self.fixture.setUp() self.mocked_betamax.use_cassette.assert_called_once_with( 'FakeBetamaxTestCase.test_fake' ) self.mocked_betamax.start.assert_called_once_with() def test_setUp_rejects_arbitrary_session_classes(self): self.fixture.SESSION_CLASS = object with pytest.raises(AssertionError): self.fixture.setUp() def test_setUp_accepts_session_subclasses(self): class TestSession(requests.Session): pass self.fixture.SESSION_CLASS = TestSession self.fixture.setUp() assert self.betamax.called is True call_kwargs = self.betamax.call_args[-1] assert isinstance(call_kwargs['session'], TestSession) def test_tearDown_calls_stop(self): recorder = mock.Mock() self.fixture.recorder = recorder self.fixture.tearDown() recorder.stop.assert_called_once_with() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_matchers.py0000644000175100001770000001510614561150445020116 0ustar00runnerdockerimport unittest from requests import PreparedRequest from requests.cookies import RequestsCookieJar from betamax import matchers class TestMatchers(unittest.TestCase): def setUp(self): self.alt_url = ('http://example.com/path/to/end/point?query=string' '&foo=bar') self.p = PreparedRequest() self.p.body = 'Foo bar' self.p.headers = {'User-Agent': 'betamax/test'} self.p.url = 'http://example.com/path/to/end/point?query=string' self.p.method = 'GET' self.p._cookies = RequestsCookieJar() def test_matcher_registry_has_body_matcher(self): assert 'body' in matchers.matcher_registry def test_matcher_registry_has_digest_auth_matcher(self): assert 'digest-auth' in matchers.matcher_registry def test_matcher_registry_has_headers_matcher(self): assert 'headers' in matchers.matcher_registry def test_matcher_registry_has_host_matcher(self): assert 'host' in matchers.matcher_registry def test_matcher_registry_has_method_matcher(self): assert 'method' in matchers.matcher_registry def test_matcher_registry_has_path_matcher(self): assert 'path' in matchers.matcher_registry def test_matcher_registry_has_query_matcher(self): assert 'query' in matchers.matcher_registry def test_matcher_registry_has_uri_matcher(self): assert 'uri' in matchers.matcher_registry def test_body_matcher(self): match = matchers.matcher_registry['body'].match assert match(self.p, { 'body': 'Foo bar', 'headers': {'User-Agent': 'betamax/test'}, 'uri': 'http://example.com/path/to/end/point?query=string', 'method': 'GET', }) assert match(self.p, { 'body': b'', 'headers': {'User-Agent': 'betamax/test'}, 'uri': 'http://example.com/path/to/end/point?query=string', 'method': 'GET', }) is False def test_body_matcher_without_body(self): p = self.p.copy() p.body = None match = matchers.matcher_registry['body'].match assert match(p, { 'body': 'Foo bar', 'headers': {'User-Agent': 'betamax/test'}, 'uri': 'http://example.com/path/to/end/point?query=string', 'method': 'GET', }) is False assert match(p, { 'body': b'', 'headers': {'User-Agent': 'betamax/test'}, 'uri': 'http://example.com/path/to/end/point?query=string', 'method': 'GET', }) def test_digest_matcher(self): match = matchers.matcher_registry['digest-auth'].match assert match(self.p, {'headers': {}}) saved_auth = ( 'Digest username="user", realm="realm", nonce="nonce", uri="/", ' 'response="r", opaque="o", qop="auth", nc=00000001, cnonce="c"' ) self.p.headers['Authorization'] = saved_auth assert match(self.p, {'headers': {}}) is False assert match(self.p, {'headers': {'Authorization': saved_auth}}) new_auth = ( 'Digest username="user", realm="realm", nonce="nonce", uri="/", ' 'response="e", opaque="o", qop="auth", nc=00000001, cnonce="n"' ) assert match(self.p, {'headers': {'Authorization': new_auth}}) new_auth = ( 'Digest username="u", realm="realm", nonce="nonce", uri="/", ' 'response="e", opaque="o", qop="auth", nc=00000001, cnonce="n"' ) assert match(self.p, {'headers': {'Authorization': new_auth}}) is False def test_headers_matcher(self): match = matchers.matcher_registry['headers'].match assert match(self.p, {'headers': {'User-Agent': 'betamax/test'}}) assert match(self.p, {'headers': {'X-Sha': '6bbde0af'}}) is False def test_host_matcher(self): match = matchers.matcher_registry['host'].match assert match(self.p, {'uri': 'http://example.com'}) assert match(self.p, {'uri': 'https://example.com'}) assert match(self.p, {'uri': 'https://example.com/path'}) assert match(self.p, {'uri': 'https://example2.com'}) is False def test_method_matcher(self): match = matchers.matcher_registry['method'].match assert match(self.p, {'method': 'GET'}) assert match(self.p, {'method': 'POST'}) is False def test_path_matcher(self): match = matchers.matcher_registry['path'].match assert match(self.p, {'uri': 'http://example.com/path/to/end/point'}) assert match(self.p, {'uri': 'http://example.com:8000/path/to/end/point'}) assert match(self.p, {'uri': 'http://example.com:8000/path/to/end/'}) is False def test_query_matcher(self): match = matchers.matcher_registry['query'].match assert match( self.p, {'uri': 'http://example.com/path/to/end/point?query=string'} ) assert match( self.p, {'uri': 'http://example.com/?query=string'} ) self.p.url = self.alt_url assert match( self.p, {'uri': self.alt_url} ) # Regression test (order independence) assert match( self.p, {'uri': 'http://example.com/?foo=bar&query=string'} ) # Regression test (no query issue) assert match(self.p, {'uri': 'http://example.com'}) is False # Regression test (query with no value) self.p.url = 'https://example.com/?foo' assert match(self.p, {'uri': 'https://httpbin.org/?foo'}) is True def test_uri_matcher(self): match = matchers.matcher_registry['uri'].match assert match( self.p, {'uri': 'http://example.com/path/to/end/point?query=string'} ) assert match(self.p, {'uri': 'http://example.com'}) is False def test_uri_matcher_handles_query_strings(self): match = matchers.matcher_registry['uri'].match self.p.url = 'http://example.com/path/to?query=string&form=value' other_uri = 'http://example.com/path/to?form=value&query=string' assert match(self.p, {'uri': other_uri}) is True class TestBaseMatcher(unittest.TestCase): def setUp(self): class Matcher(matchers.BaseMatcher): pass self.Matcher = Matcher def test_requires_name(self): self.assertRaises(ValueError, self.Matcher) def test_requires_you_overload_match(self): self.Matcher.name = 'test' m = self.Matcher() self.assertRaises(NotImplementedError, m.match, None, None) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_options.py0000644000175100001770000000530314561150445020001 0ustar00runnerdockerimport unittest from itertools import permutations import pytest from betamax import exceptions from betamax.options import Options, validate_record, validate_matchers class TestValidators(unittest.TestCase): def test_validate_record(self): for mode in ['once', 'none', 'all', 'new_episodes']: assert validate_record(mode) is True def test_validate_matchers(self): matchers = ['method', 'uri', 'query', 'host', 'body'] for i in range(1, len(matchers)): for l in permutations(matchers, i): assert validate_matchers(l) is True matchers.append('foobar') assert validate_matchers(matchers) is False class TestOptions(unittest.TestCase): def setUp(self): self.data = { 're_record_interval': 10000, 'match_requests_on': ['method'], 'serialize': 'json' } self.options = Options(self.data) def test_data_is_valid(self): for key in self.data: assert key in self.options def test_raise_on_unknown_option(self): data = self.data.copy() data['fake'] = 'value' with pytest.raises(exceptions.InvalidOption): Options(data) def test_raise_on_invalid_body_bytes(self): data = self.data.copy() data['preserve_exact_body_bytes'] = None with pytest.raises(exceptions.BodyBytesValidationError): Options(data) def test_raise_on_invalid_matchers(self): data = self.data.copy() data['match_requests_on'] = ['foo', 'bar', 'bogus'] with pytest.raises(exceptions.MatchersValidationError): Options(data) def test_raise_on_invalid_placeholders(self): data = self.data.copy() data['placeholders'] = None with pytest.raises(exceptions.PlaceholdersValidationError): Options(data) def test_raise_on_invalid_playback_repeats(self): data = self.data.copy() data['allow_playback_repeats'] = None with pytest.raises(exceptions.PlaybackRepeatsValidationError): Options(data) def test_raise_on_invalid_record(self): data = self.data.copy() data['record'] = None with pytest.raises(exceptions.RecordValidationError): Options(data) def test_raise_on_invalid_record_interval(self): data = self.data.copy() data['re_record_interval'] = -1 with pytest.raises(exceptions.RecordIntervalValidationError): Options(data) def test_raise_on_invalid_serializer(self): data = self.data.copy() data['serialize_with'] = None with pytest.raises(exceptions.SerializerValidationError): Options(data) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_recorder.py0000644000175100001770000000551614561150445020121 0ustar00runnerdockerimport unittest from betamax import matchers, serializers from betamax.adapter import BetamaxAdapter from betamax.cassette import cassette from betamax.recorder import Betamax from requests import Session from requests.adapters import HTTPAdapter class TestBetamax(unittest.TestCase): def setUp(self): self.session = Session() self.vcr = Betamax(self.session) def test_initialization_does_not_alter_the_session(self): for v in self.session.adapters.values(): assert not isinstance(v, BetamaxAdapter) assert isinstance(v, HTTPAdapter) def test_initialization_converts_placeholders(self): placeholders = [{'placeholder': '', 'replace': 'replace-with'}] default_cassette_options = {'placeholders': placeholders} self.vcr = Betamax(self.session, default_cassette_options=default_cassette_options) assert self.vcr.config.default_cassette_options['placeholders'] == [{ 'placeholder': '', 'replace': 'replace-with', }] def test_entering_context_alters_adapters(self): with self.vcr: for v in self.session.adapters.values(): assert isinstance(v, BetamaxAdapter) def test_exiting_resets_the_adapters(self): with self.vcr: pass for v in self.session.adapters.values(): assert not isinstance(v, BetamaxAdapter) def test_current_cassette(self): assert self.vcr.current_cassette is None self.vcr.use_cassette('test') assert isinstance(self.vcr.current_cassette, cassette.Cassette) def test_use_cassette_returns_cassette_object(self): assert self.vcr.use_cassette('test') is self.vcr def test_register_request_matcher(self): class FakeMatcher(object): name = 'fake_matcher' Betamax.register_request_matcher(FakeMatcher) assert 'fake_matcher' in matchers.matcher_registry assert isinstance(matchers.matcher_registry['fake_matcher'], FakeMatcher) def test_register_serializer(self): class FakeSerializer(object): name = 'fake_serializer' Betamax.register_serializer(FakeSerializer) assert 'fake_serializer' in serializers.serializer_registry assert isinstance(serializers.serializer_registry['fake_serializer'], FakeSerializer) def test_stores_the_session_instance(self): assert self.session is self.vcr.session def test_use_cassette_passes_along_placeholders(self): placeholders = [{'placeholder': '', 'replace': 'replace-with'}] self.vcr.use_cassette('test', placeholders=placeholders) assert self.vcr.current_cassette.placeholders == [ cassette.Placeholder.from_dict(p) for p in placeholders ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_replays.py0000644000175100001770000000135314561150445017766 0ustar00runnerdockerfrom betamax import Betamax, BetamaxError from requests import Session import unittest class TestReplays(unittest.TestCase): def setUp(self): self.session = Session() def test_replays_response_on_right_order(self): s = self.session opts = {'record': 'none'} with Betamax(s).use_cassette('test_replays_response_on_right_order', **opts) as betamax: self.cassette_path = betamax.current_cassette.cassette_path r0 = s.get('http://httpbin.org/get') r1 = s.get('http://httpbin.org/get') r0_found = (b'72.160.214.132' in r0.content) assert r0_found == True r1_found = (b'72.160.214.133' in r1.content) assert r1_found == True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1707397413.0 betamax-0.9.0/tests/unit/test_serializers.py0000644000175100001770000000756014561150445020651 0ustar00runnerdocker"""Tests for serializers.""" import os import unittest import pytest from betamax.serializers import base from betamax.serializers import json_serializer from betamax.serializers import proxy class TestJSONSerializer(unittest.TestCase): """Tests around the JSONSerializer default.""" def setUp(self): """Fixture setup.""" self.cassette_dir = 'fake_dir' self.cassette_name = 'cassette_name' def test_generate_cassette_name(self): """Verify the behaviour of generate_cassette_name.""" assert (os.path.join('fake_dir', 'cassette_name.json') == json_serializer.JSONSerializer.generate_cassette_name( self.cassette_dir, self.cassette_name, )) def test_generate_cassette_name_with_instance(self): """Verify generate_cassette_name works on an instance too.""" serializer = json_serializer.JSONSerializer() assert (os.path.join('fake_dir', 'cassette_name.json') == serializer.generate_cassette_name(self.cassette_dir, self.cassette_name)) class Serializer(base.BaseSerializer): """Serializer to test NotImplementedError exceptions.""" name = 'test' class BytesSerializer(base.BaseSerializer): """Serializer to test stored_as_binary.""" name = 'bytes-test' stored_as_binary = True # NOTE(sigmavirus24): These bytes, when decoded, result in a # UnicodeDecodeError serialized_bytes = b"hi \xAD" def serialize(self, *args): """Return the problematic bytes.""" return self.serialized_bytes def deserialize(self, *args): """Return the problematic bytes.""" return self.serialized_bytes class TestBaseSerializer(unittest.TestCase): """Tests around BaseSerializer behaviour.""" def test_serialize_is_an_interface(self): """Verify we handle unimplemented methods.""" serializer = Serializer() with pytest.raises(NotImplementedError): serializer.serialize({}) def test_deserialize_is_an_interface(self): """Verify we handle unimplemented methods.""" serializer = Serializer() with pytest.raises(NotImplementedError): serializer.deserialize('path') def test_requires_a_name(self): """Verify we handle unimplemented methods.""" with pytest.raises(ValueError): base.BaseSerializer() class TestBinarySerializers(unittest.TestCase): """Verify the behaviour of stored_as_binary=True.""" @pytest.fixture(autouse=True) def _setup(self): serializer = BytesSerializer() self.cassette_path = 'test_cassette.test' self.proxy = proxy.SerializerProxy( serializer, self.cassette_path, allow_serialization=True, ) def test_serialize(self): """Verify we use the right mode with open().""" mode = self.proxy.corrected_file_mode('w') assert mode == 'wb' def test_deserialize(self): """Verify we use the right mode with open().""" mode = self.proxy.corrected_file_mode('r') assert mode == 'rb' class TestTextSerializer(unittest.TestCase): """Verify the default behaviour of stored_as_binary.""" @pytest.fixture(autouse=True) def _setup(self): serializer = Serializer() self.cassette_path = 'test_cassette.test' self.proxy = proxy.SerializerProxy( serializer, self.cassette_path, allow_serialization=True, ) def test_serialize(self): """Verify we use the right mode with open().""" mode = self.proxy.corrected_file_mode('w') assert mode == 'w' def test_deserialize(self): """Verify we use the right mode with open().""" mode = self.proxy.corrected_file_mode('r') assert mode == 'r'