config.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import logging
  2. import os
  3. import re as _re
  4. from pathlib import Path
  5. from pydantic import Field
  6. from pydantic_settings import BaseSettings
  7. # Application version - single source of truth
  8. APP_VERSION = "1.2.5b2"
  9. GITHUB_REPO = "maziggy/bambuddy"
  10. BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
  11. # App directory - where the application is installed (for static files)
  12. _app_dir = Path(__file__).resolve().parent.parent.parent.parent
  13. # Data directory - for persistent data (database, archives)
  14. # Use DATA_DIR env var if set (Docker), otherwise use project root (local dev)
  15. _data_dir_env = os.environ.get("DATA_DIR")
  16. _data_dir = Path(_data_dir_env) if _data_dir_env else _app_dir
  17. # Plate calibration directory - special handling to maintain backwards compatibility
  18. # Docker: DATA_DIR/plate_calibration (e.g., /data/plate_calibration)
  19. # Local dev: project_root/data/plate_calibration (original location)
  20. _plate_cal_dir = Path(_data_dir_env) / "plate_calibration" if _data_dir_env else _app_dir / "data" / "plate_calibration"
  21. # Log directory - use LOG_DIR env var if set, otherwise use app_dir/logs
  22. _log_dir_env = os.environ.get("LOG_DIR")
  23. _log_dir = Path(_log_dir_env) if _log_dir_env else _app_dir / "logs"
  24. def _migrate_database() -> Path:
  25. """Migrate database from old name to new name if needed."""
  26. old_db = _data_dir / "bambutrack.db"
  27. new_db = _data_dir / "bambuddy.db"
  28. # If old database exists and new one doesn't, rename it
  29. if old_db.exists() and not new_db.exists():
  30. try:
  31. old_db.rename(new_db)
  32. logging.info("Migrated database: %s -> %s", old_db, new_db)
  33. except Exception as e:
  34. logging.warning("Could not migrate database: %s. Using old location.", e)
  35. return old_db
  36. # If old database exists (and new one now exists too), it was migrated
  37. # If only new exists, use new
  38. # If neither exists, use new (will be created)
  39. return new_db if new_db.exists() or not old_db.exists() else old_db
  40. # External DATABASE_URL takes priority (PostgreSQL support)
  41. _external_db_url = os.environ.get("DATABASE_URL")
  42. # Determine database path (handles migration) — only used for SQLite
  43. _db_path = _migrate_database() if not _external_db_url else None
  44. class Settings(BaseSettings):
  45. app_name: str = "Bambuddy"
  46. debug: bool = False # Default to production mode
  47. # Paths
  48. base_dir: Path = _data_dir # For backwards compatibility
  49. # `app_dir` is where the source code is checked out — distinct from `base_dir`
  50. # on native installs where DATA_DIR is set to a sibling like INSTALL_PATH/data.
  51. # Use this when you need the working tree (requirements.txt, frontend/, etc.)
  52. # rather than the data dir. On Docker / local dev where DATA_DIR is unset,
  53. # app_dir == base_dir.
  54. app_dir: Path = _app_dir
  55. archive_dir: Path = _data_dir / "archive"
  56. plate_calibration_dir: Path = _plate_cal_dir # Plate detection references
  57. static_dir: Path = _app_dir / "static" # Static files are part of app, not data
  58. log_dir: Path = _log_dir
  59. database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
  60. # Database connection pool sizing. ``None`` = use the built-in, dialect-aware
  61. # default (PostgreSQL: pool_size 20 + max_overflow 80; SQLite: 20 + 200).
  62. # Large PostgreSQL printer farms can raise these via the DB_POOL_SIZE /
  63. # DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars (issue #2572).
  64. # Make sure PostgreSQL ``max_connections`` comfortably exceeds
  65. # (pool_size + max_overflow) x number of app worker processes.
  66. db_pool_size: int | None = Field(default=None, gt=0)
  67. db_max_overflow: int | None = Field(default=None, ge=0)
  68. db_pool_timeout: int | None = Field(default=None, gt=0)
  69. db_pool_recycle: int | None = Field(default=None, gt=0)
  70. # LIFO checkout (PostgreSQL default on): reuse the most-recently-returned
  71. # connection so a bursty farm keeps a small hot set busy and lets the excess
  72. # overflow connections age out via pool_recycle instead of churning the whole
  73. # pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
  74. db_pool_use_lifo: bool | None = Field(default=None)
  75. # Logging
  76. log_level: str = "INFO" # Override with LOG_LEVEL env var or DEBUG=true
  77. log_to_file: bool = True # Set to false to disable file logging
  78. # Rotation for bambuddy.log. Read by main.py (which owns the handler) and by
  79. # the support bundle (which harvests the backups as well as the live file);
  80. # they must agree on the backup count or the bundle silently skips history.
  81. # Bounded: RotatingFileHandler treats maxBytes=0 as "never rotate", so a
  82. # zero/negative override would grow the log without limit.
  83. log_max_bytes: int = Field(default=5 * 1024 * 1024, gt=0)
  84. log_backup_count: int = Field(default=3, ge=0)
  85. # API
  86. api_prefix: str = "/api/v1"
  87. # Slicer API sidecars. Defaults match the docker-compose.yml ports in the
  88. # orca-slicer-api fork (https://github.com/maziggy/orca-slicer-api):
  89. # OrcaSlicer → port 3003 (default profile)
  90. # BambuStudio → port 3001 (built locally via Dockerfile.bambu-studio)
  91. # The slice route picks which one based on the user's preferred_slicer
  92. # setting.
  93. slicer_api_url: str = "http://localhost:3003"
  94. bambu_studio_api_url: str = "http://localhost:3001"
  95. class Config:
  96. env_file = ".env"
  97. env_file_encoding = "utf-8"
  98. # Don't reject unknown env vars — MFA_ENCRYPTION_KEY (#1219) and other
  99. # operational env vars are read directly by their owning modules and
  100. # never declared as Settings fields.
  101. extra = "ignore"
  102. settings = Settings()
  103. # S6: Warn on unknown MFA_*/BAMBUDDY_* env vars so typos like MFA_ENCYPTION_KEY
  104. # are not silently swallowed by ``extra = "ignore"``. The original Pydantic
  105. # behaviour rejected them outright and broke startup (#1219); we now accept
  106. # them but log every unrecognised one at INFO so operators can spot mistakes.
  107. _INTENTIONAL_UNSETTINGS = {
  108. "MFA_ENCRYPTION_KEY", # encryption.py reads this directly
  109. "DATA_DIR", # paths.py / config.py
  110. "DATABASE_URL", # config.py (above)
  111. "LOG_DIR", # config.py (above)
  112. "LOG_LEVEL", # main.py logging setup
  113. "BUG_REPORT_RELAY_URL", # config.py (above)
  114. }
  115. _known_settings_fields = {f.upper() for f in settings.model_fields}
  116. for _env_key in os.environ:
  117. if _re.match(r"^(MFA_|BAMBUDDY_)", _env_key, _re.IGNORECASE):
  118. _norm = _env_key.upper()
  119. if _norm not in _known_settings_fields and _norm not in _INTENTIONAL_UNSETTINGS:
  120. logging.info(
  121. "Unknown env var %r — not a declared Settings field. Possible typo? Recognised operational vars: %s",
  122. _env_key,
  123. sorted(_INTENTIONAL_UNSETTINGS),
  124. )
  125. # Ensure directories exist
  126. settings.archive_dir.mkdir(parents=True, exist_ok=True)
  127. settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)
  128. settings.static_dir.mkdir(exist_ok=True)
  129. if settings.log_to_file:
  130. settings.log_dir.mkdir(exist_ok=True)