Skip to content

Commit e5df699

Browse files
authored
Native multipart in contrib (#288)
- Add contrib multipart
1 parent 7341cbf commit e5df699

16 files changed

Lines changed: 1150 additions & 65 deletions

File tree

docs/en/docs/release-notes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ hide:
1919
### Changed
2020

2121
- Added `python-multipart` as part of the `all` and `standard` Lilya packages.
22+
- Replaced `python-multipart` with a fully native multipart, urlencoded, and octet-stream form parser.
23+
- Improved RFC 5987 parameter decoding for proper handling of UTF-8 filenames and headers.
24+
2225

2326
## 0.20.5
2427

docs_src/routing/routes/routes_fall_through_sniff.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ async def user_state_update(request):
1212
raise ContinueRouting()
1313
try:
1414
jsonob = await request.json()
15-
except Exception:
15+
except Exception: # noqa
1616
raise ContinueRouting()
1717
if jsonob.get("type") != "update_state":
1818
raise ContinueRouting()

lilya/_internal/_parsers.py

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,11 @@
1111
import anyio
1212
from anyio import SpooledTemporaryFile
1313

14+
from lilya.contrib.multipart import parsers as multipart
15+
from lilya.contrib.multipart.utils import _decode_rfc5987, parse_options_header
1416
from lilya.datastructures import DataUpload, FormData, Header
1517
from lilya.enums import FormMessage
1618

17-
try:
18-
import python_multipart as multipart
19-
from python_multipart.multipart import parse_options_header
20-
except ModuleNotFoundError: # pragma: nocover
21-
# old import name
22-
try:
23-
import multipart # type: ignore[no-redef]
24-
from multipart.multipart import parse_options_header # type: ignore[no-redef]
25-
except ModuleNotFoundError: # pragma: nocover
26-
parse_options_header = None
27-
multipart = None
28-
2919

3020
@lru_cache(1024)
3121
def cookie_parser(cookie_string: str | bytes) -> dict[str, str]:
@@ -160,7 +150,17 @@ async def parse(self) -> FormData:
160150
"on_end": self.on_end,
161151
}
162152

163-
parser = multipart.QuerystringParser(callbacks)
153+
parser = multipart.QuerystringParser()
154+
if hasattr(parser, "set_callback"):
155+
for cb_name, func in callbacks.items():
156+
name = cb_name[3:] if cb_name.startswith("on_") else cb_name
157+
try:
158+
parser.set_callback(name, func)
159+
except Exception: # noqa
160+
pass
161+
elif hasattr(parser, "callbacks") and isinstance(parser.callbacks, dict):
162+
parser.callbacks.update(callbacks)
163+
164164
field_name = b""
165165
field_value = b""
166166
items: list[tuple[str, str | DataUpload]] = []
@@ -319,30 +319,31 @@ def on_headers_finished(self) -> None:
319319
self._handle_no_filename()
320320

321321
def _set_field_name(self, options: dict[bytes, bytes]) -> None:
322-
"""
323-
Set the field name based on options in Content-Disposition header.
324-
325-
Args:
326-
options (Dict[bytes, bytes]): Parsed options from the Content-Disposition header.
327-
"""
328-
try:
322+
"""Set the field name based on options; support RFC5987 name*."""
323+
if b"name" in options:
329324
self._current_part.field_name = _user_safe_decode(options[b"name"], self._charset)
330-
except KeyError:
331-
raise MultiPartException(
332-
'The Content-Disposition header field "name" must be provided.'
333-
) from None
325+
return
326+
# RFC5987 name*
327+
raw = options.get(b"name*")
328+
if raw is not None:
329+
decoded = _decode_rfc5987(raw, self._charset)
330+
self._current_part.field_name = decoded
331+
return
332+
raise MultiPartException(
333+
'The Content-Disposition header field "name" must be provided.'
334+
) from None
334335

335336
def _handle_filename(self, options: dict[bytes, bytes]) -> None:
336-
"""
337-
Handle the case when the part has a filename.
338-
339-
Args:
340-
options (Dict[bytes, bytes]): Parsed options from the Content-Disposition header.
341-
"""
337+
"""Handle the case when the part has a filename. Support RFC5987 filename*."""
342338
self._current_files += 1
343339
self._validate_files_count()
344340

345-
filename = _user_safe_decode(options[b"filename"], self._charset)
341+
filename = ""
342+
if b"filename" in options:
343+
filename = _user_safe_decode(options[b"filename"], self._charset)
344+
elif b"filename*" in options:
345+
filename = _decode_rfc5987(options[b"filename*"], self._charset)
346+
346347
tempfile = self._create_temp_file()
347348
self._current_part.file = self._create_upload_file(filename, tempfile)
348349

@@ -483,15 +484,26 @@ def _create_multipart_parser(
483484
) -> multipart.MultipartParser:
484485
"""
485486
Create the multipart parser with the specified boundary and callbacks.
486-
487-
Args:
488-
boundary (bytes): Multipart boundary.
489-
callbacks (Dict[str, Callable]): Callbacks dictionary.
490-
491-
Returns:
492-
multipart.MultipartParser: Created multipart parser.
493-
"""
494-
return multipart.MultipartParser(boundary, cast(Any, callbacks))
487+
Works with lilya.contrib.multipart (preferred).
488+
"""
489+
# Our contrib parser signature is MultipartParser(boundary, *[, max_size=...])
490+
mp = multipart.MultipartParser(boundary)
491+
# If it exposes set_callback(name, func) use that, stripping 'on_'
492+
if hasattr(mp, "set_callback"):
493+
for cb_name, func in callbacks.items():
494+
name = cb_name[3:] if cb_name.startswith("on_") else cb_name
495+
try:
496+
mp.set_callback(name, func)
497+
except Exception: # noqa
498+
pass
499+
# Or store into callbacks dict if present
500+
if hasattr(mp, "callbacks") and isinstance(mp.callbacks, dict):
501+
for cb_name, func in callbacks.items():
502+
try:
503+
mp.callbacks[cb_name] = func
504+
except Exception: # noqa
505+
pass
506+
return mp
495507

496508
async def _write_file_data(self) -> None:
497509
"""

lilya/_internal/_responses.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ def _structure_to_annotation(self, annotation: Any, value: Any) -> Any:
390390
for a in non_none:
391391
try:
392392
return self._structure_to_annotation(a, value)
393-
except Exception:
393+
except Exception: # noqa
394394
continue
395395
return value # fallback
396396

@@ -430,8 +430,8 @@ def _structure_to_annotation(self, annotation: Any, value: Any) -> Any:
430430
# Best-effort fast path when annotation is a runtime class/type
431431
if isinstance(annotation, type) and isinstance(value, annotation):
432432
return value
433-
except Exception:
434-
pass
433+
except Exception: # noqa
434+
...
435435

436436
return apply_structure(structure=annotation, value=value)
437437

lilya/conf/global_settings.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def safe_get_type_hints(cls: type) -> dict[str, Any]:
4141
"""
4242
try:
4343
return get_type_hints(cls, include_extras=True)
44-
except Exception:
44+
except Exception: # noqa
4545
return cls.__annotations__
4646

4747

@@ -124,7 +124,7 @@ def _cast(self, value: str, typ: type[Any]) -> Any:
124124
if typ is bool or str(typ) == "bool":
125125
return value.lower() in self.__truthy__
126126
return typ(value)
127-
except Exception:
127+
except Exception: # noqa
128128
if get_origin(typ) is Union or get_origin(UnionType):
129129
type_name = " | ".join(
130130
t.__name__ if hasattr(t, "__name__") else str(t) for t in get_args(typ)
@@ -171,7 +171,7 @@ def dict(
171171
continue
172172
result_key = name.upper() if upper else name
173173
result[result_key] = value
174-
except Exception:
174+
except Exception: # noqa
175175
# Skip properties that raise errors
176176
continue
177177

lilya/contrib/mail/backends/smtp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def _quit(client: smtplib.SMTP) -> None:
162162
finally:
163163
try:
164164
client.close()
165-
except Exception:
165+
except Exception: # noqa
166166
...
167167

168168
await anyio.to_thread.run_sync(_quit, self._client)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from .exceptions import (
2+
DecodeError,
3+
FileError,
4+
FormParserError,
5+
MultipartParseError,
6+
ParseError,
7+
QuerystringParseError,
8+
)
9+
from .form import (
10+
Field,
11+
File,
12+
FormParser,
13+
create_form_parser,
14+
parse_form,
15+
)
16+
from .parsers import (
17+
BaseParser,
18+
MultipartParser,
19+
OctetStreamParser,
20+
QuerystringParser,
21+
)
22+
23+
__all__ = [
24+
# exceptions
25+
"ParseError",
26+
"DecodeError",
27+
"FileError",
28+
"FormParserError",
29+
"MultipartParseError",
30+
"QuerystringParseError",
31+
# parsers
32+
"BaseParser",
33+
"MultipartParser",
34+
"QuerystringParser",
35+
"OctetStreamParser",
36+
# form helpers
37+
"File",
38+
"Field",
39+
"FormParser",
40+
"create_form_parser",
41+
"parse_form",
42+
]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
class ParseError(Exception):
2+
"""
3+
Base class for all parsing-related errors in Lilya’s multipart/querystring
4+
handling system.
5+
6+
This should not be raised directly; instead, raise one of the more specific
7+
subclasses depending on the context (decoding, multipart, querystring, etc.).
8+
"""
9+
10+
...
11+
12+
13+
class DecodeError(ParseError):
14+
"""
15+
Raised when a header, parameter, or field value cannot be decoded properly.
16+
17+
Typical causes:
18+
- Invalid character encoding in a multipart header (e.g. malformed UTF-8).
19+
- Unsupported or malformed Content-Transfer-Encoding.
20+
"""
21+
22+
...
23+
24+
25+
class FileError(ParseError):
26+
"""
27+
Raised when an error occurs while handling uploaded file data.
28+
29+
Examples:
30+
- Failure to create or write to a temporary file.
31+
- Exceeding file size limits while streaming data.
32+
"""
33+
34+
...
35+
36+
37+
class FormParserError(ParseError):
38+
"""
39+
Raised when the high-level FormParser cannot be created or executed.
40+
41+
Examples:
42+
- Invalid or missing Content-Type headers.
43+
- Unsupported form encoding (not multipart, urlencoded, or octet-stream).
44+
"""
45+
46+
...
47+
48+
49+
class MultipartParseError(ParseError):
50+
"""
51+
Raised when a multipart/form-data body cannot be parsed successfully.
52+
53+
Examples:
54+
- Missing or invalid boundary parameter.
55+
- Truncated or malformed multipart segments.
56+
- Exceeding the maximum allowed number of parts or fields.
57+
"""
58+
59+
...
60+
61+
62+
class QuerystringParseError(ParseError):
63+
"""
64+
Raised when a query string or `application/x-www-form-urlencoded`
65+
body cannot be parsed successfully.
66+
67+
Examples:
68+
- Invalid percent-encoding in keys or values.
69+
- Malformed key/value pairs (missing '=' separator).
70+
"""
71+
72+
...

0 commit comments

Comments
 (0)