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&#xE4;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"
+            ),
+        )

Reply via email to