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

@@ -9,16 +9,16 @@ repos:
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.3.0 rev: 24.4.2
hooks: hooks:
- id: black - id: black
args: ["--check"] args: ["--check"]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.286 rev: v0.4.4
hooks: hooks:
- id: ruff - id: ruff
args: ["--target-version", "py39"] args: ["--target-version", "py39"]
- repo: https://github.com/pdm-project/pdm - repo: https://github.com/pdm-project/pdm
rev: 2.15.2 rev: 2.15.3
hooks: hooks:
- id: pdm-lock-check - id: pdm-lock-check

View File

@@ -4,6 +4,7 @@ Revision ID: ${up_revision}
Revises: ${down_revision | comma,n} Revises: ${down_revision | comma,n}
Create Date: ${create_date} Create Date: ${create_date}
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
${imports if imports else ""} ${imports if imports else ""}

View File

@@ -3,6 +3,7 @@
Revision ID: 684d32cf7e4d Revision ID: 684d32cf7e4d
Create Date: 2023-05-27 21:46:24.361353 Create Date: 2023-05-27 21:46:24.361353
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.reflection import Inspector

View File

@@ -4,6 +4,7 @@ Revision ID: b47376d60c6d
Revises: 684d32cf7e4d Revises: 684d32cf7e4d
Create Date: 2023-05-28 21:36:53.452067 Create Date: 2023-05-28 21:36:53.452067
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: db385b808a20
Revises: b47376d60c6d Revises: b47376d60c6d
Create Date: 2023-06-01 19:53:44.440616 Create Date: 2023-06-01 19:53:44.440616
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: 92186a5f7b0f
Revises: db385b808a20 Revises: db385b808a20
Create Date: 2023-07-14 12:17:13.162618 Create Date: 2023-07-14 12:17:13.162618
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: 92793c8a937c
Revises: 92186a5f7b0f Revises: 92186a5f7b0f
Create Date: 2023-08-12 21:21:08.536216 Create Date: 2023-08-12 21:21:08.536216
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: b82cd9e2aa6f
Revises: 92793c8a937c Revises: 92793c8a937c
Create Date: 2023-12-30 08:39:48.430846 Create Date: 2023-12-30 08:39:48.430846
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: ccbb17aeda7c
Revises: b82cd9e2aa6f Revises: b82cd9e2aa6f
Create Date: 2024-01-03 13:46:41.362341 Create Date: 2024-01-03 13:46:41.362341
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: b8881bdb716c
Revises: ccbb17aeda7c Revises: ccbb17aeda7c
Create Date: 2024-01-04 22:09:34.417527 Create Date: 2024-01-04 22:09:34.417527
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: aafcd7fb0e84
Revises: b8881bdb716c Revises: b8881bdb716c
Create Date: 2024-03-26 09:48:09.930022 Create Date: 2024-03-26 09:48:09.930022
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: 304a32906234
Revises: aafcd7fb0e84 Revises: aafcd7fb0e84
Create Date: 2024-03-26 13:49:26.594399 Create Date: 2024-03-26 13:49:26.594399
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

View File

@@ -4,6 +4,7 @@ Revision ID: 5f069e51bd89
Revises: 304a32906234 Revises: 304a32906234
Create Date: 2024-03-26 15:07:18.366290 Create Date: 2024-03-26 15:07:18.366290
""" """
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op

826
pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,37 +6,37 @@ authors = [
{ name = "Donkie", email = "daniel.cf.hultgren@gmail.com" }, { name = "Donkie", email = "daniel.cf.hultgren@gmail.com" },
] ]
dependencies = [ dependencies = [
"uvicorn~=0.22.0", "uvicorn~=0.29.0",
"httptools>=0.5.0; platform_machine != \"armv7l\"", "httptools>=0.5.0; platform_machine != \"armv7l\"",
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; platform_machine != \"armv7l\" and sys_platform != \"win32\" and (sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\")", "uvloop!=0.15.0,!=0.15.1,>=0.14.0; platform_machine != \"armv7l\" and sys_platform != \"win32\" and (sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\")",
"fastapi~=0.99", "fastapi~=0.110.0",
"SQLAlchemy[aiomysql,aiosqlite,asyncio,postgresql_asyncpg]~=2.0", "SQLAlchemy[aiomysql,aiosqlite,asyncio,postgresql_asyncpg]~=2.0",
"pydantic~=1.10", "pydantic~=2.7.1",
"platformdirs~=3.8", "platformdirs~=4.2.2",
"alembic~=1.11", "alembic~=1.13.1",
"scheduler~=0.8", "scheduler~=0.8.5",
"sqlalchemy-cockroachdb~=2.0", "sqlalchemy-cockroachdb~=2.0",
"asyncpg~=0.27", "asyncpg~=0.27",
"psycopg2-binary~=2.9", "psycopg2-binary~=2.9",
"setuptools~=68.0", "setuptools~=70.0.0",
"WebSockets>=11.0.3", "WebSockets~=12.0",
"prometheus-client>=0.20.0", "prometheus-client~=0.20.0",
"httpx>=0.27.0", "httpx~=0.27.0",
"hishel>=0.0.26", "hishel~=0.0.26",
] ]
requires-python = ">=3.9" requires-python = ">=3.9,<=3.12"
[project.license] [project.license]
text = "MIT" text = "MIT"
[tool.pdm.dev-dependencies] [tool.pdm.dev-dependencies]
dev = [ dev = [
"ruff==0.0.286", "ruff==0.4.4",
"black~=23.3", "black~=24.4.2",
"pre-commit~=3.3", "pre-commit~=3.7.1",
"pytest~=7.3", "pytest~=8.2.1",
"pytest-asyncio~=0.21", "pytest-asyncio~=0.23.7",
"httpx~=0.24", "httpx~=0.27.0",
] ]
[tool.pdm.scripts.docs] [tool.pdm.scripts.docs]
@@ -52,6 +52,10 @@ cmd = "uvicorn spoolman.main:app"
cmd = "python tests_integration/run.py" cmd = "python tests_integration/run.py"
[tool.ruff] [tool.ruff]
line-length = 120
target-version = "py39"
[tool.ruff.lint]
select = [ select = [
"ALL", "ALL",
] ]
@@ -77,10 +81,8 @@ ignore = [
"TD002", "TD002",
"TD003", "TD003",
] ]
line-length = 120
target-version = "py39"
[tool.ruff.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests*/*" = [ "tests*/*" = [
"ANN201", "ANN201",
"S101", "S101",

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,6 +120,7 @@ class Database:
Returns: Returns:
The path to the created backup or None if no backup was created. The path to the created backup or None if no backup was created.
""" """
if not self.is_file_based_sqlite() or self.connection_url.database is None: if not self.is_file_based_sqlite() or self.connection_url.database is None:
logger.info("Skipping backup as the database is not SQLite.") logger.info("Skipping backup as the database is not SQLite.")
@@ -159,6 +160,7 @@ def setup_db(connection_url: URL) -> None:
Args: Args:
connection_url: The URL to connect to the database. connection_url: The URL to connect to the database.
""" """
global __db # noqa: PLW0603 global __db # noqa: PLW0603
__db = Database(connection_url) __db = Database(connection_url)
@@ -170,6 +172,7 @@ async def backup_global_db(num_backups: int = 5) -> Optional[Path]:
Returns: Returns:
The path to the created backup or None if no backup was created. The path to the created backup or None if no backup was created.
""" """
if __db is None: if __db is None:
raise RuntimeError("DB is not setup.") raise RuntimeError("DB is not setup.")
@@ -198,6 +201,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
Args: Args:
scheduler: The scheduler to use for scheduling tasks. scheduler: The scheduler to use for scheduling tasks.
""" """
if __db is None: if __db is None:
raise RuntimeError("DB is not setup.") raise RuntimeError("DB is not setup.")
@@ -218,6 +222,7 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
Yields: Yields:
The database session. The database session.
""" """
if __db is None or __db.session_maker is None: if __db is None or __db.session_maker is None:
raise RuntimeError("DB is not setup.") raise RuntimeError("DB is not setup.")

View File

@@ -261,7 +261,7 @@ async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> Non
.where(models.Spool.id == spool_id) .where(models.Spool.id == spool_id)
.values( .values(
used_weight=case( used_weight=case(
(models.Spool.used_weight + weight >= 0.0, models.Spool.used_weight + weight), # noqa: PLR2004 (models.Spool.used_weight + weight >= 0.0, models.Spool.used_weight + weight),
else_=0.0, # Set used_weight to 0 if the result would be negative else_=0.0, # Set used_weight to 0 if the result would be negative
), ),
), ),

View File

@@ -23,6 +23,7 @@ def generate_openapi(app: FastAPI) -> dict[str, Any]:
Returns: Returns:
dict[str, Any]: The OpenAPI document. dict[str, Any]: The OpenAPI document.
""" """
return get_openapi( return get_openapi(
title=app.title, title=app.title,

View File

@@ -28,6 +28,7 @@ class DatabaseType(Enum):
Returns: Returns:
str: The drivername. str: The drivername.
""" """
if self is DatabaseType.POSTGRES: if self is DatabaseType.POSTGRES:
return "postgresql+asyncpg" return "postgresql+asyncpg"
@@ -47,6 +48,7 @@ def get_database_type() -> Optional[DatabaseType]:
Returns: Returns:
Optional[DatabaseType]: The database type. Optional[DatabaseType]: The database type.
""" """
database_type = os.getenv("SPOOLMAN_DB_TYPE") database_type = os.getenv("SPOOLMAN_DB_TYPE")
if database_type is None: if database_type is None:
@@ -69,6 +71,7 @@ def get_host() -> Optional[str]:
Returns: Returns:
Optional[str]: The host. Optional[str]: The host.
""" """
return os.getenv("SPOOLMAN_DB_HOST") return os.getenv("SPOOLMAN_DB_HOST")
@@ -80,6 +83,7 @@ def get_port() -> Optional[int]:
Returns: Returns:
Optional[str]: The port. Optional[str]: The port.
""" """
port = os.getenv("SPOOLMAN_DB_PORT") port = os.getenv("SPOOLMAN_DB_PORT")
if port is None: if port is None:
@@ -97,6 +101,7 @@ def get_database() -> Optional[str]:
Returns: Returns:
Optional[str]: The name. Optional[str]: The name.
""" """
return os.getenv("SPOOLMAN_DB_NAME") return os.getenv("SPOOLMAN_DB_NAME")
@@ -108,6 +113,7 @@ def get_query() -> Optional[dict[str, str]]:
Returns: Returns:
Optional[dict]: The query. Optional[dict]: The query.
""" """
query = os.getenv("SPOOLMAN_DB_QUERY") query = os.getenv("SPOOLMAN_DB_QUERY")
if query is None: if query is None:
@@ -126,6 +132,7 @@ def get_username() -> Optional[str]:
Returns: Returns:
Optional[str]: The username. Optional[str]: The username.
""" """
return os.getenv("SPOOLMAN_DB_USERNAME") return os.getenv("SPOOLMAN_DB_USERNAME")
@@ -140,6 +147,7 @@ def get_password() -> Optional[str]:
Returns: Returns:
Optional[str]: The password. Optional[str]: The password.
""" """
# First attempt: grab password from a file. This is the most secure way of storing passwords. # First attempt: grab password from a file. This is the most secure way of storing passwords.
file_path = os.getenv("SPOOLMAN_DB_PASSWORD_FILE") file_path = os.getenv("SPOOLMAN_DB_PASSWORD_FILE")
@@ -170,6 +178,7 @@ def get_logging_level() -> int:
Returns: Returns:
str: The logging level. str: The logging level.
""" """
log_level_str = os.getenv("SPOOLMAN_LOGGING_LEVEL", "INFO").upper() log_level_str = os.getenv("SPOOLMAN_LOGGING_LEVEL", "INFO").upper()
if log_level_str == "DEBUG": if log_level_str == "DEBUG":
@@ -192,6 +201,7 @@ def is_debug_mode() -> bool:
Returns: Returns:
bool: Whether debug mode is enabled. bool: Whether debug mode is enabled.
""" """
debug_mode = os.getenv("SPOOLMAN_DEBUG_MODE", "FALSE").upper() debug_mode = os.getenv("SPOOLMAN_DEBUG_MODE", "FALSE").upper()
if debug_mode in {"FALSE", "0"}: if debug_mode in {"FALSE", "0"}:
@@ -208,6 +218,7 @@ def is_automatic_backup_enabled() -> bool:
Returns: Returns:
bool: Whether automatic backup is enabled. bool: Whether automatic backup is enabled.
""" """
automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper() automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper()
if automatic_backup in {"FALSE", "0"}: if automatic_backup in {"FALSE", "0"}:
@@ -224,6 +235,7 @@ def get_data_dir() -> Path:
Returns: Returns:
Path: The data directory. Path: The data directory.
""" """
env_data_dir = os.getenv("SPOOLMAN_DIR_DATA") env_data_dir = os.getenv("SPOOLMAN_DIR_DATA")
if env_data_dir is not None: if env_data_dir is not None:
@@ -239,6 +251,7 @@ def get_logs_dir() -> Path:
Returns: Returns:
Path: The logs directory. Path: The logs directory.
""" """
env_logs_dir = os.getenv("SPOOLMAN_DIR_LOGS") env_logs_dir = os.getenv("SPOOLMAN_DIR_LOGS")
if env_logs_dir is not None: if env_logs_dir is not None:
@@ -254,6 +267,7 @@ def get_backups_dir() -> Path:
Returns: Returns:
Path: The backups directory. Path: The backups directory.
""" """
env_backups_dir = os.getenv("SPOOLMAN_DIR_BACKUPS") env_backups_dir = os.getenv("SPOOLMAN_DIR_BACKUPS")
if env_backups_dir is not None: if env_backups_dir is not None:
@@ -274,6 +288,7 @@ def get_version() -> str:
Returns: Returns:
str: The version. str: The version.
""" """
# Read version from pyproject.toml, don't use pkg_resources because it requires the package to be installed # Read version from pyproject.toml, don't use pkg_resources because it requires the package to be installed
with Path("pyproject.toml").open(encoding="utf-8") as f: with Path("pyproject.toml").open(encoding="utf-8") as f:
@@ -290,6 +305,7 @@ def get_commit_hash() -> Optional[str]:
Returns: Returns:
Optional[str]: The commit hash. Optional[str]: The commit hash.
""" """
# Read commit has from build.txt # Read commit has from build.txt
# commit is written as GIT_COMMIT=<hash> in build.txt # commit is written as GIT_COMMIT=<hash> in build.txt
@@ -308,6 +324,7 @@ def get_build_date() -> Optional[datetime]:
Returns: Returns:
Optional[datetime.datetime]: The build date. Optional[datetime.datetime]: The build date.
""" """
# Read build date has from build.txt # Read build date has from build.txt
# build date is written as BUILD_DATE=<hash> in build.txt # build date is written as BUILD_DATE=<hash> in build.txt
@@ -394,6 +411,7 @@ def is_metrics_enabled() -> bool:
Returns: Returns:
bool: Whether collect metrics is enabled. bool: Whether collect metrics is enabled.
""" """
metrics_enabled = os.getenv("SPOOLMAN_METRICS_ENABLED", "FALSE").upper() metrics_enabled = os.getenv("SPOOLMAN_METRICS_ENABLED", "FALSE").upper()
if metrics_enabled in {"FALSE", "0"}: if metrics_enabled in {"FALSE", "0"}:
@@ -412,6 +430,7 @@ def get_base_path() -> str:
Returns: Returns:
str: The base path. str: The base path.
""" """
path = os.getenv("SPOOLMAN_BASE_PATH", "") path = os.getenv("SPOOLMAN_BASE_PATH", "")
if len(path) == 0: if len(path) == 0:

View File

@@ -3,12 +3,13 @@
import datetime import datetime
import logging import logging
import os import os
from collections.abc import Iterator
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import hishel import hishel
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, RootModel
from scheduler.asyncio.scheduler import Scheduler from scheduler.asyncio.scheduler import Scheduler
from spoolman import filecache from spoolman import filecache
@@ -55,51 +56,67 @@ class Pattern(Enum):
class ExternalFilament(BaseModel): class ExternalFilament(BaseModel):
id: str = Field(description="A unique ID for this filament.", example="polymaker_pla_polysonicblack_1000_175") id: str = Field(description="A unique ID for this filament.", examples=["polymaker_pla_polysonicblack_1000_175"])
manufacturer: str = Field(description="Filament manufacturer.", example="Polymaker") manufacturer: str = Field(description="Filament manufacturer.", examples=["Polymaker"])
name: str = Field(description="Filament name.", example="Polysonic\u2122 Black") name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
material: str = Field(description="Filament material.", example="PLA") material: str = Field(description="Filament material.", examples=["PLA"])
density: float = Field(description="Density in g/cm3.", example=1.23) density: float = Field(description="Density in g/cm3.", examples=[1.23])
weight: float = Field(description="Net weight of a single spool.", example=1000) weight: float = Field(description="Net weight of a single spool.", examples=[1000])
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", example=140) spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
spool_type: Optional[SpoolType] = Field(description="Type of spool.", example=SpoolType.PLASTIC) spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
diameter: float = Field(description="Filament in mm.", example=1.75) diameter: float = Field(description="Filament in mm.", examples=[1.75])
color_hex: Optional[str] = Field( color_hex: Optional[str] = Field(
default=None, default=None,
description="Filament color code in hex format, for single-color filaments.", description="Filament color code in hex format, for single-color filaments.",
example="2c3232", examples=["2c3232"],
) )
color_hexes: Optional[list[str]] = Field( color_hexes: Optional[list[str]] = Field(
default=None, default=None,
description="For multi-color filaments. List of hex color codes in hex format.", description="For multi-color filaments. List of hex color codes in hex format.",
example=["2c3232", "5f5f5f"], examples=[["2c3232", "5f5f5f"]],
) )
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", example=210) extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", example=50) bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", example=Finish.MATTE) finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
multi_color_direction: Optional[MultiColorDirection] = Field( multi_color_direction: Optional[MultiColorDirection] = Field(
default=None, default=None,
description="Direction of multi-color filaments.", description="Direction of multi-color filaments.",
example=MultiColorDirection.COAXIAL, examples=[MultiColorDirection.COAXIAL],
) )
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", example=Pattern.MARBLE) pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
translucent: bool = Field(default=False, description="Whether the filament is translucent.") translucent: bool = Field(default=False, description="Whether the filament is translucent.")
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.") glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
class ExternalFilamentsFile(BaseModel): class ExternalFilamentsFile(RootModel):
__root__: list[ExternalFilament] root: list[ExternalFilament]
def __iter__(self) -> Iterator[ExternalFilament]:
"""Iterate over the filaments."""
return iter(self.root)
def __getitem__(self, index: int) -> ExternalFilament:
"""Get a specific filament by index."""
return self.root[index]
class ExternalMaterial(BaseModel): class ExternalMaterial(BaseModel):
material: str = Field(example="PLA") material: str = Field(examples=["PLA"])
density: float = Field(example=1.24) density: float = Field(examples=[1.24])
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", example=210) extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", example=50) bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
class ExternalMaterialsFile(BaseModel): class ExternalMaterialsFile(RootModel):
__root__: list[ExternalMaterial] root: list[ExternalMaterial]
def __iter__(self) -> Iterator[ExternalMaterial]:
"""Iterate over the materials."""
return iter(self.root)
def __getitem__(self, index: int) -> ExternalMaterial:
"""Get a specific material by index."""
return self.root[index]
def get_external_db_url() -> str: def get_external_db_url() -> str:
@@ -161,8 +178,8 @@ async def _sync() -> None:
logger.info( logger.info(
"External DB synced. Filaments: %d, Materials: %d", "External DB synced. Filaments: %d, Materials: %d",
len(filaments.__root__), len(filaments.root),
len(materials.__root__), len(materials.root),
) )
@@ -171,6 +188,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
Args: Args:
scheduler: The scheduler to use for scheduling tasks. scheduler: The scheduler to use for scheduling tasks.
""" """
if len(get_external_db_url().strip()) == 0: if len(get_external_db_url().strip()) == 0:
logger.info("External DB URL is empty. Skipping sync.") logger.info("External DB URL is empty. Skipping sync.")

View File

@@ -45,13 +45,13 @@ class ExtraFieldParameters(BaseModel):
choices: Optional[list[str]] = Field( choices: Optional[list[str]] = Field(
None, None,
description="Choices for the field, only for field type choice", description="Choices for the field, only for field type choice",
min_items=1, min_length=1,
) )
multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected") multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected")
class ExtraField(ExtraFieldParameters): class ExtraField(ExtraFieldParameters):
key: str = Field(description="Unique key", regex="^[a-z0-9_]+$", min_length=1, max_length=64) key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64)
entity_type: EntityType = Field(description="Entity type this field is for") entity_type: EntityType = Field(description="Entity type this field is for")

View File

@@ -15,6 +15,7 @@ def weight_from_length(*, length: float, diameter: float, density: float) -> flo
Returns: Returns:
float: Weight in g float: Weight in g
""" """
volume_mm3 = length * math.pi * (diameter / 2) ** 2 volume_mm3 = length * math.pi * (diameter / 2) ** 2
volume_cm3 = volume_mm3 / 1000 volume_cm3 = volume_mm3 / 1000
@@ -31,6 +32,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
Returns: Returns:
float: Length in mm float: Length in mm
""" """
volume_cm3 = weight / density volume_cm3 = weight / density
volume_mm3 = volume_cm3 * 1000 volume_mm3 = volume_cm3 * 1000

View File

@@ -1,4 +1,5 @@
"""Prometheus metrics collectors.""" """Prometheus metrics collectors."""
import logging import logging
from typing import Callable from typing import Callable
@@ -13,16 +14,16 @@ registry = REGISTRY
PREFIX = "spoolman" PREFIX = "spoolman"
SPOOL_PRICE = Gauge("%s_spool_price" % PREFIX, "Total Spool price", ["spool_id", "filament_id"]) SPOOL_PRICE = Gauge(f"{PREFIX}_spool_price", "Total Spool price", ["spool_id", "filament_id"])
SPOOL_USED_WEIGHT = Gauge("%s_spool_weight_used" % PREFIX, "Spool Used Weight", ["spool_id", "filament_id"]) SPOOL_USED_WEIGHT = Gauge(f"{PREFIX}_spool_weight_used", "Spool Used Weight", ["spool_id", "filament_id"])
FILAMENT_INFO = Gauge( FILAMENT_INFO = Gauge(
"%s_filament_info" % PREFIX, f"{PREFIX}_filament_info",
"Filament information", "Filament information",
["filament_id", "vendor", "name", "material", "color"], ["filament_id", "vendor", "name", "material", "color"],
) )
FILAMENT_DENSITY = Gauge("%s_filament_density" % PREFIX, "Density of filament", ["filament_id"]) FILAMENT_DENSITY = Gauge(f"{PREFIX}_filament_density", "Density of filament", ["filament_id"])
FILAMENT_DIAMETER = Gauge("%s_filament_diameter" % PREFIX, "Diameter of filament", ["filament_id"]) FILAMENT_DIAMETER = Gauge(f"{PREFIX}_filament_diameter", "Diameter of filament", ["filament_id"])
FILAMENT_WEIGHT = Gauge("%s_filament_weight" % PREFIX, "Net weight of filament", ["filament_id"]) FILAMENT_WEIGHT = Gauge(f"{PREFIX}_filament_weight", "Net weight of filament", ["filament_id"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,6 +42,7 @@ async def spool_metrics(db: AsyncSession) -> None:
Args: Args:
db: async db session db: async db session
""" """
stmt = sqlalchemy.select(models.Spool).where( stmt = sqlalchemy.select(models.Spool).where(
sqlalchemy.or_( sqlalchemy.or_(
@@ -61,6 +63,7 @@ async def filament_metrics(db: AsyncSession) -> None:
Args: Args:
db: async db session db: async db session
""" """
stmt = ( stmt = (
sqlalchemy.select(models.Filament) sqlalchemy.select(models.Filament)

View File

@@ -240,6 +240,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
Returns: Returns:
float: Length in mm float: Length in mm
""" """
volume_cm3 = weight / density volume_cm3 = weight / density
volume_mm3 = volume_cm3 * 1000 volume_mm3 = volume_cm3 * 1000
@@ -256,6 +257,7 @@ def assert_dicts_compatible(actual: Any, expected: Any, path: str = "") -> None:
Raises: Raises:
AssertionError: If dictionaries are not compatible. AssertionError: If dictionaries are not compatible.
""" """
# Check if both inputs are dictionaries # Check if both inputs are dictionaries
if not (isinstance(actual, dict) and isinstance(expected, dict)): if not (isinstance(actual, dict) and isinstance(expected, dict)):

View File

@@ -95,6 +95,44 @@ def test_update_filament(random_vendor: dict[str, Any]):
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
def test_update_filament_cant_set_none(random_vendor: dict[str, Any]):
"""Test updating a filament and setting density and diameter fields to None, should result in 422 error."""
# Setup
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"name": "Filament X",
"vendor_id": random_vendor["id"],
"material": "PLA",
"price": 100,
"density": 1.25,
"diameter": 1.75,
"weight": 1000,
"spool_weight": 250,
"article_number": "123456789",
"comment": "abcdefghåäö",
"settings_extruder_temp": 200,
"settings_bed_temp": 60,
"color_hex": "FF0000",
"external_id": "external_id1",
},
)
result.raise_for_status()
added_filament = result.json()
# Execute
result = httpx.patch(
f"{URL}/api/v1/filament/{added_filament['id']}",
json={"density": None, "diameter": None},
)
# Verify
assert result.status_code == 422
# Clean up
httpx.delete(f"{URL}/api/v1/filament/{added_filament['id']}").raise_for_status()
def test_update_filament_not_found(): def test_update_filament_not_found():
"""Test updating a filament that does not exist.""" """Test updating a filament that does not exist."""
# Execute # Execute