feat: Upgrade to Pydantic V2 (#3134)

* bumped pydantic
This commit is contained in:
Michael Genson
2024-02-11 10:47:37 -06:00
committed by GitHub
parent 248459671e
commit 7a107584c7
129 changed files with 1138 additions and 833 deletions

View File

@@ -0,0 +1,252 @@
"""
From Pydantic V1: https://github.com/pydantic/pydantic/blob/abcf81ec104d2da70894ac0402ae11a7186c5e47/pydantic/datetime_parse.py
"""
import re
from datetime import date, datetime, time, timedelta, timezone
date_expr = r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
time_expr = (
r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
r"(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?"
r"(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$"
)
date_re = re.compile(f"{date_expr}$")
time_re = re.compile(time_expr)
datetime_re = re.compile(f"{date_expr}[T ]{time_expr}")
standard_duration_re = re.compile(
r"^"
r"(?:(?P<days>-?\d+) (days?, )?)?"
r"((?:(?P<hours>-?\d+):)(?=\d+:\d+))?"
r"(?:(?P<minutes>-?\d+):)?"
r"(?P<seconds>-?\d+)"
r"(?:\.(?P<microseconds>\d{1,6})\d{0,6})?"
r"$"
)
# Support the sections of ISO 8601 date representation that are accepted by timedelta
iso8601_duration_re = re.compile(
r"^(?P<sign>[-+]?)"
r"P"
r"(?:(?P<days>\d+(.\d+)?)D)?"
r"(?:T"
r"(?:(?P<hours>\d+(.\d+)?)H)?"
r"(?:(?P<minutes>\d+(.\d+)?)M)?"
r"(?:(?P<seconds>\d+(.\d+)?)S)?"
r")?"
r"$"
)
EPOCH = datetime(1970, 1, 1)
# if greater than this, the number is in ms, if less than or equal it's in seconds
# (in seconds this is 11th October 2603, in ms it's 20th August 1970)
MS_WATERSHED = int(2e10)
# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9
MAX_NUMBER = int(3e20)
class DateError(ValueError):
def __init__(self, *args: object) -> None:
super().__init__("invalid date format")
class TimeError(ValueError):
def __init__(self, *args: object) -> None:
super().__init__("invalid time format")
class DateTimeError(ValueError):
def __init__(self, *args: object) -> None:
super().__init__("invalid datetime format")
class DurationError(ValueError):
def __init__(self, *args: object) -> None:
super().__init__("invalid duration format")
def get_numeric(value: str | bytes | int | float, native_expected_type: str) -> None | int | float:
if isinstance(value, int | float):
return value
try:
return float(value)
except ValueError:
return None
except TypeError as e:
raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from e
def from_unix_seconds(seconds: int | float) -> datetime:
if seconds > MAX_NUMBER:
return datetime.max
elif seconds < -MAX_NUMBER:
return datetime.min
while abs(seconds) > MS_WATERSHED:
seconds /= 1000
dt = EPOCH + timedelta(seconds=seconds)
return dt.replace(tzinfo=timezone.utc)
def _parse_timezone(value: str | None, error: type[Exception]) -> None | int | timezone:
if value == "Z":
return timezone.utc
elif value is not None:
offset_mins = int(value[-2:]) if len(value) > 3 else 0
offset = 60 * int(value[1:3]) + offset_mins
if value[0] == "-":
offset = -offset
try:
return timezone(timedelta(minutes=offset))
except ValueError as e:
raise error() from e
else:
return None
def parse_date(value: date | str | bytes | int | float) -> date:
"""
Parse a date/int/float/string and return a datetime.date.
Raise ValueError if the input is well formatted but not a valid date.
Raise ValueError if the input isn't well formatted.
"""
if isinstance(value, date):
if isinstance(value, datetime):
return value.date()
else:
return value
number = get_numeric(value, "date")
if number is not None:
return from_unix_seconds(number).date()
if isinstance(value, bytes):
value = value.decode()
match = date_re.match(value) # type: ignore
if match is None:
raise DateError()
kw = {k: int(v) for k, v in match.groupdict().items()}
try:
return date(**kw)
except ValueError as e:
raise DateError() from e
def parse_time(value: time | str | bytes | int | float) -> time:
"""
Parse a time/string and return a datetime.time.
Raise ValueError if the input is well formatted but not a valid time.
Raise ValueError if the input isn't well formatted, in particular if it contains an offset.
"""
if isinstance(value, time):
return value
number = get_numeric(value, "time")
if number is not None:
if number >= 86400:
# doesn't make sense since the time time loop back around to 0
raise TimeError()
return (datetime.min + timedelta(seconds=number)).time()
if isinstance(value, bytes):
value = value.decode()
match = time_re.match(value) # type: ignore
if match is None:
raise TimeError()
kw = match.groupdict()
if kw["microsecond"]:
kw["microsecond"] = kw["microsecond"].ljust(6, "0")
tzinfo = _parse_timezone(kw.pop("tzinfo"), TimeError)
kw_: dict[str, None | int | timezone] = {k: int(v) for k, v in kw.items() if v is not None}
kw_["tzinfo"] = tzinfo
try:
return time(**kw_) # type: ignore
except ValueError as e:
raise TimeError() from e
def parse_datetime(value: datetime | str | bytes | int | float) -> datetime:
"""
Parse a datetime/int/float/string and return a datetime.datetime.
This function supports time zone offsets. When the input contains one,
the output uses a timezone with a fixed offset from UTC.
Raise ValueError if the input is well formatted but not a valid datetime.
Raise ValueError if the input isn't well formatted.
"""
if isinstance(value, datetime):
return value
number = get_numeric(value, "datetime")
if number is not None:
return from_unix_seconds(number)
if isinstance(value, bytes):
value = value.decode()
match = datetime_re.match(value) # type: ignore
if match is None:
raise DateTimeError()
kw = match.groupdict()
if kw["microsecond"]:
kw["microsecond"] = kw["microsecond"].ljust(6, "0")
tzinfo = _parse_timezone(kw.pop("tzinfo"), DateTimeError)
kw_: dict[str, None | int | timezone] = {k: int(v) for k, v in kw.items() if v is not None}
kw_["tzinfo"] = tzinfo
try:
return datetime(**kw_) # type: ignore
except ValueError as e:
raise DateTimeError() from e
def parse_duration(value: str | bytes | int | float) -> timedelta:
"""
Parse a duration int/float/string and return a datetime.timedelta.
The preferred format for durations in Django is '%d %H:%M:%S.%f'.
Also supports ISO 8601 representation.
"""
if isinstance(value, timedelta):
return value
if isinstance(value, int | float):
# below code requires a string
value = f"{value:f}"
elif isinstance(value, bytes):
value = value.decode()
try:
match = standard_duration_re.match(value) or iso8601_duration_re.match(value)
except TypeError as e:
raise TypeError("invalid type; expected timedelta, string, bytes, int or float") from e
if not match:
raise DurationError()
kw = match.groupdict()
sign = -1 if kw.pop("sign", "+") == "-" else 1
if kw.get("microseconds"):
kw["microseconds"] = kw["microseconds"].ljust(6, "0")
if kw.get("seconds") and kw.get("microseconds") and kw["seconds"].startswith("-"):
kw["microseconds"] = "-" + kw["microseconds"]
kw_ = {k: float(v) for k, v in kw.items() if v is not None}
return sign * timedelta(**kw_)

View File

@@ -5,7 +5,7 @@ from enum import Enum
from typing import ClassVar, Protocol, TypeVar
from humps.main import camelize
from pydantic import UUID4, BaseModel
from pydantic import UUID4, BaseModel, ConfigDict
from sqlalchemy import Select, desc, func, or_, text
from sqlalchemy.orm import InstrumentedAttribute, Session
from sqlalchemy.orm.interfaces import LoaderOption
@@ -28,10 +28,7 @@ class MealieModel(BaseModel):
Searchable properties for the search API.
The first property will be used for sorting (order_by)
"""
class Config:
alias_generator = camelize
allow_population_by_field_name = True
model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
def cast(self, cls: type[T], **kwargs) -> T:
"""
@@ -48,8 +45,8 @@ class MealieModel(BaseModel):
for method chaining.
"""
for field in self.__fields__:
if field in dest.__fields__:
for field in self.model_fields:
if field in dest.model_fields:
setattr(dest, field, getattr(self, field))
return dest
@@ -59,8 +56,8 @@ class MealieModel(BaseModel):
Map matching values from another model to the current model.
"""
for field in src.__fields__:
if field in self.__fields__:
for field in src.model_fields:
if field in self.model_fields:
setattr(self, field, getattr(src, field))
def merge(self, src: T, replace_null=False):
@@ -68,9 +65,9 @@ class MealieModel(BaseModel):
Replace matching values from another instance to the current instance.
"""
for field in src.__fields__:
for field in src.model_fields:
val = getattr(src, field)
if field in self.__fields__ and (val is not None or replace_null):
if field in self.model_fields and (val is not None or replace_null):
setattr(self, field, val)
@classmethod

View File

@@ -49,7 +49,7 @@ class AdminAboutInfo(AppInfo):
api_port: int
api_docs: bool
db_type: str
db_url: str | None
db_url: str | None = None
default_group: str
build_id: str
recipe_scraper_version: str

View File

@@ -19,9 +19,9 @@ class ImportJob(BackupOptions):
class CreateBackup(BaseModel):
tag: str | None
tag: str | None = None
options: BackupOptions
templates: list[str] | None
templates: list[str] | None = None
class BackupFile(BaseModel):

View File

@@ -4,11 +4,11 @@ from pydantic.main import BaseModel
class ImportBase(BaseModel):
name: str
status: bool
exception: str | None
exception: str | None = None
class RecipeImport(ImportBase):
slug: str | None
slug: str | None = None
class CommentImport(ImportBase):

View File

@@ -1,4 +1,6 @@
from pydantic import validator
from typing import Annotated
from pydantic import ConfigDict, Field, field_validator
from slugify import slugify
from mealie.schema._mealie import MealieModel
@@ -8,14 +10,12 @@ from ..recipe.recipe_category import RecipeCategoryResponse
class CustomPageBase(MealieModel):
name: str
slug: str | None
slug: Annotated[str | None, Field(validate_default=True)]
position: int
categories: list[RecipeCategoryResponse] = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@validator("slug", always=True, pre=True)
@field_validator("slug", mode="before")
def validate_slug(slug: str, values):
name: str = values["name"]
calc_slug: str = slugify(name)
@@ -28,6 +28,4 @@ class CustomPageBase(MealieModel):
class CustomPageOut(CustomPageBase):
id: int
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,4 +1,7 @@
from pydantic import UUID4, validator
from typing import Annotated
from pydantic import UUID4, ConfigDict, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from slugify import slugify
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -14,9 +17,9 @@ from ..recipe.recipe_category import CategoryBase, TagBase
class CreateCookBook(MealieModel):
name: str
description: str = ""
slug: str | None = None
slug: Annotated[str | None, Field(validate_default=True)] = None
position: int = 1
public: bool = False
public: Annotated[bool, Field(validate_default=True)] = False
categories: list[CategoryBase] = []
tags: list[TagBase] = []
tools: list[RecipeTool] = []
@@ -24,13 +27,13 @@ class CreateCookBook(MealieModel):
require_all_tags: bool = True
require_all_tools: bool = True
@validator("public", always=True, pre=True)
def validate_public(public: bool | None, values: dict) -> bool: # type: ignore
@field_validator("public", mode="before")
def validate_public(public: bool | None) -> bool:
return False if public is None else public
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values): # type: ignore
name: str = values["name"]
@field_validator("slug", mode="before")
def validate_slug(slug: str, info: ValidationInfo):
name: str = info.data["name"]
calc_slug: str = slugify(name)
if slug != calc_slug:
@@ -50,9 +53,7 @@ class UpdateCookBook(SaveCookBook):
class ReadCookBook(UpdateCookBook):
group_id: UUID4
categories: list[CategoryBase] = []
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -66,6 +67,4 @@ class CookBookPagination(PaginationBase):
class RecipeCookBook(ReadCookBook):
group_id: UUID4
recipes: list[RecipeSummary]
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,33 +0,0 @@
from collections.abc import Callable, Mapping
from typing import Any
from pydantic.utils import GetterDict
class CustomGetterDict(GetterDict):
transformations: Mapping[str, Callable[[Any], Any]]
def get(self, key: Any, default: Any = None) -> Any:
# Transform extras into key-value dict
if key in self.transformations:
value = super().get(key, default)
return self.transformations[key](value)
# Keep all other fields as they are
else:
return super().get(key, default)
class ExtrasGetterDict(CustomGetterDict):
transformations = {"extras": lambda value: {x.key_name: x.value for x in value}}
class GroupGetterDict(CustomGetterDict):
transformations = {"group": lambda value: value.name}
class UserGetterDict(CustomGetterDict):
transformations = {
"group": lambda value: value.name,
"favorite_recipes": lambda value: [x.slug for x in value],
}

View File

@@ -1,4 +1,4 @@
from pydantic import UUID4, NoneStr
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -54,9 +54,7 @@ class GroupEventNotifierOptionsSave(GroupEventNotifierOptions):
class GroupEventNotifierOptionsOut(GroupEventNotifierOptions):
id: UUID4
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
# =======================================================================
@@ -65,7 +63,7 @@ class GroupEventNotifierOptionsOut(GroupEventNotifierOptions):
class GroupEventNotifierCreate(MealieModel):
name: str
apprise_url: str
apprise_url: str | None = None
class GroupEventNotifierSave(GroupEventNotifierCreate):
@@ -76,7 +74,7 @@ class GroupEventNotifierSave(GroupEventNotifierCreate):
class GroupEventNotifierUpdate(GroupEventNotifierSave):
id: UUID4
apprise_url: NoneStr = None
apprise_url: str | None = None
class GroupEventNotifierOut(MealieModel):
@@ -85,9 +83,7 @@ class GroupEventNotifierOut(MealieModel):
enabled: bool
group_id: UUID4
options: GroupEventNotifierOptionsOut
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -100,6 +96,4 @@ class GroupEventPagination(PaginationBase):
class GroupEventNotifierPrivate(GroupEventNotifierOut):
apprise_url: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from mealie.schema._mealie import MealieModel
@@ -13,6 +13,4 @@ class GroupDataExport(MealieModel):
path: str
size: str
expires: datetime
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,6 +1,6 @@
from uuid import UUID
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from mealie.schema._mealie import MealieModel
@@ -24,6 +24,4 @@ class CreateGroupPreferences(UpdateGroupPreferences):
class ReadGroupPreferences(CreateGroupPreferences):
id: UUID4
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,4 +1,4 @@
from pydantic import validator
from pydantic import field_validator
from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema._mealie.validators import validate_locale
@@ -7,8 +7,8 @@ from mealie.schema._mealie.validators import validate_locale
class SeederConfig(MealieModel):
locale: str
@validator("locale")
def valid_locale(cls, v, values, **kwargs):
@field_validator("locale")
def valid_locale(cls, v):
if not validate_locale(v):
raise ValueError("invalid locale")
return v

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import UUID4, validator
from pydantic import UUID4, ConfigDict, field_validator
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -15,7 +15,6 @@ from mealie.db.models.group import (
from mealie.db.models.recipe import IngredientFoodModel, RecipeModel
from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat
from mealie.schema.getter_dict import ExtrasGetterDict
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_ingredient import (
@@ -38,7 +37,8 @@ class ShoppingListItemRecipeRefCreate(MealieModel):
recipe_note: str | None = None
"""the original note from the recipe"""
@validator("recipe_quantity", pre=True)
@field_validator("recipe_quantity", mode="before")
@classmethod
def default_none_to_zero(cls, v):
return 0 if v is None else v
@@ -49,8 +49,7 @@ class ShoppingListItemRecipeRefUpdate(ShoppingListItemRecipeRefCreate):
class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class ShoppingListItemBase(RecipeIngredientBase):
@@ -67,6 +66,13 @@ class ShoppingListItemBase(RecipeIngredientBase):
is_food: bool = False
extras: dict | None = {}
@field_validator("extras", mode="before")
def convert_extras_to_dict(cls, v):
if isinstance(v, dict):
return v
return {x.key_name: x.value for x in v} if v else {}
class ShoppingListItemCreate(ShoppingListItemBase):
recipe_references: list[ShoppingListItemRecipeRefCreate] = []
@@ -85,14 +91,14 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate):
class ShoppingListItemOut(ShoppingListItemBase):
id: UUID4
food: IngredientFood | None
label: MultiPurposeLabelSummary | None
unit: IngredientUnit | None
food: IngredientFood | None = None
label: MultiPurposeLabelSummary | None = None
unit: IngredientUnit | None = None
recipe_references: list[ShoppingListItemRecipeRefOut] = []
created_at: datetime | None
update_at: datetime | None
created_at: datetime | None = None
update_at: datetime | None = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -102,9 +108,7 @@ class ShoppingListItemOut(ShoppingListItemBase):
self.label = self.food.label
self.label_id = self.label.id
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -138,9 +142,7 @@ class ShoppingListMultiPurposeLabelUpdate(ShoppingListMultiPurposeLabelCreate):
class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate):
label: MultiPurposeLabelSummary
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -155,8 +157,15 @@ class ShoppingListCreate(MealieModel):
name: str | None = None
extras: dict | None = {}
created_at: datetime | None
update_at: datetime | None
created_at: datetime | None = None
update_at: datetime | None = None
@field_validator("extras", mode="before")
def convert_extras_to_dict(cls, v):
if isinstance(v, dict):
return v
return {x.key_name: x.value for x in v} if v else {}
class ShoppingListRecipeRefOut(MealieModel):
@@ -167,9 +176,7 @@ class ShoppingListRecipeRefOut(MealieModel):
"""the number of times this recipe has been added"""
recipe: RecipeSummary
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -188,10 +195,7 @@ class ShoppingListSummary(ShoppingListSave):
id: UUID4
recipe_references: list[ShoppingListRecipeRefOut]
label_settings: list[ShoppingListMultiPurposeLabelOut]
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -222,10 +226,7 @@ class ShoppingListUpdate(ShoppingListSave):
class ShoppingListOut(ShoppingListUpdate):
recipe_references: list[ShoppingListRecipeRefOut]
label_settings: list[ShoppingListMultiPurposeLabelOut]
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -1,6 +1,6 @@
from uuid import UUID
from pydantic import NoneStr
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel
@@ -19,9 +19,7 @@ class ReadInviteToken(MealieModel):
token: str
uses_left: int
group_id: UUID
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class EmailInvitation(MealieModel):
@@ -31,4 +29,4 @@ class EmailInvitation(MealieModel):
class EmailInitationResponse(MealieModel):
success: bool
error: NoneStr = None
error: str | None = None

View File

@@ -3,10 +3,10 @@ import enum
from uuid import UUID
from isodate import parse_time
from pydantic import UUID4, validator
from pydantic.datetime_parse import parse_datetime
from pydantic import UUID4, ConfigDict, field_validator
from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.datetime_parse import parse_datetime
from mealie.schema.response.pagination import PaginationBase
@@ -22,7 +22,7 @@ class CreateWebhook(MealieModel):
webhook_type: WebhookType = WebhookType.mealplan
scheduled_time: datetime.time
@validator("scheduled_time", pre=True)
@field_validator("scheduled_time", mode="before")
@classmethod
def validate_scheduled_time(cls, v):
"""
@@ -55,9 +55,7 @@ class SaveWebhook(CreateWebhook):
class ReadWebhook(SaveWebhook):
id: UUID4
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class WebhookPagination(PaginationBase):

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import ClassVar
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
@@ -23,9 +23,7 @@ class MultiPurposeLabelUpdate(MultiPurposeLabelSave):
class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
_searchable_properties: ClassVar[list[str]] = ["name"]
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class MultiPurposeLabelPagination(PaginationBase):
@@ -33,5 +31,4 @@ class MultiPurposeLabelPagination(PaginationBase):
class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -4,6 +4,10 @@ from fastapi.exceptions import HTTPException, RequestValidationError
from pydantic import ValidationError
def format_exception(ex: Exception) -> str:
return f"{ex.__class__.__name__}: {ex}"
def make_dependable(cls):
"""
Pydantic BaseModels are very powerful because we get lots of validations and type checking right out of the box.
@@ -29,7 +33,7 @@ def make_dependable(cls):
except (ValidationError, RequestValidationError) as e:
for error in e.errors():
error["loc"] = ["query"] + list(error["loc"])
raise HTTPException(422, detail=e.errors()) from None
raise HTTPException(422, detail=[format_exception(ex) for ex in e.errors()]) from None
init_cls_and_handle_errors.__signature__ = signature(cls)
return init_cls_and_handle_errors

View File

@@ -11,14 +11,14 @@ def mapper(source: U, dest: T, **_) -> T:
Map a source model to a destination model. Only top-level fields are mapped.
"""
for field in source.__fields__:
if field in dest.__fields__:
for field in source.model_fields:
if field in dest.model_fields:
setattr(dest, field, getattr(source, field))
return dest
def cast(source: U, dest: type[T], **kwargs) -> T:
create_data = {field: getattr(source, field) for field in source.__fields__ if field in dest.__fields__}
create_data = {field: getattr(source, field) for field in source.model_fields if field in dest.model_fields}
create_data.update(kwargs or {})
return dest(**create_data)

View File

@@ -1,53 +1,45 @@
from datetime import date
import datetime
from pydantic import validator
from pydantic import ConfigDict, field_validator
from pydantic_core.core_schema import ValidationInfo
from mealie.schema._mealie import MealieModel
class MealIn(MealieModel):
slug: str | None
name: str | None
description: str | None
class Config:
orm_mode = True
slug: str | None = None
name: str | None = None
description: str | None = None
model_config = ConfigDict(from_attributes=True)
class MealDayIn(MealieModel):
date: date | None
date: datetime.date | None = None
meals: list[MealIn]
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class MealDayOut(MealDayIn):
id: int
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class MealPlanIn(MealieModel):
group: str
start_date: date
end_date: date
start_date: datetime.date
end_date: datetime.date
plan_days: list[MealDayIn]
@validator("end_date")
def end_date_after_start_date(v, values, config, field):
if "start_date" in values and v < values["start_date"]:
@field_validator("end_date")
def end_date_after_start_date(v, info: ValidationInfo):
if "start_date" in info.data and v < info.data["start_date"]:
raise ValueError("EndDate should be greater than StartDate")
return v
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class MealPlanOut(MealPlanIn):
id: int
shopping_list: int | None
class Config:
orm_mode = True
shopping_list: int | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,8 +1,10 @@
from datetime import date
from enum import Enum
from typing import Annotated
from uuid import UUID
from pydantic import validator
from pydantic import ConfigDict, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -30,13 +32,13 @@ class CreatePlanEntry(MealieModel):
entry_type: PlanEntryType = PlanEntryType.breakfast
title: str = ""
text: str = ""
recipe_id: UUID | None
recipe_id: Annotated[UUID | None, Field(validate_default=True)] = None
@validator("recipe_id", always=True)
@field_validator("recipe_id")
@classmethod
def id_or_title(cls, value, values):
if bool(value) is False and bool(values["title"]) is False:
raise ValueError(f"`recipe_id={value}` or `title={values['title']}` must be provided")
def id_or_title(cls, value, info: ValidationInfo):
if bool(value) is False and bool(info.data["title"]) is False:
raise ValueError(f"`recipe_id={value}` or `title={info.data['title']}` must be provided")
return value
@@ -44,22 +46,18 @@ class CreatePlanEntry(MealieModel):
class UpdatePlanEntry(CreatePlanEntry):
id: int
group_id: UUID
user_id: UUID | None
user_id: UUID | None = None
class SavePlanEntry(CreatePlanEntry):
group_id: UUID
user_id: UUID | None
class Config:
orm_mode = True
user_id: UUID | None = None
model_config = ConfigDict(from_attributes=True)
class ReadPlanEntry(UpdatePlanEntry):
recipe: RecipeSummary | None
class Config:
orm_mode = True
recipe: RecipeSummary | None = None
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -1,7 +1,7 @@
import datetime
from enum import Enum
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -14,14 +14,11 @@ class Category(MealieModel):
id: UUID4
name: str
slug: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class Tag(Category):
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class PlanRulesDay(str, Enum):
@@ -64,9 +61,7 @@ class PlanRulesSave(PlanRulesCreate):
class PlanRulesOut(PlanRulesSave):
id: UUID4
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -1,26 +1,22 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel
from mealie.schema.getter_dict import GroupGetterDict
class ListItem(MealieModel):
title: str | None
title: str | None = None
text: str = ""
quantity: int = 1
checked: bool = False
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class ShoppingListIn(MealieModel):
name: str
group: str | None
group: str | None = None
items: list[ListItem]
class ShoppingListOut(ShoppingListIn):
id: int
class Config:
orm_mode = True
getter_dict = GroupGetterDict
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import datetime
from numbers import Number
from pathlib import Path
from typing import Any, ClassVar
from typing import Annotated, Any, ClassVar
from uuid import uuid4
from pydantic import UUID4, BaseModel, Field, validator
from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from slugify import slugify
from sqlalchemy import Select, desc, func, or_, select, text
from sqlalchemy.orm import Session, joinedload, selectinload
@@ -22,7 +24,6 @@ from ...db.models.recipe import (
RecipeInstruction,
RecipeModel,
)
from ..getter_dict import ExtrasGetterDict
from .recipe_asset import RecipeAsset
from .recipe_comments import RecipeCommentOut
from .recipe_notes import RecipeNote
@@ -39,9 +40,7 @@ class RecipeTag(MealieModel):
slug: str
_searchable_properties: ClassVar[list[str]] = ["name"]
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeTagPagination(PaginationBase):
@@ -80,16 +79,16 @@ class CreateRecipe(MealieModel):
class RecipeSummary(MealieModel):
id: UUID4 | None
id: UUID4 | None = None
_normalize_search: ClassVar[bool] = True
user_id: UUID4 = Field(default_factory=uuid4)
group_id: UUID4 = Field(default_factory=uuid4)
user_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
group_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
name: str | None
slug: str = ""
image: Any | None
recipe_yield: str | None
name: str | None = None
slug: Annotated[str, Field(validate_default=True)] = ""
image: Any | None = None
recipe_yield: str | None = None
total_time: str | None = None
prep_time: str | None = None
@@ -97,21 +96,28 @@ class RecipeSummary(MealieModel):
perform_time: str | None = None
description: str | None = ""
recipe_category: list[RecipeCategory] | None = []
tags: list[RecipeTag] | None = []
recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
tools: list[RecipeTool] = []
rating: int | None
rating: int | None = None
org_url: str | None = Field(None, alias="orgURL")
date_added: datetime.date | None
date_updated: datetime.datetime | None
date_added: datetime.date | None = None
date_updated: datetime.datetime | None = None
created_at: datetime.datetime | None
update_at: datetime.datetime | None
last_made: datetime.datetime | None
created_at: datetime.datetime | None = None
update_at: datetime.datetime | None = None
last_made: datetime.datetime | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@field_validator("recipe_yield", "total_time", "prep_time", "cook_time", "perform_time", mode="before")
def clean_strings(val: Any):
if val is None:
return val
if isinstance(val, Number):
return str(val)
return val
class RecipePagination(PaginationBase):
@@ -119,9 +125,9 @@ class RecipePagination(PaginationBase):
class Recipe(RecipeSummary):
recipe_ingredient: list[RecipeIngredient] = []
recipe_ingredient: Annotated[list[RecipeIngredient], Field(validate_default=True)] = []
recipe_instructions: list[RecipeStep] | None = []
nutrition: Nutrition | None
nutrition: Nutrition | None = None
# Mealie Specific
settings: RecipeSettings | None = None
@@ -175,13 +181,11 @@ class Recipe(RecipeSummary):
return self.image_dir_from_id(self.id)
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_orm(cls, obj):
recipe = super().from_orm(obj)
def model_validate(cls, obj):
recipe = super().model_validate(obj)
recipe.__post_init__()
return recipe
@@ -198,15 +202,15 @@ class Recipe(RecipeSummary):
ingredient.is_food = not ingredient.disable_amount
ingredient.display = ingredient._format_display()
@validator("slug", always=True, pre=True, allow_reuse=True)
def validate_slug(slug: str, values): # type: ignore
if not values.get("name"):
@field_validator("slug", mode="before")
def validate_slug(slug: str, info: ValidationInfo):
if not info.data.get("name"):
return slug
return slugify(values["name"])
return slugify(info.data["name"])
@validator("recipe_ingredient", always=True, pre=True, allow_reuse=True)
def validate_ingredients(recipe_ingredient, values):
@field_validator("recipe_ingredient", mode="before")
def validate_ingredients(recipe_ingredient):
if not recipe_ingredient or not isinstance(recipe_ingredient, list):
return recipe_ingredient
@@ -215,30 +219,37 @@ class Recipe(RecipeSummary):
return recipe_ingredient
@validator("tags", always=True, pre=True, allow_reuse=True)
def validate_tags(cats: list[Any]): # type: ignore
@field_validator("tags", mode="before")
def validate_tags(cats: list[Any]):
if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return cats
@validator("recipe_category", always=True, pre=True, allow_reuse=True)
def validate_categories(cats: list[Any]): # type: ignore
@field_validator("recipe_category", mode="before")
def validate_categories(cats: list[Any]):
if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return cats
@validator("group_id", always=True, pre=True, allow_reuse=True)
@field_validator("group_id", mode="before")
def validate_group_id(group_id: Any):
if isinstance(group_id, int):
return uuid4()
return group_id
@validator("user_id", always=True, pre=True, allow_reuse=True)
@field_validator("user_id", mode="before")
def validate_user_id(user_id: Any):
if isinstance(user_id, int):
return uuid4()
return user_id
@field_validator("extras", mode="before")
def convert_extras_to_dict(cls, v):
if isinstance(v, dict):
return v
return {x.key_name: x.value for x in v} if v else {}
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
@@ -332,5 +343,5 @@ class RecipeLastMade(BaseModel):
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402
RecipeSummary.update_forward_refs()
Recipe.update_forward_refs()
RecipeSummary.model_rebuild()
Recipe.model_rebuild()

View File

@@ -1,10 +1,10 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel
class RecipeAsset(MealieModel):
name: str
icon: str
file_name: str | None
class Config:
orm_mode = True
file_name: str | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,4 +1,4 @@
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -17,24 +17,18 @@ class CategorySave(CategoryIn):
class CategoryBase(CategoryIn):
id: UUID4
slug: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class CategoryOut(CategoryBase):
slug: str
group_id: UUID4
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeCategoryResponse(CategoryBase):
recipes: "list[RecipeSummary]" = []
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class TagIn(CategoryIn):
@@ -52,9 +46,7 @@ class TagBase(CategoryBase):
class TagOut(TagSave):
id: UUID4
slug: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeTagResponse(RecipeCategoryResponse):
@@ -69,5 +61,5 @@ class RecipeTagResponse(RecipeCategoryResponse):
from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402
RecipeCategoryResponse.update_forward_refs()
RecipeTagResponse.update_forward_refs()
RecipeCategoryResponse.model_rebuild()
RecipeTagResponse.model_rebuild()

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -11,11 +11,9 @@ from mealie.schema.response.pagination import PaginationBase
class UserBase(MealieModel):
id: UUID4
username: str | None
username: str | None = None
admin: bool
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeCommentCreate(MealieModel):
@@ -39,9 +37,7 @@ class RecipeCommentOut(RecipeCommentCreate):
update_at: datetime
user_id: UUID4
user: UserBase
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -6,14 +6,13 @@ from fractions import Fraction
from typing import ClassVar
from uuid import UUID, uuid4
from pydantic import UUID4, Field, validator
from pydantic import UUID4, ConfigDict, Field, field_validator
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import IngredientFoodModel
from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat
from mealie.schema.getter_dict import ExtrasGetterDict
from mealie.schema.response.pagination import PaginationBase
INGREDIENT_QTY_PRECISION = 3
@@ -37,14 +36,20 @@ class UnitFoodBase(MealieModel):
description: str = ""
extras: dict | None = {}
@field_validator("extras", mode="before")
def convert_extras_to_dict(cls, v):
if isinstance(v, dict):
return v
return {x.key_name: x.value for x in v} if v else {}
class CreateIngredientFoodAlias(MealieModel):
name: str
class IngredientFoodAlias(CreateIngredientFoodAlias):
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class CreateIngredientFood(UnitFoodBase):
@@ -61,15 +66,12 @@ class IngredientFood(CreateIngredientFood):
label: MultiPurposeLabelSummary | None = None
aliases: list[IngredientFoodAlias] = []
created_at: datetime.datetime | None
update_at: datetime.datetime | None
created_at: datetime.datetime | None = None
update_at: datetime.datetime | None = None
_searchable_properties: ClassVar[list[str]] = ["name_normalized", "plural_name_normalized"]
_normalize_search: ClassVar[bool] = True
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -85,8 +87,7 @@ class CreateIngredientUnitAlias(MealieModel):
class IngredientUnitAlias(CreateIngredientUnitAlias):
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class CreateIngredientUnit(UnitFoodBase):
@@ -105,8 +106,8 @@ class IngredientUnit(CreateIngredientUnit):
id: UUID4
aliases: list[IngredientUnitAlias] = []
created_at: datetime.datetime | None
update_at: datetime.datetime | None
created_at: datetime.datetime | None = None
update_at: datetime.datetime | None = None
_searchable_properties: ClassVar[list[str]] = [
"name_normalized",
@@ -115,15 +116,13 @@ class IngredientUnit(CreateIngredientUnit):
"plural_abbreviation_normalized",
]
_normalize_search: ClassVar[bool] = True
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeIngredientBase(MealieModel):
quantity: NoneFloat = 1
unit: IngredientUnit | CreateIngredientUnit | None
food: IngredientFood | CreateIngredientFood | None
unit: IngredientUnit | CreateIngredientUnit | None = None
food: IngredientFood | CreateIngredientFood | None = None
note: str | None = ""
is_food: bool | None = None
@@ -152,14 +151,16 @@ class RecipeIngredientBase(MealieModel):
if not self.display:
self.display = self._format_display()
@validator("unit", pre=True)
@field_validator("unit", mode="before")
@classmethod
def validate_unit(cls, v):
if isinstance(v, str):
return CreateIngredientUnit(name=v)
else:
return v
@validator("food", pre=True)
@field_validator("food", mode="before")
@classmethod
def validate_food(cls, v):
if isinstance(v, str):
return CreateIngredientFood(name=v)
@@ -260,19 +261,18 @@ class IngredientUnitPagination(PaginationBase):
class RecipeIngredient(RecipeIngredientBase):
title: str | None
original_text: str | None
title: str | None = None
original_text: str | None = None
disable_amount: bool = True
# Ref is used as a way to distinguish between an individual ingredient on the frontend
# It is required for the reorder and section titles to function properly because of how
# Vue handles reactivity. ref may serve another purpose in the future.
reference_id: UUID = Field(default_factory=uuid4)
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@validator("quantity", pre=True)
@field_validator("quantity", mode="before")
@classmethod
def validate_quantity(cls, value) -> NoneFloat:
"""
Sometimes the frontend UI will provide an empty string as a "null" value because of the default
@@ -294,7 +294,7 @@ class IngredientConfidence(MealieModel):
quantity: NoneFloat = None
food: NoneFloat = None
@validator("quantity", pre=True)
@field_validator("quantity", mode="before")
@classmethod
def validate_quantity(cls, value, values) -> NoneFloat:
if isinstance(value, float):
@@ -305,7 +305,7 @@ class IngredientConfidence(MealieModel):
class ParsedIngredient(MealieModel):
input: str | None
input: str | None = None
confidence: IngredientConfidence = IngredientConfidence()
ingredient: RecipeIngredient
@@ -337,4 +337,4 @@ class MergeUnit(MealieModel):
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402
IngredientFood.update_forward_refs()
IngredientFood.model_rebuild()

View File

@@ -1,9 +1,7 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class RecipeNote(BaseModel):
title: str
text: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,14 +1,14 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel
class Nutrition(MealieModel):
calories: str | None
fat_content: str | None
protein_content: str | None
carbohydrate_content: str | None
fiber_content: str | None
sodium_content: str | None
sugar_content: str | None
class Config:
orm_mode = True
calories: str | None = None
fat_content: str | None = None
protein_content: str | None = None
carbohydrate_content: str | None = None
fiber_content: str | None = None
sodium_content: str | None = None
sugar_content: str | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,3 +1,5 @@
from pydantic import ConfigDict
from mealie.schema._mealie.mealie_model import MealieModel
@@ -8,11 +10,11 @@ class ScrapeRecipeTest(MealieModel):
class ScrapeRecipe(MealieModel):
url: str
include_tags: bool = False
class Config:
schema_extra = {
model_config = ConfigDict(
json_schema_extra={
"example": {
"url": "https://myfavoriterecipes.com/recipes",
"includeTags": True,
},
}
)

View File

@@ -1,3 +1,5 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel
@@ -9,6 +11,4 @@ class RecipeSettings(MealieModel):
disable_comments: bool = True
disable_amount: bool = True
locked: bool = False
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timedelta
from pydantic import UUID4, Field
from pydantic import UUID4, ConfigDict, Field
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -26,16 +26,12 @@ class RecipeShareTokenSave(RecipeShareTokenCreate):
class RecipeShareTokenSummary(RecipeShareTokenSave):
id: UUID4
created_at: datetime
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeShareToken(RecipeShareTokenSummary):
recipe: Recipe
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -1,6 +1,6 @@
from uuid import UUID, uuid4
from pydantic import UUID4, Field
from pydantic import UUID4, ConfigDict, Field
from mealie.schema._mealie import MealieModel
@@ -10,10 +10,8 @@ class IngredientReferences(MealieModel):
A list of ingredient references.
"""
reference_id: UUID4 | None
class Config:
orm_mode = True
reference_id: UUID4 | None = None
model_config = ConfigDict(from_attributes=True)
class RecipeStep(MealieModel):
@@ -21,6 +19,4 @@ class RecipeStep(MealieModel):
title: str | None = ""
text: str
ingredient_references: list[IngredientReferences] = []
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,8 +1,9 @@
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Annotated
from pydantic import UUID4, Field
from pydantic import UUID4, ConfigDict, Field
from mealie.core.config import get_app_dirs
from mealie.schema._mealie.mealie_model import MealieModel
@@ -32,12 +33,10 @@ class RecipeTimelineEventIn(MealieModel):
event_type: TimelineEventType
message: str | None = Field(None, alias="eventMessage")
image: TimelineEventImage | None = TimelineEventImage.does_not_have_image
image: Annotated[TimelineEventImage | None, Field(validate_default=True)] = TimelineEventImage.does_not_have_image
timestamp: datetime = datetime.now()
class Config:
use_enum_values = True
model_config = ConfigDict(use_enum_values=True)
class RecipeTimelineEventCreate(RecipeTimelineEventIn):
@@ -46,20 +45,16 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn):
class RecipeTimelineEventUpdate(MealieModel):
subject: str
message: str | None = Field(alias="eventMessage")
message: str | None = Field(None, alias="eventMessage")
image: TimelineEventImage | None = None
class Config:
use_enum_values = True
model_config = ConfigDict(use_enum_values=True)
class RecipeTimelineEventOut(RecipeTimelineEventCreate):
id: UUID4
created_at: datetime
update_at: datetime
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path:

View File

@@ -1,4 +1,4 @@
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -19,16 +19,12 @@ class RecipeToolSave(RecipeToolCreate):
class RecipeToolOut(RecipeToolCreate):
id: UUID4
slug: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class RecipeToolResponse(RecipeToolOut):
recipes: list["RecipeSummary"] = []
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -41,4 +37,4 @@ class RecipeToolResponse(RecipeToolOut):
from .recipe import RecipeSummary # noqa: E402
RecipeToolResponse.update_forward_refs()
RecipeToolResponse.model_rebuild()

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from mealie.schema._mealie import MealieModel
@@ -10,8 +10,7 @@ class RecipeSlug(MealieModel):
class SlugResponse(BaseModel):
class Config:
schema_extra = {"example": "adult-mac-and-cheese"}
model_config = ConfigDict(json_schema_extra={"example": "adult-mac-and-cheese"})
class UpdateImageResponse(BaseModel):
@@ -23,4 +22,4 @@ class RecipeZipTokenResponse(BaseModel):
class RecipeDuplicate(BaseModel):
name: str | None
name: str | None = None

View File

@@ -1,7 +1,7 @@
import datetime
import enum
from pydantic import Field
from pydantic import ConfigDict, Field
from pydantic.types import UUID4
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -34,9 +34,7 @@ class ReportEntryCreate(MealieModel):
class ReportEntryOut(ReportEntryCreate):
id: UUID4
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class ReportCreate(MealieModel):
@@ -53,9 +51,7 @@ class ReportSummary(ReportCreate):
class ReportOut(ReportSummary):
entries: list[ReportEntryOut] = []
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -1,10 +1,10 @@
import enum
from typing import Any, Generic, TypeVar
from typing import Annotated, Any, Generic, TypeVar
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from humps import camelize
from pydantic import UUID4, BaseModel, validator
from pydantic.generics import GenericModel
from pydantic import UUID4, BaseModel, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from mealie.schema._mealie import MealieModel
@@ -22,12 +22,12 @@ class OrderByNullPosition(str, enum.Enum):
class RecipeSearchQuery(MealieModel):
cookbook: UUID4 | str | None
cookbook: UUID4 | str | None = None
require_all_categories: bool = False
require_all_tags: bool = False
require_all_tools: bool = False
require_all_foods: bool = False
search: str | None
search: str | None = None
_search_seed: str | None = None
@@ -38,23 +38,23 @@ class PaginationQuery(MealieModel):
order_by_null_position: OrderByNullPosition | None = None
order_direction: OrderDirection = OrderDirection.desc
query_filter: str | None = None
pagination_seed: str | None = None
pagination_seed: Annotated[str | None, Field(validate_default=True)] = None
@validator("pagination_seed", always=True, pre=True)
def validate_randseed(cls, pagination_seed, values):
if values.get("order_by") == "random" and not pagination_seed:
@field_validator("pagination_seed", mode="before")
def validate_randseed(cls, pagination_seed, info: ValidationInfo):
if info.data.get("order_by") == "random" and not pagination_seed:
raise ValueError("paginationSeed is required when orderBy is random")
return pagination_seed
class PaginationBase(GenericModel, Generic[DataT]):
class PaginationBase(BaseModel, Generic[DataT]):
page: int = 1
per_page: int = 10
total: int = 0
total_pages: int = 0
items: list[DataT]
next: str | None
previous: str | None
next: str | None = None
previous: str | None = None
def _set_next(self, route: str, query_params: dict[str, Any]) -> None:
if self.page >= self.total_pages:

View File

@@ -14,7 +14,7 @@ class ErrorResponse(BaseModel):
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message, exception=exception).dict()
return cls(message=message, exception=exception).model_dump()
class SuccessResponse(BaseModel):
@@ -27,7 +27,7 @@ class SuccessResponse(BaseModel):
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message).dict()
return cls(message=message).model_dump()
class FileTokenResponse(MealieModel):
@@ -39,4 +39,4 @@ class FileTokenResponse(MealieModel):
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(file_token=token).dict()
return cls(file_token=token).model_dump()

View File

@@ -2,7 +2,7 @@ import datetime
import enum
from uuid import UUID
from pydantic import Field
from pydantic import ConfigDict, Field
from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
@@ -43,9 +43,7 @@ class ServerTaskCreate(MealieModel):
class ServerTask(ServerTaskCreate):
id: int
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class ServerTaskPagination(PaginationBase):

View File

@@ -1,5 +1,6 @@
from pydantic import UUID4, BaseModel
from pydantic.types import constr
from typing import Annotated
from pydantic import UUID4, BaseModel, StringConstraints
from mealie.schema._mealie.mealie_model import MealieModel
@@ -10,8 +11,8 @@ class Token(BaseModel):
class TokenData(BaseModel):
user_id: UUID4 | None
username: constr(to_lower=True, strip_whitespace=True) | None = None # type: ignore
user_id: UUID4 | None = None
username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] | None = None # type: ignore
class UnlockResults(MealieModel):

View File

@@ -1,15 +1,17 @@
from pydantic import validator
from pydantic.types import NoneStr, constr
from typing import Annotated
from pydantic import Field, StringConstraints, field_validator
from pydantic_core.core_schema import ValidationInfo
from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.validators import validate_locale
class CreateUserRegistration(MealieModel):
group: NoneStr = None
group_token: NoneStr = None
email: constr(to_lower=True, strip_whitespace=True) # type: ignore
username: constr(to_lower=True, strip_whitespace=True) # type: ignore
group: str | None = None
group_token: Annotated[str | None, Field(validate_default=True)] = None
email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
password: str
password_confirm: str
advanced: bool = False
@@ -18,23 +20,23 @@ class CreateUserRegistration(MealieModel):
seed_data: bool = False
locale: str = "en-US"
@validator("locale")
def valid_locale(cls, v, values, **kwargs):
@field_validator("locale")
def valid_locale(cls, v):
if not validate_locale(v):
raise ValueError("invalid locale")
return v
@validator("password_confirm")
@field_validator("password_confirm")
@classmethod
def passwords_match(cls, value, values):
if "password" in values and value != values["password"]:
def passwords_match(cls, value, info: ValidationInfo):
if "password" in info.data and value != info.data["password"]:
raise ValueError("passwords do not match")
return value
@validator("group_token", always=True)
@field_validator("group_token")
@classmethod
def group_or_token(cls, value, values):
if not bool(value) and not bool(values["group"]):
def group_or_token(cls, value, info: ValidationInfo):
if not bool(value) and not bool(info.data["group"]):
raise ValueError("group or group_token must be provided")
return value

View File

@@ -1,10 +1,9 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from typing import Annotated, Any
from uuid import UUID
from pydantic import UUID4, Field, validator
from pydantic.types import constr
from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -18,7 +17,6 @@ from mealie.schema.response.pagination import PaginationBase
from ...db.models.group import Group
from ...db.models.recipe import RecipeModel
from ..getter_dict import GroupGetterDict, UserGetterDict
from ..recipe import CategoryBase
DEFAULT_INTEGRATION_ID = "generic"
@@ -34,25 +32,19 @@ class LongLiveTokenOut(MealieModel):
token: str
name: str
id: int
created_at: datetime | None
class Config:
orm_mode = True
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class CreateToken(LongLiveTokenIn):
user_id: UUID4
token: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class DeleteTokenResponse(MealieModel):
token_delete: str
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class ChangePassword(MealieModel):
@@ -61,31 +53,27 @@ class ChangePassword(MealieModel):
class GroupBase(MealieModel):
name: constr(strip_whitespace=True, min_length=1) # type: ignore
class Config:
orm_mode = True
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] # type: ignore
model_config = ConfigDict(from_attributes=True)
class UserBase(MealieModel):
username: str | None
id: UUID4 | None = None
username: str | None = None
full_name: str | None = None
email: constr(to_lower=True, strip_whitespace=True) # type: ignore
email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
auth_method: AuthMethod = AuthMethod.MEALIE
admin: bool = False
group: str | None
group: str | None = None
advanced: bool = False
favorite_recipes: list[str] | None = []
can_invite: bool = False
can_manage: bool = False
can_organize: bool = False
class Config:
orm_mode = True
getter_dict = GroupGetterDict
schema_extra = {
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"username": "ChangeMe",
"fullName": "Change Me",
@@ -93,7 +81,18 @@ class UserBase(MealieModel):
"group": settings.DEFAULT_GROUP,
"admin": "false",
}
}
},
)
@field_validator("group", mode="before")
def convert_group_to_name(cls, v):
if not v or isinstance(v, str):
return v
try:
return v.name
except AttributeError:
return v
class UserIn(UserBase):
@@ -105,14 +104,10 @@ class UserOut(UserBase):
group: str
group_id: UUID4
group_slug: str
tokens: list[LongLiveTokenOut] | None
tokens: list[LongLiveTokenOut] | None = None
cache_key: str
favorite_recipes: list[str] | None = []
class Config:
orm_mode = True
getter_dict = UserGetterDict
favorite_recipes: Annotated[list[str] | None, Field(validate_default=True)] = []
model_config = ConfigDict(from_attributes=True)
@property
def is_default_user(self) -> bool:
@@ -122,6 +117,10 @@ class UserOut(UserBase):
def loader_options(cls) -> list[LoaderOption]:
return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)]
@field_validator("favorite_recipes", mode="before")
def convert_favorite_recipes_to_slugs(cls, v):
return [recipe.slug for recipe in v] if v else v
class UserPagination(PaginationBase):
items: list[UserOut]
@@ -129,10 +128,7 @@ class UserPagination(PaginationBase):
class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = [] # type: ignore
class Config:
orm_mode = True
getter_dict = GroupGetterDict
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
@@ -149,11 +145,10 @@ class PrivateUser(UserOut):
group_id: UUID4
login_attemps: int = 0
locked_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@validator("login_attemps", pre=True)
@field_validator("login_attemps", mode="before")
@classmethod
def none_to_zero(cls, v):
return 0 if v is None else v
@@ -189,11 +184,9 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup):
users: list[UserOut] | None
users: list[UserOut] | None = None
preferences: ReadGroupPreferences | None = None
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@staticmethod
def get_directory(id: UUID4) -> Path:
@@ -234,6 +227,4 @@ class GroupPagination(PaginationBase):
class LongLiveTokenInDB(CreateToken):
id: int
user: PrivateUser
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,4 +1,4 @@
from pydantic import UUID4
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -33,9 +33,7 @@ class SavePasswordResetToken(MealieModel):
class PrivatePasswordResetToken(SavePasswordResetToken):
user: PrivateUser
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]: