Skip to content

Commit ccabebd

Browse files
committed
feat(FormModel): allow scalars where collections are expected
Makes it easier on the LMS-side, since it needn't know which fields can produce multiple values. See: questionpy-org/moodle-qtype_questionpy#149 (comment)
1 parent 2fa77dd commit ccabebd

File tree

2 files changed

+55
-20
lines changed

2 files changed

+55
-20
lines changed

questionpy/form/_dsl.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# This file is part of the QuestionPy SDK. (https://questionpy.org)
22
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
33
# (c) Technische Universität Berlin, innoCampus <[email protected]>
4+
from collections.abc import Collection
45
from typing import Any, Literal, Optional, TypeAlias, TypeVar, cast, overload
56

7+
from pydantic import BeforeValidator
68
from pydantic.fields import FieldInfo
79
from pydantic_core import PydanticUndefined
810

@@ -27,20 +29,34 @@
2729
# TODO: - Add support for numeric inputs (and maybe others?)
2830
# - Make labels optional
2931

32+
3033
_S = TypeVar("_S", bound=str)
3134
_F = TypeVar("_F", bound=FormModel)
3235
_E = TypeVar("_E", bound=OptionEnum)
3336

3437
_OneOrMoreConditions: TypeAlias = Condition | list[Condition]
3538
_ZeroOrMoreConditions: TypeAlias = _OneOrMoreConditions | None
3639

40+
_T = TypeVar("_T")
41+
42+
43+
@overload
44+
def _wrap_in(coll_type: type[set], value: _T | Collection[_T] | None) -> set[_T]: ...
45+
46+
47+
@overload
48+
def _wrap_in(coll_type: type[list], value: _T | Collection[_T] | None) -> list[_T]: ...
49+
3750

38-
def _listify(value: _ZeroOrMoreConditions) -> list[Condition]:
51+
def _wrap_in(coll_type: type[set] | type[list], value: _T | Collection[_T] | None) -> Collection[_T]:
3952
if value is None:
40-
return []
41-
if isinstance(value, list):
42-
return value
43-
return [value]
53+
return coll_type()
54+
if isinstance(value, coll_type):
55+
return cast(Collection[_T], value)
56+
if isinstance(value, Collection) and not isinstance(value, str): # (str is a subclass of Collection)
57+
return coll_type(value)
58+
59+
return coll_type((cast(_T, value),)) # MyPy gets confused here without the cast.
4460

4561

4662
@overload
@@ -132,8 +148,8 @@ def text_input(
132148
default=default,
133149
placeholder=placeholder,
134150
help=help,
135-
disable_if=_listify(disable_if),
136-
hide_if=_listify(hide_if),
151+
disable_if=_wrap_in(list, disable_if),
152+
hide_if=_wrap_in(list, hide_if),
137153
),
138154
pydantic_field_info=FieldInfo(
139155
default=None if not required or disable_if or hide_if else PydanticUndefined,
@@ -231,8 +247,8 @@ def text_area(
231247
default=default,
232248
placeholder=placeholder,
233249
help=help,
234-
disable_if=_listify(disable_if),
235-
hide_if=_listify(hide_if),
250+
disable_if=_wrap_in(list, disable_if),
251+
hide_if=_wrap_in(list, hide_if),
236252
),
237253
pydantic_field_info=FieldInfo(
238254
default=None if not required or disable_if or hide_if else PydanticUndefined,
@@ -265,7 +281,12 @@ def static_text(
265281
StaticTextElement,
266282
_StaticElementInfo(
267283
lambda name: StaticTextElement(
268-
name=name, label=label, text=text, help=help, disable_if=_listify(disable_if), hide_if=_listify(hide_if)
284+
name=name,
285+
label=label,
286+
text=text,
287+
help=help,
288+
disable_if=_wrap_in(list, disable_if),
289+
hide_if=_wrap_in(list, hide_if),
269290
)
270291
),
271292
)
@@ -360,8 +381,8 @@ def checkbox(
360381
required=required,
361382
help=help,
362383
selected=selected,
363-
disable_if=_listify(disable_if),
364-
hide_if=_listify(hide_if),
384+
disable_if=_wrap_in(list, disable_if),
385+
hide_if=_wrap_in(list, hide_if),
365386
),
366387
pydantic_field_info=FieldInfo(default=False if not required or disable_if or hide_if else PydanticUndefined),
367388
)
@@ -451,8 +472,8 @@ def radio_group(
451472
options=options,
452473
required=required,
453474
help=help,
454-
disable_if=_listify(disable_if),
455-
hide_if=_listify(hide_if),
475+
disable_if=_wrap_in(list, disable_if),
476+
hide_if=_wrap_in(list, hide_if),
456477
),
457478
pydantic_field_info=FieldInfo(default=None if not required or disable_if or hide_if else PydanticUndefined),
458479
)
@@ -556,9 +577,11 @@ def select(
556577

557578
expected_type: type
558579
default: object
580+
annotate_with: tuple[object, ...] = ()
559581
if multiple:
560582
expected_type = set[enum] # type: ignore[valid-type]
561583
default = set() if not required or disable_if or hide_if else PydanticUndefined
584+
annotate_with = (BeforeValidator(lambda value: _wrap_in(set, value)),)
562585
elif not required or disable_if or hide_if:
563586
expected_type = enum | None # type: ignore[assignment]
564587
default = None
@@ -575,10 +598,11 @@ def select(
575598
required=required,
576599
options=options,
577600
help=help,
578-
disable_if=_listify(disable_if),
579-
hide_if=_listify(hide_if),
601+
disable_if=_wrap_in(list, disable_if),
602+
hide_if=_wrap_in(list, hide_if),
580603
),
581604
pydantic_field_info=FieldInfo(default=default),
605+
annotate_with=annotate_with,
582606
)
583607

584608

@@ -635,7 +659,7 @@ def hidden(value: _S, *, disable_if: _ZeroOrMoreConditions = None, hide_if: _Zer
635659
_FieldInfo(
636660
type=Optional[Literal[value]] if disable_if or hide_if else Literal[value], # noqa: UP007
637661
build=lambda name: HiddenElement(
638-
name=name, value=value, disable_if=_listify(disable_if), hide_if=_listify(hide_if)
662+
name=name, value=value, disable_if=_wrap_in(list, disable_if), hide_if=_wrap_in(list, hide_if)
639663
),
640664
pydantic_field_info=FieldInfo(default=None if disable_if or hide_if else PydanticUndefined),
641665
),
@@ -715,8 +739,8 @@ def group(
715739
label=label,
716740
elements=model.qpy_form.general,
717741
help=help,
718-
disable_if=_listify(disable_if),
719-
hide_if=_listify(hide_if),
742+
disable_if=_wrap_in(list, disable_if),
743+
hide_if=_wrap_in(list, hide_if),
720744
),
721745
# When the group dict is not provided at all in the form data, we want Pydantic to use the default values
722746
# for all grouped fields and raise if there are any required ones. Creating the nested model in a
@@ -778,6 +802,7 @@ def repeat(
778802
button_label=button_label,
779803
elements=model.qpy_form.general,
780804
),
805+
annotate_with=(BeforeValidator(lambda value: _wrap_in(list, value)),),
781806
),
782807
)
783808

questionpy/form/_model.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
from dataclasses import dataclass
66
from enum import Enum
77
from itertools import starmap
8-
from typing import TYPE_CHECKING, Any, ClassVar, Literal, get_args, get_origin
8+
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, get_args, get_origin
99

10+
from mypyc.ir.ops import Sequence
1011
from pydantic import BaseModel, Field
1112
from pydantic._internal._model_construction import ModelMetaclass # noqa: PLC2701
1213
from pydantic.fields import FieldInfo
@@ -75,6 +76,8 @@ class _FieldInfo:
7576
functions are called. So they provide a callable instead that takes the name and returns the complete form element.
7677
"""
7778
pydantic_field_info: FieldInfo | None = None
79+
annotate_with: Sequence[object] = ()
80+
"""Annotations as in [typing.Annotated][] to add to the field type. Useful for [pydantic.BeforeValidator][]."""
7881

7982

8083
@dataclass
@@ -130,11 +133,14 @@ def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict, **kwargs:
130133
form = OptionsFormDefinition()
131134

132135
for key, value in namespace.items():
136+
annotate_with: list[object] = []
133137
if isinstance(value, _FieldInfo):
134138
expected_type = value.type
135139
form.general.append(value.build(key))
136140
if value.pydantic_field_info is not None:
137141
new_namespace[key] = value.pydantic_field_info
142+
if value.annotate_with:
143+
annotate_with.extend(value.annotate_with)
138144
elif isinstance(value, _SectionInfo):
139145
form.sections.append(FormSection(name=key, header=value.header, elements=value.model.qpy_form.general))
140146
expected_type = value.model
@@ -162,6 +168,10 @@ def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict, **kwargs:
162168
# this won't help type checkers or code completion, but will allow pydantic to validate inputs
163169
annotations[key] = expected_type
164170

171+
if annotate_with:
172+
# If the field type has been annotated by the user, this will add our annotations to theirs.
173+
annotations[key] = Annotated[annotations[key], *annotate_with]
174+
165175
new_namespace["qpy_form"] = form
166176
new_namespace["__annotations__"] = annotations
167177
return super().__new__(mcs, name, bases, new_namespace, **kwargs)

0 commit comments

Comments
 (0)