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

⬆️Pydantic V2: Diverse fixes after merges from master #6627

Merged
Show file tree
Hide file tree
Changes from 14 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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pydantic import BaseModel, Field

from ..basic_types import IDStr
from ..utils.pydantic_tools_extension import NOT_REQUIRED


class DefaultApiError(BaseModel):
Expand All @@ -13,7 +12,7 @@ class DefaultApiError(BaseModel):
description="Error identifier as a code or a name. "
"Mainly for machine-machine communication purposes.",
)
detail: Any | None = Field(NOT_REQUIRED, description="Human readable error message")
detail: Any | None = Field(default=None, description="Human readable error message")

@classmethod
def from_status_code(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
from datetime import datetime
from typing import Any, Literal, TypeAlias

from models_library.folders import FolderID
from models_library.workspaces import WorkspaceID
from pydantic import ConfigDict, Field, HttpUrl, field_validator

from ..api_schemas_long_running_tasks.tasks import TaskGet
Expand All @@ -24,9 +22,10 @@
none_to_empty_str_pre_validator,
null_or_none_str_to_none_validator,
)
from ..utils.pydantic_tools_extension import FieldNotRequired
from ._base import EmptyModel, InputSchema, OutputSchema
from .folders import FolderID
from .permalinks import ProjectPermalink
from .workspaces import WorkspaceID


class ProjectCreateNew(InputSchema):
Expand Down Expand Up @@ -74,14 +73,18 @@ class ProjectGet(OutputSchema):
prj_owner: LowerCaseEmailStr
access_rights: dict[GroupIDStr, AccessRights]
tags: list[int]
classifiers: list[ClassifierID] = []
classifiers: list[ClassifierID] = Field(
default_factory=list, json_schema_extra={"default": []}
)
state: ProjectState | None = None
ui: EmptyModel | StudyUI | None = None
quality: dict[str, Any] = {}
quality: dict[str, Any] = Field(
default_factory=dict, json_schema_extra={"default": {}}
)
dev: dict | None
permalink: ProjectPermalink = FieldNotRequired()
permalink: ProjectPermalink | None = None
workspace_id: WorkspaceID | None
folder_id: FolderID | None
folder_id: FolderID | None = None
sanderegg marked this conversation as resolved.
Show resolved Hide resolved
trashed_at: datetime | None

_empty_description = field_validator("description", mode="before")(
Expand All @@ -107,13 +110,15 @@ class ProjectReplace(InputSchema):
last_change_date: DateTimeStr
workbench: NodesDict
access_rights: dict[GroupIDStr, AccessRights]
tags: list[int] | None = []
tags: list[int] | None = Field(
default_factory=list, json_schema_extra={"default": []}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the rest noticed this. perhaps add it in the description of the PR ?

)
classifiers: list[ClassifierID] | None = Field(
default_factory=list,
default_factory=list, json_schema_extra={"default": []}
)
ui: StudyUI | None = None
quality: dict[str, Any] = Field(
default_factory=dict,
default_factory=dict, json_schema_extra={"default": {}}
)

_empty_is_none = field_validator("thumbnail", mode="before")(
Expand All @@ -122,16 +127,16 @@ class ProjectReplace(InputSchema):


class ProjectPatch(InputSchema):
name: ShortTruncatedStr = FieldNotRequired()
description: LongTruncatedStr = FieldNotRequired()
thumbnail: HttpUrl = FieldNotRequired()
access_rights: dict[GroupIDStr, AccessRights] = FieldNotRequired()
classifiers: list[ClassifierID] = FieldNotRequired()
dev: dict | None = FieldNotRequired()
ui: StudyUI | None = FieldNotRequired()
quality: dict[str, Any] = FieldNotRequired()

_empty_is_none = field_validator("thumbnail", allow_reuse=True, pre=True)(
name: ShortTruncatedStr | None = Field(default=None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why you add Field when we only look for the default?

THOUGHT: I wonder if we should start using Annotated instead of abusing of the "exception to the rule" of adding a default (i.e. Field) that is not an instance of the type annotated. It kind of similar to the story with FieldRequired that exploited an edge case that now was removed.

class MyModel(BaseModel):
      foo: Annotated[LongTruncatedStr | None , Field(description="foo is the opposite of bar")] = None

Perhaps is an unnecessary burden ... it does not really pay off!

description: LongTruncatedStr | None = Field(default=None)
thumbnail: HttpUrl | None = Field(default=None)
access_rights: dict[GroupIDStr, AccessRights] | None = Field(default=None)
classifiers: list[ClassifierID] | None = Field(default=None)
dev: dict | None = Field(default=None)
ui: StudyUI | None = Field(default=None)
quality: dict[str, Any] | None = Field(default=None)

_empty_is_none = field_validator("thumbnail", mode="before")(
empty_str_to_none_pre_validator
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from ..services import ServiceKey, ServicePortKey, ServiceVersion
from ..services_enums import ServiceState
from ..services_resources import ServiceResourcesDict
from ..utils.pydantic_tools_extension import FieldNotRequired
from ._base import InputSchemaWithoutCamelCase, OutputSchema

assert ServiceResourcesDict # nosec
Expand All @@ -27,19 +26,19 @@ class NodeCreate(InputSchemaWithoutCamelCase):


class NodePatch(InputSchemaWithoutCamelCase):
service_key: ServiceKey = FieldNotRequired(alias="key")
service_version: ServiceVersion = FieldNotRequired(alias="version")
label: str = FieldNotRequired()
inputs: InputsDict = FieldNotRequired()
inputs_required: list[InputID] = FieldNotRequired(alias="inputsRequired")
input_nodes: list[NodeID] = FieldNotRequired(alias="inputNodes")
progress: float | None = FieldNotRequired(
ge=0, le=100
service_key: ServiceKey | None = Field(default=None, alias="key")
service_version: ServiceVersion | None = Field(default=None, alias="version")
label: str | None = Field(default=None)
inputs: InputsDict = Field(default=None)
inputs_required: list[InputID] | None = Field(default=None, alias="inputsRequired")
input_nodes: list[NodeID] | None = Field(default=None, alias="inputNodes")
progress: float | None = Field(
default=None, ge=0, le=100
) # NOTE: it is used by frontend for File Picker progress
boot_options: BootOptions = FieldNotRequired(alias="bootOptions")
outputs: dict[
str, Any
] = FieldNotRequired() # NOTE: it is used by frontend for File Picker
boot_options: BootOptions | None = Field(default=None, alias="bootOptions")
outputs: dict[str, Any] | None = Field(
default=None
) # NOTE: it is used by frontend for File Picker


class NodeCreated(OutputSchema):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from ..basic_types import AmountDecimal, IDStr, NonNegativeDecimal
from ..users import GroupID
from ..utils.pydantic_tools_extension import FieldNotRequired
from ..wallets import WalletID, WalletStatus
from ._base import InputSchema, OutputSchema

Expand All @@ -21,9 +20,7 @@ class WalletGet(OutputSchema):
created: datetime
modified: datetime

model_config = ConfigDict(
frozen=False
)
model_config = ConfigDict(frozen=False)


class WalletGetWithAvailableCredits(WalletGet):
Expand Down Expand Up @@ -60,7 +57,7 @@ class PutWalletBodyParams(OutputSchema):

class CreateWalletPayment(InputSchema):
price_dollars: AmountDecimal
comment: str = FieldNotRequired(max_length=100)
comment: str | None = Field(default=None, max_length=100)


class WalletPaymentInitiated(OutputSchema):
Expand All @@ -77,15 +74,15 @@ class PaymentTransaction(OutputSchema):
price_dollars: Decimal
wallet_id: WalletID
osparc_credits: Decimal
comment: str = FieldNotRequired()
comment: str | None = Field(default=None)
created_at: datetime
completed_at: datetime | None
# SEE PaymentTransactionState enum
state: Literal["PENDING", "SUCCESS", "FAILED", "CANCELED"] = Field(
..., alias="completedStatus"
)
state_message: str = FieldNotRequired()
invoice_url: HttpUrl = FieldNotRequired()
state_message: str | None = Field(default=None)
invoice_url: HttpUrl | None = Field(default=None)


class PaymentMethodInitiated(OutputSchema):
Expand Down
8 changes: 2 additions & 6 deletions packages/models-library/src/models_library/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class ProjectAtDB(BaseProjectModel):
prj_owner: int | None = Field(..., description="The project owner id")

published: bool | None = Field(
False, description="Defines if a study is available publicly"
default=False, description="Defines if a study is available publicly"
)

@field_validator("project_type", mode="before")
Expand Down Expand Up @@ -183,8 +183,4 @@ class Project(BaseProjectModel):
alias="trashedAt",
)

model_config = ConfigDict(
description="Document that stores metadata, pipeline and UI setup of a study",
title="osparc-simcore project",
extra="forbid",
)
model_config = ConfigDict(title="osparc-simcore project", extra="forbid")
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
class MyModel(BaseModel):
thumbnail: str | None

_empty_is_none = validator("thumbnail", allow_reuse=True, pre=True)(
_empty_is_none = validator("thumbnail", mode="before")(
empty_str_to_none_pre_validator
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import functools
from typing import Final, TypeVar
from typing import TypeVar

from pydantic import Field, TypeAdapter, ValidationError
from pydantic import TypeAdapter, ValidationError

T = TypeVar("T")

Expand All @@ -11,17 +10,3 @@ def parse_obj_or_none(type_: type[T], obj) -> T | None:
return TypeAdapter(type_).validate_python(obj)
except ValidationError:
return None


#
# NOTE: Helper to define non-nullable optional fields
# SEE details in test/test_utils_pydantic_tools_extension.py
#
# Two usage styles:
#
# class Model(BaseModel):
# value: FieldNotRequired(description="some optional field")
# other: Field(NOT_REQUIRED, description="alternative")
#
NOT_REQUIRED: Final = None
FieldNotRequired = functools.partial(Field, default=NOT_REQUIRED)
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
from models_library.utils.pydantic_tools_extension import (
FieldNotRequired,
parse_obj_or_none,
)
from models_library.utils.pydantic_tools_extension import parse_obj_or_none
from pydantic import BaseModel, Field, StrictInt


Expand All @@ -10,7 +7,7 @@ class MyModel(BaseModel):
b: int | None = Field(...)
c: int = 42
d: int | None = None
e: int = FieldNotRequired(description="optional non-nullable")
e: int = Field(default=324, description="optional non-nullable")


def test_schema():
Expand All @@ -27,7 +24,7 @@ def test_schema():
"title": "D",
},
"e": {
"default": None,
"default": 324,
"title": "E",
"type": "integer",
"description": "optional non-nullable",
Expand All @@ -39,7 +36,7 @@ def test_schema():

def test_only_required():
model = MyModel(a=1, b=2)
assert model.model_dump() == {"a": 1, "b": 2, "c": 42, "d": None, "e": None}
assert model.model_dump() == {"a": 1, "b": 2, "c": 42, "d": None, "e": 324}
assert model.model_dump(exclude_unset=True) == {"a": 1, "b": 2}


Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime

import sqlalchemy as sa
from pydantic import parse_obj_as
from pydantic import TypeAdapter
from pydantic.errors import PydanticErrorMixin
from sqlalchemy.ext.asyncio import AsyncConnection

Expand Down Expand Up @@ -37,5 +37,5 @@ async def get_project_last_change_date(
row = result.first()
if row is None:
raise DBProjectNotFoundError(project_uuid=project_uuid)
date = parse_obj_as(datetime, row[0])
return date.replace(tzinfo=timezone.utc)
date = TypeAdapter(datetime).validate_python(row[0])
return date.replace(tzinfo=UTC)
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
DEFAULT_NUMBER_OF_ITEMS_PER_PAGE,
MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE,
)
from models_library.utils.pydantic_tools_extension import FieldNotRequired
from pydantic import (
BaseModel,
ConfigDict,
Field,
NonNegativeInt,
ValidationInfo,
field_validator,
Expand Down Expand Up @@ -56,7 +56,7 @@ class OnePage(BaseModel, Generic[T]):
"""

items: Sequence[T]
total: NonNegativeInt = FieldNotRequired(validate_default=True)
total: NonNegativeInt | None = Field(default=None, validate_default=True)

@field_validator("total", mode="before")
@classmethod
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
from typing import TypeAlias

from common_library.pydantic_networks_extension import AnyUrlLegacy
from models_library import projects, projects_nodes_io
from models_library.utils import pydantic_tools_extension
from pydantic import BaseModel, Field
from pydantic import AnyUrl, BaseModel, Field

from .. import api_resources
from . import solvers

StudyID: TypeAlias = projects.ProjectID
NodeName: TypeAlias = str
DownloadLink: TypeAlias = AnyUrlLegacy
DownloadLink: TypeAlias = AnyUrl


class Study(BaseModel):
uid: StudyID
title: str = pydantic_tools_extension.FieldNotRequired()
description: str = pydantic_tools_extension.FieldNotRequired()
title: str | None = None
description: str | None = None

@classmethod
def compose_resource_name(cls, study_key) -> api_resources.RelativeResourceName:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@
PositiveInt,
TypeAdapter,
)
from simcore_service_api_server.exceptions.backend_errors import (
JobNotFoundError,
LogFileNotFoundError,
)
from starlette import status

from ..core.settings import DirectorV2Settings
from ..db.repositories.groups_extra_properties import GroupsExtraPropertiesRepository
from ..exceptions.backend_errors import JobNotFoundError, LogFileNotFoundError
from ..exceptions.service_errors_utils import service_exception_mapper
from ..models.schemas.jobs import PercentageInt
from ..models.schemas.studies import JobLogsMap, LogLink
Expand Down Expand Up @@ -186,12 +183,13 @@ async def get_computation_logs(
# probably not found
response.raise_for_status()

log_links: list[LogLink] = []
for r in TypeAdapter(list[TaskLogFileGet]).validate_json(response.text or "[]"):
if r.download_link:
log_links.append(
LogLink(node_name=f"{r.task_id}", download_link=r.download_link)
)
log_links: list[LogLink] = [
LogLink(node_name=f"{r.task_id}", download_link=r.download_link)
for r in TypeAdapter(list[TaskLogFileGet]).validate_json(
response.text or "[]"
)
if r.download_link
]

return JobLogsMap(log_links=log_links)

Expand Down
2 changes: 2 additions & 0 deletions services/api-server/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,8 @@ def create_project_task(self, request: httpx.Request):
"creationDate": "2018-07-01T11:13:43Z",
"lastChangeDate": "2018-07-01T11:13:43Z",
"prjOwner": "[email protected]",
"dev": None,
"trashed_at": None,
**project_create,
}
)
Expand Down
Loading
Loading