Skip to content

Commit 17106a1

Browse files
committed
feat(tests): improve VCR normalization per PR review
Address PR #1507 review comments: - Add client_secret and private_key_passphrase to _ENV_SPECIFIC_BODY_FIELDS - Switch from stdlib json to orjson for faster JSON processing - Add _normalization_configured guard to prevent silent no-op recording risk: low
1 parent da7c7e4 commit 17106a1

File tree

3 files changed

+40
-13
lines changed

3 files changed

+40
-13
lines changed

packages/tests-support/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "Tests support for GoodData Python SDK"
55
requires-python = ">=3.10"
66
version = "1.0.0"
77
dependencies = [
8+
"orjson",
89
"pyyaml>=6.0",
910
"requests",
1011
]

packages/tests-support/src/tests_support/vcrpy_utils.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
# (C) 2022 GoodData Corporation
22
from __future__ import annotations
33

4-
import json
54
import os
65
import typing
7-
from json import JSONDecodeError
86
from typing import Any
97
from urllib.parse import urlparse
108

9+
import orjson
1110
import vcr
1211
import yaml
1312
from vcr.record_mode import RecordMode
@@ -20,7 +19,15 @@
2019
# Fields stripped from request bodies before VCR body matching.
2120
# These differ between local and staging environments but don't affect
2221
# the logical identity of a request.
23-
_ENV_SPECIFIC_BODY_FIELDS = {"password", "token", "url", "username", "privateKey"}
22+
_ENV_SPECIFIC_BODY_FIELDS = {
23+
"password",
24+
"token",
25+
"url",
26+
"username",
27+
"privateKey",
28+
"client_secret",
29+
"private_key_passphrase",
30+
}
2431

2532
# Canonical (local) values — cassettes always use these.
2633
_CANONICAL_HOST = "http://localhost:3000"
@@ -33,6 +40,7 @@
3340
# Each entry is (source_string, replacement_string).
3441
# Ordered longest-first so more specific patterns match before substrings.
3542
_normalization_replacements: list[tuple[str, str]] = []
43+
_normalization_configured: bool = False
3644

3745

3846
def configure_normalization(test_config: dict[str, Any]) -> None:
@@ -49,7 +57,7 @@ def configure_normalization(test_config: dict[str, Any]) -> None:
4957
rm -rf packages/gooddata-sdk/.tox
5058
uv cache clean tests-support --force
5159
"""
52-
global _normalization_replacements
60+
global _normalization_replacements, _normalization_configured
5361
replacements: list[tuple[str, str]] = []
5462

5563
parsed = urlparse(test_config.get("host", _CANONICAL_HOST))
@@ -99,6 +107,7 @@ def configure_normalization(test_config: dict[str, Any]) -> None:
99107
replacements.sort(key=lambda pair: len(pair[0]), reverse=True)
100108

101109
_normalization_replacements = replacements
110+
_normalization_configured = True
102111

103112

104113
def _apply_replacements(text: str) -> str:
@@ -113,8 +122,8 @@ def _normalize_body(body: str | None) -> str:
113122
if not body:
114123
return body or ""
115124
try:
116-
data = json.loads(body)
117-
except (JSONDecodeError, TypeError):
125+
data = orjson.loads(body)
126+
except (orjson.JSONDecodeError, TypeError):
118127
return body
119128

120129
def _strip(obj: Any) -> Any:
@@ -124,7 +133,7 @@ def _strip(obj: Any) -> Any:
124133
return [_strip(item) for item in obj]
125134
return obj
126135

127-
return json.dumps(_strip(data), sort_keys=True)
136+
return orjson.dumps(_strip(data), option=orjson.OPT_SORT_KEYS).decode("utf-8")
128137

129138

130139
def _body_matcher(r1: Any, r2: Any) -> None:
@@ -174,13 +183,15 @@ def deserialize(self, cassette_string: str) -> dict[str, Any]:
174183
if isinstance(request_body, str) and request_body.startswith("<?xml"):
175184
interaction["request"]["body"] = request_body
176185
else:
177-
interaction["request"]["body"] = json.dumps(request_body)
186+
interaction["request"]["body"] = orjson.dumps(request_body).decode("utf-8")
178187
if response_body is not None and response_body["string"] != "":
179188
try:
180189
if isinstance(response_body["string"], str) and response_body["string"].startswith("<?xml"):
181190
interaction["response"]["body"]["string"] = response_body["string"]
182191
else:
183-
interaction["response"]["body"]["string"] = json.dumps(response_body["string"])
192+
interaction["response"]["body"]["string"] = orjson.dumps(response_body["string"]).decode(
193+
"utf-8"
194+
)
184195
except TypeError:
185196
# this exception is expected while getting XLSX file content
186197
continue
@@ -192,14 +203,14 @@ def serialize(self, cassette_dict: dict[str, Any]) -> str:
192203
response_body = interaction["response"]["body"]
193204
if request_body is not None:
194205
try:
195-
interaction["request"]["body"] = json.loads(request_body)
196-
except (JSONDecodeError, UnicodeDecodeError):
206+
interaction["request"]["body"] = orjson.loads(request_body)
207+
except (orjson.JSONDecodeError, UnicodeDecodeError):
197208
# The response can be in XML
198209
interaction["request"]["body"] = request_body
199210
if response_body is not None and response_body["string"] != "":
200211
try:
201-
interaction["response"]["body"]["string"] = json.loads(response_body["string"])
202-
except (JSONDecodeError, UnicodeDecodeError):
212+
interaction["response"]["body"]["string"] = orjson.loads(response_body["string"])
213+
except (orjson.JSONDecodeError, UnicodeDecodeError):
203214
# these exceptions are expected while getting file content
204215
continue
205216
return yaml.dump(cassette_dict, Dumper=IndentDumper, sort_keys=True)
@@ -213,6 +224,12 @@ def _normalize_uri(uri: str) -> str:
213224

214225

215226
def custom_before_request(request, headers_str: str = HEADERS_STR):
227+
if not _normalization_configured:
228+
raise RuntimeError(
229+
"VCR normalization not configured. "
230+
"Ensure your test fixture depends on 'test_config' (directly or transitively) "
231+
"so that configure_normalization() runs before cassette recording starts."
232+
)
216233
# Normalize URI to canonical host
217234
request.uri = _normalize_uri(request.uri)
218235

@@ -234,6 +251,13 @@ def custom_before_response(
234251
non_static_headers: list[str] | None = None,
235252
placeholder: list[str] | None = None,
236253
):
254+
if not _normalization_configured:
255+
raise RuntimeError(
256+
"VCR normalization not configured. "
257+
"Ensure your test fixture depends on 'test_config' (directly or transitively) "
258+
"so that configure_normalization() runs before cassette recording starts."
259+
)
260+
237261
if non_static_headers is None:
238262
non_static_headers = NON_STATIC_HEADERS
239263

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)