1
0
mirror of https://github.com/kevin1024/vcrpy.git synced 2025-12-08 16:53:23 +00:00

Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin McCarthy
b122b5c701 Release v8.1.0 2025-12-08 11:37:31 -05:00
Mathieu Dupuy
4883e3eefa Migrate to declarative Python package config (#767)
* migrate setup.py to pyproject.toml

* Revert "migrate setup.py to pyproject.toml"

This reverts commit f6c46cc138.

* migrate to setup.cfg

* migrate to pyproject.toml

---------

Co-authored-by: Mathieu Dupuy <mathieu.dupuy@gitguardian.com>
2025-12-08 10:13:56 -05:00
Kevin McCarthy
5678b13b47 Fix ruff SIM117: use combined with statement 2025-12-08 09:19:48 -05:00
Kevin McCarthy
48f5f84f86 Fix ruff linting issues in aiohttp tests 2025-12-08 09:16:46 -05:00
Leonardo Rochael Almeida
31d8c3498b aiohttp: Allow both data and json arguments (#624)
If at least one of them is `None`.

Previously, a `data=None` parameter would cause the `json` parameter to
be ignored, resulting in an empty request body payload on the cassette.
2025-12-08 09:13:51 -05:00
immerrr again
b28316ab10 Enable brotli decompression if it is available (#620)
* Enable brotli decompression if it is available

* Apply PR feedback
2025-12-05 16:50:36 -05:00
9 changed files with 245 additions and 131 deletions

View File

@@ -7,6 +7,14 @@ For a full list of triaged issues, bugs and PRs and what release they are target
All help in providing PRs to close out bug issues is appreciated. Even if that is providing a repo that fully replicates issues. We have very generous contributors that have added these to bug issues which meant another contributor picked up the bug and closed it out. All help in providing PRs to close out bug issues is appreciated. Even if that is providing a repo that fully replicates issues. We have very generous contributors that have added these to bug issues which meant another contributor picked up the bug and closed it out.
- 8.1.0
- Enable brotli decompression if available (via ``brotli``, ``brotlipy`` or ``brotlicffi``) (#620) - thanks @immerrr
- Fix aiohttp allowing both ``data`` and ``json`` arguments when one is None (#624) - thanks @leorochael
- Fix usage of io-like interface with VCR.py (#906) - thanks @tito and @kevdevg
- Migrate to declarative Python package config (#767) - thanks @deronnax
- Various linting fixes - thanks @jairhenrique
- CI: bump actions/checkout from 5 to 6 (#955)
- 8.0.0 - 8.0.0
- BREAKING: Drop support for Python 3.9 (major version bump) - thanks @jairhenrique - BREAKING: Drop support for Python 3.9 (major version bump) - thanks @jairhenrique
- BREAKING: Drop support for urllib3 < 2 - fixes CVE warnings from urllib3 1.x (#926, #880) - thanks @jairhenrique - BREAKING: Drop support for urllib3 < 2 - fixes CVE warnings from urllib3 1.x (#926, #880) - thanks @jairhenrique

View File

@@ -1,3 +1,72 @@
[project]
name = "vcrpy"
authors = [{name = "Kevin McCarthy", email = "me@kevinmccarthy.org"}]
license = {text = "MIT"}
description = "Automatically mock your HTTP interactions to simplify and speed up testing"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Software Development :: Testing",
"Topic :: Internet :: WWW/HTTP",
"License :: OSI Approved :: MIT License",
]
urls = {Homepage = "https://github.com/kevin1024/vcrpy"}
requires-python = ">=3.10"
dependencies = [
"PyYAML",
"wrapt",
]
dynamic = ["version"]
[project.readme]
file = "README.rst"
content-type = "text/x-rst"
[project.optional-dependencies]
tests = [
"aiohttp",
"boto3",
"cryptography",
"httpbin",
"httpcore",
"httplib2",
"httpx",
"pycurl; platform_python_implementation !='PyPy'",
"pytest",
"pytest-aiohttp",
"pytest-asyncio",
"pytest-cov",
"pytest-httpbin",
"requests>=2.22.0",
"tornado",
"urllib3",
"werkzeug==2.0.3",
]
[tool.setuptools]
include-package-data = false
[tool.setuptools.packages.find]
exclude = ["tests*"]
namespaces = false
[tool.setuptools.dynamic]
version = {attr = "vcr.__version__"}
[build-system]
requires = ["setuptools>=61.2"]
build-backend = "setuptools.build_meta"
[tool.codespell] [tool.codespell]
skip = '.git,*.pdf,*.svg,.tox' skip = '.git,*.pdf,*.svg,.tox'
ignore-regex = "\\\\[fnrstv]" ignore-regex = "\\\\[fnrstv]"

View File

@@ -1,2 +1,58 @@
[bdist_wheel] [metadata]
universal=1 name = vcrpy
version = attr: vcr.__version__
author = Kevin McCarthy
author_email = me@kevinmccarthy.org
license = MIT
description = Automatically mock your HTTP interactions to simplify and speed up testing
url = https://github.com/kevin1024/vcrpy
long_description = file: README.rst
long_description_content_type = text/x-rst
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Console
Intended Audience :: Developers
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
Programming Language :: Python :: 3.13
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: Implementation :: PyPy
Topic :: Software Development :: Testing
Topic :: Internet :: WWW/HTTP
License :: OSI Approved :: MIT License
[options]
packages = find:
python_requires = >=3.10
install_requires =
PyYAML
wrapt
tests_require =
vcrpy[tests]
[options.packages.find]
exclude = tests*
[options.extras_require]
tests =
aiohttp
boto3
cryptography
httpbin
httpcore
httplib2
httpx
pycurl; platform_python_implementation !='PyPy'
pytest
pytest-aiohttp
pytest-asyncio
pytest-cov
pytest-httpbin
requests>=2.22.0
tornado
urllib3
werkzeug==2.0.3

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env python
import codecs
import os
import re
from pathlib import Path
from setuptools import find_packages, setup
long_description = Path("README.rst").read_text()
here = os.path.abspath(os.path.dirname(__file__))
def read(*parts):
# intentionally *not* adding an encoding option to open, See:
# https://github.com/pypa/virtualenv/issues/201#issuecomment-3145690
with codecs.open(os.path.join(here, *parts), "r") as fp:
return fp.read()
def find_version(*file_paths):
version_file = read(*file_paths)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
install_requires = [
"PyYAML",
"wrapt",
]
extras_require = {
"tests": [
"aiohttp",
"boto3",
"cryptography",
"httpbin",
"httpcore",
"httplib2",
"httpx",
"pycurl; platform_python_implementation !='PyPy'",
"pytest",
"pytest-aiohttp",
"pytest-asyncio",
"pytest-cov",
"pytest-httpbin",
"requests>=2.22.0",
"tornado",
"urllib3",
"werkzeug==2.0.3",
],
}
setup(
name="vcrpy",
version=find_version("vcr", "__init__.py"),
description=("Automatically mock your HTTP interactions to simplify and speed up testing"),
long_description=long_description,
long_description_content_type="text/x-rst",
author="Kevin McCarthy",
author_email="me@kevinmccarthy.org",
url="https://github.com/kevin1024/vcrpy",
packages=find_packages(exclude=["tests*"]),
python_requires=">=3.10",
install_requires=install_requires,
license="MIT",
extras_require=extras_require,
tests_require=extras_require["tests"],
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Software Development :: Testing",
"Topic :: Internet :: WWW/HTTP",
"License :: OSI Approved :: MIT License",
],
)

View File

@@ -137,19 +137,29 @@ def test_stream(tmpdir, httpbin):
assert cassette.play_count == 1 assert cassette.play_count == 1
POST_DATA = {"key1": "value1", "key2": "value2"}
@pytest.mark.online @pytest.mark.online
@pytest.mark.parametrize("body", ["data", "json"]) @pytest.mark.parametrize(
def test_post(tmpdir, body, caplog, httpbin): "kwargs",
[
{"data": POST_DATA},
{"json": POST_DATA},
{"data": POST_DATA, "json": None},
{"data": None, "json": POST_DATA},
],
)
def test_post(tmpdir, kwargs, caplog, httpbin):
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
data = {"key1": "value1", "key2": "value2"} url = httpbin.url + "/post"
url = httpbin.url
with vcr.use_cassette(str(tmpdir.join("post.yaml"))): with vcr.use_cassette(str(tmpdir.join("post.yaml"))):
_, response_json = post(url, **{body: data}) _, response_json = post(url, **kwargs)
with vcr.use_cassette(str(tmpdir.join("post.yaml"))) as cassette: with vcr.use_cassette(str(tmpdir.join("post.yaml"))) as cassette:
request = cassette.requests[0] request = cassette.requests[0]
assert request.body == data assert request.body == POST_DATA
_, cassette_response_json = post(url, **{body: data}) _, cassette_response_json = post(url, **kwargs)
assert cassette_response_json == response_json assert cassette_response_json == response_json
assert cassette.play_count == 1 assert cassette.play_count == 1
@@ -163,6 +173,17 @@ def test_post(tmpdir, body, caplog, httpbin):
), "Log message not found." ), "Log message not found."
@pytest.mark.online
def test_post_data_plus_json_error(tmpdir, httpbin):
url = httpbin.url + "/post"
with (
vcr.use_cassette(str(tmpdir.join("post.yaml"))) as cassette,
pytest.raises(ValueError, match="data and json parameters can not be used at the same time"),
):
post(url, data=POST_DATA, json=POST_DATA)
assert cassette.requests == []
@pytest.mark.online @pytest.mark.online
def test_params(tmpdir, httpbin): def test_params(tmpdir, httpbin):
url = httpbin.url + "/get?d=d" url = httpbin.url + "/get?d=d"

View File

@@ -7,6 +7,7 @@ from urllib.request import Request, urlopen
import pytest import pytest
import vcr import vcr
from vcr.filters import brotli
from ..assertions import assert_cassette_has_one_response, assert_is_json_bytes from ..assertions import assert_cassette_has_one_response, assert_is_json_bytes
@@ -138,6 +139,22 @@ def test_decompress_deflate(tmpdir, httpbin):
assert_is_json_bytes(decoded_response) assert_is_json_bytes(decoded_response)
def test_decompress_brotli(tmpdir, httpbin):
if brotli is None:
# XXX: this is never true, because brotlipy is installed with "httpbin"
pytest.skip("Brotli is not installed")
url = httpbin.url + "/brotli"
request = Request(url, headers={"Accept-Encoding": ["gzip, deflate, br"]})
cass_file = str(tmpdir.join("brotli_response.yaml"))
with vcr.use_cassette(cass_file, decode_compressed_response=True):
urlopen(request)
with vcr.use_cassette(cass_file) as cass:
decoded_response = urlopen(url).read()
assert_cassette_has_one_response(cass)
assert_is_json_bytes(decoded_response)
def test_decompress_regular(tmpdir, httpbin): def test_decompress_regular(tmpdir, httpbin):
"""Test that it doesn't try to decompress content that isn't compressed""" """Test that it doesn't try to decompress content that isn't compressed"""
url = httpbin.url + "/get" url = httpbin.url + "/get"

View File

@@ -4,7 +4,7 @@ from logging import NullHandler
from .config import VCR from .config import VCR
from .record_mode import RecordMode as mode # noqa: F401 from .record_mode import RecordMode as mode # noqa: F401
__version__ = "8.0.0" __version__ = "8.1.0"
logging.getLogger(__name__).addHandler(NullHandler()) logging.getLogger(__name__).addHandler(NullHandler())

View File

@@ -6,6 +6,49 @@ from urllib.parse import urlencode, urlparse, urlunparse
from .util import CaseInsensitiveDict from .util import CaseInsensitiveDict
try:
# This supports both brotli & brotlipy packages
import brotli
except ImportError:
try:
import brotlicffi as brotli
except ImportError:
brotli = None
def decompress_deflate(body):
try:
return zlib.decompress(body)
except zlib.error:
# Assume the response was already decompressed
return body
def decompress_gzip(body):
# To (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16.
try:
return zlib.decompress(body, zlib.MAX_WBITS | 16)
except zlib.error:
# Assume the response was already decompressed
return body
AVAILABLE_DECOMPRESSORS = {
"deflate": decompress_deflate,
"gzip": decompress_gzip,
}
if brotli is not None:
def decompress_brotli(body):
try:
return brotli.decompress(body)
except brotli.error:
# Assume the response was already decompressed
return body
AVAILABLE_DECOMPRESSORS["br"] = decompress_brotli
def replace_headers(request, replacements): def replace_headers(request, replacements):
"""Replace headers in request according to replacements. """Replace headers in request according to replacements.
@@ -136,45 +179,30 @@ def remove_post_data_parameters(request, post_data_parameters_to_remove):
def decode_response(response): def decode_response(response):
""" """
If the response is compressed with gzip or deflate: If the response is compressed with any supported compression (gzip,
deflate, br if available):
1. decompress the response body 1. decompress the response body
2. delete the content-encoding header 2. delete the content-encoding header
3. update content-length header to decompressed length 3. update content-length header to decompressed length
""" """
def is_compressed(headers):
encoding = headers.get("content-encoding", [])
return encoding and encoding[0] in ("gzip", "deflate")
def decompress_body(body, encoding):
"""Returns decompressed body according to encoding using zlib.
to (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16
"""
if not body:
return ""
if encoding == "gzip":
try:
return zlib.decompress(body, zlib.MAX_WBITS | 16)
except zlib.error:
return body # assumes that the data was already decompressed
else: # encoding == 'deflate'
try:
return zlib.decompress(body)
except zlib.error:
return body # assumes that the data was already decompressed
# Deepcopy here in case `headers` contain objects that could # Deepcopy here in case `headers` contain objects that could
# be mutated by a shallow copy and corrupt the real response. # be mutated by a shallow copy and corrupt the real response.
response = copy.deepcopy(response) response = copy.deepcopy(response)
headers = CaseInsensitiveDict(response["headers"]) headers = CaseInsensitiveDict(response["headers"])
if is_compressed(headers): content_encoding = headers.get("content-encoding")
encoding = headers["content-encoding"][0] if not content_encoding:
headers["content-encoding"].remove(encoding) return response
if not headers["content-encoding"]: decompressor = AVAILABLE_DECOMPRESSORS.get(content_encoding[0])
del headers["content-encoding"] if not decompressor:
return response
new_body = decompress_body(response["body"]["string"], encoding) headers["content-encoding"].remove(content_encoding[0])
response["body"]["string"] = new_body if not headers["content-encoding"]:
headers["content-length"] = [str(len(new_body))] del headers["content-encoding"]
response["headers"] = dict(headers)
new_body = decompressor(response["body"]["string"])
response["body"]["string"] = new_body
headers["content-length"] = [str(len(new_body))]
response["headers"] = dict(headers)
return response return response

View File

@@ -245,7 +245,11 @@ def vcr_request(cassette, real_request):
headers = kwargs.get("headers") headers = kwargs.get("headers")
auth = kwargs.get("auth") auth = kwargs.get("auth")
headers = self._prepare_headers(headers) headers = self._prepare_headers(headers)
data = kwargs.get("data", kwargs.get("json")) data = kwargs.get("data")
if data is None:
data = kwargs.get("json")
elif kwargs.get("json") is not None:
raise ValueError("data and json parameters can not be used at the same time")
params = kwargs.get("params") params = kwargs.get("params")
cookies = kwargs.get("cookies") cookies = kwargs.get("cookies")