Updated most python dependencies

Primarily FastAPI and Pydantic to v2. Also ruff to latest.

Updated some code to support Pydantic v2
This commit is contained in:
Donkie
2024-05-23 19:42:27 +02:00
parent 306dbe40af
commit 535fa40ad2
29 changed files with 839 additions and 531 deletions

View File

@@ -6,10 +6,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, validator
from pydantic.error_wrappers import ErrorWrapper
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Filament, FilamentEvent, Message
@@ -32,69 +30,80 @@ router = APIRouter(
class FilamentParameters(BaseModel):
name: Optional[str] = Field(
None,
max_length=64,
description=(
"Filament name, to distinguish this filament type among others from the same vendor."
"Should contain its color for example."
),
example="PolyTerra™ Charcoal Black",
examples=["PolyTerra™ Charcoal Black"],
)
vendor_id: Optional[int] = Field(description="The ID of the vendor of this filament type.")
vendor_id: Optional[int] = Field(None, description="The ID of the vendor of this filament type.")
material: Optional[str] = Field(
None,
max_length=64,
description="The material of this filament, e.g. PLA.",
example="PLA",
examples=["PLA"],
)
price: Optional[float] = Field(
None,
ge=0,
description="The price of this filament in the system configured currency.",
example=20.0,
examples=[20.0],
)
density: float = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
density: float = Field(gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", examples=[1.75])
weight: Optional[float] = Field(
None,
gt=0,
description="The weight of the filament in a full spool, in grams. (net weight)",
example=1000,
examples=[1000],
)
spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
spool_weight: Optional[float] = Field(None, gt=0, description="The empty spool weight, in grams.", examples=[140])
article_number: Optional[str] = Field(
None,
max_length=64,
description="Vendor article number, e.g. EAN, QR code, etc.",
example="PM70820",
examples=["PM70820"],
)
comment: Optional[str] = Field(
None,
max_length=1024,
description="Free text comment about this filament type.",
example="",
examples=[""],
)
settings_extruder_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden extruder temperature, in °C.",
example=210,
examples=[210],
)
settings_bed_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden bed temperature, in °C.",
example=60,
examples=[60],
)
color_hex: Optional[str] = Field(
None,
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000",
examples=["FF0000"],
)
external_id: Optional[str] = Field(
None,
max_length=256,
description=(
"Set if this filament comes from an external database. This contains the ID in the external database."
),
example="polymaker_pla_polysonicblack_1000_175",
examples=["polymaker_pla_polysonicblack_1000_175"],
)
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this filament.",
)
@validator("color_hex")
@field_validator("color_hex")
@classmethod
@classmethod
def color_hex_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
"""Validate the color_hex field."""
@@ -115,8 +124,16 @@ class FilamentParameters(BaseModel):
class FilamentUpdateParameters(FilamentParameters):
density: Optional[float] = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
diameter: Optional[float] = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
density: Optional[float] = Field(None, gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
diameter: Optional[float] = Field(None, gt=0, description="The diameter of this filament in mm.", examples=[1.75])
@field_validator("density", "diameter")
@classmethod
def prevent_none(cls: type["FilamentUpdateParameters"], v: Optional[float]) -> Optional[float]:
"""Prevent density and diameter from being None."""
if v is None:
raise ValueError("Value must not be None.")
return v
@router.get(
@@ -149,6 +166,7 @@ async def find(
title="Vendor ID",
description="See vendor.id.",
deprecated=True,
pattern=r"^-?\d+(,-?\d+)*$",
),
vendor_name: Optional[str] = Query(
alias="vendor.name",
@@ -167,6 +185,7 @@ async def find(
"Match an exact vendor ID. Separate multiple IDs with a comma. "
"Specify -1 to match filaments with no vendor."
),
pattern=r"^-?\d+(,-?\d+)*$",
examples=["1", "1,2"],
),
name: Optional[str] = Query(
@@ -243,10 +262,7 @@ async def find(
vendor_id = vendor_id if vendor_id is not None else vendor_id_old
if vendor_id is not None:
try:
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
except ValueError as e:
raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
else:
vendor_ids = None
@@ -399,12 +415,7 @@ async def update( # noqa: ANN201
filament_id: int,
body: FilamentUpdateParameters,
):
patch_data = body.dict(exclude_unset=True)
if "density" in patch_data and body.density is None:
raise RequestValidationError([ErrorWrapper(ValueError("density cannot be unset"), ("query", "density"))])
if "diameter" in patch_data and body.diameter is None:
raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))])
patch_data = body.model_dump(exclude_unset=True)
if body.extra:
all_fields = await get_extra_fields(db, EntityType.filament)

View File

@@ -2,10 +2,9 @@
from datetime import datetime, timezone
from enum import Enum
from typing import Literal, Optional
from typing import Annotated, Literal, Optional
from pydantic import BaseModel as PydanticBaseModel
from pydantic import Field
from pydantic import BaseModel, Field, PlainSerializer
from spoolman.database import models
from spoolman.math import length_from_weight
@@ -19,13 +18,7 @@ def datetime_to_str(dt: datetime) -> str:
return dt.isoformat().replace("+00:00", "Z")
class BaseModel(PydanticBaseModel):
class Config:
"""Pydantic configuration."""
json_encoders = { # noqa: RUF012
datetime: datetime_to_str,
}
SpoolmanDateTime = Annotated[datetime, PlainSerializer(datetime_to_str)]
class Message(BaseModel):
@@ -57,16 +50,27 @@ class SettingKV(BaseModel):
class Vendor(BaseModel):
id: int = Field(description="Unique internal ID of this vendor.")
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.")
name: str = Field(max_length=64, description="Vendor name.", example="Polymaker")
comment: Optional[str] = Field(max_length=1024, description="Free text comment about this vendor.", example="")
empty_spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
registered: SpoolmanDateTime = Field(description="When the vendor was registered in the database. UTC Timezone.")
name: str = Field(max_length=64, description="Vendor name.", examples=["Polymaker"])
comment: Optional[str] = Field(
None,
max_length=1024,
description="Free text comment about this vendor.",
examples=[""],
)
empty_spool_weight: Optional[float] = Field(
None,
gt=0,
description="The empty spool weight, in grams.",
examples=[140],
)
external_id: Optional[str] = Field(
None,
max_length=256,
description=(
"Set if this vendor comes from an external database. This contains the ID in the external database."
),
example="eSun",
examples=["eSun"],
)
extra: dict[str, str] = Field(
description=(
@@ -91,66 +95,76 @@ class Vendor(BaseModel):
class Filament(BaseModel):
id: int = Field(description="Unique internal ID of this filament type.")
registered: datetime = Field(description="When the filament was registered in the database. UTC Timezone.")
registered: SpoolmanDateTime = Field(description="When the filament was registered in the database. UTC Timezone.")
name: Optional[str] = Field(
None,
max_length=64,
description=(
"Filament name, to distinguish this filament type among others from the same vendor."
"Should contain its color for example."
),
example="PolyTerra™ Charcoal Black",
examples=["PolyTerra™ Charcoal Black"],
)
vendor: Optional[Vendor] = Field(description="The vendor of this filament type.")
vendor: Optional[Vendor] = Field(None, description="The vendor of this filament type.")
material: Optional[str] = Field(
None,
max_length=64,
description="The material of this filament, e.g. PLA.",
example="PLA",
examples=["PLA"],
)
price: Optional[float] = Field(
None,
ge=0,
description="The price of this filament in the system configured currency.",
example=20.0,
examples=[20.0],
)
density: float = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
density: float = Field(gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", examples=[1.75])
weight: Optional[float] = Field(
None,
gt=0,
description="The weight of the filament in a full spool, in grams.",
example=1000,
examples=[1000],
)
spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
spool_weight: Optional[float] = Field(None, gt=0, description="The empty spool weight, in grams.", examples=[140])
article_number: Optional[str] = Field(
None,
max_length=64,
description="Vendor article number, e.g. EAN, QR code, etc.",
example="PM70820",
examples=["PM70820"],
)
comment: Optional[str] = Field(
None,
max_length=1024,
description="Free text comment about this filament type.",
example="",
examples=[""],
)
settings_extruder_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden extruder temperature, in °C.",
example=210,
examples=[210],
)
settings_bed_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden bed temperature, in °C.",
example=60,
examples=[60],
)
color_hex: Optional[str] = Field(
None,
min_length=6,
max_length=8,
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000",
examples=["FF0000"],
)
external_id: Optional[str] = Field(
None,
max_length=256,
description=(
"Set if this filament comes from an external database. This contains the ID in the external database."
),
example="polymaker_pla_polysonicblack_1000_175",
examples=["polymaker_pla_polysonicblack_1000_175"],
)
extra: dict[str, str] = Field(
description=(
@@ -185,14 +199,21 @@ class Filament(BaseModel):
class Spool(BaseModel):
id: int = Field(description="Unique internal ID of this spool of filament.")
registered: datetime = Field(description="When the spool was registered in the database. UTC Timezone.")
first_used: Optional[datetime] = Field(description="First logged occurence of spool usage. UTC Timezone.")
last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage. UTC Timezone.")
registered: SpoolmanDateTime = Field(description="When the spool was registered in the database. UTC Timezone.")
first_used: Optional[SpoolmanDateTime] = Field(
None,
description="First logged occurence of spool usage. UTC Timezone.",
)
last_used: Optional[SpoolmanDateTime] = Field(
None,
description="Last logged occurence of spool usage. UTC Timezone.",
)
filament: Filament = Field(description="The filament type of this spool.")
price: Optional[float] = Field(
None,
ge=0,
description="The price of this spool in the system configured currency.",
example=20.0,
examples=[20.0],
)
remaining_weight: Optional[float] = Field(
default=None,
@@ -201,21 +222,25 @@ class Spool(BaseModel):
"Estimated remaining weight of filament on the spool in grams. "
"Only set if the filament type has a weight set."
),
example=500.6,
examples=[500.6],
)
initial_weight: Optional[float] = Field(
default=None,
ge=0,
description=("The initial weight, in grams, of the filament on the spool (net weight)."),
example=1246,
examples=[1246],
)
spool_weight: Optional[float] = Field(
default=None,
ge=0,
description=("Weight of an empty spool (tare weight)."),
example=246,
examples=[246],
)
used_weight: float = Field(
ge=0,
description="Consumed weight of filament from the spool in grams.",
examples=[500.3],
)
used_weight: float = Field(ge=0, description="Consumed weight of filament from the spool in grams.", example=500.3)
remaining_length: Optional[float] = Field(
default=None,
ge=0,
@@ -223,23 +248,30 @@ class Spool(BaseModel):
"Estimated remaining length of filament on the spool in millimeters."
" Only set if the filament type has a weight set."
),
example=5612.4,
examples=[5612.4],
)
used_length: float = Field(
ge=0,
description="Consumed length of filament from the spool in millimeters.",
example=50.7,
examples=[50.7],
)
location: Optional[str] = Field(
None,
max_length=64,
description="Where this spool can be found.",
examples=["Shelf A"],
)
location: Optional[str] = Field(max_length=64, description="Where this spool can be found.", example="Shelf A")
lot_nr: Optional[str] = Field(
None,
max_length=64,
description="Vendor manufacturing lot/batch number of the spool.",
example="52342",
examples=["52342"],
)
comment: Optional[str] = Field(
None,
max_length=1024,
description="Free text comment about this specific spool.",
example="",
examples=[""],
)
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
extra: dict[str, str] = Field(
@@ -300,26 +332,26 @@ class Spool(BaseModel):
class Info(BaseModel):
version: str = Field(example="0.7.0")
debug_mode: bool = Field(example=False)
automatic_backups: bool = Field(example=True)
data_dir: str = Field(example="/home/app/.local/share/spoolman")
logs_dir: str = Field(example="/home/app/.local/share/spoolman")
backups_dir: str = Field(example="/home/app/.local/share/spoolman/backups")
db_type: str = Field(example="sqlite")
git_commit: Optional[str] = Field(example="a1b2c3d")
build_date: Optional[datetime] = Field(example="2021-01-01T00:00:00Z")
version: str = Field(examples=["0.7.0"])
debug_mode: bool = Field(examples=[False])
automatic_backups: bool = Field(examples=[True])
data_dir: str = Field(examples=["/home/app/.local/share/spoolman"])
logs_dir: str = Field(examples=["/home/app/.local/share/spoolman"])
backups_dir: str = Field(examples=["/home/app/.local/share/spoolman/backups"])
db_type: str = Field(examples=["sqlite"])
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
class HealthCheck(BaseModel):
status: str = Field(example="healthy")
status: str = Field(examples=["healthy"])
class BackupResponse(BaseModel):
path: str = Field(
default=None,
description="Path to the created backup file.",
example="/home/app/.local/share/spoolman/backups/spoolman.db",
examples=["/home/app/.local/share/spoolman/backups/spoolman.db"],
)
@@ -336,7 +368,7 @@ class Event(BaseModel):
type: EventType = Field(description="Event type.")
resource: str = Field(description="Resource type.")
date: datetime = Field(description="When the event occured. UTC Timezone.")
date: SpoolmanDateTime = Field(description="When the event occured. UTC Timezone.")
payload: BaseModel

View File

@@ -7,10 +7,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from pydantic.error_wrappers import ErrorWrapper
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Spool, SpoolEvent
@@ -32,42 +30,58 @@ router = APIRouter(
class SpoolParameters(BaseModel):
first_used: Optional[datetime] = Field(description="First logged occurence of spool usage.")
last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage.")
first_used: Optional[datetime] = Field(None, description="First logged occurence of spool usage.")
last_used: Optional[datetime] = Field(None, description="Last logged occurence of spool usage.")
filament_id: int = Field(description="The ID of the filament type of this spool.")
price: Optional[float] = Field(
None,
ge=0,
description="The price of this filament in the system configured currency.",
example=20.0,
examples=[20.0],
)
initial_weight: Optional[float] = Field(
None,
ge=0,
description="The initial weight of the filament on the spool, in grams. (net weight)",
example=200,
examples=[200],
)
spool_weight: Optional[float] = Field(
None,
ge=0,
description="The weight of an empty spool, in grams. (tare weight)",
example=200,
examples=[200],
)
remaining_weight: Optional[float] = Field(
None,
ge=0,
description=(
"Remaining weight of filament on the spool. Can only be used if the filament type has a weight set."
),
example=800,
examples=[800],
)
used_weight: Optional[float] = Field(
None,
ge=0,
description="Used weight of filament on the spool.",
examples=[200],
)
location: Optional[str] = Field(
None,
max_length=64,
description="Where this spool can be found.",
examples=["Shelf A"],
)
used_weight: Optional[float] = Field(ge=0, description="Used weight of filament on the spool.", example=200)
location: Optional[str] = Field(max_length=64, description="Where this spool can be found.", example="Shelf A")
lot_nr: Optional[str] = Field(
None,
max_length=64,
description="Vendor manufacturing lot/batch number of the spool.",
example="52342",
examples=["52342"],
)
comment: Optional[str] = Field(
None,
max_length=1024,
description="Free text comment about this specific spool.",
example="",
examples=[""],
)
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
extra: Optional[dict[str, str]] = Field(
@@ -77,16 +91,24 @@ class SpoolParameters(BaseModel):
class SpoolUpdateParameters(SpoolParameters):
filament_id: Optional[int] = Field(description="The ID of the filament type of this spool.")
filament_id: Optional[int] = Field(None, description="The ID of the filament type of this spool.")
@field_validator("filament_id")
@classmethod
def prevent_none(cls: type["SpoolUpdateParameters"], v: Optional[int]) -> Optional[int]:
"""Prevent filament_id from being None."""
if v is None:
raise ValueError("Value must not be None.")
return v
class SpoolUseParameters(BaseModel):
use_length: Optional[float] = Field(description="Length of filament to reduce by, in mm.", example=2.2)
use_weight: Optional[float] = Field(description="Filament weight to reduce by, in g.", example=5.3)
use_length: Optional[float] = Field(None, description="Length of filament to reduce by, in mm.", examples=[2.2])
use_weight: Optional[float] = Field(None, description="Filament weight to reduce by, in g.", examples=[5.3])
class SpoolMeasureParameters(BaseModel):
weight: float = Field(description="Current gross weight of the spool, in g.", example=200)
weight: float = Field(description="Current gross weight of the spool, in g.", examples=[200])
@router.get(
@@ -119,6 +141,7 @@ async def find(
title="Filament ID",
description="See filament.id.",
deprecated=True,
pattern=r"^-?\d+(,-?\d+)*$",
),
filament_material_old: Optional[str] = Query(
alias="filament_material",
@@ -140,6 +163,7 @@ async def find(
title="Vendor ID",
description="See filament.vendor.id.",
deprecated=True,
pattern=r"^-?\d+(,-?\d+)*$",
),
filament_name: Optional[str] = Query(
alias="filament.name",
@@ -156,6 +180,7 @@ async def find(
title="Filament ID",
description="Match an exact filament ID. Separate multiple IDs with a comma.",
examples=["1", "1,2"],
pattern=r"^-?\d+(,-?\d+)*$",
),
filament_material: Optional[str] = Query(
alias="filament.material",
@@ -184,6 +209,7 @@ async def find(
"Set it to -1 to match spools with filaments with no vendor."
),
examples=["1", "1,2"],
pattern=r"^-?\d+(,-?\d+)*$",
),
location: Optional[str] = Query(
default=None,
@@ -233,21 +259,13 @@ async def find(
filament_id = filament_id if filament_id is not None else filament_id_old
if filament_id is not None:
try:
filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")]
except ValueError as e:
raise RequestValidationError(
[ErrorWrapper(ValueError("Invalid filament_id"), ("query", "filament_id"))],
) from e
filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")]
else:
filament_ids = None
filament_vendor_id = filament_vendor_id if filament_vendor_id is not None else vendor_id_old
if filament_vendor_id is not None:
try:
filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")]
except ValueError as e:
raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e
filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")]
else:
filament_vendor_ids = None
@@ -409,7 +427,7 @@ async def update( # noqa: ANN201
spool_id: int,
body: SpoolUpdateParameters,
):
patch_data = body.dict(exclude_unset=True)
patch_data = body.model_dump(exclude_unset=True)
if body.remaining_weight is not None and body.used_weight is not None:
return JSONResponse(
@@ -424,11 +442,6 @@ async def update( # noqa: ANN201
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
if "filament_id" in patch_data and body.filament_id is None:
raise RequestValidationError(
[ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))],
)
try:
db_item = await spool.update(
db=db,

View File

@@ -5,10 +5,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from pydantic.error_wrappers import ErrorWrapper
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Vendor, VendorEvent
@@ -27,23 +25,26 @@ router = APIRouter(
class VendorParameters(BaseModel):
name: str = Field(max_length=64, description="Vendor name.", example="Polymaker")
name: str = Field(max_length=64, description="Vendor name.", examples=["Polymaker"])
comment: Optional[str] = Field(
None,
max_length=1024,
description="Free text comment about this vendor.",
example="",
examples=[""],
)
empty_spool_weight: Optional[float] = Field(
None,
ge=0,
description="The weight of an empty spool, in grams.",
example=200,
examples=[200],
)
external_id: Optional[str] = Field(
None,
max_length=256,
description=(
"Set if this vendor comes from an external database. This contains the ID in the external database."
),
example="eSun",
examples=["eSun"],
)
extra: Optional[dict[str, str]] = Field(
None,
@@ -52,7 +53,15 @@ class VendorParameters(BaseModel):
class VendorUpdateParameters(VendorParameters):
name: Optional[str] = Field(max_length=64, description="Vendor name.", example="Polymaker")
name: Optional[str] = Field(None, max_length=64, description="Vendor name.", examples=["Polymaker"])
@field_validator("name")
@classmethod
def prevent_none(cls: type["VendorUpdateParameters"], v: Optional[str]) -> Optional[str]:
"""Prevent name from being None."""
if v is None:
raise ValueError("Value must not be None.")
return v
@router.get(
@@ -233,10 +242,7 @@ async def update( # noqa: ANN201
vendor_id: int,
body: VendorUpdateParameters,
):
patch_data = body.dict(exclude_unset=True)
if "name" in patch_data and body.name is None:
raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))])
patch_data = body.model_dump(exclude_unset=True)
if body.extra:
all_fields = await get_extra_fields(db, EntityType.vendor)