Skip to content

Commit 4920b07

Browse files
committed
Add files pattern
1 parent 07ae887 commit 4920b07

File tree

4 files changed

+170
-0
lines changed

4 files changed

+170
-0
lines changed

docs/api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,17 @@ Matches request *form data*, excluding files, using [eq](#eq) as default lookup.
316316
respx.post("https://example.org/", data={"foo": "bar"})
317317
```
318318

319+
### Files
320+
Matches files within request *form data*, using [contains](#contains) as default lookup.
321+
> Key: `files`
322+
> Lookups: [contains](#contains), [eq](#eq)
323+
``` python
324+
respx.post("https://example.org/", files={"some_file": b"..."})
325+
respx.post("https://example.org/", files={"some_file": ANY})
326+
respx.post("https://example.org/", files={"some_file": ("filename.txt", b"...")})
327+
respx.post("https://example.org/", files={"some_file": ("filename.txt", ANY)})
328+
```
329+
319330
### JSON
320331
Matches request *json* content, using [eq](#eq) as default lookup.
321332
> Key: `json`

respx/patterns.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json as jsonlib
22
import operator
3+
import pathlib
34
import re
45
from abc import ABC
56
from enum import Enum
@@ -12,6 +13,7 @@
1213
ClassVar,
1314
Dict,
1415
List,
16+
Mapping,
1517
Optional,
1618
Pattern as RegexPattern,
1719
Sequence,
@@ -30,8 +32,10 @@
3032
from .types import (
3133
URL as RawURL,
3234
CookieTypes,
35+
FileTypes,
3336
HeaderTypes,
3437
QueryParamTypes,
38+
RequestFiles,
3539
URLPatternTypes,
3640
)
3741

@@ -551,6 +555,38 @@ def parse(self, request: httpx.Request) -> Any:
551555
return data
552556

553557

558+
class Files(MultiItemsMixin, Pattern):
559+
lookups = (Lookup.CONTAINS, Lookup.EQUAL)
560+
key = "files"
561+
value: MultiItems
562+
563+
def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, ...]:
564+
# Mimic httpx `FileField` to normalize `files` kwarg to shortest tuple style
565+
if isinstance(value, tuple):
566+
filename, fileobj = value[:2]
567+
else:
568+
try:
569+
filename = pathlib.Path(str(getattr(value, "name"))).name # noqa: B009
570+
except AttributeError:
571+
filename = ANY
572+
fileobj = value
573+
574+
return filename, fileobj
575+
576+
def clean(self, value: RequestFiles) -> MultiItems:
577+
if isinstance(value, Mapping):
578+
value = list(value.items())
579+
580+
files = MultiItems(
581+
(name, self._normalize_file_value(file_value)) for name, file_value in value
582+
)
583+
return files
584+
585+
def parse(self, request: httpx.Request) -> Any:
586+
_, files = decode_data(request)
587+
return files
588+
589+
554590
def M(*patterns: Pattern, **lookups: Any) -> Pattern:
555591
extras = None
556592

respx/types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import (
2+
IO,
23
Any,
34
AsyncIterable,
45
Awaitable,
@@ -7,6 +8,7 @@
78
Iterable,
89
Iterator,
910
List,
11+
Mapping,
1012
Optional,
1113
Pattern,
1214
Sequence,
@@ -53,3 +55,17 @@
5355
Type[Exception],
5456
Iterator[SideEffectListTypes],
5557
]
58+
59+
# Borrowed from HTTPX's "private" types.
60+
FileContent = Union[IO[bytes], bytes, str]
61+
FileTypes = Union[
62+
# file (or bytes)
63+
FileContent,
64+
# (filename, file (or bytes))
65+
Tuple[Optional[str], FileContent],
66+
# (filename, file (or bytes), content_type)
67+
Tuple[Optional[str], FileContent, Optional[str]],
68+
# (filename, file (or bytes), content_type, headers)
69+
Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
70+
]
71+
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]

tests/test_patterns.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Content,
1111
Cookies,
1212
Data,
13+
Files,
1314
Headers,
1415
Host,
1516
Lookup,
@@ -389,6 +390,112 @@ def test_data_pattern(lookup, data, request_data, expected):
389390
assert bool(match) is expected
390391

391392

393+
@pytest.mark.parametrize(
394+
("lookup", "files", "request_files", "expected"),
395+
[
396+
(
397+
Lookup.EQUAL,
398+
[("file_1", b"foo..."), ("file_2", b"bar...")],
399+
None,
400+
True,
401+
),
402+
(
403+
Lookup.EQUAL,
404+
{"file_1": b"foo...", "file_2": b"bar..."},
405+
None,
406+
True,
407+
),
408+
(
409+
Lookup.EQUAL,
410+
{"file_1": ANY},
411+
{"file_1": b"foobar..."},
412+
True,
413+
),
414+
(
415+
Lookup.EQUAL,
416+
{
417+
"file_1": ("filename_1.txt", b"foo..."),
418+
"file_2": ("filename_2.txt", b"bar..."),
419+
},
420+
None,
421+
True,
422+
),
423+
(
424+
Lookup.EQUAL,
425+
{"file_1": ("filename_1.txt", ANY)},
426+
{"file_1": ("filename_1.txt", b"...")},
427+
True,
428+
),
429+
(
430+
Lookup.EQUAL,
431+
{"upload": b"foo..."},
432+
{"upload": b"bar..."}, # Wrong file data
433+
False,
434+
),
435+
(
436+
Lookup.EQUAL,
437+
{
438+
"file_1": ("filename_1.txt", b"foo..."),
439+
"file_2": ("filename_2.txt", b"bar..."),
440+
},
441+
{
442+
"file_1": ("filename_1.txt", b"foo..."),
443+
"file_2": ("filename_2.txt", b"ham..."), # Wrong file data
444+
},
445+
False,
446+
),
447+
(
448+
Lookup.CONTAINS,
449+
{
450+
"file_1": ("filename_1.txt", b"foo..."),
451+
},
452+
{
453+
"file_1": ("filename_1.txt", b"foo..."),
454+
"file_2": ("filename_2.txt", b"bar..."),
455+
},
456+
True,
457+
),
458+
(
459+
Lookup.CONTAINS,
460+
{
461+
"file_1": ("filename_1.txt", ANY),
462+
},
463+
{
464+
"file_1": ("filename_1.txt", b"foo..."),
465+
"file_2": ("filename_2.txt", b"bar..."),
466+
},
467+
True,
468+
),
469+
(
470+
Lookup.CONTAINS,
471+
[("file_1", ANY)],
472+
{
473+
"file_1": ("filename_1.txt", b"foo..."),
474+
"file_2": ("filename_2.txt", b"bar..."),
475+
},
476+
True,
477+
),
478+
(
479+
Lookup.CONTAINS,
480+
[("file_1", b"ham...")],
481+
{
482+
"file_1": ("filename_1.txt", b"foo..."),
483+
"file_2": ("filename_2.txt", b"bar..."),
484+
},
485+
False,
486+
),
487+
],
488+
)
489+
def test_files_pattern(lookup, files, request_files, expected):
490+
request = httpx.Request(
491+
"POST",
492+
"https://foo.bar/",
493+
files=request_files or files,
494+
)
495+
match = Files(files, lookup=lookup).match(request)
496+
assert bool(match) is expected
497+
498+
392499
@pytest.mark.parametrize(
393500
("lookup", "value", "json", "expected"),
394501
[

0 commit comments

Comments
 (0)