From 3da192e3253c57eb69bc9b46d09154e37abcd8aa Mon Sep 17 00:00:00 2001 From: joshuarli Date: Mon, 23 Mar 2026 14:02:32 -0700 Subject: [PATCH 01/13] chore: pin GitHub Actions to full-length commit SHAs (#789) --- .github/workflows/build.yml | 6 +++--- .github/workflows/ci.yml | 8 ++++---- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/release.yml | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18ad1d29..98b0d029 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,15 +12,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 + - uses: actions/setup-python@e9aba2c848f5ebd159c070c61ea2c4e2b122355e # v2 with: python-version: 3.9 - run: | pip install wheel python setup.py bdist_wheel sdist --formats=gztar - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ github.sha }} path: dist/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3225254a..358a6a31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 - name: Install dependencies run: | python -m pip install tox @@ -37,11 +37,11 @@ jobs: steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 with: fetch-depth: 1 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: ${{ matrix.python-version }} @@ -58,4 +58,4 @@ jobs: - name: Code Coverage Report if: success() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457 # v3 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 821650c0..97b84153 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@8dca8a82e2fa1a2c8908956f711300f9c4a4f4f6 # v2 with: languages: ${{ matrix.language }} @@ -36,7 +36,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@8dca8a82e2fa1a2c8908956f711300f9c4a4f4f6 # v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -49,6 +49,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@8dca8a82e2fa1a2c8908956f711300f9c4a4f4f6 # v2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index f6afabd4..c6730122 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,6 +11,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Enforce License Compliance' - uses: getsentry/action-enforce-license-compliance@main + uses: getsentry/action-enforce-license-compliance@48236a773346cb6552a7bda1ee370d2797365d87 # main with: fossa_api_key: ${{ secrets.FOSSA_API_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 557c725b..25aab7d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v2 + - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 From 84c2b081402f61eaf25ef9b27b0bc425bb48479e Mon Sep 17 00:00:00 2001 From: jlariza Date: Fri, 10 Apr 2026 14:18:56 -0500 Subject: [PATCH 02/13] Add Spanish translation of the README documentation (#790) * Adds spanish documentation * Adds spanish documentation to CHANGES --- CHANGES | 2 + README.es.rst | 1560 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1562 insertions(+) create mode 100644 README.es.rst diff --git a/CHANGES b/CHANGES index dc0c08e0..4e8a06bd 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,5 @@ +* Added Spanish translation of the README (``README.es.rst``) + 0.26.0 ------ diff --git a/README.es.rst b/README.es.rst new file mode 100644 index 00000000..920f0270 --- /dev/null +++ b/README.es.rst @@ -0,0 +1,1560 @@ +Responses +========= + +.. image:: https://img.shields.io/pypi/v/responses.svg + :target: https://pypi.python.org/pypi/responses/ + +.. image:: https://img.shields.io/pypi/pyversions/responses.svg + :target: https://pypi.org/project/responses/ + +.. image:: https://img.shields.io/pypi/dm/responses + :target: https://pypi.python.org/pypi/responses/ + +.. image:: https://codecov.io/gh/getsentry/responses/branch/master/graph/badge.svg + :target: https://codecov.io/gh/getsentry/responses/ + +Una biblioteca utilitaria para simular (*mock*) la biblioteca Python ``requests``. + +.. note:: + + Responses requiere Python 3.8 o superior, y requests >= 2.30.0 + + +Tabla de Contenidos +------------------- + +.. contents:: + + +Instalación +----------- + +``pip install responses`` + + +Deprecaciones y Ruta de Migración +---------------------------------- + +Aquí encontrarás una lista de funcionalidades deprecadas y la ruta de migración para cada una. +Asegúrate de actualizar tu código según las indicaciones. + +.. list-table:: Deprecaciones y Migración + :widths: 50 25 50 + :header-rows: 1 + + * - Funcionalidad Deprecada + - Deprecada en Versión + - Ruta de Migración + * - ``responses.json_params_matcher`` + - 0.14.0 + - ``responses.matchers.json_params_matcher`` + * - ``responses.urlencoded_params_matcher`` + - 0.14.0 + - ``responses.matchers.urlencoded_params_matcher`` + * - argumento ``stream`` en ``Response`` y ``CallbackResponse`` + - 0.15.0 + - Usa el argumento ``stream`` directamente en la solicitud. + * - argumento ``match_querystring`` en ``Response`` y ``CallbackResponse``. + - 0.17.0 + - Usa ``responses.matchers.query_param_matcher`` o ``responses.matchers.query_string_matcher`` + * - ``responses.assert_all_requests_are_fired``, ``responses.passthru_prefixes``, ``responses.target`` + - 0.20.0 + - Usa ``responses.mock.assert_all_requests_are_fired``, + ``responses.mock.passthru_prefixes``, ``responses.mock.target`` en su lugar. + +Conceptos Básicos +----------------- + +El núcleo de ``responses`` consiste en registrar respuestas simuladas y envolver la función de +prueba con el decorador ``responses.activate``. ``responses`` ofrece una interfaz similar a +``requests``. + +Interfaz Principal +^^^^^^^^^^^^^^^^^^ + +* responses.add(``Response`` o argumentos de ``Response``) — permite registrar un objeto + ``Response`` o proporcionar directamente los argumentos del objeto ``Response``. + Ver `Parámetros de Response`_ + +.. code-block:: python + + import responses + import requests + + + @responses.activate + def test_simple(): + # Registro mediante objeto 'Response' + rsp1 = responses.Response( + method="PUT", + url="http://example.com", + ) + responses.add(rsp1) + # Registro mediante argumentos directos + responses.add( + responses.GET, + "http://twitter.com/api/1/foobar", + json={"error": "not found"}, + status=404, + ) + + resp = requests.get("http://twitter.com/api/1/foobar") + resp2 = requests.put("http://example.com") + + assert resp.json() == {"error": "not found"} + assert resp.status_code == 404 + + assert resp2.status_code == 200 + assert resp2.request.method == "PUT" + + +Si intentas acceder a una URL que no coincide con ninguna registrada, ``responses`` +lanzará un ``ConnectionError``: + +.. code-block:: python + + import responses + import requests + + from requests.exceptions import ConnectionError + + + @responses.activate + def test_simple(): + with pytest.raises(ConnectionError): + requests.get("http://twitter.com/api/1/foobar") + + +Atajos +^^^^^^ + +Los atajos ofrecen una versión abreviada de ``responses.add()`` donde el argumento del +método ya viene predefinido. + +* responses.delete(``argumentos de Response``) — registra una respuesta DELETE +* responses.get(``argumentos de Response``) — registra una respuesta GET +* responses.head(``argumentos de Response``) — registra una respuesta HEAD +* responses.options(``argumentos de Response``) — registra una respuesta OPTIONS +* responses.patch(``argumentos de Response``) — registra una respuesta PATCH +* responses.post(``argumentos de Response``) — registra una respuesta POST +* responses.put(``argumentos de Response``) — registra una respuesta PUT + +.. code-block:: python + + import responses + import requests + + + @responses.activate + def test_simple(): + responses.get( + "http://twitter.com/api/1/foobar", + json={"type": "get"}, + ) + + responses.post( + "http://twitter.com/api/1/foobar", + json={"type": "post"}, + ) + + responses.patch( + "http://twitter.com/api/1/foobar", + json={"type": "patch"}, + ) + + resp_get = requests.get("http://twitter.com/api/1/foobar") + resp_post = requests.post("http://twitter.com/api/1/foobar") + resp_patch = requests.patch("http://twitter.com/api/1/foobar") + + assert resp_get.json() == {"type": "get"} + assert resp_post.json() == {"type": "post"} + assert resp_patch.json() == {"type": "patch"} + +Responses como context manager +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +En lugar de envolver toda la función con un decorador, puedes usar un context manager. + +.. code-block:: python + + import responses + import requests + + + def test_my_api(): + with responses.RequestsMock() as rsps: + rsps.add( + responses.GET, + "http://twitter.com/api/1/foobar", + body="{}", + status=200, + content_type="application/json", + ) + resp = requests.get("http://twitter.com/api/1/foobar") + + assert resp.status_code == 200 + + # fuera del context manager, las solicitudes llegan al servidor real + resp = requests.get("http://twitter.com/api/1/foobar") + resp.status_code == 404 + + +Parámetros de Response +----------------------- + +Los siguientes atributos pueden pasarse a un mock de Response: + +method (``str``) + El método HTTP (GET, POST, etc.). + +url (``str`` o ``expresión regular compilada``) + La URL completa del recurso. + +match_querystring (``bool``) + DEPRECADO: Usa ``responses.matchers.query_param_matcher`` o + ``responses.matchers.query_string_matcher`` + + Incluye la cadena de consulta (*query string*) al comparar solicitudes. + Activado por defecto si la URL de respuesta contiene una cadena de consulta; + desactivado si no la contiene o si la URL es una expresión regular. + +body (``str`` o ``BufferedReader`` o ``Exception``) + El cuerpo de la respuesta. Lee más en `Excepción como cuerpo de Response`_ + +json + Un objeto Python que representa el cuerpo de la respuesta en formato JSON. + Configura automáticamente el ``Content-Type`` apropiado. + +status (``int``) + El código de estado HTTP. + +content_type (``content_type``) + Por defecto es ``text/plain``. + +headers (``dict``) + Cabeceras de la respuesta. + +stream (``bool``) + DEPRECADO: usa el argumento ``stream`` directamente en la solicitud. + +auto_calculate_content_length (``bool``) + Desactivado por defecto. Calcula automáticamente la longitud de un cuerpo de tipo + cadena o JSON. + +match (``tuple``) + Un iterable (se recomienda ``tuple``) de callbacks para comparar solicitudes en + función de sus atributos. + El módulo proporciona múltiples comparadores que puedes utilizar para verificar: + + * contenido del cuerpo en formato JSON + * contenido del cuerpo en formato URL-encoded + * parámetros de consulta de la solicitud + * cadena de consulta de la solicitud (similar a los parámetros de consulta, pero acepta una cadena como entrada) + * kwargs proporcionados a la solicitud, por ejemplo ``stream``, ``verify`` + * contenido y cabeceras de tipo ``multipart/form-data`` en la solicitud + * cabeceras de la solicitud + * identificador de fragmento de la solicitud + + El usuario también puede crear un comparador personalizado. + Lee más en `Comparación de Solicitudes`_ + + +Excepción como cuerpo de Response +----------------------------------- + +Puedes pasar una ``Exception`` como cuerpo para provocar un error en la solicitud: + +.. code-block:: python + + import responses + import requests + + + @responses.activate + def test_simple(): + responses.get("http://twitter.com/api/1/foobar", body=Exception("...")) + with pytest.raises(Exception): + requests.get("http://twitter.com/api/1/foobar") + + +Comparación de Solicitudes +--------------------------- + +Comparación del Contenido del Cuerpo de la Solicitud +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Al agregar respuestas para endpoints que reciben datos en la solicitud, puedes añadir +comparadores para asegurarte de que tu código envía los parámetros correctos y para +ofrecer respuestas diferentes según el contenido del cuerpo. ``responses`` ofrece +comparadores para cuerpos de solicitud en formato JSON y URL-encoded. + +Datos URL-encoded +""""""""""""""""" + +.. code-block:: python + + import responses + import requests + from responses import matchers + + + @responses.activate + def test_calc_api(): + responses.post( + url="http://calc.com/sum", + body="4", + match=[matchers.urlencoded_params_matcher({"left": "1", "right": "3"})], + ) + requests.post("http://calc.com/sum", data={"left": 1, "right": 3}) + + +Datos JSON +"""""""""" + +La comparación de datos codificados en JSON se realiza con ``matchers.json_params_matcher()``. + +.. code-block:: python + + import responses + import requests + from responses import matchers + + + @responses.activate + def test_calc_api(): + responses.post( + url="http://example.com/", + body="one", + match=[ + matchers.json_params_matcher({"page": {"name": "first", "type": "json"}}) + ], + ) + resp = requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "application/json"}, + json={"page": {"name": "first", "type": "json"}}, + ) + + +Comparador de Parámetros de Consulta +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Parámetros de Consulta como Diccionario +"""""""""""""""""""""""""""""""""""""""" + +Puedes usar la función ``matchers.query_param_matcher`` para comparar contra el +parámetro ``params`` de la solicitud. Utiliza el mismo diccionario que usarías en el +argumento ``params`` de ``request``. + +Nota: no incluyas los parámetros de consulta como parte de la URL. Evita usar el +argumento deprecado ``match_querystring``. + +.. code-block:: python + + import responses + import requests + from responses import matchers + + + @responses.activate + def test_calc_api(): + url = "http://example.com/test" + params = {"hello": "world", "I am": "a big test"} + responses.get( + url=url, + body="test", + match=[matchers.query_param_matcher(params)], + ) + + resp = requests.get(url, params=params) + + constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world" + assert resp.url == constructed_url + assert resp.request.url == constructed_url + assert resp.request.params == params + +Por defecto, el comparador valida que todos los parámetros coincidan estrictamente. +Para validar que solo los parámetros especificados en el comparador estén presentes en +la solicitud original, usa ``strict_match=False``. + +Parámetros de Consulta como Cadena +"""""""""""""""""""""""""""""""""""" + +Como alternativa, puedes usar el valor de la cadena de consulta en +``matchers.query_string_matcher`` para comparar los parámetros de consulta de tu +solicitud. + +.. code-block:: python + + import requests + import responses + from responses import matchers + + + @responses.activate + def my_func(): + responses.get( + "https://httpbin.org/get", + match=[matchers.query_string_matcher("didi=pro&test=1")], + ) + resp = requests.get("https://httpbin.org/get", params={"test": 1, "didi": "pro"}) + + + my_func() + + +Comparador de Argumentos de la Solicitud +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Para validar los argumentos de la solicitud, usa la función +``matchers.request_kwargs_matcher`` para comparar contra los kwargs de la solicitud. + +Solo se admiten los siguientes argumentos: ``timeout``, ``verify``, ``proxies``, ``stream``, ``cert``. + +Nota: solo se validarán los argumentos proporcionados a ``matchers.request_kwargs_matcher``. + +.. code-block:: python + + import responses + import requests + from responses import matchers + + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + req_kwargs = { + "stream": True, + "verify": False, + } + rsps.add( + "GET", + "http://111.com", + match=[matchers.request_kwargs_matcher(req_kwargs)], + ) + + requests.get("http://111.com", stream=True) + + # >>> Los argumentos no coinciden: {stream: True, verify: True} no coinciden con {stream: True, verify: False} + + +Validación de Datos ``multipart/form-data`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Para validar el cuerpo y las cabeceras de una solicitud con datos ``multipart/form-data``, +puedes usar ``matchers.multipart_matcher``. Los parámetros ``data`` y ``files`` +proporcionados se compararán con la solicitud: + +.. code-block:: python + + import requests + import responses + from responses.matchers import multipart_matcher + + + @responses.activate + def my_func(): + req_data = {"some": "other", "data": "fields"} + req_files = {"file_name": b"Old World!"} + responses.post( + url="http://httpbin.org/post", + match=[multipart_matcher(req_files, data=req_data)], + ) + resp = requests.post("http://httpbin.org/post", files={"file_name": b"New World!"}) + + + my_func() + # >>> genera ConnectionError: multipart/form-data no coincide. El cuerpo (body) del request es diferente + +Validación del Identificador de Fragmento de la Solicitud +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Para validar el identificador de fragmento de la URL de la solicitud, puedes usar +``matchers.fragment_identifier_matcher``. El comparador toma como entrada la cadena +de fragmento (todo lo que aparece después del signo ``#``): + +.. code-block:: python + + import requests + import responses + from responses.matchers import fragment_identifier_matcher + + + @responses.activate + def run(): + url = "http://example.com?ab=xy&zed=qwe#test=1&foo=bar" + responses.get( + url, + match=[fragment_identifier_matcher("test=1&foo=bar")], + body=b"test", + ) + + # dos solicitudes para verificar el orden inverso del identificador de fragmento + resp = requests.get("http://example.com?ab=xy&zed=qwe#test=1&foo=bar") + resp = requests.get("http://example.com?zed=qwe&ab=xy#foo=bar&test=1") + + + run() + +Validación de Cabeceras de la Solicitud +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Al agregar respuestas puedes especificar comparadores para asegurarte de que tu código +envía las cabeceras correctas y para ofrecer respuestas diferentes según las cabeceras +de la solicitud. + +.. code-block:: python + + import responses + import requests + from responses import matchers + + + @responses.activate + def test_content_type(): + responses.get( + url="http://example.com/", + body="hello world", + match=[matchers.header_matcher({"Accept": "text/plain"})], + ) + + responses.get( + url="http://example.com/", + json={"content": "hello world"}, + match=[matchers.header_matcher({"Accept": "application/json"})], + ) + + # ¡solicitudes en orden inverso al que fueron agregadas! + resp = requests.get("http://example.com/", headers={"Accept": "application/json"}) + assert resp.json() == {"content": "hello world"} + + resp = requests.get("http://example.com/", headers={"Accept": "text/plain"}) + assert resp.text == "hello world" + +Dado que ``requests`` enviará varias cabeceras estándar además de las especificadas por +tu código, las cabeceras adicionales a las pasadas al comparador se ignoran por defecto. +Puedes cambiar este comportamiento pasando ``strict_match=True`` al comparador para +asegurarte de que solo se envíen exactamente las cabeceras esperadas. Ten en cuenta que +probablemente necesitarás usar un ``PreparedRequest`` en tu código para evitar que +``requests`` incluya cabeceras adicionales. + +.. code-block:: python + + import responses + import requests + from responses import matchers + + + @responses.activate + def test_content_type(): + responses.get( + url="http://example.com/", + body="hello world", + match=[matchers.header_matcher({"Accept": "text/plain"}, strict_match=True)], + ) + + # esto fallará porque requests agrega sus propias cabeceras + with pytest.raises(ConnectionError): + requests.get("http://example.com/", headers={"Accept": "text/plain"}) + + # una solicitud preparada donde se sobreescriben las cabeceras antes del envío sí funcionará + session = requests.Session() + prepped = session.prepare_request( + requests.Request( + method="GET", + url="http://example.com/", + ) + ) + prepped.headers = {"Accept": "text/plain"} + + resp = session.send(prepped) + assert resp.text == "hello world" + + +Creación de un Comparador Personalizado +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Si tu aplicación requiere otras codificaciones o una validación de datos diferente, +puedes crear tu propio comparador que devuelva ``Tuple[matches: bool, reason: str]``. +El booleano indica ``True`` o ``False`` según si los parámetros de la solicitud +coinciden, y la cadena contiene la razón en caso de fallo. Tu comparador puede esperar +recibir un parámetro ``PreparedRequest`` proporcionado por ``responses``. + +Nota: ``PreparedRequest`` está personalizado y tiene atributos adicionales ``params`` y ``req_kwargs``. + +Registro de Responses +---------------------- + +Registro por Defecto +^^^^^^^^^^^^^^^^^^^^^ + +Por defecto, ``responses`` buscará entre todos los objetos ``Response`` registrados y +devolverá la primera coincidencia. Si solo hay un ``Response`` registrado, el registro +permanece sin cambios. Sin embargo, si se encuentran múltiples coincidencias para la +misma solicitud, se devuelve la primera coincidencia y se elimina del registro. + +Registro Ordenado +^^^^^^^^^^^^^^^^^ + +En algunos escenarios es importante preservar el orden de las solicitudes y respuestas. +Puedes usar ``registries.OrderedRegistry`` para forzar que todos los objetos ``Response`` +dependan del orden de inserción y del índice de invocación. +En el siguiente ejemplo se agregan múltiples objetos ``Response`` que apuntan a la misma +URL. Sin embargo, verás que el código de estado depende del orden de invocación. + +.. code-block:: python + + import requests + + import responses + from responses.registries import OrderedRegistry + + + @responses.activate(registry=OrderedRegistry) + def test_invocation_index(): + responses.get( + "http://twitter.com/api/1/foobar", + json={"msg": "not found"}, + status=404, + ) + responses.get( + "http://twitter.com/api/1/foobar", + json={"msg": "OK"}, + status=200, + ) + responses.get( + "http://twitter.com/api/1/foobar", + json={"msg": "OK"}, + status=200, + ) + responses.get( + "http://twitter.com/api/1/foobar", + json={"msg": "not found"}, + status=404, + ) + + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 404 + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 200 + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 200 + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 404 + + +Registro Personalizado +^^^^^^^^^^^^^^^^^^^^^^^ + +Los ``registries`` incluidos son adecuados para la mayoría de los casos de uso, pero para +manejar condiciones especiales puedes implementar un registro personalizado que siga la +interfaz de ``registries.FirstMatchRegistry``. Redefinir el método ``find`` te permitirá +crear una lógica de búsqueda personalizada y devolver el ``Response`` apropiado. + +Ejemplo que muestra cómo establecer un registro personalizado: + +.. code-block:: python + + import responses + from responses import registries + + + class CustomRegistry(registries.FirstMatchRegistry): + pass + + + print("Before tests:", responses.mock.get_registry()) + """ Before tests: """ + + + # usando decorador de función + @responses.activate(registry=CustomRegistry) + def run(): + print("Within test:", responses.mock.get_registry()) + """ Within test: <__main__.CustomRegistry object> """ + + + run() + + print("After test:", responses.mock.get_registry()) + """ After test: """ + + # usando context manager + with responses.RequestsMock(registry=CustomRegistry) as rsps: + print("In context manager:", rsps.get_registry()) + """ In context manager: <__main__.CustomRegistry object> """ + + print("After exit from context manager:", responses.mock.get_registry()) + """ + After exit from context manager: + """ + +Respuestas Dinámicas +--------------------- + +Puedes usar callbacks para proporcionar respuestas dinámicas. El callback debe devolver +una tupla de (``status``, ``headers``, ``body``). + +.. code-block:: python + + import json + + import responses + import requests + + + @responses.activate + def test_calc_api(): + def request_callback(request): + payload = json.loads(request.body) + resp_body = {"value": sum(payload["numbers"])} + headers = {"request-id": "728d329e-0e86-11e4-a748-0c84dc037c13"} + return (200, headers, json.dumps(resp_body)) + + responses.add_callback( + responses.POST, + "http://calc.com/sum", + callback=request_callback, + content_type="application/json", + ) + + resp = requests.post( + "http://calc.com/sum", + json.dumps({"numbers": [1, 2, 3]}), + headers={"content-type": "application/json"}, + ) + + assert resp.json() == {"value": 6} + + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == "http://calc.com/sum" + assert responses.calls[0].response.text == '{"value": 6}' + assert ( + responses.calls[0].response.headers["request-id"] + == "728d329e-0e86-11e4-a748-0c84dc037c13" + ) + +También puedes pasar una expresión regular compilada a ``add_callback`` para que +coincida con múltiples URLs: + +.. code-block:: python + + import re, json + + from functools import reduce + + import responses + import requests + + operators = { + "sum": lambda x, y: x + y, + "prod": lambda x, y: x * y, + "pow": lambda x, y: x**y, + } + + + @responses.activate + def test_regex_url(): + def request_callback(request): + payload = json.loads(request.body) + operator_name = request.path_url[1:] + + operator = operators[operator_name] + + resp_body = {"value": reduce(operator, payload["numbers"])} + headers = {"request-id": "728d329e-0e86-11e4-a748-0c84dc037c13"} + return (200, headers, json.dumps(resp_body)) + + responses.add_callback( + responses.POST, + re.compile("http://calc.com/(sum|prod|pow|unsupported)"), + callback=request_callback, + content_type="application/json", + ) + + resp = requests.post( + "http://calc.com/prod", + json.dumps({"numbers": [2, 3, 4]}), + headers={"content-type": "application/json"}, + ) + assert resp.json() == {"value": 24} + + + test_regex_url() + + +Si quieres pasar argumentos adicionales al callback, por ejemplo para reutilizar una +función callback con un resultado ligeramente diferente, puedes usar ``functools.partial``: + +.. code-block:: python + + from functools import partial + + + def request_callback(request, id=None): + payload = json.loads(request.body) + resp_body = {"value": sum(payload["numbers"])} + headers = {"request-id": id} + return (200, headers, json.dumps(resp_body)) + + + responses.add_callback( + responses.POST, + "http://calc.com/sum", + callback=partial(request_callback, id="728d329e-0e86-11e4-a748-0c84dc037c13"), + content_type="application/json", + ) + + +Integración con Frameworks de Pruebas Unitarias +------------------------------------------------ + +Responses como fixture de ``pytest`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Usa el paquete pytest-responses para exportar ``responses`` como una fixture de pytest. + +``pip install pytest-responses`` + +Luego puedes acceder a ella en un script de pytest de la siguiente manera: + +.. code-block:: python + + import pytest_responses + + + def test_api(responses): + responses.get( + "http://twitter.com/api/1/foobar", + body="{}", + status=200, + content_type="application/json", + ) + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 200 + +Agregar respuestas por defecto para cada prueba +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Al ejecutar con pruebas de ``unittest``, responses puede usarse para definir respuestas +genéricas a nivel de clase que cada prueba puede complementar. Se puede aplicar una +interfaz similar en el framework ``pytest``. + +.. code-block:: python + + class TestMyApi(unittest.TestCase): + def setUp(self): + responses.get("https://example.com", body="within setup") + # aquí van otros self.responses.add(...) + + @responses.activate + def test_my_func(self): + responses.get( + "https://httpbin.org/get", + match=[matchers.query_param_matcher({"test": "1", "didi": "pro"})], + body="within test", + ) + resp = requests.get("https://example.com") + resp2 = requests.get( + "https://httpbin.org/get", params={"test": "1", "didi": "pro"} + ) + print(resp.text) + # >>> within setup + print(resp2.text) + # >>> within test + + +Métodos de RequestMock: start, stop, reset +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``responses`` tiene los métodos ``start``, ``stop``, ``reset``, muy análogos a +`unittest.mock.patch `_. +Esto simplifica el uso de mocks de solicitudes en métodos ``setup`` o cuando se quieren +aplicar múltiples parches sin anidar decoradores ni sentencias ``with``. + +.. code-block:: python + + class TestUnitTestPatchSetup: + def setup(self): + """Crea una instancia de ``RequestsMock`` y la inicia.""" + self.r_mock = responses.RequestsMock(assert_all_requests_are_fired=True) + self.r_mock.start() + + # opcionalmente se pueden registrar algunas respuestas por defecto + self.r_mock.get("https://example.com", status=505) + self.r_mock.put("https://example.com", status=506) + + def teardown(self): + """Detiene y reinicia la instancia de RequestsMock. + + Si ``assert_all_requests_are_fired`` está en ``True``, se lanzará un error + si algunas solicitudes no fueron procesadas. + """ + self.r_mock.stop() + self.r_mock.reset() + + def test_function(self): + resp = requests.get("https://example.com") + assert resp.status_code == 505 + + resp = requests.put("https://example.com") + assert resp.status_code == 506 + + +Aserciones sobre Responses Declaradas +--------------------------------------- + +Cuando se usa como context manager, Responses lanzará por defecto un error de +aserción si una URL fue registrada pero no fue accedida. Esto puede desactivarse +pasando el valor ``assert_all_requests_are_fired``: + +.. code-block:: python + + import responses + import requests + + + def test_my_api(): + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add( + responses.GET, + "http://twitter.com/api/1/foobar", + body="{}", + status=200, + content_type="application/json", + ) + +Cuando ``assert_all_requests_are_fired=True`` y ocurre una excepción dentro del gestor +de contexto, las aserciones sobre las solicitudes no ejecutadas se lanzarán igualmente. +Esto proporciona contexto valioso sobre qué solicitudes simuladas fueron o no invocadas +al depurar fallos en las pruebas. + +.. code-block:: python + + import responses + import requests + + + def test_with_exception(): + with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps: + rsps.add(responses.GET, "http://example.com/users", body="test") + rsps.add(responses.GET, "http://example.com/profile", body="test") + requests.get("http://example.com/users") + raise ValueError("Something went wrong") + + # Salida: + # ValueError: Something went wrong + # + # During handling of the above exception, another exception occurred: + # + # AssertionError: Not all requests have been executed [('GET', 'http://example.com/profile')] + +Verificar el Número de Llamadas a una Solicitud +------------------------------------------------- + +Aserción basada en el objeto ``Response`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Cada objeto ``Response`` tiene el atributo ``call_count`` que puede inspeccionarse +para comprobar cuántas veces fue invocada cada solicitud. + +.. code-block:: python + + @responses.activate + def test_call_count_with_matcher(): + rsp = responses.get( + "http://www.example.com", + match=(matchers.query_param_matcher({}),), + ) + rsp2 = responses.get( + "http://www.example.com", + match=(matchers.query_param_matcher({"hello": "world"}),), + status=777, + ) + requests.get("http://www.example.com") + resp1 = requests.get("http://www.example.com") + requests.get("http://www.example.com?hello=world") + resp2 = requests.get("http://www.example.com?hello=world") + + assert resp1.status_code == 200 + assert resp2.status_code == 777 + + assert rsp.call_count == 2 + assert rsp2.call_count == 2 + +Aserción basada en la URL exacta +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Verifica que la solicitud fue invocada exactamente n veces. + +.. code-block:: python + + import responses + import requests + + + @responses.activate + def test_assert_call_count(): + responses.get("http://example.com") + + requests.get("http://example.com") + assert responses.assert_call_count("http://example.com", 1) is True + + requests.get("http://example.com") + with pytest.raises(AssertionError) as excinfo: + responses.assert_call_count("http://example.com", 1) + assert ( + "Expected URL 'http://example.com' to be called 1 times. Called 2 times." + in str(excinfo.value) + ) + + + @responses.activate + def test_assert_call_count_always_match_qs(): + responses.get("http://www.example.com") + requests.get("http://www.example.com") + requests.get("http://www.example.com?hello=world") + + # Una llamada por cada URL; la cadena de consulta se compara por defecto + responses.assert_call_count("http://www.example.com", 1) is True + responses.assert_call_count("http://www.example.com?hello=world", 1) is True + + +Verificar los Datos de las Llamadas a Solicitudes +-------------------------------------------------- + +El objeto ``Request`` tiene una lista ``calls`` cuyos elementos corresponden a objetos +``Call`` en la lista global del ``Registry``. Esto puede ser útil cuando el orden de +las solicitudes no está garantizado pero necesitas verificar su corrección, por ejemplo +en aplicaciones multihilo. + +.. code-block:: python + + import concurrent.futures + import responses + import requests + + + @responses.activate + def test_assert_calls_on_resp(): + rsp1 = responses.patch("http://www.foo.bar/1/", status=200) + rsp2 = responses.patch("http://www.foo.bar/2/", status=400) + rsp3 = responses.patch("http://www.foo.bar/3/", status=200) + + def update_user(uid, is_active): + url = f"http://www.foo.bar/{uid}/" + response = requests.patch(url, json={"is_active": is_active}) + return response + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + future_to_uid = { + executor.submit(update_user, uid, is_active): uid + for (uid, is_active) in [("3", True), ("2", True), ("1", False)] + } + for future in concurrent.futures.as_completed(future_to_uid): + uid = future_to_uid[future] + response = future.result() + print(f"{uid} updated with {response.status_code} status code") + + assert len(responses.calls) == 3 # total de llamadas + + assert rsp1.call_count == 1 + assert rsp1.calls[0] in responses.calls + assert rsp1.calls[0].response.status_code == 200 + assert json.loads(rsp1.calls[0].request.body) == {"is_active": False} + + assert rsp2.call_count == 1 + assert rsp2.calls[0] in responses.calls + assert rsp2.calls[0].response.status_code == 400 + assert json.loads(rsp2.calls[0].request.body) == {"is_active": True} + + assert rsp3.call_count == 1 + assert rsp3.calls[0] in responses.calls + assert rsp3.calls[0].response.status_code == 200 + assert json.loads(rsp3.calls[0].request.body) == {"is_active": True} + +Múltiples Responses +-------------------- + +También puedes agregar múltiples respuestas para la misma URL: + +.. code-block:: python + + import responses + import requests + + + @responses.activate + def test_my_api(): + responses.get("http://twitter.com/api/1/foobar", status=500) + responses.get( + "http://twitter.com/api/1/foobar", + body="{}", + status=200, + content_type="application/json", + ) + + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 500 + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.status_code == 200 + + +Redirección de URL +------------------- + +En el siguiente ejemplo puedes ver cómo crear una cadena de redirección y agregar una +excepción personalizada que se lanzará durante la ejecución y contendrá el historial +de redirecciones. + +.. code-block:: + + A -> 301 redirect -> B + B -> 301 redirect -> C + C -> connection issue + +.. code-block:: python + + import pytest + import requests + + import responses + + + @responses.activate + def test_redirect(): + # crea múltiples objetos Response donde los dos primeros contienen cabeceras de redirección + rsp1 = responses.Response( + responses.GET, + "http://example.com/1", + status=301, + headers={"Location": "http://example.com/2"}, + ) + rsp2 = responses.Response( + responses.GET, + "http://example.com/2", + status=301, + headers={"Location": "http://example.com/3"}, + ) + rsp3 = responses.Response(responses.GET, "http://example.com/3", status=200) + + # registra los objetos Response generados en el módulo ``responses`` + responses.add(rsp1) + responses.add(rsp2) + responses.add(rsp3) + + # realiza la primera solicitud para generar una respuesta genuina de ``requests`` + # este objeto contendrá atributos reales de la respuesta, como ``history`` + rsp = requests.get("http://example.com/1") + responses.calls.reset() + + # personaliza la excepción con el atributo ``response`` + my_error = requests.ConnectionError("custom error") + my_error.response = rsp + + # actualiza el cuerpo de la 3ª respuesta con una excepción; esta se lanzará durante la ejecución + rsp3.body = my_error + + with pytest.raises(requests.ConnectionError) as exc_info: + requests.get("http://example.com/1") + + assert exc_info.value.args[0] == "custom error" + assert rsp1.url in exc_info.value.response.history[0].url + assert rsp2.url in exc_info.value.response.history[1].url + + +Validar el Mecanismo de ``Retry`` +---------------------------------- + +Si usas las características de ``Retry`` de ``urllib3`` y quieres cubrir escenarios que +pongan a prueba tus límites de reintentos, también puedes hacerlo con ``responses``. +El mejor enfoque es usar un `Registro Ordenado`_. + +.. code-block:: python + + import requests + + import responses + from responses import registries + from urllib3.util import Retry + + + @responses.activate(registry=registries.OrderedRegistry) + def test_max_retries(): + url = "https://example.com" + rsp1 = responses.get(url, body="Error", status=500) + rsp2 = responses.get(url, body="Error", status=500) + rsp3 = responses.get(url, body="Error", status=500) + rsp4 = responses.get(url, body="OK", status=200) + + session = requests.Session() + + adapter = requests.adapters.HTTPAdapter( + max_retries=Retry( + total=4, + backoff_factor=0.1, + status_forcelist=[500], + method_whitelist=["GET", "POST", "PATCH"], + ) + ) + session.mount("https://", adapter) + + resp = session.get(url) + + assert resp.status_code == 200 + assert rsp1.call_count == 1 + assert rsp2.call_count == 1 + assert rsp3.call_count == 1 + assert rsp4.call_count == 1 + + +Usar un Callback para Modificar la Respuesta +--------------------------------------------- + +Si usas procesamiento personalizado en ``requests`` mediante subclases o mixins, o si +tienes herramientas que interactúan con ``requests`` a bajo nivel, puede +que necesites agregar procesamiento extendido al objeto Response simulado para simular +completamente el entorno de tus pruebas. Se puede usar un ``response_callback``, que +será envuelto antes de devolverse al invocador. El callback acepta +una ``response`` como único argumento y se espera que devuelva un único objeto +``response``. + +.. code-block:: python + + import responses + import requests + + + def response_callback(resp): + resp.callback_processed = True + return resp + + + with responses.RequestsMock(response_callback=response_callback) as m: + m.add(responses.GET, "http://example.com", body=b"test") + resp = requests.get("http://example.com") + assert resp.text == "test" + assert hasattr(resp, "callback_processed") + assert resp.callback_processed is True + + +Permitir el Paso de Solicitudes Reales +--------------------------------------- + +En algunos casos puede ser necesario permitir que ciertas solicitudes pasen a través de +responses y lleguen a un servidor real. Esto se puede hacer con los métodos +``add_passthru``: + +.. code-block:: python + + import responses + + + @responses.activate + def test_my_api(): + responses.add_passthru("https://percy.io") + +Esto permitirá que cualquier solicitud que coincida con ese prefijo, y que no esté +registrada como respuesta simulada, pase usando el comportamiento estándar. + +Los endpoints de paso pueden configurarse con patrones de expresión regular si necesitas +permitir que todo un dominio o subárbol de ruta envíe solicitudes: + +.. code-block:: python + + responses.add_passthru(re.compile("https://percy.io/\\w+")) + + +Por último, puedes usar el argumento ``passthrough`` del objeto ``Response`` para forzar +que una respuesta se comporte como paso directo. + +.. code-block:: python + + # Habilitar passthrough para una sola respuesta + response = Response( + responses.GET, + "http://example.com", + body="not used", + passthrough=True, + ) + responses.add(response) + + # Usar PassthroughResponse + response = PassthroughResponse(responses.GET, "http://example.com") + responses.add(response) + +Ver/Modificar Responses Registradas +------------------------------------- + +Las responses registradas están disponibles como método público de la instancia +RequestMock. A veces es útil para depuración ver la pila de responses registradas, +a la que se puede acceder mediante ``responses.registered()``. + +La función ``replace`` permite modificar una ``response`` previamente registrada. +La firma del método es idéntica a ``add``. Las ``response`` s se identifican por +``method`` y ``url``. Solo se reemplaza la primera ``response`` que coincide. + +.. code-block:: python + + import responses + import requests + + + @responses.activate + def test_replace(): + responses.get("http://example.org", json={"data": 1}) + responses.replace(responses.GET, "http://example.org", json={"data": 2}) + + resp = requests.get("http://example.org") + + assert resp.json() == {"data": 2} + + +La función ``upsert`` permite modificar una ``response`` previamente registrada al igual +que ``replace``. Si la response no está registrada, la función ``upsert`` la registrará +como ``add``. + +``remove`` acepta un argumento ``method`` y ``url`` y eliminará **todas** las responses +coincidentes de la lista de registradas. + +Por último, ``reset`` reiniciará todas las responses registradas. + +Corrutinas y Multihilo +----------------------- + +``responses`` admite tanto corrutinas como multihilo de forma nativa. +Ten en cuenta que ``responses`` bloquea el hilo en el objeto ``RequestMock``, +permitiendo que solo un hilo acceda a él a la vez. + +.. code-block:: python + + async def test_async_calls(): + @responses.activate + async def run(): + responses.get( + "http://twitter.com/api/1/foobar", + json={"error": "not found"}, + status=404, + ) + + resp = requests.get("http://twitter.com/api/1/foobar") + assert resp.json() == {"error": "not found"} + assert responses.calls[0].request.url == "http://twitter.com/api/1/foobar" + + await run() + +Funcionalidades BETA +--------------------- + +A continuación encontrarás una lista de funcionalidades BETA. Aunque intentaremos +mantener la compatibilidad con versiones anteriores de la API con la versión publicada, nos +reservamos el derecho de cambiar estas APIs antes de que sean consideradas estables. +Comparte tu opinión a través de +`GitHub Issues `_. + +Grabar Responses en Archivos +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Puedes realizar solicitudes reales al servidor y ``responses`` grabará automáticamente +la salida en un archivo. Los datos grabados se almacenan en formato +`YAML `_. + +Aplica el decorador ``@responses._recorder.record(file_path="out.yaml")`` a cualquier +función donde realices solicitudes para grabar las responses en el archivo ``out.yaml``. + +El siguiente código: + +.. code-block:: python + + import requests + from responses import _recorder + + + def another(): + rsp = requests.get("https://httpstat.us/500") + rsp = requests.get("https://httpstat.us/202") + + + @_recorder.record(file_path="out.yaml") + def test_recorder(): + rsp = requests.get("https://httpstat.us/404") + rsp = requests.get("https://httpbin.org/status/wrong") + another() + +producirá la siguiente salida: + +.. code-block:: yaml + + responses: + - response: + auto_calculate_content_length: false + body: 404 Not Found + content_type: text/plain + method: GET + status: 404 + url: https://httpstat.us/404 + - response: + auto_calculate_content_length: false + body: Invalid status code + content_type: text/plain + method: GET + status: 400 + url: https://httpbin.org/status/wrong + - response: + auto_calculate_content_length: false + body: 500 Internal Server Error + content_type: text/plain + method: GET + status: 500 + url: https://httpstat.us/500 + - response: + auto_calculate_content_length: false + body: 202 Accepted + content_type: text/plain + method: GET + status: 202 + url: https://httpstat.us/202 + +Si estás en el REPL, también puedes activar el grabador para todas las responses +siguientes: + +.. code-block:: python + + import requests + from responses import _recorder + + _recorder.recorder.start() + + requests.get("https://httpstat.us/500") + + _recorder.recorder.dump_to_file("out.yaml") + + # puedes detener o reiniciar el grabador + _recorder.recorder.stop() + _recorder.recorder.reset() + +Reproducir Responses (poblar el registro) desde Archivos +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Puedes poblar tu registro activo desde un archivo ``yaml`` con responses grabadas. +(Consulta `Grabar Responses en Archivos`_ para entender cómo obtener el archivo.) +Para ello necesitas ejecutar ``responses._add_from_file(file_path="out.yaml")`` dentro +de un decorador activado o un context manager. + +El siguiente ejemplo registra una respuesta ``patch``, luego todas las responses +presentes en el archivo ``out.yaml`` y finalmente una respuesta ``post``. + +.. code-block:: python + + import responses + + + @responses.activate + def run(): + responses.patch("http://httpbin.org") + responses._add_from_file(file_path="out.yaml") + responses.post("http://httpbin.org/form") + + + run() + + +Contribuir +----------- + +Configuración del Entorno +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Responses usa varias utilidades de linting y autoformateo, por lo que es importante +que al enviar parches utilices la cadena de herramientas adecuada: + +Clona el repositorio: + +.. code-block:: shell + + git clone https://github.com/getsentry/responses.git + +Crea un entorno (por ejemplo con ``virtualenv``): + +.. code-block:: shell + + virtualenv .env && source .env/bin/activate + +Configura los requisitos de desarrollo: + +.. code-block:: shell + + make develop + + +Pruebas y Validación de Calidad del Código +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +La forma más sencilla de validar tu código es ejecutar las pruebas mediante ``tox``. +La configuración actual de ``tox`` ejecuta las mismas verificaciones que se usan en +el pipeline de CI/CD de GitHub Actions. + +Ejecuta el siguiente comando desde la raíz del proyecto para validar tu código: + +* Pruebas unitarias en todas las versiones de Python admitidas por este proyecto +* Validación de tipos mediante ``mypy`` +* Todos los hooks de ``pre-commit`` + +.. code-block:: shell + + tox + +También puedes ejecutar una sola prueba en cualquier momento. Consulta la documentación +a continuación. + +Pruebas Unitarias +""""""""""""""""" + +Responses usa `Pytest `_ para las pruebas. +Puedes ejecutar todas las pruebas con: + +.. code-block:: shell + + tox -e py37 + tox -e py310 + +O activando manualmente la versión de Python requerida y ejecutando: + +.. code-block:: shell + + pytest + +Y ejecutar una sola prueba con: + +.. code-block:: shell + + pytest -k '' + +Validación de Tipos +"""""""""""""""""""" + +Para verificar el cumplimiento de ``type``, ejecuta el linter +`mypy `_: + +.. code-block:: shell + + tox -e mypy + +O bien: + +.. code-block:: shell + + mypy --config-file=./mypy.ini -p responses + +Calidad y Estilo del Código +""""""""""""""""""""""""""""" + +Para verificar y reformatear el estilo del código, ejecuta: + +.. code-block:: shell + + tox -e precom + +O bien: + +.. code-block:: shell + + pre-commit run --all-files From 71be9a2f9718f0f4d5c3feb28ba7e3797b7bdfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 7 May 2026 08:06:42 -0600 Subject: [PATCH 03/13] fix: remove content-type from headers in _add_from_file to avoid RuntimeError (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove content-type from headers in _add_from_file to avoid RuntimeError The recorder stores content_type as a top-level YAML key but may also capture a 'content-type' entry inside the headers dict (since HTTP servers return Content-Type as a response header). When _add_from_file() calls add() with both content_type= and headers={'content-type': ...}, add() raises: RuntimeError: You cannot define both content_type and headers[Content-Type]. Fix: strip any 'content-type' key from headers before calling add() when content_type is also present in the loaded response dict. The content_type kwarg takes precedence, which is already documented as the recommended approach. Fixes #741 * test: add regression test for Content-Type conflict in _add_from_file Fixes the case where a YAML fixture has Content-Type in both headers and content_type fields — the recorder captures both, causing RuntimeError when _add_from_file calls add(). Added test_add_from_file_content_type_in_headers to verify the fix works end-to-end with a YAML fixture. * test: use mismatched Content-Type values to verify content_type precedence Per markstory's review: using different values for headers['Content-Type'] ('text/html') and content_type ('application/json'/'text/plain') makes the assertion non-trivial — it now explicitly verifies that content_type wins over the conflicting header, not just that both happen to carry the same value. * fix(test): use .yaml extension so _add_from_file selects YAML loader As suggested by @markstory — the fixture file needs an explicit extension so that _add_from_file can detect the correct loader. Changed out_file to use a .yaml suffix for the content_type_in_headers test. * fix(test): clean up .yaml/.toml fixture variants in teardown_method The content_type test creates response_record.yaml but teardown_method only deleted response_record (no extension). Add cleanup for .yaml and .toml variants to avoid stale artifacts across test runs. * fix(test): restore _parse_response_file in teardown + reformat for black Root cause of the CI failure: test_add_from_file[tomli_w] monkey-patches responses.mock._parse_response_file to a TOML loader and never restores it, so test_add_from_file_content_type_in_headers inherits the TOML parser and chokes on the YAML fixture (tomllib.TOMLDecodeError). The prior fixes (.yaml extension, .yaml/.toml teardown cleanup) don't help because _parse_response_file always calls yaml.safe_load regardless of file extension — the bug is in test state leakage, not the loader dispatch. - teardown_method: always reset _parse_response_file back to the class default so any per-test monkey-patch is contained. - __init__.py: black wrap for the Content-Type-stripping dict comp (was 91 chars, over the 88-char limit). * fix(test): unwind mock inside test_add_from_file instead of teardown per markstory's review: capture the original `_parse_response_file` before monkey-patching it for the tomli_w parametrize variant, then restore it in a finally block so the mock is contained to the test that introduces it. removes the class-wide reset from teardown_method that was leaking responsibility upward. --------- Co-authored-by: Julio César Suástegui --- responses/__init__.py | 11 ++- responses/tests/test_recorder.py | 120 ++++++++++++++++++++++++++----- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/responses/__init__.py b/responses/__init__.py index d719476d..ab84c7f5 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -856,12 +856,21 @@ def _add_from_file(self, file_path: "Union[str, bytes, os.PathLike[Any]]") -> No for rsp in data["responses"]: rsp = rsp["response"] + headers = rsp["headers"] if "headers" in rsp else None + + if headers is not None and "content_type" in rsp: + headers = { + k: v for k, v in headers.items() if k.lower() != "content-type" + } + if not headers: + headers = None + self.add( method=rsp["method"], url=rsp["url"], body=rsp["body"], status=rsp["status"], - headers=rsp["headers"] if "headers" in rsp else None, + headers=headers, content_type=rsp["content_type"], auto_calculate_content_length=rsp["auto_calculate_content_length"], ) diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py index fece12fc..d0175b29 100644 --- a/responses/tests/test_recorder.py +++ b/responses/tests/test_recorder.py @@ -190,6 +190,12 @@ def teardown_method(self): if self.out_file.exists(): self.out_file.unlink() + # Clean up any extension variants created by individual tests + for suffix in (".yaml", ".toml"): + p = Path(str(self.out_file) + suffix) + if p.exists(): + p.unlink() + assert not self.out_file.exists() @pytest.mark.parametrize("parser", (yaml, tomli_w)) @@ -204,6 +210,8 @@ def test_add_from_file(self, parser): # type: ignore[misc] @responses.activate def run(): responses.patch("http://httpbin.org") + + original_parser = responses.mock._parse_response_file if parser == tomli_w: def _parse_resp_f(file_path): @@ -213,28 +221,104 @@ def _parse_resp_f(file_path): responses.mock._parse_response_file = _parse_resp_f # type: ignore[method-assign] - responses._add_from_file(file_path=self.out_file) - responses.post("http://httpbin.org/form") + try: + responses._add_from_file(file_path=self.out_file) + responses.post("http://httpbin.org/form") + + assert responses.registered()[0].url == "http://httpbin.org/" + assert responses.registered()[1].url == "http://example.com:8080/404" + assert ( + responses.registered()[2].url + == "http://example.com:8080/status/wrong" + ) + assert responses.registered()[3].url == "http://example.com:8080/500" + assert responses.registered()[4].url == "http://example.com:8080/202" + assert responses.registered()[5].url == "http://httpbin.org/form" + + assert responses.registered()[0].method == "PATCH" + assert responses.registered()[2].method == "GET" + assert responses.registered()[4].method == "PUT" + assert responses.registered()[5].method == "POST" + + assert responses.registered()[2].status == 400 + assert responses.registered()[3].status == 500 + + assert responses.registered()[3].body == "500 Internal Server Error" - assert responses.registered()[0].url == "http://httpbin.org/" - assert responses.registered()[1].url == "http://example.com:8080/404" - assert ( - responses.registered()[2].url == "http://example.com:8080/status/wrong" - ) - assert responses.registered()[3].url == "http://example.com:8080/500" - assert responses.registered()[4].url == "http://example.com:8080/202" - assert responses.registered()[5].url == "http://httpbin.org/form" + assert responses.registered()[3].content_type == "text/plain" + finally: + responses.mock._parse_response_file = original_parser # type: ignore[method-assign] - assert responses.registered()[0].method == "PATCH" - assert responses.registered()[2].method == "GET" - assert responses.registered()[4].method == "PUT" - assert responses.registered()[5].method == "POST" + run() + + def test_add_from_file_content_type_in_headers(self): + """Fixture files may contain Content-Type in both headers and content_type. + + The recorder captures ``Content-Type`` inside the ``headers`` dict *and* + as the dedicated ``content_type`` field. Passing both to ``add()`` + raises a ``RuntimeError`` because ``content_type`` and a ``Content-Type`` + header conflict. ``_add_from_file`` should strip the duplicate header + entry so that the dedicated ``content_type`` kwarg wins. + + Using mismatched values (``text/html`` in headers vs ``application/json`` + in ``content_type``) ensures the assertion is non-trivial and confirms + that ``content_type`` takes precedence over the header value. + + The fixture is saved as a ``.yaml`` file so that ``_add_from_file`` + selects the YAML loader by extension. + """ + data = { + "responses": [ + { + "response": { + "method": "GET", + "url": "http://example.com/api", + "body": '{"status": "ok"}', + "status": 200, + # headers has a *different* Content-Type than content_type + # to verify that content_type wins (not just that both happen + # to be the same value). + "headers": {"Content-Type": "text/html"}, + "content_type": "application/json", + "auto_calculate_content_length": False, + } + }, + { + "response": { + "method": "POST", + "url": "http://example.com/submit", + "body": "created", + "status": 201, + "headers": { + "Content-Type": "text/html", + "X-Request-Id": "abc123", + }, + "content_type": "text/plain", + "auto_calculate_content_length": False, + } + }, + ] + } + + yaml_file = Path(str(self.out_file) + ".yaml") + with open(yaml_file, "w") as f: + yaml.dump(data, f) + + @responses.activate + def run(): + responses._add_from_file(file_path=yaml_file) - assert responses.registered()[2].status == 400 - assert responses.registered()[3].status == 500 + # Verify responses were registered without RuntimeError + assert len(responses.registered()) == 2 - assert responses.registered()[3].body == "500 Internal Server Error" + # content_type must win over the conflicting Content-Type header + assert responses.registered()[0].url == "http://example.com/api" + assert responses.registered()[0].content_type == "application/json" - assert responses.registered()[3].content_type == "text/plain" + assert responses.registered()[1].url == "http://example.com/submit" + assert responses.registered()[1].content_type == "text/plain" + # Non-content-type headers should be preserved + resp = requests.post("http://example.com/submit") + assert resp.headers["X-Request-Id"] == "abc123" run() From ab8d4808bf973aa968757755bf9ea38621a4d070 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 8 May 2026 17:53:17 -0400 Subject: [PATCH 04/13] chore: Fix lint build and update changes (#795) * chore: Fix lint build and update changes * Fix mypy better --- CHANGES | 5 +++++ responses/__init__.py | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 4e8a06bd..c5bc7839 100644 --- a/CHANGES +++ b/CHANGES @@ -1,4 +1,9 @@ +0.26.1 +------ + * Added Spanish translation of the README (``README.es.rst``) +* When both `content_type` and `headers['content-type']` are in a response mock file, + `content_type` is now used. 0.26.0 ------ diff --git a/responses/__init__.py b/responses/__init__.py index ab84c7f5..48ce6580 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -39,7 +39,7 @@ try: from typing_extensions import Literal except ImportError: # pragma: no cover - from typing import Literal # pragma: no cover + from typing import Literal from io import BufferedReader from io import BytesIO @@ -249,7 +249,9 @@ def __getitem__(self, idx: int) -> Call: """Overload for scenario when index is provided.""" @overload - def __getitem__(self, idx: "slice[int, int, Optional[int]]") -> List[Call]: + def __getitem__( + self, idx: "slice[Optional[int], Optional[int], Optional[int]]" + ) -> List[Call]: """Overload for scenario when slice is provided.""" def __getitem__(self, idx: Union[int, slice]) -> Union[Call, List[Call]]: From 1fda8978e1c927706f82c1baafe30d9c013972ca Mon Sep 17 00:00:00 2001 From: Douglas Silva Date: Thu, 21 May 2026 16:52:50 -0300 Subject: [PATCH 05/13] Add strict_match parameter to urlencoded_params_matcher (#796) * Add strict_match parameter to urlencoded_params_matcher * Add to CHANGES the new strict_match param for urlencoded_params_matcher * Prevent non-strict matching of empty params with non-empty body * Test for successful strict matching * Check for falsy/truthy params and request_body instead of None With this change, "params={}" has the same effect as "params=None". * Show note when match fails and strict match is ON --- CHANGES | 2 + responses/matchers.py | 41 ++++++++++-- responses/tests/test_matchers.py | 111 +++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index c5bc7839..3d02c8c8 100644 --- a/CHANGES +++ b/CHANGES @@ -4,6 +4,8 @@ * Added Spanish translation of the README (``README.es.rst``) * When both `content_type` and `headers['content-type']` are in a response mock file, `content_type` is now used. +* Added strict_match to urlencoded_params_matcher, enabling partial request parameter + matching. 0.26.0 ------ diff --git a/responses/matchers.py b/responses/matchers.py index 40f3a83d..c33e9257 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -21,6 +21,13 @@ def _filter_dict_recursively( dict1: Mapping[Any, Any], dict2: Mapping[Any, Any] ) -> Mapping[Any, Any]: + """ + Make a new dictionary using only keys that exist in both + dictionary arguments. It will also work with deeply nested keys. + :param dict1: dictionary to filter + :param dict2: dictionary to filter + :return: new dictionary based on `dict1` and `dict2` + """ filtered_dict = {} for k, val in dict1.items(): if k in dict2: @@ -47,29 +54,53 @@ def match(request: PreparedRequest) -> Tuple[bool, str]: def urlencoded_params_matcher( - params: Optional[Mapping[str, str]], *, allow_blank: bool = False + params: Optional[Mapping[str, str]], + *, + allow_blank: bool = False, + strict_match: bool = True, ) -> Callable[..., Any]: """ Matches URL encoded data :param params: (dict) data provided to 'data' arg of request + :param allow_blank If true, blank values are accounted as empty strings + :param strict_match If true, all keys must match; + otherwise, partial matches allowed :return: (func) matcher """ def match(request: PreparedRequest) -> Tuple[bool, str]: reason = "" request_body = request.body - qsl_body = ( + qsl_body: Mapping[Any, Any] = ( dict(parse_qsl(request_body, keep_blank_values=allow_blank)) # type: ignore[type-var] if request_body else {} ) - params_dict = params or {} - valid = params is None if request_body is None else params_dict == qsl_body + request_params = qsl_body + match_params = params or {} + + if not strict_match: + request_params = _filter_dict_recursively(qsl_body, match_params) + + valid = ( + params is None if request_body is None else match_params == request_params + ) + + # Prevents non-strict match of empty params with non-empty + # request body (due to dictionary filtering) + if not params and request_body: + valid = False + if not valid: reason = ( - f"request.body doesn't match: {qsl_body} doesn't match {params_dict}" + f"request.body doesn't match: {qsl_body} doesn't match {match_params}" ) + if strict_match: + reason += ( + "\nNote: You're using strict parameter check. " + "To try a partial match, use strict_match=False" + ) return valid, reason diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 44544f34..15b7499a 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -243,6 +243,117 @@ def run(): assert_reset() +def test_urlencoded_params_matcher_strict(): + """Test for partial urlencoded parameter matching""" + + @responses.activate + def run(): + responses.add( + method=responses.POST, + url="http://example.com/", + body="body1", + match=[ + matchers.urlencoded_params_matcher( + {"key1": "value1"}, strict_match=False + ) + ], + ) + responses.add( + method=responses.POST, + url="http://example.com/", + body="body2", + match=[ + matchers.urlencoded_params_matcher( + {"key2": "value2"}, strict_match=False + ) + ], + ) + # Fail for insufficient params and strict matching: + responses.add( + method=responses.POST, + url="http://example.com/", + body="body3", + match=[ + matchers.urlencoded_params_matcher( + {"key3": "value3"}, strict_match=True # Note: Strict match + ) + ], + ) + # Fail for non-strict matching of empty params with non-empty body: + responses.add( + method=responses.POST, + url="http://example.com/", + body="body4", + match=[matchers.urlencoded_params_matcher({}, strict_match=False)], + ) + # Test for successful strict matching: + responses.add( + method=responses.POST, + url="http://example.com/", + body="body5", + match=[ + matchers.urlencoded_params_matcher( + {"key5": "value5", "type": "urlencoded"}, strict_match=True + ) + ], + ) + + resp1 = requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "x-www-form-urlencoded"}, + data={"key1": "value1", "type": "urlencoded"}, + ) + assert_response(resp1, "body1") + + resp2 = requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "x-www-form-urlencoded"}, + data={"key2": "value2", "type": "urlencoded"}, + ) + assert_response(resp2, "body2") + + # The third request should NOT match, as it's strict + with pytest.raises(ConnectionError) as excinfo: + requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "x-www-form-urlencoded"}, + data={"key3": "value3", "type": "urlencoded"}, + ) + msg = str(excinfo.value) + assert ( + "request.body doesn't match: {'key3': 'value3', 'type': 'urlencoded'} " + "doesn't match {'key3': 'value3'}" in msg + ) + + # The fourth request should NOT match with empty params + with pytest.raises(ConnectionError) as excinfo: + requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "x-www-form-urlencoded"}, + data={"key4": "value4", "type": "urlencoded"}, + ) + msg = str(excinfo.value) + assert ( + "request.body doesn't match: {'key4': 'value4', 'type': 'urlencoded'} " + "doesn't match {}" in msg + ) + + resp5 = requests.request( + "POST", + "http://example.com/", + headers={"Content-Type": "x-www-form-urlencoded"}, + data={"key5": "value5", "type": "urlencoded"}, + ) + assert_response(resp5, "body5") + + run() + assert_reset() + + def test_query_params_numbers(): @responses.activate def run(): From 7a80232a3716cadde4b0aa36b34cc56006366179 Mon Sep 17 00:00:00 2001 From: markstory <24086+markstory@users.noreply.github.com> Date: Thu, 21 May 2026 19:53:57 +0000 Subject: [PATCH 06/13] release: 0.26.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d0633292..7d137927 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ setup( name="responses", - version="0.26.0", + version="0.26.1", author="David Cramer", description="A utility library for mocking out the `requests` Python library.", url="https://github.com/getsentry/responses", From 853e7d2ddde86315d2dd936185de427b595499bc Mon Sep 17 00:00:00 2001 From: Sueun Cho Date: Wed, 3 Jun 2026 13:20:12 -0400 Subject: [PATCH 07/13] docs: update tox Python version examples (#797) --- README.es.rst | 2 +- README.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.es.rst b/README.es.rst index 920f0270..fa7593d4 100644 --- a/README.es.rst +++ b/README.es.rst @@ -1513,7 +1513,7 @@ Puedes ejecutar todas las pruebas con: .. code-block:: shell - tox -e py37 + tox -e py38 tox -e py310 O activando manualmente la versión de Python requerida y ejecutando: diff --git a/README.rst b/README.rst index a7919906..7585db57 100644 --- a/README.rst +++ b/README.rst @@ -1500,7 +1500,7 @@ testing. You can run all tests by: .. code-block:: shell - tox -e py37 + tox -e py38 tox -e py310 OR manually activate required version of Python and run From 1d48f2f202a5a9cdbb008aa9de2e255cbe897c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 18 Jun 2026 08:59:46 -0600 Subject: [PATCH 08/13] docs: fix typos (#800) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7585db57..a5a8bb0e 100644 --- a/README.rst +++ b/README.rst @@ -1401,7 +1401,7 @@ will produce next output: status: 202 url: https://httpstat.us/202 -If you are in the REPL, you can also activete the recorder for all following responses: +If you are in the REPL, you can also activate the recorder for all following responses: .. code-block:: python From 88e15db329b8731d27210fe73e11056210309829 Mon Sep 17 00:00:00 2001 From: Mikhail Golikov Date: Thu, 25 Jun 2026 23:09:07 +0100 Subject: [PATCH 09/13] Strip default headers from recorded files case-insensitively (#798) _remove_default_headers matched header names by exact case, so the default headers (Content-Type, Date, Server, and the others) were only stripped when servers used the canonical casing. HTTP header names are case-insensitive and HTTP/2 servers send them lowercase, so responses recorded from such servers kept these redundant headers in the file. Match the names without regard to case. --- CHANGES | 4 ++++ responses/_recorder.py | 12 ++++++---- responses/tests/test_recorder.py | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 3d02c8c8..c179355c 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,10 @@ `content_type` is now used. * Added strict_match to urlencoded_params_matcher, enabling partial request parameter matching. +* Default headers (such as ``Content-Type``, ``Date`` and ``Server``) are now stripped + from recorded files regardless of header name case. Previously, responses recorded from + servers that send lowercase header names (for example over HTTP/2) kept these redundant + headers in the generated file. 0.26.0 ------ diff --git a/responses/_recorder.py b/responses/_recorder.py index 73e0b431..e0fac897 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -52,11 +52,15 @@ def _remove_default_headers(data: "Any") -> "Any": "Connection", "Content-Encoding", ] + # HTTP header names are case-insensitive, and HTTP/2 servers send them + # lowercase, so match without regard to case. + keys_to_remove_lower = {key.lower() for key in keys_to_remove} for i, response in enumerate(data["responses"]): - for key in keys_to_remove: - if key in response["response"]["headers"]: - del data["responses"][i]["response"]["headers"][key] - if not response["response"]["headers"]: + headers = data["responses"][i]["response"]["headers"] + for key in list(headers): + if key.lower() in keys_to_remove_lower: + del headers[key] + if not headers: del data["responses"][i]["response"]["headers"] return data diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py index d0175b29..b761dc80 100644 --- a/responses/tests/test_recorder.py +++ b/responses/tests/test_recorder.py @@ -8,6 +8,7 @@ import responses from responses import _recorder from responses._recorder import _dump +from responses._recorder import _remove_default_headers try: import tomli as _toml @@ -67,6 +68,44 @@ def get_data(host, port): return data +def test_remove_default_headers_is_case_insensitive(): + """Default headers should be stripped regardless of their case. + + HTTP/2 servers send header names in lowercase, so the recorded file may + contain e.g. ``content-type`` / ``date`` rather than ``Content-Type`` / + ``Date``. These still need to be removed, otherwise the recorded file + keeps verbose default headers (and, for content-type, a value that is + already stored in the ``content_type`` field). + """ + data = { + "responses": [ + { + "response": { + # mixed lower/title/upper case to pin the case-insensitive contract + "headers": { + "content-type": "application/json", + "Date": "Mon, 01 Jan 2024 00:00:00 GMT", + "SERVER": "nginx", + "x-custom": "keep-me", + } + } + }, + { + # a response whose headers are all default ones is left without a + # "headers" key at all + "response": { + "headers": {"content-length": "12", "connection": "keep-alive"} + } + }, + ] + } + + result = _remove_default_headers(data) + + assert result["responses"][0]["response"]["headers"] == {"x-custom": "keep-me"} + assert "headers" not in result["responses"][1]["response"] + + class TestRecord: def setup_method(self): self.out_file = Path("response_record") From a19add7777d7d0c64eeb364f6cc9de624c33f4f8 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Fri, 26 Jun 2026 16:26:07 +0200 Subject: [PATCH 10/13] fix: query_param_matcher no longer mutates the caller's params dict (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numeric values (int/float) in the params mapping were being converted to strings in-place, silently modifying the original dict passed by the caller. Copy the mapping into a local dict before performing the int/float→str normalisation. --------- Co-authored-by: gaoflow --- CHANGES | 2 ++ responses/matchers.py | 2 +- responses/tests/test_matchers.py | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index c179355c..fb92f607 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,8 @@ from recorded files regardless of header name case. Previously, responses recorded from servers that send lowercase header names (for example over HTTP/2) kept these redundant headers in the generated file. +* Fixed `query_param_matcher` mutating the caller's params dict when numeric + values are provided. See #801 0.26.0 ------ diff --git a/responses/matchers.py b/responses/matchers.py index c33e9257..4b9d1f1b 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -215,7 +215,7 @@ def query_param_matcher( """ - params_dict = params or {} + params_dict = dict(params) if params else {} for k, v in params_dict.items(): if isinstance(v, (int, float)): diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 15b7499a..688e51d1 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -371,6 +371,24 @@ def run(): assert_reset() +def test_query_param_matcher_does_not_mutate_input(): + """query_param_matcher must not modify the caller's params dict. + + Numeric values (int/float) are converted to strings internally for + comparison, but the conversion must not happen on the original dict. + """ + params = {"page": 1, "ratio": 0.5, "name": "test"} + original_types = {k: type(v) for k, v in params.items()} + + matchers.query_param_matcher(params) + + for k, expected_type in original_types.items(): + assert type(params[k]) is expected_type, ( + f"query_param_matcher mutated params[{k!r}]: " + f"expected {expected_type.__name__}, got {type(params[k]).__name__}" + ) + + def test_query_param_matcher_empty_value(): @responses.activate def run(): From aae1d471f3ef5c120c99a178a621e8276c164ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20S=C3=BCtterlin?= Date: Fri, 3 Jul 2026 16:57:20 +0200 Subject: [PATCH 11/13] Set license format to SPDX identifier in setup.py (#803) Right now the License uses `Apache 2.0` instead of the SPDX compliant `Apache-2.0`. This causes problems with some license scanners like https://github.com/anchore/grant --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7d137927..b3b974ae 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "Documentation": "https://github.com/getsentry/responses/blob/master/README.rst", "Source Code": "https://github.com/getsentry/responses", }, - license="Apache 2.0", + license="Apache-2.0", long_description=open("README.rst", encoding="utf-8").read(), long_description_content_type="text/x-rst", packages=["responses"], From 6fb610d1ca8f4bf35af8bfa5d0ac4a97fd53a99c Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 3 Jul 2026 12:40:56 -0400 Subject: [PATCH 12/13] Fix CHANGES (#802) Move recently merged changes under a new heading instead of under 0.26.1 which has already been released. --- CHANGES | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index fb92f607..b286c134 100644 --- a/CHANGES +++ b/CHANGES @@ -1,11 +1,6 @@ -0.26.1 +0.26.2 ------ -* Added Spanish translation of the README (``README.es.rst``) -* When both `content_type` and `headers['content-type']` are in a response mock file, - `content_type` is now used. -* Added strict_match to urlencoded_params_matcher, enabling partial request parameter - matching. * Default headers (such as ``Content-Type``, ``Date`` and ``Server``) are now stripped from recorded files regardless of header name case. Previously, responses recorded from servers that send lowercase header names (for example over HTTP/2) kept these redundant @@ -13,6 +8,15 @@ * Fixed `query_param_matcher` mutating the caller's params dict when numeric values are provided. See #801 +0.26.1 +------ + +* Added Spanish translation of the README (``README.es.rst``) +* When both `content_type` and `headers['content-type']` are in a response mock file, + `content_type` is now used. +* Added strict_match to urlencoded_params_matcher, enabling partial request parameter + matching. + 0.26.0 ------ From 31d28eb21745379bd1ad2154838187e23aa92a3e Mon Sep 17 00:00:00 2001 From: markstory <24086+markstory@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:42:28 +0000 Subject: [PATCH 13/13] release: 0.26.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b3b974ae..7504a635 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ setup( name="responses", - version="0.26.1", + version="0.26.2", author="David Cramer", description="A utility library for mocking out the `requests` Python library.", url="https://github.com/getsentry/responses",