Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-respx for openSUSE:Factory checked in at 2024-03-22 15:20:20 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-respx (Old) and /work/SRC/openSUSE:Factory/.python-respx.new.1905 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-respx" Fri Mar 22 15:20:20 2024 rev:7 rq:1160432 version:0.21.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-respx/python-respx.changes 2024-01-10 21:52:37.872913582 +0100 +++ /work/SRC/openSUSE:Factory/.python-respx.new.1905/python-respx.changes 2024-03-22 15:32:21.074817231 +0100 @@ -1,0 +2,13 @@ +Thu Mar 21 17:03:25 UTC 2024 - Dirk Müller <[email protected]> + +- update to 0.21.0: + * Fix matching request data when files are provided + * Add support for data\_\_contains lookup + * Add `files` pattern to support matching on uploads + * Add `SetCookie` utility for easier mocking of response cookie + headers + * Enhance documentation on iterable side effects + * Enhance documentation on named routes and add tip about a + catch-all route + +------------------------------------------------------------------- Old: ---- respx-0.20.2.tar.gz New: ---- respx-0.21.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-respx.spec ++++++ --- /var/tmp/diff_new_pack.3PUCCL/_old 2024-03-22 15:32:21.702840320 +0100 +++ /var/tmp/diff_new_pack.3PUCCL/_new 2024-03-22 15:32:21.702840320 +0100 @@ -17,7 +17,7 @@ Name: python-respx -Version: 0.20.2 +Version: 0.21.0 Release: 0 Summary: Mock HTTPX with request patterns and response side effects License: BSD-3-Clause ++++++ respx-0.20.2.tar.gz -> respx-0.21.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/CHANGELOG.md new/respx-0.21.0/CHANGELOG.md --- old/respx-0.20.2/CHANGELOG.md 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/CHANGELOG.md 2024-03-19 17:19:59.000000000 +0100 @@ -5,6 +5,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - 2024-03-19 + +### Fixed + +- Fix matching request data when files are provided, thanks @ziima for input (#252) + +### Added + +- Add support for data\_\_contains lookup (#252) +- Add `files` pattern to support matching on uploads, thanks @ziima for input (#253) +- Add `SetCookie` utility for easier mocking of response cookie headers (#254) + +### Changed + +- Enhance documentation on iterable side effects (#255) +- Enhance documentation on named routes and add tip about a catch-all route (#257) + ## [0.20.2] - 2023-07-21 ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/docs/api.md new/respx-0.21.0/docs/api.md --- old/respx-0.20.2/docs/api.md 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/docs/api.md 2024-03-19 17:19:59.000000000 +0100 @@ -133,15 +133,17 @@ Shortcut for creating and mocking a `HTTPX` [Response](#response). -> <code>route.<strong>respond</strong>(*status_code=200, headers=None, content=None, text=None, html=None, json=None, stream=None*)</strong></code> +> <code>route.<strong>respond</strong>(*status_code=200, headers=None, cookies=None, content=None, text=None, html=None, json=None, stream=None, content_type=None*)</strong></code> > > **Parameters:** > > * **status_code** - *(optional) int - default: `200`* > Response status code to mock. -> * **headers** - *(optional) dict* +> * **headers** - *(optional) dict | Sequence[tuple[str, str]]* > Response headers to mock. -> * **content** - *(optional) bytes | str | iterable bytes* +> * **cookies** - *(optional) dict | Sequence[tuple[str, str]] | Sequence[SetCookie]* +> Response cookies to mock as `Set-Cookie` headers. See [SetCookie](#setcookie). +> * **content** - *(optional) bytes | str | Iterable[bytes]* > Response raw content to mock. > * **text** - *(optional) str* > Response *text* content to mock, with automatic content-type header added. @@ -151,6 +153,8 @@ > Response *JSON* content to mock, with automatic content-type header added. > * **stream** - *(optional) Iterable[bytes]* > Response *stream* to mock. +> * **content_type** - *(optional) str* +> Response `Content-Type` header to mock. > > **Returns:** `Route` @@ -191,6 +195,24 @@ > * **stream** - *(optional) Iterable[bytes]* > Content *stream*. +!!! tip "Cookies" + Use [respx.SetCookie(...)](#setcookie) to produce `Set-Cookie` headers. + +--- + +## SetCookie + +A utility to render a `("Set-Cookie", <cookie header value>)` tuple. See route [respond](#respond) shortcut for alternative use. + +> <code>respx.<strong>SetCookie</strong>(*name, value, path=None, domain=None, expires=None, max_age=None, http_only=False, same_site=None, secure=False, partitioned=False*)</strong></code> + +``` python +import respx +respx.post("https://example.org/").mock( + return_value=httpx.Response(200, headers=[SetCookie("foo", "bar")]) +) +``` + --- ## Patterns @@ -309,13 +331,24 @@ ``` ### Data -Matches request *form data*, using [eq](#eq) as default lookup. +Matches request *form data*, excluding files, using [eq](#eq) as default lookup. > Key: `data` -> Lookups: [eq](#eq) +> Lookups: [eq](#eq), [contains](#contains) ``` python respx.post("https://example.org/", data={"foo": "bar"}) ``` +### Files +Matches files within request *form data*, using [contains](#contains) as default lookup. +> Key: `files` +> Lookups: [contains](#contains), [eq](#eq) +``` python +respx.post("https://example.org/", files={"some_file": b"..."}) +respx.post("https://example.org/", files={"some_file": ANY}) +respx.post("https://example.org/", files={"some_file": ("filename.txt", b"...")}) +respx.post("https://example.org/", files={"some_file": ("filename.txt", ANY)}) +``` + ### JSON Matches request *json* content, using [eq](#eq) as default lookup. > Key: `json` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/docs/guide.md new/respx-0.21.0/docs/guide.md --- old/respx-0.20.2/docs/guide.md 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/docs/guide.md 2024-03-19 17:19:59.000000000 +0100 @@ -260,15 +260,9 @@ import httpx import respx -from httpx import Response - - -api_mock = respx.mock(assert_all_called=False) -api_mock.route( - url="https://api.foo.bar/baz/", - name="baz", -).mock( - return_value=Response(200, json={"name": "baz"}), +api_mock = respx.mock(base_url="https://api.foo.bar/", assert_all_called=False) +api_mock.get("/baz/", name="baz").mock( + return_value=httpx.Response(200, json={"name": "baz"}), ) ... @@ -286,6 +280,8 @@ ... ``` +!!! tip "Catch-all" + Add a *catch-all* route last as a fallback for any non-matching request, e.g. `api_mock.route().respond(404)`. !!! note "NOTE" Named routes in a *reusable router* can be directly accessed via `my_mock_router[<route name>]` @@ -522,6 +518,29 @@ assert route.call_count == 2 ``` +Once the iterable is *exhausted*, the route will fallback and respond with the `return_value`, if set. + +``` python +import httpx +import respx + + [email protected] +def test_stacked_responses(): + respx.post("https://example.org/").mock( + side_effect=[httpx.Response(201)], + return_value=httpx.Response(200) + ) + + response1 = httpx.post("https://example.org/") + response2 = httpx.post("https://example.org/") + response3 = httpx.post("https://example.org/") + + assert response1.status_code == 201 + assert response2.status_code == 200 + assert response3.status_code == 200 +``` + ### Shortcuts #### Respond diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/flake.lock new/respx-0.21.0/flake.lock --- old/respx-0.20.2/flake.lock 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/flake.lock 2024-03-19 17:19:59.000000000 +0100 @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1681202837, - "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1684266851, - "narHash": "sha256-DCYaTgZpT9BtHrVEJOc1b0J/8eTDa1SRqyGbcisjauM=", + "lastModified": 1710777701, + "narHash": "sha256-hMyIBLJY2VjsM/dOmXta5XdyxcuQoKUkm4M/K0c0xlo=", "owner": "nixos", "repo": "nixpkgs", - "rev": "5b973b9f07c586dfade51f6736db166f5b97d97c", + "rev": "f78a4dcd452449992e526fd88a60a2d45e0ae969", "type": "github" }, "original": { @@ -51,11 +51,11 @@ }, "nixpkgsUnstable": { "locked": { - "lastModified": 1684242266, - "narHash": "sha256-uaCQ2k1bmojHKjWQngvnnnxQJMY8zi1zq527HdWgQf8=", + "lastModified": 1710734606, + "narHash": "sha256-rFJl+WXfksu2NkWJWKGd5Km17ZGEjFg9hOQNwstsoU8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "7e0743a5aea1dc755d4b761daf75b20aa486fdad", + "rev": "79bb4155141a5e68f2bdee2bf6af35b1d27d3a1d", "type": "github" }, "original": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/flake.nix new/respx-0.21.0/flake.nix --- old/respx-0.20.2/flake.nix 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/flake.nix 2024-03-19 17:19:59.000000000 +0100 @@ -13,15 +13,17 @@ pkgsUnstable = nixpkgsUnstable.legacyPackages.${system}; in { packages = flakeUtils.lib.flattenTree { + python312 = pkgs.python312; python311 = pkgs.python311; python310 = pkgs.python310; python39 = pkgs.python39; - python38 = pkgs.python38; + python38 = pkgs22.python38; python37 = pkgs22.python37; go-task = pkgsUnstable.go-task; }; devShell = pkgs.mkShell { buildInputs = with self.packages.${system}; [ + python312 python311 python310 python39 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/noxfile.py new/respx-0.21.0/noxfile.py --- old/respx-0.20.2/noxfile.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/noxfile.py 2024-03-19 17:19:59.000000000 +0100 @@ -5,7 +5,7 @@ nox.options.keywords = "test + mypy" [email protected](python=["3.7", "3.8", "3.9", "3.10", "3.11"]) [email protected](python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]) def test(session): deps = ["pytest", "pytest-asyncio", "pytest-cov", "trio", "starlette", "flask"] session.install("--upgrade", *deps) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/respx/__init__.py new/respx-0.21.0/respx/__init__.py --- old/respx-0.20.2/respx/__init__.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/respx/__init__.py 2024-03-19 17:19:59.000000000 +0100 @@ -2,6 +2,7 @@ from .handlers import ASGIHandler, WSGIHandler from .models import MockResponse, Route from .router import MockRouter, Router +from .utils import SetCookie from .api import ( # isort:skip mock, @@ -24,6 +25,7 @@ options, ) + __all__ = [ "__version__", "MockResponse", @@ -32,6 +34,7 @@ "WSGIHandler", "Router", "Route", + "SetCookie", "mock", "routes", "calls", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/respx/__version__.py new/respx-0.21.0/respx/__version__.py --- old/respx-0.20.2/respx/__version__.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/respx/__version__.py 2024-03-19 17:19:59.000000000 +0100 @@ -1 +1 @@ -__version__ = "0.20.2" +__version__ = "0.21.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/respx/models.py new/respx-0.21.0/respx/models.py --- old/respx-0.20.2/respx/models.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/respx/models.py 2024-03-19 17:19:59.000000000 +0100 @@ -16,10 +16,13 @@ import httpx +from respx.utils import SetCookie + from .patterns import M, Pattern from .types import ( CallableSideEffect, Content, + CookieTypes, HeaderTypes, ResolvedResponseTypes, RouteResultTypes, @@ -90,6 +93,7 @@ content: Optional[Content] = None, content_type: Optional[str] = None, http_version: Optional[str] = None, + cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None, **kwargs: Any, ) -> None: if not isinstance(content, (str, bytes)) and ( @@ -110,6 +114,19 @@ if content_type: self.headers["Content-Type"] = content_type + if cookies: + if isinstance(cookies, dict): + cookies = tuple(cookies.items()) + self.headers = httpx.Headers( + ( + *self.headers.multi_items(), + *( + cookie if isinstance(cookie, SetCookie) else SetCookie(*cookie) + for cookie in cookies + ), + ) + ) + class Route: def __init__( @@ -256,6 +273,7 @@ status_code: int = 200, *, headers: Optional[HeaderTypes] = None, + cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None, content: Optional[Content] = None, text: Optional[str] = None, html: Optional[str] = None, @@ -268,6 +286,7 @@ response = MockResponse( status_code, headers=headers, + cookies=cookies, content=content, text=text, html=html, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/respx/patterns.py new/respx-0.21.0/respx/patterns.py --- old/respx-0.20.2/respx/patterns.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/respx/patterns.py 2024-03-19 17:19:59.000000000 +0100 @@ -1,5 +1,6 @@ import json as jsonlib import operator +import pathlib import re from abc import ABC from enum import Enum @@ -12,6 +13,7 @@ ClassVar, Dict, List, + Mapping, Optional, Pattern as RegexPattern, Sequence, @@ -25,11 +27,15 @@ import httpx +from respx.utils import MultiItems, decode_data + from .types import ( URL as RawURL, CookieTypes, + FileTypes, HeaderTypes, QueryParamTypes, + RequestFiles, URLPatternTypes, ) @@ -536,17 +542,51 @@ return jsonlib.dumps(value, sort_keys=True) -class Data(ContentMixin, Pattern): - lookups = (Lookup.EQUAL,) +class Data(MultiItemsMixin, Pattern): + lookups = (Lookup.EQUAL, Lookup.CONTAINS) key = "data" - value: bytes + value: MultiItems - def clean(self, value: Dict) -> bytes: - request = httpx.Request("POST", "/", data=value) - data = request.read() + def clean(self, value: Dict) -> MultiItems: + return MultiItems(value) + + def parse(self, request: httpx.Request) -> Any: + data, _ = decode_data(request) return data +class Files(MultiItemsMixin, Pattern): + lookups = (Lookup.CONTAINS, Lookup.EQUAL) + key = "files" + value: MultiItems + + def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, ...]: + # Mimic httpx `FileField` to normalize `files` kwarg to shortest tuple style + if isinstance(value, tuple): + filename, fileobj = value[:2] + else: + try: + filename = pathlib.Path(str(getattr(value, "name"))).name # noqa: B009 + except AttributeError: + filename = ANY + fileobj = value + + return filename, fileobj + + def clean(self, value: RequestFiles) -> MultiItems: + if isinstance(value, Mapping): + value = list(value.items()) + + files = MultiItems( + (name, self._normalize_file_value(file_value)) for name, file_value in value + ) + return files + + def parse(self, request: httpx.Request) -> Any: + _, files = decode_data(request) + return files + + def M(*patterns: Pattern, **lookups: Any) -> Pattern: extras = None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/respx/types.py new/respx-0.21.0/respx/types.py --- old/respx-0.20.2/respx/types.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/respx/types.py 2024-03-19 17:19:59.000000000 +0100 @@ -1,4 +1,5 @@ from typing import ( + IO, Any, AsyncIterable, Awaitable, @@ -7,6 +8,7 @@ Iterable, Iterator, List, + Mapping, Optional, Pattern, Sequence, @@ -53,3 +55,17 @@ Type[Exception], Iterator[SideEffectListTypes], ] + +# Borrowed from HTTPX's "private" types. +FileContent = Union[IO[bytes], bytes, str] +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/respx/utils.py new/respx-0.21.0/respx/utils.py --- old/respx-0.20.2/respx/utils.py 1970-01-01 01:00:00.000000000 +0100 +++ new/respx-0.21.0/respx/utils.py 2024-03-19 17:19:59.000000000 +0100 @@ -0,0 +1,138 @@ +import email +from datetime import datetime +from email.message import Message +from typing import Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union, cast +from urllib.parse import parse_qsl + +try: + from typing import Literal # type: ignore[attr-defined] +except ImportError: # pragma: no cover + from typing_extensions import Literal + +import httpx + + +class MultiItems(dict): + def get_list(self, key: str) -> List[str]: + try: + return [self[key]] + except KeyError: # pragma: no cover + return [] + + def multi_items(self) -> List[Tuple[str, str]]: + return list(self.items()) + + +def _parse_multipart_form_data( + content: bytes, *, content_type: str, encoding: str +) -> Tuple[MultiItems, MultiItems]: + form_data = b"\r\n".join( + ( + b"MIME-Version: 1.0", + b"Content-Type: " + content_type.encode(encoding), + b"\r\n" + content, + ) + ) + data = MultiItems() + files = MultiItems() + for payload in email.message_from_bytes(form_data).get_payload(): + payload = cast(Message, payload) + name = payload.get_param("name", header="Content-Disposition") + filename = payload.get_filename() + content_type = payload.get_content_type() + value = payload.get_payload(decode=True) + assert isinstance(value, bytes) + if content_type.startswith("text/") and filename is None: + # Text field + data[name] = value.decode(payload.get_content_charset() or "utf-8") + else: + # File field + files[name] = filename, value + + return data, files + + +def _parse_urlencoded_data(content: bytes, *, encoding: str) -> MultiItems: + return MultiItems( + (key, value) + for key, value in parse_qsl(content.decode(encoding), keep_blank_values=True) + ) + + +def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]: + content = request.read() + content_type = request.headers.get("Content-Type", "") + + if content_type.startswith("multipart/form-data"): + data, files = _parse_multipart_form_data( + content, + content_type=content_type, + encoding=request.headers.encoding, + ) + else: + data = _parse_urlencoded_data( + content, + encoding=request.headers.encoding, + ) + files = MultiItems() + + return data, files + + +Self = TypeVar("Self", bound="SetCookie") + + +class SetCookie( + NamedTuple( + "SetCookie", + [ + ("header_name", Literal["Set-Cookie"]), + ("header_value", str), + ], + ) +): + def __new__( + cls: Type[Self], + name: str, + value: str, + *, + path: Optional[str] = None, + domain: Optional[str] = None, + expires: Optional[Union[str, datetime]] = None, + max_age: Optional[int] = None, + http_only: bool = False, + same_site: Optional[Literal["Strict", "Lax", "None"]] = None, + secure: bool = False, + partitioned: bool = False, + ) -> Self: + """ + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#syntax + """ + attrs: Dict[str, Union[str, bool]] = {name: value} + if path is not None: + attrs["Path"] = path + if domain is not None: + attrs["Domain"] = domain + if expires is not None: + if isinstance(expires, datetime): # pragma: no branch + expires = expires.strftime("%a, %d %b %Y %H:%M:%S GMT") + attrs["Expires"] = expires + if max_age is not None: + attrs["Max-Age"] = str(max_age) + if http_only: + attrs["HttpOnly"] = True + if same_site is not None: + attrs["SameSite"] = same_site + if same_site == "None": # pragma: no branch + secure = True + if secure: + attrs["Secure"] = True + if partitioned: + attrs["Partitioned"] = True + + string = "; ".join( + _name if _value is True else f"{_name}={_value}" + for _name, _value in attrs.items() + ) + self = super().__new__(cls, "Set-Cookie", string) + return self diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/setup.py new/respx-0.21.0/setup.py --- old/respx-0.20.2/setup.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/setup.py 2024-03-19 17:19:59.000000000 +0100 @@ -28,6 +28,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], project_urls={ "GitHub": "https://github.com/lundberg/respx", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/tests/test_api.py new/respx-0.21.0/tests/test_api.py --- old/respx-0.20.2/tests/test_api.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/tests/test_api.py 2024-03-19 17:19:59.000000000 +0100 @@ -263,6 +263,25 @@ assert get_route.called +def test_data_post_body(): + with respx.mock: + url = "https://foo.bar/" + route = respx.post(url, data={"foo": "bar"}) % 201 + response = httpx.post(url, data={"foo": "bar"}, files={"file": b"..."}) + assert response.status_code == 201 + assert route.called + + +def test_files_post_body(): + with respx.mock: + url = "https://foo.bar/" + file = ("file", ("filename.txt", b"...", "text/plain", {"X-Foo": "bar"})) + route = respx.post(url, files={"file": mock.ANY}) % 201 + response = httpx.post(url, files=[file]) + assert response.status_code == 201 + assert route.called + + async def test_raising_content(client): async with MockRouter() as respx_mock: url = "https://foo.bar/" @@ -545,6 +564,46 @@ route.respond(content=Exception()) # type: ignore[arg-type] +def test_can_respond_with_cookies(): + with respx.mock: + route = respx.get("https://foo.bar/").respond( + json={}, headers={"X-Foo": "bar"}, cookies={"foo": "bar", "ham": "spam"} + ) + response = httpx.get("https://foo.bar/") + assert len(response.headers) == 5 + assert response.headers["X-Foo"] == "bar", "mocked header is missing" + assert len(response.cookies) == 2 + assert response.cookies["foo"] == "bar" + assert response.cookies["ham"] == "spam" + + route.respond(cookies=[("egg", "yolk")]) + response = httpx.get("https://foo.bar/") + assert len(response.cookies) == 1 + assert response.cookies["egg"] == "yolk" + + route.respond( + cookies=[respx.SetCookie("foo", "bar", path="/", same_site="Lax")] + ) + response = httpx.get("https://foo.bar/") + assert len(response.cookies) == 1 + assert response.cookies["foo"] == "bar" + + +def test_can_mock_response_with_set_cookie_headers(): + request = httpx.Request("GET", "https://example.com/") + response = httpx.Response( + 200, + headers=[ + respx.SetCookie("foo", value="bar"), + respx.SetCookie("ham", value="spam"), + ], + request=request, + ) + assert len(response.cookies) == 2 + assert response.cookies["foo"] == "bar" + assert response.cookies["ham"] == "spam" + + @pytest.mark.parametrize( "kwargs", [ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/tests/test_patterns.py new/respx-0.21.0/tests/test_patterns.py --- old/respx-0.20.2/tests/test_patterns.py 2023-07-21 00:41:35.000000000 +0200 +++ new/respx-0.21.0/tests/test_patterns.py 2024-03-19 17:19:59.000000000 +0100 @@ -10,6 +10,7 @@ Content, Cookies, Data, + Files, Headers, Host, Lookup, @@ -323,14 +324,175 @@ @pytest.mark.parametrize( - ("lookup", "data", "expected"), + ("lookup", "data", "request_data", "expected"), [ - (Lookup.EQUAL, {"foo": "bar", "ham": "spam"}, True), + ( + Lookup.EQUAL, + {"foo": "bar", "ham": "spam"}, + None, + True, + ), + ( + Lookup.EQUAL, + {"foo": "bar", "ham": "spam"}, + {"ham": "spam", "foo": "bar"}, + True, + ), + ( + Lookup.EQUAL, + {"uni": "äpple", "mixed": "GehäusegröÃe"}, + None, + True, + ), + ( + Lookup.EQUAL, + {"blank_value": ""}, + None, + True, + ), + ( + Lookup.EQUAL, + {"x": "a"}, + {"x": "b"}, + False, + ), + ( + Lookup.EQUAL, + {"foo": "bar"}, + {"foo": "bar", "ham": "spam"}, + False, + ), + ( + Lookup.CONTAINS, + {"foo": "bar"}, + {"foo": "bar", "ham": "spam"}, + True, + ), + ], +) +def test_data_pattern(lookup, data, request_data, expected): + request_with_data = httpx.Request( + "POST", + "https://foo.bar/", + data=request_data or data, + ) + request_with_data_and_files = httpx.Request( + "POST", + "https://foo.bar/", + data=request_data or data, + files={"upload-file": ("report.xls", b"<...>", "application/vnd.ms-excel")}, + ) + + match = Data(data, lookup=lookup).match(request_with_data) + assert bool(match) is expected + + match = Data(data, lookup=lookup).match(request_with_data_and_files) + assert bool(match) is expected + + [email protected]( + ("lookup", "files", "request_files", "expected"), + [ + ( + Lookup.EQUAL, + [("file_1", b"foo..."), ("file_2", b"bar...")], + None, + True, + ), + ( + Lookup.EQUAL, + {"file_1": b"foo...", "file_2": b"bar..."}, + None, + True, + ), + ( + Lookup.EQUAL, + {"file_1": ANY}, + {"file_1": b"foobar..."}, + True, + ), + ( + Lookup.EQUAL, + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"bar..."), + }, + None, + True, + ), + ( + Lookup.EQUAL, + {"file_1": ("filename_1.txt", ANY)}, + {"file_1": ("filename_1.txt", b"...")}, + True, + ), + ( + Lookup.EQUAL, + {"upload": b"foo..."}, + {"upload": b"bar..."}, # Wrong file data + False, + ), + ( + Lookup.EQUAL, + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"bar..."), + }, + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"ham..."), # Wrong file data + }, + False, + ), + ( + Lookup.CONTAINS, + { + "file_1": ("filename_1.txt", b"foo..."), + }, + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"bar..."), + }, + True, + ), + ( + Lookup.CONTAINS, + { + "file_1": ("filename_1.txt", ANY), + }, + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"bar..."), + }, + True, + ), + ( + Lookup.CONTAINS, + [("file_1", ANY)], + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"bar..."), + }, + True, + ), + ( + Lookup.CONTAINS, + [("file_1", b"ham...")], + { + "file_1": ("filename_1.txt", b"foo..."), + "file_2": ("filename_2.txt", b"bar..."), + }, + False, + ), ], ) -def test_data_pattern(lookup, data, expected): - request = httpx.Request("POST", "https://foo.bar/", data=data) - match = Data(data, lookup=lookup).match(request) +def test_files_pattern(lookup, files, request_files, expected): + request = httpx.Request( + "POST", + "https://foo.bar/", + files=request_files or files, + ) + match = Files(files, lookup=lookup).match(request) assert bool(match) is expected diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/respx-0.20.2/tests/test_utils.py new/respx-0.21.0/tests/test_utils.py --- old/respx-0.20.2/tests/test_utils.py 1970-01-01 01:00:00.000000000 +0100 +++ new/respx-0.21.0/tests/test_utils.py 2024-03-19 17:19:59.000000000 +0100 @@ -0,0 +1,33 @@ +from datetime import datetime, timezone + +from respx.utils import SetCookie + + +class TestSetCookie: + def test_can_render_all_attributes(self) -> None: + expires = datetime.fromtimestamp(0, tz=timezone.utc) + cookie = SetCookie( + "foo", + value="bar", + path="/", + domain=".example.com", + expires=expires, + max_age=44, + http_only=True, + same_site="None", + partitioned=True, + ) + assert cookie == ( + "Set-Cookie", + ( + "foo=bar; " + "Path=/; " + "Domain=.example.com; " + "Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + "Max-Age=44; " + "HttpOnly; " + "SameSite=None; " + "Secure; " + "Partitioned" + ), + )
