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:
@@ -9,16 +9,16 @@ repos:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.3.0
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
args: ["--check"]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.286
|
||||
rev: v0.4.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--target-version", "py39"]
|
||||
- repo: https://github.com/pdm-project/pdm
|
||||
rev: 2.15.2
|
||||
rev: 2.15.3
|
||||
hooks:
|
||||
- id: pdm-lock-check
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Revision ID: 684d32cf7e4d
|
||||
Create Date: 2023-05-27 21:46:24.361353
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: b47376d60c6d
|
||||
Revises: 684d32cf7e4d
|
||||
Create Date: 2023-05-28 21:36:53.452067
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: db385b808a20
|
||||
Revises: b47376d60c6d
|
||||
Create Date: 2023-06-01 19:53:44.440616
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 92186a5f7b0f
|
||||
Revises: db385b808a20
|
||||
Create Date: 2023-07-14 12:17:13.162618
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 92793c8a937c
|
||||
Revises: 92186a5f7b0f
|
||||
Create Date: 2023-08-12 21:21:08.536216
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: b82cd9e2aa6f
|
||||
Revises: 92793c8a937c
|
||||
Create Date: 2023-12-30 08:39:48.430846
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: ccbb17aeda7c
|
||||
Revises: b82cd9e2aa6f
|
||||
Create Date: 2024-01-03 13:46:41.362341
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: b8881bdb716c
|
||||
Revises: ccbb17aeda7c
|
||||
Create Date: 2024-01-04 22:09:34.417527
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: aafcd7fb0e84
|
||||
Revises: b8881bdb716c
|
||||
Create Date: 2024-03-26 09:48:09.930022
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 304a32906234
|
||||
Revises: aafcd7fb0e84
|
||||
Create Date: 2024-03-26 13:49:26.594399
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 5f069e51bd89
|
||||
Revises: 304a32906234
|
||||
Create Date: 2024-03-26 15:07:18.366290
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
@@ -6,37 +6,37 @@ authors = [
|
||||
{ name = "Donkie", email = "daniel.cf.hultgren@gmail.com" },
|
||||
]
|
||||
dependencies = [
|
||||
"uvicorn~=0.22.0",
|
||||
"uvicorn~=0.29.0",
|
||||
"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\")",
|
||||
"fastapi~=0.99",
|
||||
"fastapi~=0.110.0",
|
||||
"SQLAlchemy[aiomysql,aiosqlite,asyncio,postgresql_asyncpg]~=2.0",
|
||||
"pydantic~=1.10",
|
||||
"platformdirs~=3.8",
|
||||
"alembic~=1.11",
|
||||
"scheduler~=0.8",
|
||||
"pydantic~=2.7.1",
|
||||
"platformdirs~=4.2.2",
|
||||
"alembic~=1.13.1",
|
||||
"scheduler~=0.8.5",
|
||||
"sqlalchemy-cockroachdb~=2.0",
|
||||
"asyncpg~=0.27",
|
||||
"psycopg2-binary~=2.9",
|
||||
"setuptools~=68.0",
|
||||
"WebSockets>=11.0.3",
|
||||
"prometheus-client>=0.20.0",
|
||||
"httpx>=0.27.0",
|
||||
"hishel>=0.0.26",
|
||||
"setuptools~=70.0.0",
|
||||
"WebSockets~=12.0",
|
||||
"prometheus-client~=0.20.0",
|
||||
"httpx~=0.27.0",
|
||||
"hishel~=0.0.26",
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.9,<=3.12"
|
||||
|
||||
[project.license]
|
||||
text = "MIT"
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"ruff==0.0.286",
|
||||
"black~=23.3",
|
||||
"pre-commit~=3.3",
|
||||
"pytest~=7.3",
|
||||
"pytest-asyncio~=0.21",
|
||||
"httpx~=0.24",
|
||||
"ruff==0.4.4",
|
||||
"black~=24.4.2",
|
||||
"pre-commit~=3.7.1",
|
||||
"pytest~=8.2.1",
|
||||
"pytest-asyncio~=0.23.7",
|
||||
"httpx~=0.27.0",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.docs]
|
||||
@@ -52,6 +52,10 @@ cmd = "uvicorn spoolman.main:app"
|
||||
cmd = "python tests_integration/run.py"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py39"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"ALL",
|
||||
]
|
||||
@@ -77,10 +81,8 @@ ignore = [
|
||||
"TD002",
|
||||
"TD003",
|
||||
]
|
||||
line-length = 120
|
||||
target-version = "py39"
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests*/*" = [
|
||||
"ANN201",
|
||||
"S101",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -120,6 +120,7 @@ class Database:
|
||||
|
||||
Returns:
|
||||
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:
|
||||
logger.info("Skipping backup as the database is not SQLite.")
|
||||
@@ -159,6 +160,7 @@ def setup_db(connection_url: URL) -> None:
|
||||
|
||||
Args:
|
||||
connection_url: The URL to connect to the database.
|
||||
|
||||
"""
|
||||
global __db # noqa: PLW0603
|
||||
__db = Database(connection_url)
|
||||
@@ -170,6 +172,7 @@ async def backup_global_db(num_backups: int = 5) -> Optional[Path]:
|
||||
|
||||
Returns:
|
||||
The path to the created backup or None if no backup was created.
|
||||
|
||||
"""
|
||||
if __db is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
@@ -198,6 +201,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to use for scheduling tasks.
|
||||
|
||||
"""
|
||||
if __db is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
@@ -218,6 +222,7 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
Yields:
|
||||
The database session.
|
||||
|
||||
"""
|
||||
if __db is None or __db.session_maker is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
|
||||
@@ -261,7 +261,7 @@ async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> Non
|
||||
.where(models.Spool.id == spool_id)
|
||||
.values(
|
||||
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
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ def generate_openapi(app: FastAPI) -> dict[str, Any]:
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The OpenAPI document.
|
||||
|
||||
"""
|
||||
return get_openapi(
|
||||
title=app.title,
|
||||
|
||||
@@ -28,6 +28,7 @@ class DatabaseType(Enum):
|
||||
|
||||
Returns:
|
||||
str: The drivername.
|
||||
|
||||
"""
|
||||
if self is DatabaseType.POSTGRES:
|
||||
return "postgresql+asyncpg"
|
||||
@@ -47,6 +48,7 @@ def get_database_type() -> Optional[DatabaseType]:
|
||||
|
||||
Returns:
|
||||
Optional[DatabaseType]: The database type.
|
||||
|
||||
"""
|
||||
database_type = os.getenv("SPOOLMAN_DB_TYPE")
|
||||
if database_type is None:
|
||||
@@ -69,6 +71,7 @@ def get_host() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The host.
|
||||
|
||||
"""
|
||||
return os.getenv("SPOOLMAN_DB_HOST")
|
||||
|
||||
@@ -80,6 +83,7 @@ def get_port() -> Optional[int]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The port.
|
||||
|
||||
"""
|
||||
port = os.getenv("SPOOLMAN_DB_PORT")
|
||||
if port is None:
|
||||
@@ -97,6 +101,7 @@ def get_database() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The name.
|
||||
|
||||
"""
|
||||
return os.getenv("SPOOLMAN_DB_NAME")
|
||||
|
||||
@@ -108,6 +113,7 @@ def get_query() -> Optional[dict[str, str]]:
|
||||
|
||||
Returns:
|
||||
Optional[dict]: The query.
|
||||
|
||||
"""
|
||||
query = os.getenv("SPOOLMAN_DB_QUERY")
|
||||
if query is None:
|
||||
@@ -126,6 +132,7 @@ def get_username() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The username.
|
||||
|
||||
"""
|
||||
return os.getenv("SPOOLMAN_DB_USERNAME")
|
||||
|
||||
@@ -140,6 +147,7 @@ def get_password() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The password.
|
||||
|
||||
"""
|
||||
# First attempt: grab password from a file. This is the most secure way of storing passwords.
|
||||
file_path = os.getenv("SPOOLMAN_DB_PASSWORD_FILE")
|
||||
@@ -170,6 +178,7 @@ def get_logging_level() -> int:
|
||||
|
||||
Returns:
|
||||
str: The logging level.
|
||||
|
||||
"""
|
||||
log_level_str = os.getenv("SPOOLMAN_LOGGING_LEVEL", "INFO").upper()
|
||||
if log_level_str == "DEBUG":
|
||||
@@ -192,6 +201,7 @@ def is_debug_mode() -> bool:
|
||||
|
||||
Returns:
|
||||
bool: Whether debug mode is enabled.
|
||||
|
||||
"""
|
||||
debug_mode = os.getenv("SPOOLMAN_DEBUG_MODE", "FALSE").upper()
|
||||
if debug_mode in {"FALSE", "0"}:
|
||||
@@ -208,6 +218,7 @@ def is_automatic_backup_enabled() -> bool:
|
||||
|
||||
Returns:
|
||||
bool: Whether automatic backup is enabled.
|
||||
|
||||
"""
|
||||
automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper()
|
||||
if automatic_backup in {"FALSE", "0"}:
|
||||
@@ -224,6 +235,7 @@ def get_data_dir() -> Path:
|
||||
|
||||
Returns:
|
||||
Path: The data directory.
|
||||
|
||||
"""
|
||||
env_data_dir = os.getenv("SPOOLMAN_DIR_DATA")
|
||||
if env_data_dir is not None:
|
||||
@@ -239,6 +251,7 @@ def get_logs_dir() -> Path:
|
||||
|
||||
Returns:
|
||||
Path: The logs directory.
|
||||
|
||||
"""
|
||||
env_logs_dir = os.getenv("SPOOLMAN_DIR_LOGS")
|
||||
if env_logs_dir is not None:
|
||||
@@ -254,6 +267,7 @@ def get_backups_dir() -> Path:
|
||||
|
||||
Returns:
|
||||
Path: The backups directory.
|
||||
|
||||
"""
|
||||
env_backups_dir = os.getenv("SPOOLMAN_DIR_BACKUPS")
|
||||
if env_backups_dir is not None:
|
||||
@@ -274,6 +288,7 @@ def get_version() -> str:
|
||||
|
||||
Returns:
|
||||
str: The version.
|
||||
|
||||
"""
|
||||
# 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:
|
||||
@@ -290,6 +305,7 @@ def get_commit_hash() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The commit hash.
|
||||
|
||||
"""
|
||||
# Read commit has from build.txt
|
||||
# commit is written as GIT_COMMIT=<hash> in build.txt
|
||||
@@ -308,6 +324,7 @@ def get_build_date() -> Optional[datetime]:
|
||||
|
||||
Returns:
|
||||
Optional[datetime.datetime]: The build date.
|
||||
|
||||
"""
|
||||
# Read build date has from build.txt
|
||||
# build date is written as BUILD_DATE=<hash> in build.txt
|
||||
@@ -394,6 +411,7 @@ def is_metrics_enabled() -> bool:
|
||||
|
||||
Returns:
|
||||
bool: Whether collect metrics is enabled.
|
||||
|
||||
"""
|
||||
metrics_enabled = os.getenv("SPOOLMAN_METRICS_ENABLED", "FALSE").upper()
|
||||
if metrics_enabled in {"FALSE", "0"}:
|
||||
@@ -412,6 +430,7 @@ def get_base_path() -> str:
|
||||
|
||||
Returns:
|
||||
str: The base path.
|
||||
|
||||
"""
|
||||
path = os.getenv("SPOOLMAN_BASE_PATH", "")
|
||||
if len(path) == 0:
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import hishel
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import filecache
|
||||
@@ -55,51 +56,67 @@ class Pattern(Enum):
|
||||
|
||||
|
||||
class ExternalFilament(BaseModel):
|
||||
id: str = Field(description="A unique ID for this filament.", example="polymaker_pla_polysonicblack_1000_175")
|
||||
manufacturer: str = Field(description="Filament manufacturer.", example="Polymaker")
|
||||
name: str = Field(description="Filament name.", example="Polysonic\u2122 Black")
|
||||
material: str = Field(description="Filament material.", example="PLA")
|
||||
density: float = Field(description="Density in g/cm3.", example=1.23)
|
||||
weight: float = Field(description="Net weight of a single spool.", example=1000)
|
||||
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", example=140)
|
||||
spool_type: Optional[SpoolType] = Field(description="Type of spool.", example=SpoolType.PLASTIC)
|
||||
diameter: float = Field(description="Filament in mm.", example=1.75)
|
||||
id: str = Field(description="A unique ID for this filament.", examples=["polymaker_pla_polysonicblack_1000_175"])
|
||||
manufacturer: str = Field(description="Filament manufacturer.", examples=["Polymaker"])
|
||||
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
|
||||
material: str = Field(description="Filament material.", examples=["PLA"])
|
||||
density: float = Field(description="Density in g/cm3.", examples=[1.23])
|
||||
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.", examples=[140])
|
||||
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
|
||||
diameter: float = Field(description="Filament in mm.", examples=[1.75])
|
||||
color_hex: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filament color code in hex format, for single-color filaments.",
|
||||
example="2c3232",
|
||||
examples=["2c3232"],
|
||||
)
|
||||
color_hexes: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
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)
|
||||
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", example=50)
|
||||
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", example=Finish.MATTE)
|
||||
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.", examples=[50])
|
||||
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
|
||||
multi_color_direction: Optional[MultiColorDirection] = Field(
|
||||
default=None,
|
||||
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.")
|
||||
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
||||
|
||||
|
||||
class ExternalFilamentsFile(BaseModel):
|
||||
__root__: list[ExternalFilament]
|
||||
class ExternalFilamentsFile(RootModel):
|
||||
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):
|
||||
material: str = Field(example="PLA")
|
||||
density: float = Field(example=1.24)
|
||||
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", example=210)
|
||||
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", example=50)
|
||||
material: str = Field(examples=["PLA"])
|
||||
density: float = Field(examples=[1.24])
|
||||
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.", examples=[50])
|
||||
|
||||
|
||||
class ExternalMaterialsFile(BaseModel):
|
||||
__root__: list[ExternalMaterial]
|
||||
class ExternalMaterialsFile(RootModel):
|
||||
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:
|
||||
@@ -161,8 +178,8 @@ async def _sync() -> None:
|
||||
|
||||
logger.info(
|
||||
"External DB synced. Filaments: %d, Materials: %d",
|
||||
len(filaments.__root__),
|
||||
len(materials.__root__),
|
||||
len(filaments.root),
|
||||
len(materials.root),
|
||||
)
|
||||
|
||||
|
||||
@@ -171,6 +188,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to use for scheduling tasks.
|
||||
|
||||
"""
|
||||
if len(get_external_db_url().strip()) == 0:
|
||||
logger.info("External DB URL is empty. Skipping sync.")
|
||||
|
||||
@@ -45,13 +45,13 @@ class ExtraFieldParameters(BaseModel):
|
||||
choices: Optional[list[str]] = Field(
|
||||
None,
|
||||
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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ def weight_from_length(*, length: float, diameter: float, density: float) -> flo
|
||||
|
||||
Returns:
|
||||
float: Weight in g
|
||||
|
||||
"""
|
||||
volume_mm3 = length * math.pi * (diameter / 2) ** 2
|
||||
volume_cm3 = volume_mm3 / 1000
|
||||
@@ -31,6 +32,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
|
||||
|
||||
Returns:
|
||||
float: Length in mm
|
||||
|
||||
"""
|
||||
volume_cm3 = weight / density
|
||||
volume_mm3 = volume_cm3 * 1000
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Prometheus metrics collectors."""
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
@@ -13,16 +14,16 @@ registry = REGISTRY
|
||||
|
||||
PREFIX = "spoolman"
|
||||
|
||||
SPOOL_PRICE = Gauge("%s_spool_price" % PREFIX, "Total Spool price", ["spool_id", "filament_id"])
|
||||
SPOOL_USED_WEIGHT = Gauge("%s_spool_weight_used" % PREFIX, "Spool Used Weight", ["spool_id", "filament_id"])
|
||||
SPOOL_PRICE = Gauge(f"{PREFIX}_spool_price", "Total Spool price", ["spool_id", "filament_id"])
|
||||
SPOOL_USED_WEIGHT = Gauge(f"{PREFIX}_spool_weight_used", "Spool Used Weight", ["spool_id", "filament_id"])
|
||||
FILAMENT_INFO = Gauge(
|
||||
"%s_filament_info" % PREFIX,
|
||||
f"{PREFIX}_filament_info",
|
||||
"Filament information",
|
||||
["filament_id", "vendor", "name", "material", "color"],
|
||||
)
|
||||
FILAMENT_DENSITY = Gauge("%s_filament_density" % PREFIX, "Density of filament", ["filament_id"])
|
||||
FILAMENT_DIAMETER = Gauge("%s_filament_diameter" % PREFIX, "Diameter of filament", ["filament_id"])
|
||||
FILAMENT_WEIGHT = Gauge("%s_filament_weight" % PREFIX, "Net weight of filament", ["filament_id"])
|
||||
FILAMENT_DENSITY = Gauge(f"{PREFIX}_filament_density", "Density of filament", ["filament_id"])
|
||||
FILAMENT_DIAMETER = Gauge(f"{PREFIX}_filament_diameter", "Diameter of filament", ["filament_id"])
|
||||
FILAMENT_WEIGHT = Gauge(f"{PREFIX}_filament_weight", "Net weight of filament", ["filament_id"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +42,7 @@ async def spool_metrics(db: AsyncSession) -> None:
|
||||
|
||||
Args:
|
||||
db: async db session
|
||||
|
||||
"""
|
||||
stmt = sqlalchemy.select(models.Spool).where(
|
||||
sqlalchemy.or_(
|
||||
@@ -61,6 +63,7 @@ async def filament_metrics(db: AsyncSession) -> None:
|
||||
|
||||
Args:
|
||||
db: async db session
|
||||
|
||||
"""
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Filament)
|
||||
|
||||
@@ -240,6 +240,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
|
||||
|
||||
Returns:
|
||||
float: Length in mm
|
||||
|
||||
"""
|
||||
volume_cm3 = weight / density
|
||||
volume_mm3 = volume_cm3 * 1000
|
||||
@@ -256,6 +257,7 @@ def assert_dicts_compatible(actual: Any, expected: Any, path: str = "") -> None:
|
||||
|
||||
Raises:
|
||||
AssertionError: If dictionaries are not compatible.
|
||||
|
||||
"""
|
||||
# Check if both inputs are dictionaries
|
||||
if not (isinstance(actual, dict) and isinstance(expected, dict)):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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():
|
||||
"""Test updating a filament that does not exist."""
|
||||
# Execute
|
||||
|
||||
Reference in New Issue
Block a user