Skip to content

Commit 9cf058e

Browse files
Support tuples in forms (#52)
Co-authored-by: Samuel Colvin <s@muelcolvin.com>
1 parent 429275b commit 9cf058e

File tree

4 files changed

+148
-2
lines changed

4 files changed

+148
-2
lines changed

demo/forms.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ class BigModel(BaseModel):
155155
)
156156
size: SizeModel
157157

158+
position: tuple[
159+
Annotated[int, Field(description='X Coordinate')],
160+
Annotated[int, Field(description='Y Coordinate')],
161+
]
162+
158163
@field_validator('name')
159164
def name_validator(cls, v: str | None) -> str:
160165
if v and v[0].islower():

src/python-fastui/fastui/forms.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ def unflatten(form_data: ds.FormData) -> NestedDict:
199199
else:
200200
d[last_key] = values
201201

202+
# this logic takes care of converting `dict[int, str]` to `list[str]`
203+
# we recursively process each dict in `result_dict` and convert it to a list if all keys are ints
204+
dicts = [result_dict]
205+
while dicts:
206+
d = dicts.pop()
207+
for key, value in d.items():
208+
if isinstance(value, dict):
209+
if all(isinstance(k, int) for k in value):
210+
# sort key-value pairs based on the keys, then take just the values as a list
211+
d[key] = [v for _, v in sorted(value.items())]
212+
else:
213+
dicts.append(value)
214+
202215
return result_dict
203216

204217

src/python-fastui/fastui/json_schema.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,24 @@ def json_schema_array_to_fields(
203203
if value := schema.get(field_name):
204204
items_schema[field_name] = value # type: ignore
205205
if field := special_string_field(items_schema, loc_to_name(loc), title, required, True):
206-
return [field]
207-
raise NotImplementedError('todo')
206+
yield field
207+
return
208+
209+
# for fixed length tuples (min_items == max_items), where all items are required,
210+
# we "inline" the fields into the list of form fields
211+
if (min_items := schema.get('minItems')) and min_items == schema.get('maxItems'):
212+
if items := schema.get('prefixItems'):
213+
for i, item in enumerate(items):
214+
fields = list(json_schema_any_to_fields(item, loc + [i], title, required, defs))
215+
if any(not f.required for f in fields):
216+
raise NotImplementedError(
217+
'Tuples with optional fields are not yet supported, '
218+
'see https://github.com/pydantic/FastUI/pull/52'
219+
)
220+
yield from fields
221+
return
222+
223+
raise NotImplementedError('Array fields are not fully supported, see https://github.com/pydantic/FastUI/pull/52')
208224

209225

210226
def special_string_field(

src/python-fastui/tests/test_forms.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,115 @@ async def test_multiple_files_multiple():
334334

335335
m = await fastui_form(FormMultipleFiles).dependency(request)
336336
assert m.model_dump() == {'files': [file1, file2]}
337+
338+
339+
class FixedTuple(BaseModel):
340+
foo: Tuple[str, int, int]
341+
342+
343+
def test_fixed_tuple():
344+
m = components.ModelForm(model=FixedTuple, submit_url='/foo/')
345+
# insert_assert(m.model_dump(by_alias=True, exclude_none=True))
346+
assert m.model_dump(by_alias=True, exclude_none=True) == {
347+
'submitUrl': '/foo/',
348+
'method': 'POST',
349+
'type': 'ModelForm',
350+
'formFields': [
351+
{
352+
'name': 'foo.0',
353+
'title': ['Foo', '0'],
354+
'required': True,
355+
'locked': False,
356+
'htmlType': 'text',
357+
'type': 'FormFieldInput',
358+
},
359+
{
360+
'name': 'foo.1',
361+
'title': ['Foo', '1'],
362+
'required': True,
363+
'locked': False,
364+
'htmlType': 'number',
365+
'type': 'FormFieldInput',
366+
},
367+
{
368+
'name': 'foo.2',
369+
'title': ['Foo', '2'],
370+
'required': True,
371+
'locked': False,
372+
'htmlType': 'number',
373+
'type': 'FormFieldInput',
374+
},
375+
],
376+
}
377+
378+
379+
async def test_fixed_tuple_submit():
380+
request = FakeRequest([('foo.0', 'bar'), ('foo.1', '123'), ('foo.2', '456')])
381+
382+
m = await fastui_form(FixedTuple).dependency(request)
383+
assert m.model_dump() == {'foo': ('bar', 123, 456)}
384+
385+
386+
class NestedTuple(BaseModel):
387+
bar: FixedTuple
388+
389+
390+
def test_fixed_tuple_nested():
391+
m = components.ModelForm(model=NestedTuple, submit_url='/foobar/')
392+
# insert_assert(m.model_dump(by_alias=True, exclude_none=True))
393+
assert m.model_dump(by_alias=True, exclude_none=True) == {
394+
'submitUrl': '/foobar/',
395+
'method': 'POST',
396+
'type': 'ModelForm',
397+
'formFields': [
398+
{
399+
'name': 'bar.foo.0',
400+
'title': ['FixedTuple', 'Foo', '0'],
401+
'required': True,
402+
'locked': False,
403+
'htmlType': 'text',
404+
'type': 'FormFieldInput',
405+
},
406+
{
407+
'name': 'bar.foo.1',
408+
'title': ['FixedTuple', 'Foo', '1'],
409+
'required': True,
410+
'locked': False,
411+
'htmlType': 'number',
412+
'type': 'FormFieldInput',
413+
},
414+
{
415+
'name': 'bar.foo.2',
416+
'title': ['FixedTuple', 'Foo', '2'],
417+
'required': True,
418+
'locked': False,
419+
'htmlType': 'number',
420+
'type': 'FormFieldInput',
421+
},
422+
],
423+
}
424+
425+
426+
async def test_fixed_tuple_nested_submit():
427+
request = FakeRequest([('bar.foo.0', 'bar'), ('bar.foo.1', '123'), ('bar.foo.2', '456')])
428+
429+
m = await fastui_form(NestedTuple).dependency(request)
430+
assert m.model_dump() == {'bar': {'foo': ('bar', 123, 456)}}
431+
432+
433+
def test_variable_tuple():
434+
class VarTuple(BaseModel):
435+
foo: Tuple[str, ...]
436+
437+
m = components.ModelForm(model=VarTuple, submit_url='/foo/')
438+
with pytest.raises(NotImplementedError, match='Array fields are not fully supported'):
439+
m.model_dump(by_alias=True, exclude_none=True)
440+
441+
442+
def test_tuple_optional():
443+
class TupleOptional(BaseModel):
444+
foo: Tuple[str, Union[str, None]]
445+
446+
m = components.ModelForm(model=TupleOptional, submit_url='/foo/')
447+
with pytest.raises(NotImplementedError, match='Tuples with optional fields are not yet supported'):
448+
m.model_dump(by_alias=True, exclude_none=True)

0 commit comments

Comments
 (0)