labeljetty 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. labeljetty/__init__.py +3 -0
  2. labeljetty/__main__.py +3 -0
  3. labeljetty/_version.py +24 -0
  4. labeljetty/app.py +64 -0
  5. labeljetty/config.py +245 -0
  6. labeljetty/core/__init__.py +0 -0
  7. labeljetty/core/db.py +121 -0
  8. labeljetty/core/logging.py +177 -0
  9. labeljetty/core/sqltypes.py +25 -0
  10. labeljetty/integrations/__init__.py +0 -0
  11. labeljetty/integrations/homebox.py +142 -0
  12. labeljetty/printer/__init__.py +15 -0
  13. labeljetty/printer/connection.py +405 -0
  14. labeljetty/printer/render.py +121 -0
  15. labeljetty/printer/tspl.py +1141 -0
  16. labeljetty/service/__init__.py +0 -0
  17. labeljetty/service/worker.py +338 -0
  18. labeljetty/testbench.py +281 -0
  19. labeljetty/version.py +41 -0
  20. labeljetty/web/__init__.py +0 -0
  21. labeljetty/web/api.py +394 -0
  22. labeljetty/web/app.py +153 -0
  23. labeljetty/web/auth.py +202 -0
  24. labeljetty/web/password.py +84 -0
  25. labeljetty/web/static/htmx.min.js +1 -0
  26. labeljetty/web/static/style.css +155 -0
  27. labeljetty/web/templates/_homebox.html +23 -0
  28. labeljetty/web/templates/_homebox_results.html +32 -0
  29. labeljetty/web/templates/_jobs.html +20 -0
  30. labeljetty/web/templates/_preview.html +14 -0
  31. labeljetty/web/templates/_print_result.html +5 -0
  32. labeljetty/web/templates/_status.html +42 -0
  33. labeljetty/web/templates/base.html +29 -0
  34. labeljetty/web/templates/homebox_setup.html +67 -0
  35. labeljetty/web/templates/index.html +190 -0
  36. labeljetty/web/templates/login.html +23 -0
  37. labeljetty/web/ui.py +524 -0
  38. labeljetty-0.1.0.dist-info/METADATA +64 -0
  39. labeljetty-0.1.0.dist-info/RECORD +42 -0
  40. labeljetty-0.1.0.dist-info/WHEEL +4 -0
  41. labeljetty-0.1.0.dist-info/entry_points.txt +4 -0
  42. labeljetty-0.1.0.dist-info/licenses/LICENSE +21 -0
labeljetty/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from labeljetty.version import get_version
2
+
3
+ __version__ = get_version()
labeljetty/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from labeljetty.app import run
2
+
3
+ run()
labeljetty/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
labeljetty/app.py ADDED
@@ -0,0 +1,64 @@
1
+ from typing import cast
2
+ from pathlib import Path
3
+ import uvicorn
4
+ import asyncio
5
+ from uvicorn.config import LOGGING_CONFIG
6
+ from uvicorn.config import LifespanType
7
+
8
+ from labeljetty.web.app import FastApiAppContainer
9
+ from labeljetty.service.worker import PrintServiceManager
10
+
11
+
12
+ def run():
13
+ from labeljetty.config import Config
14
+ from labeljetty.core.logging import get_logger, get_uvicorn_loglevel
15
+
16
+ config = Config()
17
+ log = get_logger()
18
+ log.info(f"LOG_LEVEL: {config.LOG_LEVEL}")
19
+ log.info(f"UVICORN_LOG_LEVEL: {get_uvicorn_loglevel()}")
20
+ log.info(f"Create image storage directory at '{config.IMAGE_STORAGE_DIRECTORY}'")
21
+ log.info(f"USB Printer at {config.PRINTER_USB} if not exists")
22
+ if config.auth_enabled():
23
+ log.info(f"AUTH_MODE=protected — {len(config.AUTH_TOKENS)} token(s), {len(config.AUTH_USERS)} user(s)")
24
+ else:
25
+ log.warning(
26
+ "AUTH_MODE=open — NO AUTHENTICATION. Every endpoint is public. "
27
+ "Only run this on a trusted LAN. Set AUTH_MODE=protected with "
28
+ "AUTH_TOKENS/AUTH_USERS before exposing it more widely."
29
+ )
30
+ Path(config.IMAGE_STORAGE_DIRECTORY).mkdir(parents=True, exist_ok=True)
31
+
32
+ event_loop = asyncio.get_event_loop()
33
+ uvicorn_log_config = LOGGING_CONFIG
34
+ fast_api_container = FastApiAppContainer()
35
+ uvicorn_config = uvicorn.Config(
36
+ app=fast_api_container.app,
37
+ host=config.SERVER_LISTENING_HOST,
38
+ port=config.SERVER_LISTENING_PORT,
39
+ log_level=get_uvicorn_loglevel(),
40
+ log_config=uvicorn_log_config,
41
+ loop=event_loop,
42
+ lifespan=cast(LifespanType, "on"),
43
+ )
44
+ uvicorn_server = uvicorn.Server(config=uvicorn_config)
45
+ print_service = PrintServiceManager()
46
+ fast_api_container.add_startup_callback(print_service.start)
47
+ fast_api_container.add_shutdown_callback(print_service.shutdown)
48
+ try:
49
+ log.debug("Start uvicorn server...")
50
+ event_loop.run_until_complete(uvicorn_server.serve())
51
+ except KeyboardInterrupt:
52
+ log.info("KeyboardInterrupt shutdown...")
53
+ except Exception:
54
+ log.info("Panic shutdown...")
55
+ raise
56
+ finally:
57
+ # Always tear the worker down with us — uvicorn's lifespan shutdown does
58
+ # not run when KeyboardInterrupt escapes serve(), so the lifespan callback
59
+ # alone is not enough. shutdown() is idempotent.
60
+ print_service.shutdown()
61
+
62
+
63
+ if __name__ == "__main__":
64
+ run()
labeljetty/config.py ADDED
@@ -0,0 +1,245 @@
1
+ import os
2
+ from typing import TYPE_CHECKING, List, Self
3
+ from pathlib import Path
4
+ from pydantic import BaseModel, Field, model_validator
5
+ from pydantic_settings import BaseSettings
6
+ from typing import Literal, Optional
7
+
8
+ if TYPE_CHECKING:
9
+ from labeljetty.printer.connection import TSPLPrinterConnectionUSB
10
+
11
+ # `.env` is looked up relative to the current working directory (the repo root,
12
+ # per the README) — the override env var takes precedence. It is intentionally
13
+ # not resolved relative to this file, which now lives inside the package.
14
+ env_file_path = os.environ.get(
15
+ "LABELJETTY_DOT_ENV_FILE", Path(".env")
16
+ )
17
+
18
+
19
+ class LabelProfile(BaseModel):
20
+ """A named label stock (width × height in mm), selectable in the web UI."""
21
+
22
+ name: str
23
+ width_mm: int
24
+ height_mm: int
25
+ dpi: Optional[int] = None
26
+
27
+
28
+ class AuthToken(BaseModel):
29
+ """A named API token for machine-to-machine access (sent as a Bearer token)."""
30
+
31
+ name: str
32
+ token: str
33
+
34
+
35
+ class AuthUser(BaseModel):
36
+ """A local login user. ``password_hash`` is a ``pbkdf2_sha256$…`` string —
37
+ generate it with the ``labeljetty-hash-password`` CLI (never store plaintext)."""
38
+
39
+ username: str
40
+ password_hash: str
41
+
42
+
43
+ class Config(BaseSettings):
44
+ APP_NAME: str = "LabelJetty"
45
+ LOG_LEVEL: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = Field(
46
+ default="DEBUG"
47
+ )
48
+ LOG_DISABLE_COLORS: bool = False
49
+ UVICORN_LOG_LEVEL: Optional[str] = Field(
50
+ default=None,
51
+ description="The log level of the uvicorn server. If not defined it will be the same as LOG_LEVEL.",
52
+ )
53
+ SERVER_LISTENING_PORT: int = Field(default=8888)
54
+ SERVER_LISTENING_HOST: str = Field(
55
+ default="localhost",
56
+ examples=["0.0.0.0", "localhost", "127.0.0.1", "176.16.8.123"],
57
+ )
58
+ SQLITE_PATH: str = Field(default="./printjobs.sqlite")
59
+ IMAGE_STORAGE_DIRECTORY: str = Field(
60
+ default="./images", description="Storage for posted images to print"
61
+ )
62
+ # --- Authentication ----------------------------------------------------- #
63
+ # AUTH_MODE selects the policy. "open" (default) = no auth at all — intended
64
+ # for a trusted LAN appliance. See the fat warning in the README before
65
+ # exposing the service beyond a trusted network. "protected" = every route
66
+ # requires a valid credential from ANY configured provider (tokens and/or
67
+ # local users; OIDC is planned as a drop-in third provider).
68
+ AUTH_MODE: Literal["open", "protected"] = Field(default="open")
69
+ # API tokens for machine-to-machine access, as a JSON list, e.g.
70
+ # AUTH_TOKENS='[{"name":"ci","token":"s3cr3t"}]'
71
+ # Sent by clients as `Authorization: Bearer <token>`.
72
+ AUTH_TOKENS: List[AuthToken] = Field(default_factory=list)
73
+ # Local login users (humans, via the /login form → session cookie), e.g.
74
+ # AUTH_USERS='[{"username":"tim","password_hash":"pbkdf2_sha256$..."}]'
75
+ # Generate password_hash with: labeljetty-hash-password
76
+ AUTH_USERS: List[AuthUser] = Field(default_factory=list)
77
+ # Secret used to sign session cookies. Leave unset for an ephemeral random
78
+ # secret (logins won't survive a restart) — set a stable value in production.
79
+ SESSION_SECRET: Optional[str] = Field(default=None)
80
+ SESSION_COOKIE_NAME: str = Field(default="labeljetty_session")
81
+ SESSION_MAX_AGE: int = Field(default=1_209_600, description="Session lifetime in seconds (default 14 days).")
82
+ # --- OIDC (reserved — planned drop-in provider, not yet implemented) ----- #
83
+ # AUTH_OIDC_ISSUER / AUTH_OIDC_CLIENT_ID / AUTH_OIDC_CLIENT_SECRET will be
84
+ # added when OIDC lands. The auth framework already returns a Principal and
85
+ # resolves providers from the session, so OIDC slots in without route changes.
86
+ DELETE_OLD_JOBS_AFTER_DAYS: int = Field(
87
+ default=100,
88
+ description="Old job entries in the database will be removed with associated files.",
89
+ )
90
+ # Default label geometry — used when a print job does not specify its own.
91
+ DEFAULT_LABEL_WIDTH_MM: int = Field(default=100)
92
+ DEFAULT_LABEL_HEIGHT_MM: int = Field(default=30)
93
+ DEFAULT_DPI: int = Field(default=203)
94
+ # Named label profiles selectable in the web UI, as a JSON list, e.g.
95
+ # LABEL_PROFILES='[{"name":"Homebox","width_mm":57,"height_mm":32}]'
96
+ # The server default geometry is always offered as a "Default" profile too.
97
+ LABEL_PROFILES: List[LabelProfile] = Field(default_factory=list)
98
+
99
+ # --- Homebox integration (optional module) ------------------------------ #
100
+ # The module's UI section + endpoints appear only when HOMEBOX_URL and
101
+ # HOMEBOX_API_KEY are both set (and HOMEBOX_ENABLED is true).
102
+ HOMEBOX_ENABLED: bool = Field(default=True)
103
+ HOMEBOX_URL: Optional[str] = Field(
104
+ default=None, description="Base URL of the Homebox server, e.g. https://box.example.com"
105
+ )
106
+ HOMEBOX_API_KEY: Optional[str] = Field(
107
+ default=None, description="Homebox API key (prefixed 'hb_…')."
108
+ )
109
+ HOMEBOX_API_PREFIX: str = Field(
110
+ default="/api/v1",
111
+ description="API path prefix on the Homebox server (entities live at <prefix>/entities).",
112
+ )
113
+ HOMEBOX_ENTITY_URL_TEMPLATE: str = Field(
114
+ default="/item/{id}",
115
+ description="Web path (appended to HOMEBOX_URL) an entity opens at; '{id}' is substituted. Used for the QR link.",
116
+ )
117
+ HOMEBOX_LABEL_SERVICE_AUTOPRINT: bool = Field(
118
+ default=True,
119
+ description=(
120
+ "For the push 'external label service' endpoint: also enqueue the print "
121
+ "as a side effect of Homebox requesting the label image. Disable if your "
122
+ "Homebox build calls the label-service URL for previews too (would cause "
123
+ "spurious prints) — then use the print-command script (path C) instead."
124
+ ),
125
+ )
126
+
127
+ def homebox_configured(self) -> bool:
128
+ """True when the Homebox module should be active."""
129
+ return bool(self.HOMEBOX_ENABLED and self.HOMEBOX_URL and self.HOMEBOX_API_KEY)
130
+
131
+ def auth_enabled(self) -> bool:
132
+ """True when routes require authentication (``AUTH_MODE == "protected"``)."""
133
+ return self.AUTH_MODE == "protected"
134
+
135
+ def find_user(self, username: str) -> Optional[AuthUser]:
136
+ """Return the configured user with this username, or None."""
137
+ for user in self.AUTH_USERS:
138
+ if user.username == username:
139
+ return user
140
+ return None
141
+
142
+ @model_validator(mode="after")
143
+ def validate_auth_config(self) -> Self:
144
+ """Guard against a lock-everyone-out / no-op auth configuration."""
145
+ if self.AUTH_MODE == "protected" and not self.AUTH_TOKENS and not self.AUTH_USERS:
146
+ raise ValueError(
147
+ "AUTH_MODE=protected but neither AUTH_TOKENS nor AUTH_USERS is "
148
+ "configured — this would lock out every request. Configure at "
149
+ "least one token or user, or set AUTH_MODE=open."
150
+ )
151
+ return self
152
+
153
+ def get_label_profiles(self) -> List[LabelProfile]:
154
+ """Configured profiles, preceded by the server-default geometry."""
155
+ default = LabelProfile(
156
+ name="Default",
157
+ width_mm=self.DEFAULT_LABEL_WIDTH_MM,
158
+ height_mm=self.DEFAULT_LABEL_HEIGHT_MM,
159
+ dpi=self.DEFAULT_DPI,
160
+ )
161
+ return [default, *self.LABEL_PROFILES]
162
+ # USB Printer - Choose ONE of these methods:
163
+ PRINTER_USB: str = Field(
164
+ description=(
165
+ "USB printer identifier. Can be:\n"
166
+ " - Serial number: 'serial:ABC123456'\n"
167
+ " - Device path: 'path:/dev/bus/usb/001/004' or 'path:001/004'\n"
168
+ " - USB port: 'port:3-1-2'\n"
169
+ " - Vendor+Product ID: 'vid:1234:pid:5678' or 'vid:1234' (first match)\n"
170
+ " - Bus+Address: 'bus:1:addr:4'"
171
+ ),
172
+ examples=[
173
+ "serial:ABC123456",
174
+ "path:/dev/bus/usb/001/004",
175
+ "port:3-1-2",
176
+ "vid:1234:pid:5678",
177
+ "bus:1:addr:4",
178
+ ],
179
+ )
180
+
181
+ @model_validator(mode="after")
182
+ def validate_printer_config(self) -> Self:
183
+ """Ensure printer USB identifier is provided"""
184
+ if not self.PRINTER_USB:
185
+ raise ValueError(
186
+ "PRINTER_USB must be set. Examples:\n"
187
+ " PRINTER_USB=serial:ABC123456\n"
188
+ " PRINTER_USB=port:3-1-2\n"
189
+ " PRINTER_USB=vid:1234:pid:5678"
190
+ )
191
+ return self
192
+
193
+ def get_printer_connection(self) -> "TSPLPrinterConnectionUSB":
194
+ """Returns a printer connection using the configured identifier"""
195
+ from labeljetty.printer.connection import TSPLPrinterConnectionUSB
196
+
197
+ usb_id = self.PRINTER_USB
198
+
199
+ if usb_id.startswith("serial:"):
200
+ serial = usb_id.split(":", 1)[1]
201
+ return TSPLPrinterConnectionUSB.by_serial(serial)
202
+
203
+ elif usb_id.startswith("path:"):
204
+ path = usb_id.split(":", 1)[1]
205
+ return TSPLPrinterConnectionUSB.by_device_path(path)
206
+
207
+ elif usb_id.startswith("port:"):
208
+ port = usb_id.split(":", 1)[1]
209
+ return TSPLPrinterConnectionUSB.by_port(port)
210
+
211
+ elif usb_id.startswith("bus:"):
212
+ # Format: bus:1:addr:4
213
+ parts = usb_id.split(":")
214
+ if len(parts) != 4 or parts[2] != "addr":
215
+ raise ValueError(
216
+ f"Invalid bus format: {usb_id}. Expected 'bus:1:addr:4'"
217
+ )
218
+ bus = int(parts[1])
219
+ addr = int(parts[3])
220
+ return TSPLPrinterConnectionUSB.by_bus_and_device_id(bus, addr)
221
+
222
+ elif usb_id.startswith("vid:"):
223
+ # Format: vid:1234:pid:5678 or vid:1234
224
+ parts = usb_id.split(":")
225
+ vendor = parts[1]
226
+ product = parts[3] if len(parts) >= 4 and parts[2] == "pid" else None
227
+ return TSPLPrinterConnectionUSB.by_vendor_and_product_id(vendor, product)
228
+
229
+ else:
230
+ raise ValueError(
231
+ f"Invalid PRINTER_USB format: {usb_id}\n"
232
+ "Must start with: serial:, path:, port:, bus:, or vid:"
233
+ )
234
+
235
+ ###### CONFIG END ######
236
+ # "class Config:" is a pydantic-settings pre-defined config class to control the behaviour of our settings model
237
+ # you could call it a "meta config" class
238
+ # if you dont know what this is you can ignore it.
239
+ # https://docs.pydantic.dev/latest/api/base_model/#pydantic.main.BaseModel.model_config
240
+
241
+ class Config:
242
+ env_nested_delimiter = "__"
243
+ env_file = env_file_path
244
+ env_file_encoding = "utf-8"
245
+ extra = "ignore"
File without changes
labeljetty/core/db.py ADDED
@@ -0,0 +1,121 @@
1
+ from typing import Optional, Literal, Dict, Generator, Any
2
+ from datetime import datetime
3
+ from sqlmodel import SQLModel, Field, Column, Session, create_engine
4
+ from sqlalchemy import String
5
+ from contextlib import contextmanager
6
+ from labeljetty.core.sqltypes import SqlJsonText
7
+ from labeljetty.config import Config
8
+ import uuid
9
+ from pathlib import Path
10
+ from pydantic import field_serializer, field_validator
11
+ from labeljetty.printer import JobType, TSPLPrinterStatusMessage
12
+
13
+ config = Config()
14
+ # Database URL - configure as needed
15
+ DATABASE_URL = f"sqlite:///{config.SQLITE_PATH}"
16
+
17
+ # Create engine
18
+ engine = create_engine(DATABASE_URL, echo=False)
19
+
20
+
21
+ class PrintJob(SQLModel, table=True):
22
+ id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
23
+
24
+ job_type: JobType = Field(
25
+ sa_column=Column(String), description="Which renderer the worker should use"
26
+ )
27
+ # Type-specific parameters (text content, barcode_type, ecc_level, font_size, ...)
28
+ params: Dict[str, Any] = Field(
29
+ default_factory=dict,
30
+ sa_column=Column(SqlJsonText),
31
+ )
32
+ # Stored upload for file-based jobs (png/pdf); None for parameter-only jobs.
33
+ input_file_name: Optional[str] = None
34
+
35
+ # Per-job label geometry override; None → fall back to config defaults.
36
+ label_width_mm: Optional[int] = None
37
+ label_height_mm: Optional[int] = None
38
+ dpi: Optional[int] = None
39
+ copies: int = 1
40
+
41
+ error: Optional[str] = None
42
+ printer_status_on_finished: Optional[TSPLPrinterStatusMessage] = Field(
43
+ default=None,
44
+ sa_column=Column(SqlJsonText),
45
+ )
46
+ created_at: datetime = Field(default_factory=datetime.now)
47
+ started_at: Optional[datetime] = None
48
+ finished_at: Optional[datetime] = None
49
+
50
+ def get_status(self) -> Literal["queued", "processing", "done", "failed"]:
51
+ if self.started_at is None:
52
+ return "queued"
53
+ elif self.finished_at is None:
54
+ return "processing"
55
+ elif self.error is not None:
56
+ return "failed"
57
+ else:
58
+ return "done"
59
+
60
+ def get_input_file_path(self) -> Optional[Path]:
61
+ if self.input_file_name is None:
62
+ return None
63
+ return Path(f"{config.IMAGE_STORAGE_DIRECTORY}/{self.input_file_name}")
64
+
65
+ @field_serializer("printer_status_on_finished")
66
+ def serialize_printer_status(
67
+ self, value: Optional[TSPLPrinterStatusMessage]
68
+ ) -> Optional[Dict[str, Any]]:
69
+ """Convert TSPLPrinterStatusMessage to dict for database storage"""
70
+ return value.model_dump() if value is not None else None
71
+
72
+ @field_validator("printer_status_on_finished", mode="before")
73
+ @classmethod
74
+ def validate_printer_status(cls, value: Dict[str, Any] | None):
75
+ """Convert dict to TSPLPrinterStatusMessage when loading from database"""
76
+ if value is None or isinstance(value, TSPLPrinterStatusMessage):
77
+ return value
78
+ if isinstance(value, dict):
79
+ return TSPLPrinterStatusMessage(**value)
80
+ return value
81
+
82
+
83
+ class WorkerStatus(SQLModel, table=True):
84
+ id: Optional[int] = Field(
85
+ default=1, primary_key=True, description="dummy pk field. Will always be one"
86
+ )
87
+ worker_error: Optional[str] = Field(
88
+ default=None, description="If set the worker is dead"
89
+ )
90
+ process_id: Optional[int] = Field(default=None)
91
+
92
+
93
+ # Database initialization
94
+ def init_db():
95
+ """Initialize database tables"""
96
+ SQLModel.metadata.create_all(engine)
97
+
98
+
99
+ # Session management
100
+ @contextmanager
101
+ def get_session() -> Generator[Session, None, None]:
102
+ """Context manager for database sessions.
103
+
104
+ ``expire_on_commit=False`` keeps attributes populated after commit so jobs can
105
+ be safely returned/serialized once the session has closed (FastAPI responses,
106
+ the worker handing a fetched job to the printer).
107
+ """
108
+ session = Session(engine, expire_on_commit=False)
109
+ try:
110
+ yield session
111
+ session.commit()
112
+ except Exception:
113
+ session.rollback()
114
+ raise
115
+ finally:
116
+ session.close()
117
+
118
+
119
+ def get_db_url() -> str:
120
+ """Get database URL"""
121
+ return DATABASE_URL
@@ -0,0 +1,177 @@
1
+ import logging
2
+ import sys
3
+ import os
4
+ import hashlib
5
+ from typing import Optional, Dict, Tuple
6
+ import inspect
7
+ from pathlib import Path
8
+ from labeljetty.config import Config
9
+
10
+ config = Config()
11
+
12
+
13
+ APP_LOGGER_DEFAULT_NAME = config.APP_NAME
14
+
15
+
16
+ # suppress "AttributeError: module 'bcrypt' has no attribute '__about__'"-warning
17
+ # https://github.com/pyca/bcrypt/issues/684
18
+ logging.getLogger("passlib").setLevel(logging.ERROR)
19
+
20
+
21
+ # ANSI Color codes
22
+ class Colors:
23
+ """ANSI color codes for terminal output"""
24
+
25
+ RESET = "\033[0m"
26
+
27
+ # Log level colors
28
+ DEBUG = "\033[36m" # Cyan
29
+ INFO = "\033[32m" # Green
30
+ WARNING = "\033[33m" # Yellow
31
+ ERROR = "\033[31m" # Red
32
+ CRITICAL = "\033[35m" # Magenta
33
+
34
+ # Module name colors - distinctive, neutral palette
35
+ MODULE_COLORS = [
36
+ "\033[34m", # Blue
37
+ "\033[33m", # Yellow
38
+ "\033[32m", # Green
39
+ "\033[36m", # Cyan
40
+ "\033[35m", # Magenta
41
+ "\033[94m", # Bright Blue
42
+ "\033[93m", # Bright Yellow
43
+ "\033[92m", # Bright Green
44
+ "\033[96m", # Bright Cyan
45
+ "\033[95m", # Bright Magenta
46
+ "\033[91m", # Bright Red (subtle, for modules)
47
+ ]
48
+
49
+
50
+ def get_loglevel():
51
+ return os.getenv("LOG_LEVEL", config.LOG_LEVEL)
52
+
53
+
54
+ def get_module_color(module_name: str) -> str:
55
+ """
56
+ Generate a deterministic, unique color for each module name.
57
+ Same module name will always get the same color.
58
+ """
59
+ if config.LOG_DISABLE_COLORS:
60
+ return ""
61
+
62
+ hash_digest = hashlib.md5(module_name.encode()).hexdigest()
63
+ hash_int = int(hash_digest, 16)
64
+ color_index = hash_int % len(Colors.MODULE_COLORS)
65
+ return Colors.MODULE_COLORS[color_index]
66
+
67
+
68
+ def get_loglevel_color(level: int) -> str:
69
+ """Get the color for a specific log level"""
70
+ if config.LOG_DISABLE_COLORS:
71
+ return ""
72
+
73
+ if level >= logging.CRITICAL:
74
+ return Colors.CRITICAL
75
+ elif level >= logging.ERROR:
76
+ return Colors.ERROR
77
+ elif level >= logging.WARNING:
78
+ return Colors.WARNING
79
+ elif level >= logging.INFO:
80
+ return Colors.INFO
81
+ else: # DEBUG and below
82
+ return Colors.DEBUG
83
+
84
+
85
+ class ColoredFormatter(logging.Formatter):
86
+ """Custom formatter that adds colors to log output"""
87
+
88
+ GRAY = "\033[90m" # Bright black (dark gray)
89
+
90
+ def format(self, record):
91
+ # Get colors
92
+ levelcolor = get_loglevel_color(record.levelno)
93
+
94
+ # Color the level name
95
+ record.levelname = f"{levelcolor}{record.levelname}{Colors.RESET if not config.LOG_DISABLE_COLORS else ''}"
96
+
97
+ # Format the record
98
+ result = super().format(record)
99
+
100
+ # Color the timestamp gray if colors are enabled
101
+ if not config.LOG_DISABLE_COLORS:
102
+ parts = result.split(" - ", 1)
103
+ if parts:
104
+ timestamp = parts[0]
105
+ rest = " - " + parts[1] if len(parts) > 1 else ""
106
+ result = f"{self.GRAY}{timestamp}{Colors.RESET}{rest}"
107
+
108
+ return result
109
+
110
+
111
+ active_loggers_store = None
112
+
113
+
114
+ def get_logger(
115
+ name: Optional[str] = APP_LOGGER_DEFAULT_NAME, modulename: Optional[str] = ""
116
+ ) -> logging.Logger:
117
+ global active_loggers_store
118
+ if active_loggers_store is None:
119
+ active_loggers_store = {}
120
+ if not modulename:
121
+ modulename = Path(inspect.stack()[1].filename).name
122
+ store_name = f"{name}{modulename}"
123
+ module = ""
124
+ module_color_code = ""
125
+
126
+ if modulename:
127
+ module_color_code = get_module_color(modulename)
128
+ module = f" - [{module_color_code}{modulename}{Colors.RESET}]"
129
+
130
+ logger_ = None
131
+
132
+ if store_name not in active_loggers_store:
133
+ logger_ = logging.getLogger(store_name)
134
+ logger_.setLevel(get_loglevel())
135
+
136
+ # Clear existing handlers to avoid duplicate logs
137
+ logger_.handlers.clear()
138
+
139
+ handler = logging.StreamHandler(sys.stdout)
140
+
141
+ format_string = f"%(asctime)s - {name}{module} - %(levelname)s - %(message)s"
142
+ formatter = ColoredFormatter(format_string)
143
+ handler.setFormatter(formatter)
144
+
145
+ logger_.addHandler(handler)
146
+ active_loggers_store[store_name] = logger_
147
+ else:
148
+ logger_ = active_loggers_store[store_name]
149
+
150
+ return logger_
151
+
152
+
153
+ def get_uvicorn_loglevel() -> str:
154
+ # uvicorn has a different log level naming system than python, we need to translate the log level setting
155
+ UVICORN_LOG_LEVEL_map: Dict[Tuple[int | str, ...], str] = {
156
+ (logging.NOTSET, "NOTSET", "notset", "0"): "trace",
157
+ (logging.CRITICAL, "50", "CRITICAL", "critical", "FATAL", "fatal"): "critical",
158
+ (logging.ERROR, "40", "ERROR", "error"): "error",
159
+ (logging.WARNING, "30", "WARNING", "warning", "WARN", "warn"): "warning",
160
+ (logging.INFO, "20", "INFO", "info"): "info",
161
+ (logging.DEBUG, "10", "DEBUG", "debug"): "debug",
162
+ }
163
+
164
+ # if the uvicorn log level is not defined, it will be the same as the python log level
165
+ UVICORN_LOG_LEVEL: str = (
166
+ config.UVICORN_LOG_LEVEL
167
+ if config.UVICORN_LOG_LEVEL is not None
168
+ else config.LOG_LEVEL
169
+ )
170
+ uvicorn_log_level_mapped: str | None = None
171
+ for key, val in UVICORN_LOG_LEVEL_map.items():
172
+ if UVICORN_LOG_LEVEL in key:
173
+ uvicorn_log_level_mapped = val
174
+ break
175
+ if uvicorn_log_level_mapped is None:
176
+ uvicorn_log_level_mapped = "info"
177
+ return uvicorn_log_level_mapped
@@ -0,0 +1,25 @@
1
+ from typing import Any, Optional
2
+ from sqlalchemy.types import TypeDecorator, Text
3
+ import json
4
+
5
+
6
+ class SqlJsonText(TypeDecorator):
7
+ """Stores JSON-serializable Python objects (dicts or Pydantic models) as TEXT.
8
+
9
+ Pydantic models are dumped via ``model_dump()`` before serialization; ``None``
10
+ is stored as SQL NULL and read back as ``None``.
11
+ """
12
+
13
+ impl = Text
14
+
15
+ def process_bind_param(self, value: Any, dialect) -> Optional[str]:
16
+ if value is None:
17
+ return None
18
+ if hasattr(value, "model_dump"):
19
+ value = value.model_dump()
20
+ return json.dumps(value)
21
+
22
+ def process_result_value(self, value: Optional[str], dialect) -> Any:
23
+ if value is None:
24
+ return None
25
+ return json.loads(value)
File without changes