Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: yield form in fastui_form to keep file handler open #170

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions src/python-fastui/fastui/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import typing as _t
from contextlib import asynccontextmanager
from itertools import groupby
from mimetypes import MimeTypes
from operator import itemgetter
Expand Down Expand Up @@ -34,17 +35,18 @@ def __class_getitem__(cls, model: _t.Type[FormModel]) -> fastapi_params.Depends:


def fastui_form(model: _t.Type[FormModel]) -> fastapi_params.Depends:
@asynccontextmanager
async def run_fastui_form(request: fastapi.Request):
async with request.form() as form_data:
model_data = unflatten(form_data)

try:
return model.model_validate(model_data)
except pydantic.ValidationError as e:
raise fastapi.HTTPException(
status_code=422,
detail={'form': e.errors(include_input=False, include_url=False, include_context=False)},
)
try:
yield model.model_validate(model_data)
except pydantic.ValidationError as e:
raise fastapi.HTTPException(
status_code=422,
detail={'form': e.errors(include_input=False, include_url=False, include_context=False)},
)

return fastapi.Depends(run_fastui_form)

Expand Down
61 changes: 37 additions & 24 deletions src/python-fastui/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ def __init__(self, form_data_list: List[Tuple[str, Union[str, UploadFile]]]):
@asynccontextmanager
async def form(self):
yield self._form_data
for value in self._form_data.values():
if isinstance(value, UploadFile):
value.file.close()


def test_simple_form_fields():
Expand Down Expand Up @@ -94,9 +97,9 @@ async def test_simple_form_submit():

request = FakeRequest([('name', 'bar'), ('size', '123')])

m = await form_dep.dependency(request)
assert isinstance(m, SimpleForm)
assert m.model_dump() == {'name': 'bar', 'size': 123}
async with form_dep.dependency(request) as m:
assert isinstance(m, SimpleForm)
assert m.model_dump() == {'name': 'bar', 'size': 123}


async def test_simple_form_submit_repeat():
Expand All @@ -105,7 +108,8 @@ async def test_simple_form_submit_repeat():
request = FakeRequest([('name', 'bar'), ('size', '123'), ('size', '456')])

with pytest.raises(HTTPException) as exc_info:
await form_dep.dependency(request)
async with form_dep.dependency(request):
pass

# insert_assert(exc_info.value.detail)
assert exc_info.value.detail == {
Expand Down Expand Up @@ -156,9 +160,9 @@ async def test_w_nested_form_submit():

request = FakeRequest([('name', 'bar'), ('nested.x', '123')])

m = await form_dep.dependency(request)
assert isinstance(m, FormWithNested)
assert m.model_dump() == {'name': 'bar', 'nested': {'x': 123}}
async with form_dep.dependency(request) as m:
assert isinstance(m, FormWithNested)
assert m.model_dump() == {'name': 'bar', 'nested': {'x': 123}}


class FormWithFile(BaseModel):
Expand Down Expand Up @@ -190,8 +194,9 @@ async def test_file_submit():
file = UploadFile(BytesIO(b'foobar'), size=6, filename='testing.txt')
request = FakeRequest([('profile_pic', file)])

m = await fastui_form(FormWithFile).dependency(request)
assert m.model_dump() == {'profile_pic': file}
async with fastui_form(FormWithFile).dependency(request) as m:
assert m.model_dump() == {'profile_pic': file}
assert not m.profile_pic.file.closed


async def test_file_submit_repeat():
Expand All @@ -200,7 +205,8 @@ async def test_file_submit_repeat():
request = FakeRequest([('profile_pic', file1), ('profile_pic', file2)])

with pytest.raises(HTTPException) as exc_info:
await fastui_form(FormWithFile).dependency(request)
async with fastui_form(FormWithFile).dependency(request):
pass

# insert_assert(exc_info.value.detail)
assert exc_info.value.detail == {
Expand Down Expand Up @@ -239,16 +245,18 @@ async def test_file_constrained_submit():
file = UploadFile(BytesIO(b'foobar'), size=16_000, headers=headers)
request = FakeRequest([('profile_pic', file)])

m = await fastui_form(FormWithFileConstraint).dependency(request)
assert m.model_dump() == {'profile_pic': file}
async with fastui_form(FormWithFileConstraint).dependency(request) as m:
assert m.model_dump() == {'profile_pic': file}
assert not m.profile_pic.file.closed


async def test_file_constrained_submit_filename():
file = UploadFile(BytesIO(b'foobar'), size=16_000, filename='image.png')
request = FakeRequest([('profile_pic', file)])

m = await fastui_form(FormWithFileConstraint).dependency(request)
assert m.model_dump() == {'profile_pic': file}
async with fastui_form(FormWithFileConstraint).dependency(request) as m:
assert m.model_dump() == {'profile_pic': file}
assert not m.profile_pic.file.closed


async def test_file_constrained_submit_too_big():
Expand All @@ -257,7 +265,8 @@ async def test_file_constrained_submit_too_big():
request = FakeRequest([('profile_pic', file)])

with pytest.raises(HTTPException) as exc_info:
await fastui_form(FormWithFileConstraint).dependency(request)
async with fastui_form(FormWithFileConstraint).dependency(request):
pass

# insert_assert(exc_info.value.detail)
assert exc_info.value.detail == {
Expand All @@ -277,7 +286,8 @@ async def test_file_constrained_submit_wrong_type():
request = FakeRequest([('profile_pic', file)])

with pytest.raises(HTTPException) as exc_info:
await fastui_form(FormWithFileConstraint).dependency(request)
async with fastui_form(FormWithFileConstraint).dependency(request):
pass

# insert_assert(exc_info.value.detail)
assert exc_info.value.detail == {
Expand Down Expand Up @@ -323,17 +333,20 @@ async def test_multiple_files_single():
file = UploadFile(BytesIO(b'foobar'), size=16_000, filename='image.png')
request = FakeRequest([('files', file)])

m = await fastui_form(FormMultipleFiles).dependency(request)
assert m.model_dump() == {'files': [file]}
async with fastui_form(FormMultipleFiles).dependency(request) as m:
assert m.model_dump() == {'files': [file]}
assert not m.files[0].file.closed


async def test_multiple_files_multiple():
file1 = UploadFile(BytesIO(b'foobar'), size=6, filename='image1.png')
file2 = UploadFile(BytesIO(b'foobar'), size=6, filename='image2.png')
request = FakeRequest([('files', file1), ('files', file2)])

m = await fastui_form(FormMultipleFiles).dependency(request)
assert m.model_dump() == {'files': [file1, file2]}
async with fastui_form(FormMultipleFiles).dependency(request) as m:
assert m.model_dump() == {'files': [file1, file2]}
assert not m.files[0].file.closed
assert not m.files[1].file.closed


class FixedTuple(BaseModel):
Expand Down Expand Up @@ -379,8 +392,8 @@ def test_fixed_tuple():
async def test_fixed_tuple_submit():
request = FakeRequest([('foo.0', 'bar'), ('foo.1', '123'), ('foo.2', '456')])

m = await fastui_form(FixedTuple).dependency(request)
assert m.model_dump() == {'foo': ('bar', 123, 456)}
async with fastui_form(FixedTuple).dependency(request) as m:
assert m.model_dump() == {'foo': ('bar', 123, 456)}


class NestedTuple(BaseModel):
Expand Down Expand Up @@ -426,8 +439,8 @@ def test_fixed_tuple_nested():
async def test_fixed_tuple_nested_submit():
request = FakeRequest([('bar.foo.0', 'bar'), ('bar.foo.1', '123'), ('bar.foo.2', '456')])

m = await fastui_form(NestedTuple).dependency(request)
assert m.model_dump() == {'bar': {'foo': ('bar', 123, 456)}}
async with fastui_form(NestedTuple).dependency(request) as m:
assert m.model_dump() == {'bar': {'foo': ('bar', 123, 456)}}


def test_variable_tuple():
Expand Down
Loading