Skip to content

Commit eff4e27

Browse files
feat: support Pydantic2 (as well as Pydantic1) (#570)
* feat: support pydantic2 * add test * style(pre-commit.ci): auto fixes [...] * update pydantic test * use headless gui * add pyqt6 for pydantic1 * ci(pre-commit.ci): autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.276 → v0.0.281](astral-sh/ruff-pre-commit@v0.0.276...v0.0.281) - [github.com/psf/black: 23.3.0 → 23.7.0](psf/black@23.3.0...23.7.0) * style(pre-commit.ci): auto fixes [...] * fix: fix pre-commit * style: update pre=commit * ci: use concurrency * fix: backport json_schema_extra --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1809a0e commit eff4e27

File tree

4 files changed

+135
-24
lines changed

4 files changed

+135
-24
lines changed

.github/workflows/test_and_deploy.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,30 @@ jobs:
5656
with:
5757
fail_ci_if_error: false
5858

59+
test-pydantic1:
60+
name: Test pydantic1
61+
runs-on: ubuntu-latest
62+
63+
steps:
64+
- uses: actions/checkout@v3
65+
- uses: actions/setup-python@v4
66+
with:
67+
python-version: "3.11"
68+
69+
- uses: tlambert03/[email protected]
70+
71+
- name: Install dependencies
72+
run: |
73+
python -m pip install -e .
74+
python -m pip install pytest 'pydantic<2' attrs pytest-cov pyqt6
75+
76+
- name: Test
77+
uses: aganders3/headless-gui@v1
78+
with:
79+
run: pytest tests/test_ui_field.py -v --color=yes --cov=magicgui --cov-report=xml
80+
81+
- uses: codecov/codecov-action@v3
82+
5983
test_napari:
6084
name: napari tests
6185
runs-on: ubuntu-latest
@@ -163,7 +187,6 @@ jobs:
163187
working-directory: PartSeg
164188
run: python -m pytest -v --color=yes -W ignore package/tests/test_PartSeg/test_napari_widgets.py
165189

166-
167190
deploy:
168191
needs: test
169192
runs-on: ubuntu-latest

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ testing = [
7777
"toolz",
7878
"ipywidgets",
7979
"ipykernel",
80-
"pydantic<2",
80+
"pydantic",
8181
"attrs",
8282
"annotated_types",
8383
]
@@ -97,7 +97,7 @@ dev = [
9797
"pillow>=4.0",
9898
"pint>=0.13.0",
9999
"pre-commit",
100-
"pydantic<2",
100+
"pydantic",
101101
"pydocstyle",
102102
"pyqt6",
103103
"pytest-cov",

src/magicgui/schema/_ui_field.py

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import pydantic
3131
from annotated_types import BaseMetadata
3232
from attrs import Attribute
33-
from pydantic.fields import ModelField
33+
from pydantic.fields import FieldInfo, ModelField
3434

3535
from magicgui.widgets.bases import ContainerWidget, ValueWidget
3636

@@ -534,14 +534,19 @@ def _uifield_from_attrs(field: Attribute) -> UiField:
534534
)
535535

536536

537-
def _uifield_from_pydantic(model_field: ModelField) -> UiField:
537+
def _uifield_from_pydantic1(model_field: ModelField) -> UiField:
538538
"""Create a UiField from a pydantic ModelField."""
539539
from pydantic.fields import SHAPE_SINGLETON
540540
from pydantic.fields import Undefined as PydanticUndefined
541541

542542
finfo = model_field.field_info
543543

544-
extra = {k: v for k, v in finfo.extra.items() if k in _UI_FIELD_NAMES}
544+
_extra_dict = finfo.extra.copy()
545+
# backport from pydantic2
546+
if "json_schema_extra" in _extra_dict:
547+
_extra_dict.update(_extra_dict.pop("json_schema_extra"))
548+
549+
extra = {k: v for k, v in _extra_dict.items() if k in _UI_FIELD_NAMES}
545550
const = finfo.const if finfo.const not in (None, PydanticUndefined) else Undefined
546551
default = (
547552
Undefined if finfo.default in (PydanticUndefined, Ellipsis) else finfo.default
@@ -579,6 +584,63 @@ def _uifield_from_pydantic(model_field: ModelField) -> UiField:
579584
)
580585

581586

587+
def _uifield_from_pydantic2(finfo: FieldInfo, name: str) -> UiField:
588+
"""Create a UiField from a pydantic ModelField."""
589+
import annotated_types as at
590+
from pydantic_core import PydanticUndefined
591+
592+
if isinstance(finfo.json_schema_extra, dict):
593+
extra = {
594+
k: v for k, v in finfo.json_schema_extra.items() if k in _UI_FIELD_NAMES
595+
}
596+
else:
597+
extra = {}
598+
default = (
599+
Undefined if finfo.default in (PydanticUndefined, Ellipsis) else finfo.default
600+
)
601+
602+
nullable = None
603+
if get_origin(finfo.annotation) is Union and any(
604+
i for i in get_args(finfo.annotation) if i is type(None)
605+
):
606+
nullable = True
607+
608+
restrictions: dict = {}
609+
for meta in finfo.metadata:
610+
if isinstance(meta, at.Ge):
611+
restrictions["minimum"] = meta.ge
612+
elif isinstance(meta, at.Gt):
613+
restrictions["exclusive_minimum"] = meta.gt
614+
elif isinstance(meta, at.Le):
615+
restrictions["maximum"] = meta.le
616+
elif isinstance(meta, at.Lt):
617+
restrictions["exclusive_maximum"] = meta.lt
618+
elif isinstance(meta, at.MultipleOf):
619+
restrictions["multiple_of"] = meta.multiple_of
620+
elif isinstance(meta, at.MinLen):
621+
restrictions["min_length"] = meta.min_length
622+
elif isinstance(meta, at.MaxLen):
623+
restrictions["max_length"] = meta.max_length
624+
elif hasattr(meta, "__dict__"):
625+
# PydanticGeneralMetadata
626+
restrictions["pattern"] = meta.__dict__.get("pattern")
627+
628+
return UiField(
629+
name=name,
630+
title=finfo.title,
631+
description=finfo.description,
632+
default=default,
633+
default_factory=finfo.default_factory,
634+
type=finfo.annotation,
635+
nullable=nullable,
636+
# const=const,
637+
**restrictions,
638+
# format=finfo.format,
639+
_native_field=finfo,
640+
**extra,
641+
)
642+
643+
582644
# TODO:
583645
class _ContainerFields:
584646
autofocus: str | None = field(
@@ -657,8 +719,25 @@ def _ui_fields_from_annotation(cls: type) -> Iterator[UiField]:
657719

658720

659721
def _iter_ui_fields(object: Any) -> Iterator[UiField]:
722+
# check if it's a pydantic model
723+
model = _get_pydantic_model(object)
724+
if model is not None:
725+
if hasattr(model, "model_fields"):
726+
for name, field_info in model.model_fields.items():
727+
yield _uifield_from_pydantic2(field_info, name)
728+
else:
729+
for pf in model.__fields__.values():
730+
yield _uifield_from_pydantic1(pf)
731+
return
732+
733+
if hasattr(object, "__pydantic_fields__"):
734+
# pydantic2 style dataclass
735+
for name, field_info in object.__pydantic_fields__.items():
736+
yield _uifield_from_pydantic2(field_info, name)
737+
return
738+
660739
# check if it's a (non-pydantic) dataclass
661-
if dc.is_dataclass(object) and not hasattr(object, "__pydantic_model__"):
740+
if dc.is_dataclass(object):
662741
for df in dc.fields(object):
663742
yield _uifield_from_dataclass(df)
664743
return
@@ -669,13 +748,6 @@ def _iter_ui_fields(object: Any) -> Iterator[UiField]:
669748
yield _uifield_from_attrs(af)
670749
return
671750

672-
# check if it's a pydantic model
673-
model = _get_pydantic_model(object)
674-
if model is not None:
675-
for pf in model.__fields__.values():
676-
yield _uifield_from_pydantic(pf)
677-
return
678-
679751
# fallback to looking at __annotations__ (named tuple, typed dict, function)
680752
if hasattr(object, "__annotations__"):
681753
yield from _ui_fields_from_annotation(object)
@@ -768,8 +840,12 @@ def _get_values(obj: Any) -> dict | None:
768840
return cast(dict, attr.asdict(obj))
769841

770842
# pydantic models
771-
dict_method = getattr(obj, "dict", None)
772-
return dict_method() if callable(dict_method) else None
843+
if hasattr(obj, "model_dump"):
844+
return cast(dict, obj.model_dump())
845+
elif hasattr(obj, "dict"):
846+
return cast(dict, obj.dict())
847+
848+
return None
773849

774850

775851
# TODO: unify this with magicgui

tests/test_ui_field.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
EXPECTED = (
1111
UiField(name="a", type=int, nullable=True),
1212
UiField(name="b", type=str, description="the b"),
13-
UiField(name="c", default=0, type=float, widget="FloatSlider"),
13+
UiField(name="c", default=0.0, type=float, widget="FloatSlider"),
1414
)
1515

1616

1717
def _assert_uifields(cls, instantiate=True):
18-
assert tuple(get_ui_fields(cls)) == EXPECTED
18+
result = tuple(get_ui_fields(cls))
19+
assert result == EXPECTED
1920
wdg = build_widget(cls)
2021
assert isinstance(wdg, Container)
2122
assert wdg.asdict() == {
@@ -58,12 +59,23 @@ class Foo:
5859

5960

6061
def test_pydantic():
61-
pydantic = pytest.importorskip("pydantic")
62+
pytest.importorskip("pydantic")
63+
import pydantic.version
64+
from pydantic import BaseModel, Field
6265

63-
class Foo(pydantic.BaseModel):
64-
a: Optional[int]
65-
b: str = pydantic.Field(description="the b")
66-
c: float = pydantic.Field(0, widget="FloatSlider")
66+
if pydantic.version.VERSION.startswith("1"):
67+
68+
class Foo(BaseModel):
69+
a: Optional[int]
70+
b: str = Field(description="the b")
71+
c: float = Field(0.0, widget="FloatSlider")
72+
73+
else:
74+
75+
class Foo(BaseModel):
76+
a: Optional[int]
77+
b: str = Field(description="the b")
78+
c: float = Field(0.0, json_schema_extra={"widget": "FloatSlider"})
6779

6880
_assert_uifields(Foo)
6981

@@ -75,7 +87,7 @@ def test_pydantic_dataclass():
7587
class Foo:
7688
a: Optional[int]
7789
b: str = pydantic.Field(description="the b")
78-
c: float = pydantic.Field(0, widget="FloatSlider")
90+
c: float = pydantic.Field(0.0, json_schema_extra={"widget": "FloatSlider"})
7991

8092
_assert_uifields(Foo)
8193

0 commit comments

Comments
 (0)