/ spoolman / externaldb.py
externaldb.py
  1  """Functions for syncing data from an external database of manufacturers, filaments, materials, etc."""
  2  
  3  import datetime
  4  import logging
  5  import os
  6  from collections.abc import Iterator
  7  from enum import Enum
  8  from pathlib import Path
  9  from urllib.parse import urljoin
 10  
 11  import hishel
 12  from pydantic import BaseModel, Field, RootModel
 13  from scheduler.asyncio.scheduler import Scheduler
 14  
 15  from spoolman import filecache
 16  from spoolman.env import get_cache_dir
 17  
 18  logger = logging.getLogger(__name__)
 19  
 20  
 21  DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
 22  DEFAULT_SYNC_INTERVAL = 3600
 23  
 24  controller = hishel.Controller(allow_stale=True)
 25  try:
 26      cache_path = get_cache_dir() / "hishel"
 27      cache_storage = hishel.AsyncFileStorage(base_path=cache_path)
 28  except PermissionError:
 29      logger.warning(
 30          "Failed to setup disk-based cache due to permission error. Ensure the path %s is writable. "
 31          "Using in-memory cache instead as fallback.",
 32          str(cache_path.resolve()),
 33      )
 34      cache_storage = hishel.AsyncInMemoryStorage()
 35  
 36  
 37  class SpoolType(Enum):
 38      PLASTIC = "plastic"
 39      CARDBOARD = "cardboard"
 40      METAL = "metal"
 41  
 42  
 43  class Finish(Enum):
 44      MATTE = "matte"
 45      GLOSSY = "glossy"
 46  
 47  
 48  class MultiColorDirection(Enum):
 49      COAXIAL = "coaxial"
 50      LONGITUDINAL = "longitudinal"
 51  
 52  
 53  class Pattern(Enum):
 54      MARBLE = "marble"
 55      SPARKLE = "sparkle"
 56  
 57  
 58  class ExternalFilament(BaseModel):
 59      id: str = Field(description="A unique ID for this filament.", examples=["polymaker_pla_polysonicblack_1000_175"])
 60      manufacturer: str = Field(description="Filament manufacturer.", examples=["Polymaker"])
 61      name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
 62      material: str = Field(description="Filament material.", examples=["PLA"])
 63      density: float = Field(description="Density in g/cm3.", examples=[1.23])
 64      weight: float = Field(description="Net weight of a single spool.", examples=[1000])
 65      spool_weight: float | None = Field(default=None, description="Weight of an empty spool.", examples=[140])
 66      spool_type: SpoolType | None = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
 67      diameter: float = Field(description="Filament in mm.", examples=[1.75])
 68      color_hex: str | None = Field(
 69          default=None,
 70          description="Filament color code in hex format, for single-color filaments.",
 71          examples=["2c3232"],
 72      )
 73      color_hexes: list[str] | None = Field(
 74          default=None,
 75          description="For multi-color filaments. List of hex color codes in hex format.",
 76          examples=[["2c3232", "5f5f5f"]],
 77      )
 78      extruder_temp: int | None = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
 79      bed_temp: int | None = Field(default=None, description="Bed temperature in °C.", examples=[50])
 80      finish: Finish | None = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
 81      multi_color_direction: MultiColorDirection | None = Field(
 82          default=None,
 83          description="Direction of multi-color filaments.",
 84          examples=[MultiColorDirection.COAXIAL],
 85      )
 86      pattern: Pattern | None = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
 87      translucent: bool = Field(default=False, description="Whether the filament is translucent.")
 88      glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
 89  
 90  
 91  class ExternalFilamentsFile(RootModel):
 92      root: list[ExternalFilament]
 93  
 94      def __iter__(self) -> Iterator[ExternalFilament]:
 95          """Iterate over the filaments."""
 96          return iter(self.root)
 97  
 98      def __getitem__(self, index: int) -> ExternalFilament:
 99          """Get a specific filament by index."""
100          return self.root[index]
101  
102  
103  class ExternalMaterial(BaseModel):
104      material: str = Field(examples=["PLA"])
105      density: float = Field(examples=[1.24])
106      extruder_temp: int | None = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
107      bed_temp: int | None = Field(default=None, description="Bed temperature in °C.", examples=[50])
108  
109  
110  class ExternalMaterialsFile(RootModel):
111      root: list[ExternalMaterial]
112  
113      def __iter__(self) -> Iterator[ExternalMaterial]:
114          """Iterate over the materials."""
115          return iter(self.root)
116  
117      def __getitem__(self, index: int) -> ExternalMaterial:
118          """Get a specific material by index."""
119          return self.root[index]
120  
121  
122  def get_external_db_url() -> str:
123      """Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
124      return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL)
125  
126  
127  def get_external_db_sync_interval() -> int:
128      """Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
129      return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
130  
131  
132  async def _download_file(url: str) -> bytes:
133      """Download a file from a URL and return the contents as a string.
134  
135      Uses a file-based cache.
136      """
137      async with hishel.AsyncCacheClient(storage=cache_storage, controller=controller) as client:
138          response = await client.get(url)
139          response.raise_for_status()
140          return response.read()
141  
142  
143  def _parse_filaments_from_bytes(data: bytes) -> ExternalFilamentsFile:
144      """Parse a bytes string into a list of ExternalFilament objects."""
145      return ExternalFilamentsFile.parse_raw(data)
146  
147  
148  def _parse_materials_from_bytes(data: bytes) -> ExternalMaterialsFile:
149      """Parse a bytes string into a list of ExternalMaterial objects."""
150      return ExternalMaterialsFile.parse_raw(data)
151  
152  
153  def _write_to_local_cache(filename: str, data: bytes) -> None:
154      """Write data to the local cache."""
155      filecache.update_file(filename, data)
156  
157  
158  def get_filaments_file() -> Path:
159      """Get the path to the filaments file."""
160      return filecache.get_file("filaments.json")
161  
162  
163  def get_materials_file() -> Path:
164      """Get the path to the materials file."""
165      return filecache.get_file("materials.json")
166  
167  
168  async def _sync() -> None:
169      logger.info("Syncing external DB.")
170  
171      url = get_external_db_url()
172  
173      filaments = _parse_filaments_from_bytes(await _download_file(urljoin(url, "filaments.json")))
174      materials = _parse_materials_from_bytes(await _download_file(urljoin(url, "materials.json")))
175  
176      _write_to_local_cache("filaments.json", filaments.json().encode())
177      _write_to_local_cache("materials.json", materials.json().encode())
178  
179      logger.info(
180          "External DB synced. Filaments: %d, Materials: %d",
181          len(filaments.root),
182          len(materials.root),
183      )
184  
185  
186  def schedule_tasks(scheduler: Scheduler) -> None:
187      """Schedule tasks to be executed by the provided scheduler.
188  
189      Args:
190          scheduler: The scheduler to use for scheduling tasks.
191  
192      """
193      if len(get_external_db_url().strip()) == 0:
194          logger.info("External DB URL is empty. Skipping sync.")
195          return
196  
197      logger.info("Scheduling external DB sync.")
198  
199      # Run once on startup
200      scheduler.once(datetime.timedelta(seconds=0), _sync)  # type: ignore[arg-type]
201  
202      sync_interval = get_external_db_sync_interval()
203      if sync_interval > 0:
204          scheduler.cyclic(datetime.timedelta(seconds=DEFAULT_SYNC_INTERVAL), _sync)  # type: ignore[arg-type]
205      else:
206          logger.info("Sync interval is 0, skipping periodic sync of external db.")