WIP: Add VCR-compatible YAML serializer
diff --git a/requests_cache/serializers/__init__.py b/requests_cache/serializers/__init__.py
index 72420b7..09a7434 100644
--- a/requests_cache/serializers/__init__.py
+++ b/requests_cache/serializers/__init__.py
@@ -13,6 +13,7 @@
utf8_encoder,
yaml_serializer,
)
+from .vcr import vcr_serializer
__all__ = [
'SERIALIZERS',
diff --git a/requests_cache/serializers/vcr.py b/requests_cache/serializers/vcr.py
new file mode 100644
index 0000000..db979f5
--- /dev/null
+++ b/requests_cache/serializers/vcr.py
@@ -0,0 +1,118 @@
+# TODO: Maybe this should be a one-way conversion function instead of a full serializer?
+"""YAML serializer compatible with VCR-based libraries, including `vcrpy
+<https://github.com/kevin1024/vcrpy>`_ and `betamax <https://github.com/betamaxpy/betamax>`_.
+"""
+from os import makedirs
+from os.path import abspath, dirname, expanduser, join
+from typing import Any, Dict, Iterable
+from urllib.parse import urlparse
+
+from .. import __version__, get_placeholder_class
+from ..models import CachedHTTPResponse, CachedRequest, CachedResponse
+from .pipeline import SerializerPipeline, Stage
+from .preconf import yaml_preconf_stage
+
+
+def to_vcr_cassette(responses: Iterable[CachedResponse]) -> Dict:
+ """Convert responses to a VCR cassette"""
+ return {
+ 'http_interactions': [to_vcr_episode(r) for r in responses],
+ 'recorded_with': f'requests-cache {__version__}',
+ }
+
+
+def to_vcr_episode(response: CachedResponse) -> Dict:
+ """Convert a single response to a VCR-compatible response ("episode") dict"""
+ # Do most of the work with cattrs + default YAML conversions
+ response_dict = yaml_preconf_stage.dumps(response)
+
+ def _to_multidict(d):
+ return {k: [v] for k, v in d.items()}
+
+ # Translate requests.Response structure into VCR format
+ return {
+ 'request': {
+ 'body': response_dict['request']['body'],
+ 'headers': _to_multidict(response_dict['request']['headers']),
+ 'method': response_dict['request']['method'],
+ 'uri': response_dict['request']['url'],
+ },
+ 'response': {
+ 'body': {'string': response_dict['_content'], 'encoding': response_dict['encoding']},
+ 'headers': _to_multidict(response_dict['headers']),
+ 'status': {'code': response_dict['status_code'], 'message': response_dict['reason']},
+ 'url': response_dict['url'],
+ },
+ 'recorded_at': response_dict['created_at'],
+ }
+
+
+def from_vcr_episode(cassette_dict: Dict):
+ data = cassette_dict.get('interactions') or cassette_dict.get('http_interactions') or cassette_dict
+ request = CachedRequest(
+ body=data['request']['body'],
+ headers=data['request']['headers'],
+ method=data['request']['method'],
+ url=data['request']['uri'],
+ )
+ raw_response = CachedHTTPResponse(body=data['response']['body']) # type: ignore # TODO: fix type hint
+ return CachedResponse(
+ content=data['response']['body']['string'],
+ encoding=data['response']['body'].get('encoding'),
+ headers=data['response']['headers'],
+ raw=raw_response,
+ reason=data['response']['status']['message'],
+ request=request,
+ status_code=data['response']['status']['code'],
+ url=data['response']['url'],
+ )
+
+
+try:
+ import yaml
+
+ vcr_stage = Stage(dumps=to_vcr_episode, loads=from_vcr_episode)
+ vcr_serializer = SerializerPipeline(
+ [
+ yaml_preconf_stage,
+ vcr_stage,
+ Stage(yaml, loads='safe_load', dumps='safe_dump'),
+ ]
+ ) #: VRC-compatible YAML serializer
+except ImportError as e:
+ yaml_serializer = get_placeholder_class(e)
+
+
+# Experimental
+# ------------
+
+
+def save_vcr_cassette(responses: Iterable[CachedResponse], path: str):
+ """Save responses as VCR-compatible YAML files"""
+ _write_cassette(to_vcr_cassette(responses), path)
+
+
+def save_vcr_cassettes_by_host(responses: Iterable[CachedResponse], cassette_dir: str):
+ """Save responses as VCR-compatible YAML files, with one cassette per request host"""
+
+ cassettes_by_host = _to_vcr_cassettes_by_host(responses)
+ for host, cassette in cassettes_by_host.items():
+ _write_cassette(cassette, join(cassette_dir, f'{host}.yml'))
+
+
+def _to_vcr_cassettes_by_host(responses: Iterable[CachedResponse]) -> Dict[str, Dict]:
+ responses_by_host: Dict[str, Any] = {}
+ for response in responses:
+ host = urlparse(response.request.url).netloc
+ responses_by_host.setdefault(host, [])
+ responses_by_host[host].append(response)
+ return {host: to_vcr_cassette(responses) for host, responses in responses_by_host.items()}
+
+
+def _write_cassette(cassette, path):
+ import yaml
+
+ path = abspath(expanduser(path))
+ makedirs(dirname(path), exist_ok=True)
+ with open(path, 'w') as f:
+ f.write(yaml.safe_dump(cassette))
diff --git a/tests/sample_data/vcr_betamax.yaml b/tests/sample_data/vcr_betamax.yaml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/sample_data/vcr_betamax.yaml
diff --git a/tests/sample_data/vcr_requests_cache.yaml b/tests/sample_data/vcr_requests_cache.yaml
new file mode 100644
index 0000000..a827b0e
--- /dev/null
+++ b/tests/sample_data/vcr_requests_cache.yaml
@@ -0,0 +1,46 @@
+http_interactions:
+- recorded_at: '2021-08-15T02:14:11.003635'
+ request:
+ body: !!binary |
+ Tm9uZQ==
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ User-Agent:
+ - python-requests/2.26.0
+ method: GET
+ uri: https://httpbin.org/get
+ response:
+ body:
+ encoding: utf-8
+ string: !!binary |
+ ewogICJhcmdzIjoge30sIAogICJoZWFkZXJzIjogewogICAgIkFjY2VwdCI6ICIqLyoiLCAKICAg
+ ICJBY2NlcHQtRW5jb2RpbmciOiAiZ3ppcCwgZGVmbGF0ZSIsIAogICAgIkhvc3QiOiAiaHR0cGJp
+ bi5vcmciLCAKICAgICJVc2VyLUFnZW50IjogInB5dGhvbi1yZXF1ZXN0cy8yLjI2LjAiLCAKICAg
+ ICJYLUFtem4tVHJhY2UtSWQiOiAiUm9vdD0xLTYxMTg3ODcyLTM4YjE1NmZhMjliOTU3ODMzM2Nj
+ NmJmMCIKICB9LCAKICAib3JpZ2luIjogIjE3My4yMS4xMjYuMTQ1IiwgCiAgInVybCI6ICJodHRw
+ czovL2h0dHBiaW4ub3JnL2dldCIKfQo=
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Origin:
+ - '*'
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '308'
+ Content-Type:
+ - application/json
+ Date:
+ - Sun, 15 Aug 2021 02:14:10 GMT
+ Server:
+ - gunicorn/19.9.0
+ status:
+ code: 200
+ reason: OK
+ url: https://httpbin.org/get
+recorded_with: requests-cache 0.8.0
diff --git a/tests/sample_data/vcr_vcrpy.yaml b/tests/sample_data/vcr_vcrpy.yaml
new file mode 100644
index 0000000..bcf8836
--- /dev/null
+++ b/tests/sample_data/vcr_vcrpy.yaml
@@ -0,0 +1,76 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ User-Agent:
+ - python-requests/2.26.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.26.0\", \n \"X-Amzn-Trace-Id\":
+ \"Root=1-61269390-110f184a5a3b52ef31278574\"\n }, \n \"origin\": \"173.21.126.145\",
+ \n \"url\": \"https://httpbin.org/get\"\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Origin:
+ - '*'
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '308'
+ Content-Type:
+ - application/json
+ Date:
+ - Wed, 25 Aug 2021 19:01:36 GMT
+ Server:
+ - gunicorn/19.9.0
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ User-Agent:
+ - python-requests/2.26.0
+ method: GET
+ uri: https://httpbin.org/ip
+ response:
+ body:
+ string: "{\n \"origin\": \"173.21.126.145\"\n}\n"
+ headers:
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Origin:
+ - '*'
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '33'
+ Content-Type:
+ - application/json
+ Date:
+ - Wed, 25 Aug 2021 19:09:56 GMT
+ Server:
+ - gunicorn/19.9.0
+ Foo:
+ - bar
+ status:
+ code: 200
+ message: OK
+version: 1