Skip to content

Commit effd6c8

Browse files
committed
APP-6435: Added support for vcrpy test utilities to mock HTTP interactions with 3rd-party APIs
1 parent 09d5423 commit effd6c8

File tree

4 files changed

+289
-1
lines changed

4 files changed

+289
-1
lines changed

pyatlan/test_utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
# Copyright 2024 Atlan Pte. Ltd.
2+
# Copyright 2025 Atlan Pte. Ltd.
33
import logging
44
import random
55
from os import path

pyatlan/test_utils/base_vcr.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2025 Atlan Pte. Ltd.
3+
4+
import pkg_resources # type: ignore[import-untyped]
5+
6+
from pyatlan.utils import DependencyNotFoundError
7+
8+
try:
9+
# Check if pytest-vcr plugin is installed
10+
pkg_resources.get_distribution("pytest-vcr")
11+
except pkg_resources.DistributionNotFound:
12+
raise DependencyNotFoundError("pytest-vcr")
13+
14+
import json
15+
import os
16+
from typing import Any, Dict, Union
17+
18+
import pytest
19+
import yaml # type: ignore[import-untyped]
20+
21+
22+
class LiteralBlockScalar(str):
23+
"""Formats the string as a literal block scalar, preserving whitespace and
24+
without interpreting escape characters"""
25+
26+
27+
def literal_block_scalar_presenter(dumper, data):
28+
"""Represents a scalar string as a literal block, via '|' syntax"""
29+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
30+
31+
32+
yaml.add_representer(LiteralBlockScalar, literal_block_scalar_presenter)
33+
34+
35+
def process_string_value(string_value):
36+
"""Pretty-prints JSON or returns long strings as a LiteralBlockScalar"""
37+
try:
38+
json_data = json.loads(string_value)
39+
return LiteralBlockScalar(json.dumps(json_data, indent=2))
40+
except (ValueError, TypeError):
41+
if len(string_value) > 80:
42+
return LiteralBlockScalar(string_value)
43+
return string_value
44+
45+
46+
def convert_body_to_literal(data):
47+
"""Searches the data for body strings, attempting to pretty-print JSON"""
48+
if isinstance(data, dict):
49+
for key, value in data.items():
50+
# Handle response body case (e.g: response.body.string)
51+
if key == "body" and isinstance(value, dict) and "string" in value:
52+
value["string"] = process_string_value(value["string"])
53+
54+
# Handle request body case (e.g: request.body)
55+
elif key == "body" and isinstance(value, str):
56+
data[key] = process_string_value(value)
57+
58+
else:
59+
convert_body_to_literal(value)
60+
61+
elif isinstance(data, list):
62+
for idx, choice in enumerate(data):
63+
data[idx] = convert_body_to_literal(choice)
64+
65+
return data
66+
67+
68+
class VCRPrettyPrintYamlJSONBody:
69+
"""This makes request and response YAML JSON body recordings more readable."""
70+
71+
@staticmethod
72+
def serialize(cassette_dict):
73+
cassette_dict = convert_body_to_literal(cassette_dict)
74+
return yaml.dump(cassette_dict, default_flow_style=False, allow_unicode=True)
75+
76+
@staticmethod
77+
def deserialize(cassette_string):
78+
return yaml.safe_load(cassette_string)
79+
80+
81+
class VCRPrettyPrintJSONBody:
82+
"""Makes request and response JSON body recordings more readable."""
83+
84+
@staticmethod
85+
def _parse_json_body(
86+
body: Union[str, bytes, None],
87+
) -> Union[Dict[str, Any], str, None, bytes]:
88+
"""Parse JSON body if possible, otherwise return the original body."""
89+
if body is None:
90+
return None
91+
92+
# Convert bytes to string if needed
93+
if isinstance(body, bytes):
94+
try:
95+
body = body.decode("utf-8")
96+
except UnicodeDecodeError:
97+
return body # Return original if can't decode
98+
99+
# If it's a string, try to parse as JSON
100+
if isinstance(body, str):
101+
try:
102+
return json.loads(body)
103+
except json.JSONDecodeError:
104+
return body # Return original if not valid JSON
105+
106+
return body # Return original for other types
107+
108+
@staticmethod
109+
def serialize(cassette_dict: dict) -> str:
110+
"""
111+
Converts body strings to parsed JSON objects for better readability when possible.
112+
"""
113+
# Safety check for cassette_dict
114+
if not cassette_dict or not isinstance(cassette_dict, dict):
115+
cassette_dict = {}
116+
117+
interactions = cassette_dict.get("interactions", []) or []
118+
119+
for interaction in interactions:
120+
if not interaction:
121+
continue
122+
123+
# Handle response body
124+
response = interaction.get("response") or {}
125+
body_container = response.get("body")
126+
if isinstance(body_container, dict) and "string" in body_container:
127+
parsed_body = VCRPrettyPrintJSONBody._parse_json_body(
128+
body_container["string"]
129+
)
130+
if isinstance(parsed_body, dict):
131+
# Replace string field with parsed_json field
132+
response["body"] = {"parsed_json": parsed_body}
133+
134+
# Handle request body
135+
request = interaction.get("request") or {}
136+
body_container = request.get("body")
137+
if isinstance(body_container, dict) and "string" in body_container:
138+
parsed_body = VCRPrettyPrintJSONBody._parse_json_body(
139+
body_container["string"]
140+
)
141+
if isinstance(parsed_body, dict):
142+
# Replace string field with parsed_json field
143+
request["body"] = {"parsed_json": parsed_body}
144+
145+
# Serialize the final dictionary into a JSON string with pretty formatting
146+
try:
147+
return json.dumps(cassette_dict, indent=2, ensure_ascii=False) + "\n"
148+
except TypeError as exc:
149+
raise TypeError(
150+
"Does this HTTP interaction contain binary data? "
151+
"If so, use a different serializer (like the YAML serializer)."
152+
) from exc
153+
154+
@staticmethod
155+
def deserialize(cassette_string: str) -> dict:
156+
"""
157+
Deserializes a JSON string into a dictionary and converts
158+
parsed_json fields back to string fields.
159+
"""
160+
# Safety check for cassette_string
161+
if not cassette_string:
162+
return {}
163+
164+
try:
165+
cassette_dict = json.loads(cassette_string)
166+
except json.JSONDecodeError:
167+
return {}
168+
169+
# Convert parsed_json back to string format
170+
interactions = cassette_dict.get("interactions", []) or []
171+
172+
for interaction in interactions:
173+
if not interaction:
174+
continue
175+
176+
# Handle response body
177+
response = interaction.get("response") or {}
178+
body_container = response.get("body")
179+
if isinstance(body_container, dict) and "parsed_json" in body_container:
180+
json_body = body_container["parsed_json"]
181+
response["body"] = {"string": json.dumps(json_body)}
182+
183+
# Handle request body
184+
request = interaction.get("request") or {}
185+
body_container = request.get("body")
186+
if isinstance(body_container, dict) and "parsed_json" in body_container:
187+
json_body = body_container["parsed_json"]
188+
request["body"] = {"string": json.dumps(json_body)}
189+
190+
return cassette_dict
191+
192+
193+
class VCRRemoveAllHeaders:
194+
"""
195+
A class responsible for removing all headers from requests and responses.
196+
This can be useful for scenarios where headers are not needed for matching or comparison
197+
in VCR (Virtual Cassette Recorder) interactions, such as when recording or replaying HTTP requests.
198+
"""
199+
200+
@staticmethod
201+
def remove_all_request_headers(request):
202+
# Save only what's necessary for matching
203+
request.headers = {}
204+
return request
205+
206+
@staticmethod
207+
def remove_all_response_headers(response):
208+
# Save only what's necessary for matching
209+
response["headers"] = {}
210+
return response
211+
212+
213+
class BaseVCR:
214+
"""
215+
A base class for configuring VCR (Virtual Cassette Recorder)
216+
for HTTP request/response recording and replaying.
217+
218+
This class provides pytest fixtures to set up the VCR configuration
219+
and custom serializers for JSON and YAML formats.
220+
It also handles cassette directory configuration.
221+
"""
222+
223+
_CASSETTES_DIR = None
224+
225+
@pytest.fixture(scope="module")
226+
def vcr(self, vcr):
227+
"""
228+
Registers custom serializers for VCR and returns the VCR instance.
229+
230+
The method registers two custom serializers:
231+
- "pretty-json" for pretty-printing JSON responses.
232+
- "pretty-yaml" for pretty-printing YAML responses.
233+
234+
:param vcr: The VCR instance provided by the pytest-vcr plugin
235+
:returns: modified VCR instance with custom serializers registered
236+
"""
237+
vcr.register_serializer("pretty-json", VCRPrettyPrintJSONBody)
238+
vcr.register_serializer("pretty-yaml", VCRPrettyPrintYamlJSONBody)
239+
return vcr
240+
241+
@pytest.fixture(scope="module")
242+
def vcr_config(self):
243+
"""
244+
Provides the VCR configuration dictionary.
245+
246+
The configuration includes default options for the recording mode,
247+
serializer, response decoding, and filtering headers.
248+
This configuration is used to set up VCR behavior during tests.
249+
250+
:returns: a dictionary with VCR configuration options
251+
"""
252+
return {
253+
# More config options can be found at:
254+
# https://vcrpy.readthedocs.io/en/latest/configuration.html#configuration
255+
"record_mode": "once", # (default: "once", "always", "none", "new_episodes")
256+
"serializer": "pretty-yaml", # (default: "yaml")
257+
"decode_compressed_response": True, # Decode compressed responses
258+
# (optional) Replace the Authorization request header with "**REDACTED**" in cassettes
259+
# "filter_headers": [("authorization", "**REDACTED**")],
260+
"before_record_request": VCRRemoveAllHeaders.remove_all_request_headers,
261+
"before_record_response": VCRRemoveAllHeaders.remove_all_response_headers,
262+
}
263+
264+
@pytest.fixture(scope="module")
265+
def vcr_cassette_dir(self, request):
266+
"""
267+
Provides the directory path for storing VCR cassettes.
268+
269+
If a custom cassette directory is set in the class, it is used;
270+
otherwise, the default directory structure is created under "tests/cassettes".
271+
The directory path will be based on the module name.
272+
273+
:param request: request object which provides metadata about the test
274+
275+
:returns: directory path for storing cassettes
276+
"""
277+
# Set self._CASSETTES_DIR or use the default directory path based on the test module name
278+
return self._CASSETTES_DIR or os.path.join(
279+
"tests/cassettes", request.module.__name__
280+
)

pyatlan/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,10 @@ def validate_single_required_field(field_names: List[str], values: List[Any]):
471471
raise ValueError(
472472
f"Only one of the following parameters are allowed: {', '.join(names)}"
473473
)
474+
475+
476+
class DependencyNotFoundError(Exception):
477+
def __init__(self, dependency):
478+
super().__init__(
479+
f"{dependency} is not installed, but it is required to use this module. Please install {dependency}."
480+
)

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mypy~=1.9.0
22
ruff~=0.9.9
33
types-requests~=2.32.0.20241016
44
pytest~=8.3.4
5+
pytest-vcr~=1.0.2
56
pytest-order~=1.3.0
67
pytest-timer[termcolor]~=1.0.0
78
pytest-sugar~=1.0.0

0 commit comments

Comments
 (0)