1
0
mirror of https://github.com/kevin1024/vcrpy.git synced 2025-12-09 17:15:35 +00:00

Compare commits

...

39 Commits
v6.0.2 ... uv

Author SHA1 Message Date
Jair Henrique
c3cf955b91 Use uv on CI 2025-03-23 19:24:44 -03:00
pre-commit
19bd4e012c pre-commit: Autoupdate 2025-03-23 16:52:07 -03:00
Karolina Surma
558c7fc625 Import iscoroutinefunction() from inspect rather than asyncio
The asyncio function is deprecated starting from Python 3.14 and
will be removed from Python 3.16.
2025-03-23 16:49:33 -03:00
Sebastian Pipping
8217a4c21b Merge pull request #903 from kevin1024/precommit-autoupdate
pre-commit: Autoupdate
2025-01-17 21:03:56 +01:00
pre-commit
bd0aa59cd2 pre-commit: Autoupdate 2025-01-17 20:50:18 +01:00
Sebastian Pipping
9a37817a3a Merge pull request #904 from kevin1024/fix-ci
Fix CI for error `unshare: write failed /proc/self/uid_map: Operation not permitted` with Ubuntu >=24.04
2025-01-17 20:49:41 +01:00
Sebastian Pipping
b4c65bd677 main.yml: Allow creation of user namespaces to unshare in Ubuntu >=24.04 2025-01-17 20:36:18 +01:00
Sebastian Pipping
93bc59508c Pin GitHub Actions and Read the Docs to explicit Ubuntu 24.04 2025-01-17 20:16:30 +01:00
pre-commit
e313a9cd52 pre-commit: Autoupdate 2025-01-12 09:28:43 -03:00
Sebastian Pipping
5f1b20c4ca Merge pull request #763 from danielnsilva/drop-unused-requests
Add an option to remove unused requests from cassette
2025-01-11 20:51:28 +01:00
Daniel Silva
cd31d71901 refactor: move logic for building used interactions dict before saving 2025-01-11 16:56:59 +00:00
Daniel Silva
4607ca1102 fix: add drop_unused_requests check in cassette saving logic 2025-01-11 16:55:04 +00:00
Daniel Silva
e3ced4385e docs: update example in advanced.rst
Co-authored-by: Sebastian Pipping <sebastian@pipping.org>
2025-01-11 11:21:55 -05:00
Jair Henrique
80099ac6d7 Clean pytest configurations 2025-01-08 16:16:56 -03:00
Sebastian Pipping
440bc20faf Merge pull request #809 from alga/alga-https-proxy
Fix HTTPS proxy handling
2025-01-08 00:20:54 +01:00
Albertas Agejevas
3ddff27cda Remove redundant assertions.
They are covered by the next line.
2025-01-07 21:48:24 +02:00
Albertas Agejevas
30b423e8c0 Use mode="none" in proxy tests as suggested by @hartwork. 2025-01-07 21:48:24 +02:00
Albertas Agejevas
752ba0b749 Fix HTTPS proxy handling. 2025-01-07 21:48:24 +02:00
Albertas Agejevas
c16e526d6a Integration test for HTTPS proxy handling. 2025-01-07 21:48:24 +02:00
Daniel Silva
d64cdd337b style: fix formatting issues to comply with pre-commit hooks 2025-01-04 23:45:43 +00:00
Martin Brunthaler
ac230b76af Call urllib.parse less frequently 2025-01-04 15:42:49 -03:00
pre-commit
965f3658d5 pre-commit: Autoupdate 2025-01-04 15:21:25 -03:00
Jair Henrique
6465a5995b Fix docs conf 2025-01-04 15:19:50 -03:00
dependabot[bot]
69ca261a88 build(deps): bump sphinx-rtd-theme from 2.0.0 to 3.0.2
Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 2.0.0 to 3.0.2.
- [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst)
- [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/2.0.0...3.0.2)

---
updated-dependencies:
- dependency-name: sphinx-rtd-theme
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-04 15:19:50 -03:00
kevin1024
3278619dcc Release v7.0.0 2024-12-30 18:59:50 -05:00
Aleksei Kozharin
3fb62e0f9b fix: correctly handle asyncio.run when loop exists 2024-12-30 13:34:03 -03:00
dependabot[bot]
81978659f1 build(deps): update sphinx requirement from <8 to <9
Updates the requirements on [sphinx](https://github.com/sphinx-doc/sphinx) to permit the latest version.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/v8.0.2/CHANGES.rst)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v0.1.61611...v8.0.2)

---
updated-dependencies:
- dependency-name: sphinx
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-28 14:03:41 -03:00
pre-commit
be651bd27c pre-commit: Autoupdate 2024-12-28 13:48:49 -03:00
Jair Henrique
a6698ed060 Fix aiohttp tests 2024-12-28 13:42:54 -03:00
Igor Gumenyuk
48d0a2e453 Fixed missing version_string attribute when used with urllib3>=2.3.0
urllib3 in v2.3.0 introduced attribute `version_string` (https://github.com/urllib3/urllib3/pull/3316/files). This attribute  is missing in `VCRHTTPResponse` which causes errors like AttributeError: 'VCRHTTPResponse' object has no attribute 'version_string'

This fixes https://github.com/kevin1024/vcrpy/issues/888
2024-12-28 13:39:53 -03:00
Jair Henrique
5b858b132d Fix lint 2024-12-28 13:25:04 -03:00
Jair Henrique
c8d99a99ec Fix ruff configuration 2024-12-28 13:21:15 -03:00
Sebastian Pipping
ce27c63685 Merge pull request #736 from kevin1024/drop-python38
[14 Oct 2024] Drop python 3.8 support
2024-10-13 04:05:13 +02:00
Jair Henrique
ab8944d3ca Drop python 3.8 support 2024-10-12 22:44:42 -03:00
Sebastian Pipping
c6a7f4ae15 Merge pull request #872 from kevin1024/precommit-autoupdate
pre-commit: Autoupdate
2024-10-11 01:22:08 +02:00
pre-commit
7275e5d65d pre-commit: Autoupdate 2024-10-04 16:05:32 +00:00
Daniel Silva
36c7465cf7 docs: add drop_unused_requests option 2023-01-04 21:59:58 +00:00
Daniel Silva
010fa268d1 test: add tests to drop_unused_requests option 2023-01-04 20:09:31 +00:00
Daniel Silva
99c0384770 feat: add an option to exclude unused interactions
Introduce the `drop_unused_requests` option (False by default). If True, it will force the `Cassette` saving operation with only played old interactions and new ones if they exist. As a result, unused old requests are dropped.

Add `_old_interactions`, `_played_interactions` and `_new_interactions()`.  The `_old_interactions` are previously recorded interactions loaded from Cassette files. The `_played_interactions` is a set of old interactions that were marked as played.  A new interaction is a tuple (request, response) in `self.data` that is not in `_old_interactions` list.
2023-01-02 03:52:52 +00:00
26 changed files with 237 additions and 61 deletions

View File

@@ -13,7 +13,7 @@ permissions:
jobs: jobs:
codespell: codespell:
name: Check for spelling errors name: Check for spelling errors
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -11,18 +11,16 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: python-version:
- "3.8"
- "3.9" - "3.9"
- "3.10" - "3.10"
- "3.11" - "3.11"
- "3.12" - "3.12"
- "3.13" - "3.13"
- "pypy-3.8"
- "pypy-3.9" - "pypy-3.9"
- "pypy-3.10" - "pypy-3.10"
urllib3-requirement: urllib3-requirement:
@@ -30,10 +28,6 @@ jobs:
- "urllib3<2" - "urllib3<2"
exclude: exclude:
- python-version: "3.8"
urllib3-requirement: "urllib3>=2"
- python-version: "pypy-3.8"
urllib3-requirement: "urllib3>=2"
- python-version: "3.9" - python-version: "3.9"
urllib3-requirement: "urllib3>=2" urllib3-requirement: "urllib3>=2"
- python-version: "pypy-3.9" - python-version: "pypy-3.9"
@@ -43,6 +37,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
@@ -53,9 +49,16 @@ jobs:
- name: Install project dependencies - name: Install project dependencies
run: | run: |
pip install --upgrade pip setuptools uv pip install --system --upgrade pip setuptools
pip install codecov '.[tests]' '${{ matrix.urllib3-requirement }}' uv pip install --system codecov '.[tests]' '${{ matrix.urllib3-requirement }}'
pip check uv pip check
- name: Allow creation of user namespaces (e.g. to the unshare command)
run: |
# .. so that we don't get error:
# unshare: write failed /proc/self/uid_map: Operation not permitted
# Idea from https://github.com/YoYoGames/GameMaker-Bugs/issues/6015#issuecomment-2135552784 .
sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0
- name: Run online tests - name: Run online tests
run: ./runtests.sh --cov=./vcr --cov-branch --cov-report=xml --cov-append -m online run: ./runtests.sh --cov=./vcr --cov-branch --cov-report=xml --cov-append -m online

View File

@@ -16,7 +16,7 @@ permissions:
jobs: jobs:
pre_commit_detect_outdated: pre_commit_detect_outdated:
name: Detect outdated pre-commit hooks name: Detect outdated pre-commit hooks
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -11,7 +11,7 @@ on:
jobs: jobs:
pre-commit: pre-commit:
name: Run pre-commit name: Run pre-commit
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5

View File

@@ -3,14 +3,14 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.6 rev: v0.11.2
hooks: hooks:
- id: ruff - id: ruff
args: ["--output-format=full"] args: ["--output-format=full"]
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v5.0.0
hooks: hooks:
- id: check-merge-conflict - id: check-merge-conflict
- id: end-of-file-fixer - id: end-of-file-fixer

View File

@@ -7,7 +7,7 @@ version: 2
# Set the version of Python and other tools you might need # Set the version of Python and other tools you might need
build: build:
os: ubuntu-22.04 os: ubuntu-24.04
tools: tools:
python: "3.12" python: "3.12"

View File

@@ -427,3 +427,16 @@ If you want to save the cassette only when the test succeeds, set the Cassette
# Since there was an exception, the cassette file hasn't been created. # Since there was an exception, the cassette file hasn't been created.
assert not os.path.exists('fixtures/vcr_cassettes/synopsis.yaml') assert not os.path.exists('fixtures/vcr_cassettes/synopsis.yaml')
Drop unused requests
--------------------
Even if any HTTP request is changed or removed from tests, previously recorded
interactions remain in the cassette file. If set the ``drop_unused_requests``
option to ``True``, VCR will not save old HTTP interactions if they are not used.
.. code:: python
my_vcr = VCR(drop_unused_requests=True)
with my_vcr.use_cassette('fixtures/vcr_cassettes/synopsis.yaml'):
... # your HTTP interactions here

View File

@@ -7,6 +7,11 @@ 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.
- 7.0.0
- Drop support for python 3.8 (major version bump) - thanks @jairhenrique
- Various linting and test fixes - thanks @jairhenrique
- Bugfix for urllib2>=2.3.0 - missing version_string (#888)
- Bugfix for asyncio.run - thanks @alekeik1
- 6.0.2 - 6.0.2
- Ensure body is consumed only once (#846) - thanks @sathieu - Ensure body is consumed only once (#846) - thanks @sathieu
- Permit urllib3 2.x for non-PyPy Python >=3.10 - Permit urllib3 2.x for non-PyPy Python >=3.10

View File

@@ -316,5 +316,5 @@ texinfo_documents = [
# Example configuration for intersphinx: refer to the Python standard library. # Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"https://docs.python.org/": None} intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
html_theme = "alabaster" html_theme = "alabaster"

View File

@@ -9,7 +9,7 @@ with pip::
Compatibility Compatibility
------------- -------------
VCR.py supports Python 3.8+, and `pypy <http://pypy.org>`__. VCR.py supports Python 3.9+, and `pypy <http://pypy.org>`__.
The following HTTP libraries are supported: The following HTTP libraries are supported:

View File

@@ -1,2 +1,2 @@
sphinx<8 sphinx<9
sphinx_rtd_theme==2.0.0 sphinx_rtd_theme==3.0.2

View File

@@ -1,21 +1,17 @@
[tool.codespell] [tool.codespell]
skip = '.git,*.pdf,*.svg,.tox' skip = '.git,*.pdf,*.svg,.tox'
ignore-regex = "\\\\[fnrstv]" ignore-regex = "\\\\[fnrstv]"
#
# ignore-words-list = ''
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = [ addopts = ["--strict-config", "--strict-markers"]
"--strict-config", asyncio_default_fixture_loop_scope = "function"
"--strict-markers",
]
markers = ["online"] markers = ["online"]
filterwarnings = [
"error",
'''ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version.*:DeprecationWarning''',
]
[tool.ruff] [tool.ruff]
line-length = 110
target-version = "py39"
[tool.ruff.lint]
select = [ select = [
"B", # flake8-bugbear "B", # flake8-bugbear
"C4", # flake8-comprehensions "C4", # flake8-comprehensions
@@ -29,8 +25,6 @@ select = [
"UP", # pyupgrade "UP", # pyupgrade
"W", # pycodestyle warning "W", # pycodestyle warning
] ]
line-length = 110
target-version = "py38"
[tool.ruff.isort] [tool.ruff.lint.isort]
known-first-party = ["vcr"] known-first-party = ["vcr"]

View File

@@ -78,7 +78,7 @@ setup(
author_email="me@kevinmccarthy.org", author_email="me@kevinmccarthy.org",
url="https://github.com/kevin1024/vcrpy", url="https://github.com/kevin1024/vcrpy",
packages=find_packages(exclude=["tests*"]), packages=find_packages(exclude=["tests*"]),
python_requires=">=3.8", python_requires=">=3.9",
install_requires=install_requires, install_requires=install_requires,
license="MIT", license="MIT",
extras_require=extras_require, extras_require=extras_require,
@@ -89,11 +89,11 @@ setup(
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",

View File

@@ -4,6 +4,7 @@ import urllib.parse
import pytest import pytest
import pytest_httpbin.certs import pytest_httpbin.certs
import yarl
import vcr import vcr
@@ -403,7 +404,7 @@ def test_cookies_redirect(httpbin_both, tmpdir):
async with aiohttp.ClientSession(loop=loop, cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: async with aiohttp.ClientSession(loop=loop, cookie_jar=aiohttp.CookieJar(unsafe=True)) as session:
cookies_resp = await session.get(cookies_url, ssl=HTTPBIN_SSL_CONTEXT) cookies_resp = await session.get(cookies_url, ssl=HTTPBIN_SSL_CONTEXT)
assert not cookies_resp.cookies assert not cookies_resp.cookies
cookies = session.cookie_jar.filter_cookies(cookies_url) cookies = session.cookie_jar.filter_cookies(yarl.URL(cookies_url))
assert cookies["Cookie_1"].value == "Val_1" assert cookies["Cookie_1"].value == "Val_1"
assert cassette.play_count == 0 assert cassette.play_count == 0
@@ -414,7 +415,7 @@ def test_cookies_redirect(httpbin_both, tmpdir):
async with aiohttp.ClientSession(loop=loop, cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: async with aiohttp.ClientSession(loop=loop, cookie_jar=aiohttp.CookieJar(unsafe=True)) as session:
cookies_resp = await session.get(cookies_url, ssl=HTTPBIN_SSL_CONTEXT) cookies_resp = await session.get(cookies_url, ssl=HTTPBIN_SSL_CONTEXT)
assert not cookies_resp.cookies assert not cookies_resp.cookies
cookies = session.cookie_jar.filter_cookies(cookies_url) cookies = session.cookie_jar.filter_cookies(yarl.URL(cookies_url))
assert cookies["Cookie_1"].value == "Val_1" assert cookies["Cookie_1"].value == "Val_1"
assert cassette.play_count == 2 assert cassette.play_count == 2
@@ -428,7 +429,7 @@ def test_cookies_redirect(httpbin_both, tmpdir):
async with aiohttp.ClientSession(loop=loop, cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: async with aiohttp.ClientSession(loop=loop, cookie_jar=aiohttp.CookieJar(unsafe=True)) as session:
cookies_resp = await session.get(cookies_url, ssl=HTTPBIN_SSL_CONTEXT) cookies_resp = await session.get(cookies_url, ssl=HTTPBIN_SSL_CONTEXT)
assert not cookies_resp.cookies assert not cookies_resp.cookies
cookies = session.cookie_jar.filter_cookies(cookies_url) cookies = session.cookie_jar.filter_cookies(yarl.URL(cookies_url))
assert cookies["Cookie_1"].value == "Val_1" assert cookies["Cookie_1"].value == "Val_1"
run_in_loop(run) run_in_loop(run)

View File

@@ -5,6 +5,7 @@ from urllib.request import urlopen
import pytest import pytest
import vcr import vcr
from vcr.cassette import Cassette
@pytest.mark.online @pytest.mark.online
@@ -85,3 +86,21 @@ def test_dont_record_on_exception(tmpdir, httpbin):
assert b"Not in content" in urlopen(httpbin.url).read() assert b"Not in content" in urlopen(httpbin.url).read()
assert not os.path.exists(str(tmpdir.join("dontsave2.yml"))) assert not os.path.exists(str(tmpdir.join("dontsave2.yml")))
def test_set_drop_unused_requests(tmpdir, httpbin):
my_vcr = vcr.VCR(drop_unused_requests=True)
file = str(tmpdir.join("test.yaml"))
with my_vcr.use_cassette(file):
urlopen(httpbin.url)
urlopen(httpbin.url + "/get")
cassette = Cassette.load(path=file)
assert len(cassette) == 2
with my_vcr.use_cassette(file):
urlopen(httpbin.url)
cassette = Cassette.load(path=file)
assert len(cassette) == 1

View File

@@ -1,5 +1,6 @@
"""Test using a proxy.""" """Test using a proxy."""
import asyncio
import http.server import http.server
import socketserver import socketserver
import threading import threading
@@ -36,6 +37,35 @@ class Proxy(http.server.SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.copyfile(upstream_response, self.wfile) self.copyfile(upstream_response, self.wfile)
def do_CONNECT(self):
host, port = self.path.split(":")
asyncio.run(self._tunnel(host, port, self.connection))
async def _tunnel(self, host, port, client_sock):
target_r, target_w = await asyncio.open_connection(host=host, port=port)
self.send_response(http.HTTPStatus.OK)
self.end_headers()
source_r, source_w = await asyncio.open_connection(sock=client_sock)
async def channel(reader, writer):
while True:
data = await reader.read(1024)
if not data:
break
writer.write(data)
await writer.drain()
writer.close()
await writer.wait_closed()
await asyncio.gather(
channel(target_r, source_w),
channel(source_r, target_w),
)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def proxy_server(): def proxy_server():
@@ -52,10 +82,26 @@ def test_use_proxy(tmpdir, httpbin, proxy_server):
with vcr.use_cassette(str(tmpdir.join("proxy.yaml"))): with vcr.use_cassette(str(tmpdir.join("proxy.yaml"))):
response = requests.get(httpbin.url, proxies={"http": proxy_server}) response = requests.get(httpbin.url, proxies={"http": proxy_server})
with vcr.use_cassette(str(tmpdir.join("proxy.yaml")), mode="once") as cassette: with vcr.use_cassette(str(tmpdir.join("proxy.yaml")), mode="none") as cassette:
cassette_response = requests.get(httpbin.url, proxies={"http": proxy_server}) cassette_response = requests.get(httpbin.url, proxies={"http": proxy_server})
for key in set(cassette_response.headers.keys()) & set(response.headers.keys()):
assert cassette_response.headers[key] == response.headers[key]
assert cassette_response.headers == response.headers assert cassette_response.headers == response.headers
assert cassette.play_count == 1 assert cassette.play_count == 1
def test_use_https_proxy(tmpdir, httpbin_secure, proxy_server):
"""Ensure that it works with an HTTPS proxy."""
with vcr.use_cassette(str(tmpdir.join("proxy.yaml"))):
response = requests.get(httpbin_secure.url, proxies={"https": proxy_server})
with vcr.use_cassette(str(tmpdir.join("proxy.yaml")), mode="none") as cassette:
cassette_response = requests.get(
httpbin_secure.url,
proxies={"https": proxy_server},
)
assert cassette_response.headers == response.headers
assert cassette.play_count == 1
# The cassette URL points to httpbin, not the proxy
assert cassette.requests[0].url == httpbin_secure.url + "/"

View File

@@ -11,6 +11,7 @@ import yaml
from vcr.cassette import Cassette from vcr.cassette import Cassette
from vcr.errors import UnhandledHTTPRequestError from vcr.errors import UnhandledHTTPRequestError
from vcr.patch import force_reset from vcr.patch import force_reset
from vcr.request import Request
from vcr.stubs import VCRHTTPSConnection from vcr.stubs import VCRHTTPSConnection
@@ -410,3 +411,25 @@ def test_find_requests_with_most_matches_many_similar_requests(mock_get_matchers
(1, ["method", "path"], [("query", "failed : query")]), (1, ["method", "path"], [("query", "failed : query")]),
(3, ["method", "path"], [("query", "failed : query")]), (3, ["method", "path"], [("query", "failed : query")]),
] ]
def test_used_interactions(tmpdir):
interactions = [
{"request": {"body": "", "uri": "foo1", "method": "GET", "headers": {}}, "response": "bar1"},
{"request": {"body": "", "uri": "foo2", "method": "GET", "headers": {}}, "response": "bar2"},
{"request": {"body": "", "uri": "foo3", "method": "GET", "headers": {}}, "response": "bar3"},
]
file = tmpdir.join("test_cassette.yml")
file.write(yaml.dump({"interactions": [interactions[0], interactions[1]]}))
cassette = Cassette.load(path=str(file))
request = Request._from_dict(interactions[1]["request"])
cassette.play_response(request)
assert len(cassette._played_interactions) < len(cassette._old_interactions)
request = Request._from_dict(interactions[2]["request"])
cassette.append(request, interactions[2]["response"])
assert len(cassette._new_interactions()) == 1
used_interactions = cassette._played_interactions + cassette._new_interactions()
assert len(used_interactions) == 2

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__ = "6.0.2" __version__ = "7.0.0"
logging.getLogger(__name__).addHandler(NullHandler()) logging.getLogger(__name__).addHandler(NullHandler())

View File

@@ -3,7 +3,7 @@ import contextlib
import copy import copy
import inspect import inspect
import logging import logging
from asyncio import iscoroutinefunction from inspect import iscoroutinefunction
import wrapt import wrapt
@@ -177,6 +177,7 @@ class Cassette:
custom_patches=(), custom_patches=(),
inject=False, inject=False,
allow_playback_repeats=False, allow_playback_repeats=False,
drop_unused_requests=False,
): ):
self._persister = persister or FilesystemPersister self._persister = persister or FilesystemPersister
self._path = path self._path = path
@@ -189,6 +190,7 @@ class Cassette:
self.record_mode = record_mode self.record_mode = record_mode
self.custom_patches = custom_patches self.custom_patches = custom_patches
self.allow_playback_repeats = allow_playback_repeats self.allow_playback_repeats = allow_playback_repeats
self.drop_unused_requests = drop_unused_requests
# self.data is the list of (req, resp) tuples # self.data is the list of (req, resp) tuples
self.data = [] self.data = []
@@ -196,6 +198,10 @@ class Cassette:
self.dirty = False self.dirty = False
self.rewound = False self.rewound = False
# Subsets of self.data to store old and played interactions
self._old_interactions = []
self._played_interactions = []
@property @property
def play_count(self): def play_count(self):
return sum(self.play_counts.values()) return sum(self.play_counts.values())
@@ -215,7 +221,7 @@ class Cassette:
@property @property
def write_protected(self): def write_protected(self):
return self.rewound and self.record_mode == RecordMode.ONCE or self.record_mode == RecordMode.NONE return (self.rewound and self.record_mode == RecordMode.ONCE) or self.record_mode == RecordMode.NONE
def append(self, request, response): def append(self, request, response):
"""Add a request, response pair to this cassette""" """Add a request, response pair to this cassette"""
@@ -257,6 +263,7 @@ class Cassette:
for index, response in self._responses(request): for index, response in self._responses(request):
if self.play_counts[index] == 0 or self.allow_playback_repeats: if self.play_counts[index] == 0 or self.allow_playback_repeats:
self.play_counts[index] += 1 self.play_counts[index] += 1
self._played_interactions.append((request, response))
return response return response
# The cassette doesn't contain the request asked for. # The cassette doesn't contain the request asked for.
raise UnhandledHTTPRequestError( raise UnhandledHTTPRequestError(
@@ -317,12 +324,36 @@ class Cassette:
return final_best_matches return final_best_matches
def _new_interactions(self):
"""List of new HTTP interactions (request/response tuples)"""
new_interactions = []
for request, response in self.data:
if all(
not requests_match(request, old_request, self._match_on)
for old_request, _ in self._old_interactions
):
new_interactions.append((request, response))
return new_interactions
def _as_dict(self): def _as_dict(self):
return {"requests": self.requests, "responses": self.responses} return {"requests": self.requests, "responses": self.responses}
def _build_used_interactions_dict(self):
interactions = self._played_interactions + self._new_interactions()
cassete_dict = {
"requests": [request for request, _ in interactions],
"responses": [response for _, response in interactions],
}
return cassete_dict
def _save(self, force=False): def _save(self, force=False):
if self.drop_unused_requests and len(self._played_interactions) < len(self._old_interactions):
cassete_dict = self._build_used_interactions_dict()
force = True
else:
cassete_dict = self._as_dict()
if force or self.dirty: if force or self.dirty:
self._persister.save_cassette(self._path, self._as_dict(), serializer=self._serializer) self._persister.save_cassette(self._path, cassete_dict, serializer=self._serializer)
self.dirty = False self.dirty = False
def _load(self): def _load(self):
@@ -330,6 +361,7 @@ class Cassette:
requests, responses = self._persister.load_cassette(self._path, serializer=self._serializer) requests, responses = self._persister.load_cassette(self._path, serializer=self._serializer)
for request, response in zip(requests, responses): for request, response in zip(requests, responses):
self.append(request, response) self.append(request, response)
self._old_interactions.append((request, response))
self.dirty = False self.dirty = False
self.rewound = True self.rewound = True
except (CassetteDecodeError, CassetteNotFoundError): except (CassetteDecodeError, CassetteNotFoundError):

View File

@@ -48,6 +48,7 @@ class VCR:
func_path_generator=None, func_path_generator=None,
decode_compressed_response=False, decode_compressed_response=False,
record_on_exception=True, record_on_exception=True,
drop_unused_requests=False,
): ):
self.serializer = serializer self.serializer = serializer
self.match_on = match_on self.match_on = match_on
@@ -81,6 +82,7 @@ class VCR:
self.decode_compressed_response = decode_compressed_response self.decode_compressed_response = decode_compressed_response
self.record_on_exception = record_on_exception self.record_on_exception = record_on_exception
self._custom_patches = tuple(custom_patches) self._custom_patches = tuple(custom_patches)
self.drop_unused_requests = drop_unused_requests
def _get_serializer(self, serializer_name): def _get_serializer(self, serializer_name):
try: try:
@@ -151,6 +153,7 @@ class VCR:
"func_path_generator": func_path_generator, "func_path_generator": func_path_generator,
"allow_playback_repeats": kwargs.get("allow_playback_repeats", False), "allow_playback_repeats": kwargs.get("allow_playback_repeats", False),
"record_on_exception": record_on_exception, "record_on_exception": record_on_exception,
"drop_unused_requests": kwargs.get("drop_unused_requests", self.drop_unused_requests),
} }
path = kwargs.get("path") path = kwargs.get("path")
if path: if path:

View File

@@ -3,11 +3,10 @@ import logging
import urllib import urllib
import xmlrpc.client import xmlrpc.client
from string import hexdigits from string import hexdigits
from typing import List, Set
from .util import read_body from .util import read_body
_HEXDIG_CODE_POINTS: Set[int] = {ord(s.encode("ascii")) for s in hexdigits} _HEXDIG_CODE_POINTS: set[int] = {ord(s.encode("ascii")) for s in hexdigits}
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -109,7 +108,7 @@ def _dechunk(body):
CHUNK_GAP = b"\r\n" CHUNK_GAP = b"\r\n"
BODY_LEN: int = len(body) BODY_LEN: int = len(body)
chunks: List[bytes] = [] chunks: list[bytes] = []
pos: int = 0 pos: int = 0
while True: while True:

View File

@@ -27,6 +27,15 @@ class Request:
self.headers = headers self.headers = headers
log.debug("Invoking Request %s", self.uri) log.debug("Invoking Request %s", self.uri)
@property
def uri(self):
return self._uri
@uri.setter
def uri(self, uri):
self._uri = uri
self.parsed_uri = urlparse(uri)
@property @property
def headers(self): def headers(self):
return self._headers return self._headers
@@ -61,30 +70,29 @@ class Request:
@property @property
def scheme(self): def scheme(self):
return urlparse(self.uri).scheme return self.parsed_uri.scheme
@property @property
def host(self): def host(self):
return urlparse(self.uri).hostname return self.parsed_uri.hostname
@property @property
def port(self): def port(self):
parse_uri = urlparse(self.uri) port = self.parsed_uri.port
port = parse_uri.port
if port is None: if port is None:
try: try:
port = {"https": 443, "http": 80}[parse_uri.scheme] port = {"https": 443, "http": 80}[self.parsed_uri.scheme]
except KeyError: except KeyError:
pass pass
return port return port
@property @property
def path(self): def path(self):
return urlparse(self.uri).path return self.parsed_uri.path
@property @property
def query(self): def query(self):
q = urlparse(self.uri).query q = self.parsed_uri.query
return sorted(parse_qsl(q)) return sorted(parse_qsl(q))
# alias for backwards compatibility # alias for backwards compatibility

View File

@@ -66,6 +66,7 @@ class VCRHTTPResponse(HTTPResponse):
self.reason = recorded_response["status"]["message"] self.reason = recorded_response["status"]["message"]
self.status = self.code = recorded_response["status"]["code"] self.status = self.code = recorded_response["status"]["code"]
self.version = None self.version = None
self.version_string = None
self._content = BytesIO(self.recorded_response["body"]["string"]) self._content = BytesIO(self.recorded_response["body"]["string"])
self._closed = False self._closed = False
self._original_response = self # for requests.session.Session cookie extraction self._original_response = self # for requests.session.Session cookie extraction
@@ -186,22 +187,34 @@ class VCRConnection:
""" """
Returns empty string for the default port and ':port' otherwise Returns empty string for the default port and ':port' otherwise
""" """
port = self.real_connection.port port = (
self.real_connection.port
if not self.real_connection._tunnel_host
else self.real_connection._tunnel_port
)
default_port = {"https": 443, "http": 80}[self._protocol] default_port = {"https": 443, "http": 80}[self._protocol]
return f":{port}" if port != default_port else "" return f":{port}" if port != default_port else ""
def _real_host(self):
"""Returns the request host"""
if self.real_connection._tunnel_host:
# The real connection is to an HTTPS proxy
return self.real_connection._tunnel_host
else:
return self.real_connection.host
def _uri(self, url): def _uri(self, url):
"""Returns request absolute URI""" """Returns request absolute URI"""
if url and not url.startswith("/"): if url and not url.startswith("/"):
# Then this must be a proxy request. # Then this must be a proxy request.
return url return url
uri = f"{self._protocol}://{self.real_connection.host}{self._port_postfix()}{url}" uri = f"{self._protocol}://{self._real_host()}{self._port_postfix()}{url}"
log.debug("Absolute URI: %s", uri) log.debug("Absolute URI: %s", uri)
return uri return uri
def _url(self, uri): def _url(self, uri):
"""Returns request selector url from absolute URI""" """Returns request selector url from absolute URI"""
prefix = f"{self._protocol}://{self.real_connection.host}{self._port_postfix()}" prefix = f"{self._protocol}://{self._real_host()}{self._port_postfix()}"
return uri.replace(prefix, "", 1) return uri.replace(prefix, "", 1)
def request(self, method, url, body=None, headers=None, *args, **kwargs): def request(self, method, url, body=None, headers=None, *args, **kwargs):

View File

@@ -4,8 +4,9 @@ import asyncio
import functools import functools
import json import json
import logging import logging
from collections.abc import Mapping
from http.cookies import CookieError, Morsel, SimpleCookie from http.cookies import CookieError, Morsel, SimpleCookie
from typing import Mapping, Union from typing import Union
from aiohttp import ClientConnectionError, ClientResponse, CookieJar, RequestInfo, hdrs, streams from aiohttp import ClientConnectionError, ClientResponse, CookieJar, RequestInfo, hdrs, streams
from aiohttp.helpers import strip_auth_from_url from aiohttp.helpers import strip_auth_from_url

View File

@@ -166,6 +166,22 @@ def async_vcr_send(cassette, real_send):
return _inner_send return _inner_send
def _run_async_function(sync_func, *args, **kwargs):
"""
Safely run an asynchronous function from a synchronous context.
Handles both cases:
- An event loop is already running.
- No event loop exists yet.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(sync_func(*args, **kwargs))
else:
# If inside a running loop, create a task and wait for it
return asyncio.ensure_future(sync_func(*args, **kwargs))
def _sync_vcr_send(cassette, real_send, *args, **kwargs): def _sync_vcr_send(cassette, real_send, *args, **kwargs):
vcr_request, response = _shared_vcr_send(cassette, real_send, *args, **kwargs) vcr_request, response = _shared_vcr_send(cassette, real_send, *args, **kwargs)
if response: if response:
@@ -174,7 +190,7 @@ def _sync_vcr_send(cassette, real_send, *args, **kwargs):
return response return response
real_response = real_send(*args, **kwargs) real_response = real_send(*args, **kwargs)
asyncio.run(_record_responses(cassette, vcr_request, real_response, aread=False)) _run_async_function(_record_responses, cassette, vcr_request, real_response, aread=False)
return real_response return real_response