Skip to content

Commit bf88ddb

Browse files
authored
Merge branch 'main' into feat/sse-ping-interval
2 parents 5faaeba + 69a1ad9 commit bf88ddb

File tree

11 files changed

+162
-199
lines changed

11 files changed

+162
-199
lines changed

.github/workflows/cherry-pick-v2.yaml

Lines changed: 0 additions & 99 deletions
This file was deleted.

docs/release-notes/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,9 @@
371371
Add a new attribute :attr:`~litestar.middleware.ASGIMiddleware.should_bypass_for_scope`;
372372
A callable which takes in a :class:`~litestar.types.Scope` and returns a boolean
373373
to indicate whether to bypass the middleware for the current request.
374+
375+
.. change:: Fix KeyError when ClassVar exists on msgspec Struct
376+
:type: bugfix
377+
:pr: 4665
378+
379+
Fix a bug in :class:`MsgspecDTO` where a KeyError was raised if a :class:`msgspec.Struct` contained a :class:`~typing.ClassVar`. ClassVars are now correctly skipped when generating field definitions.

litestar/_kwargs/extractors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,11 @@ async def _extract_multipart(
371371

372372
for name, tp in field_definition.get_type_hints().items():
373373
value = form_values.get(name)
374+
if value == "" and is_optional_union(tp):
375+
inner: Any = make_non_optional_union(tp)
376+
if isinstance(inner, type) and issubclass(inner, UploadFile):
377+
form_values[name] = None # pyright: ignore
378+
continue
374379
if (
375380
value is not None
376381
and not isinstance(value, list)

litestar/datastructures/headers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,15 +398,16 @@ def priority(self) -> tuple[int, int]:
398398
# Use fixed point values with two decimals to avoid problems
399399
# when comparing float values
400400
quality = 100
401-
if "q" in self.params:
401+
qparam = self.params.get("q")
402+
if qparam is not None:
402403
with suppress(ValueError):
403-
quality = int(100 * float(self.params["q"]))
404+
quality = int(100 * float(qparam))
404405

405406
if self.maintype == "*":
406407
specificity = 0
407408
elif self.subtype == "*":
408409
specificity = 1
409-
elif not self.params or ("q" in self.params and len(self.params) == 1):
410+
elif not self.params or (qparam is not None and len(self.params) == 1):
410411
# no params or 'q' is the only one which we ignore
411412
specificity = 2
412413
else:

litestar/dto/msgspec_dto.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def generate_field_definitions(cls, model_type: type[Struct]) -> Generator[DTOFi
4848
property_fields = cls.get_property_fields(model_type)
4949

5050
for key, field_definition in cls.get_model_type_hints(model_type).items():
51+
if key not in inspect_fields:
52+
continue
5153
kwarg_definition, extra = kwarg_definition_from_field(inspect_fields[key])
5254
field_definition = dataclasses.replace(field_definition, kwarg_definition=kwarg_definition)
5355
field_definition.extra.update(extra)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ dev = [
146146
"psutil>=5.9.8",
147147
"hypercorn>=0.16.0",
148148
"daphne>=4.0.0",
149-
"opentelemetry-sdk<1.40.0",
149+
"opentelemetry-sdk",
150150
"httpx-sse",
151151
"structlog",
152152
]

tests/unit/test_contrib/test_msgspec.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import itertools
44
from dataclasses import replace
5-
from typing import TYPE_CHECKING, Annotated
5+
from typing import TYPE_CHECKING, Annotated, ClassVar
66
from unittest.mock import ANY
77

88
import pytest
@@ -216,3 +216,15 @@ def handler_3(data: Model3) -> None:
216216
"required": ["foo", "regular_field"],
217217
"title": "Model3",
218218
}
219+
220+
221+
def test_msgspec_dto_with_classvar() -> None:
222+
class ModelWithClassVar(Struct):
223+
regular_field: str
224+
class_field: ClassVar[str] = "a string in the class"
225+
226+
field_defs = list(MsgspecDTO.generate_field_definitions(ModelWithClassVar))
227+
228+
# Only the regular field should be included, not the ClassVar
229+
assert len(field_defs) == 1
230+
assert field_defs[0].name == "regular_field"

tests/unit/test_contrib/test_opentelemetry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def resource() -> Resource:
3131

3232
@pytest.fixture(scope="session")
3333
def reader() -> InMemoryMetricReader:
34-
aggregation_last_value = {Counter: ExplicitBucketHistogramAggregation()}
34+
aggregation_last_value = {Counter: ExplicitBucketHistogramAggregation(boundaries=[])}
3535
return InMemoryMetricReader(preferred_aggregation=aggregation_last_value) # type: ignore[arg-type]
3636

3737

@@ -104,7 +104,7 @@ def handler() -> dict:
104104
resource_metrics = metric_data.resource_metrics[0]
105105
assert resource_metrics.scope_metrics
106106

107-
scope_metrics = resource_metrics.scope_metrics[0]
107+
scope_metrics = next(sm for sm in resource_metrics.scope_metrics if sm.scope.name != "opentelemetry-sdk")
108108
assert scope_metrics.metrics
109109

110110
request_metric = scope_metrics.metrics[0]

tests/unit/test_datastructures/test_headers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,6 @@ def test_etag_to_header_weak() -> None:
358358
(
359359
("text/plain", ["text/plain"], "text/plain"),
360360
("text/plain", [MediaType.TEXT], MediaType.TEXT),
361-
("text/plain", ["text/plain"], "text/plain"),
362361
("text/plain", ["text/html"], None),
363362
("text/*", ["text/html"], "text/html"),
364363
("*/*", ["text/html"], "text/html"),
@@ -368,6 +367,7 @@ def test_etag_to_header_weak() -> None:
368367
("text/plain", ["text/*"], "text/plain"),
369368
("text/html", ["*/*"], "text/html"),
370369
("text/plain;q=0.8,text/html", ["text/plain", "text/html"], "text/html"),
370+
("text/plain;q=ab,text/html", ["text/plain", "text/html"], "text/plain"),
371371
("text/*,text/html", ["text/plain", "text/html"], "text/html"),
372372
),
373373
)

tests/unit/test_kwargs/test_multipart_data.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,39 @@ async def consistency_handler(request: Request) -> str:
652652

653653
# Results should be identical
654654
assert response_url.text == response_multipart.text
655+
656+
657+
# https://github.com/litestar-org/litestar/issues/4647
658+
def test_optional_upload_file_without_file_submitted() -> None:
659+
"""Optional[UploadFile] in a dataclass should be None when no file is submitted via multipart."""
660+
661+
@dataclass
662+
class UploadForm:
663+
file: Optional[UploadFile] = None
664+
665+
@post("/", signature_namespace={"UploadForm": UploadForm, "UploadFile": UploadFile})
666+
async def handler(
667+
data: Annotated[UploadForm, Body(media_type=RequestEncodingType.MULTI_PART)],
668+
) -> str:
669+
return "none" if data.file is None else "file"
670+
671+
with create_test_client([handler]) as client:
672+
# Simulate browser submitting form without selecting a file (sends filename="" with empty body)
673+
response = client.post(
674+
"/",
675+
content=(
676+
b"--testboundary\r\n"
677+
b'Content-Disposition: form-data; name="file"; filename=""\r\n'
678+
b"Content-Type: application/octet-stream\r\n\r\n"
679+
b"\r\n"
680+
b"--testboundary--\r\n"
681+
),
682+
headers={"Content-Type": "multipart/form-data; boundary=testboundary"},
683+
)
684+
assert response.status_code == HTTP_201_CREATED
685+
assert response.text == "none"
686+
687+
# Submitting with an actual file should still work
688+
response = client.post("/", files={"file": ("test.txt", b"hello", "text/plain")})
689+
assert response.status_code == HTTP_201_CREATED
690+
assert response.text == "file"

0 commit comments

Comments
 (0)