Compare commits

..

No commits in common. "353ef0da955133b3bddcb9c8072f9b91168ac7bb" and "8e7692bd32e28251032871bc603c3c3bd9894a8a" have entirely different histories.

14 changed files with 655 additions and 710 deletions

View File

@ -56,6 +56,8 @@ COPY /templates/ templates/
COPY .env app/ COPY .env app/
COPY alembic.ini /panettone/alembic.ini COPY alembic.ini /panettone/alembic.ini
COPY /alembic/ /panettone/alembic/ COPY /alembic/ /panettone/alembic/
COPY logging-uvicorn.json /panettone/logging-uvicorn.json
COPY logging-granian.json /panettone/logging-granian.json
COPY pyproject.toml /panettone/pyproject.toml COPY pyproject.toml /panettone/pyproject.toml
RUN python -V RUN python -V

View File

@ -1,11 +1,12 @@
import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request, status from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import EmailStr from pydantic import EmailStr
from rotoger import AppStructLogger
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from app.services.smtp import SMTPEmailService from app.services.smtp import SMTPEmailService
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
@ -33,7 +34,7 @@ async def redis_check(request: Request):
try: try:
redis_info = await redis_client.info() redis_info = await redis_client.info()
except Exception as e: except Exception as e:
await logger.aerror(f"Redis error: {e}") logging.error(f"Redis error: {e}")
return redis_info return redis_info
@ -87,7 +88,7 @@ async def smtp_check(
"subject": subject, "subject": subject,
} }
await logger.ainfo("Sending email.", email_data=email_data) logger.info("Sending email with data: %s", email_data)
await run_in_threadpool( await run_in_threadpool(
smtp.send_email, smtp.send_email,

View File

@ -2,9 +2,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Form from fastapi import APIRouter, Depends, Form
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from rotoger import AppStructLogger
from app.services.llm import get_llm_service from app.services.llm import get_llm_service
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()

View File

@ -1,11 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from rotoger import AppStructLogger
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models.stuff import Stuff from app.models.stuff import Stuff
from app.schemas.stuff import StuffResponse, StuffSchema from app.schemas.stuff import StuffResponse, StuffSchema
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
@ -21,13 +21,13 @@ async def create_multi_stuff(
db_session.add_all(stuff_instances) db_session.add_all(stuff_instances)
await db_session.commit() await db_session.commit()
except SQLAlchemyError as ex: except SQLAlchemyError as ex:
await logger.aerror(f"Error inserting instances of Stuff: {repr(ex)}") logger.error(f"Error inserting instances of Stuff: {repr(ex)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)
) from ex ) from ex
else: else:
await logger.ainfo( logger.info(
f"{len(stuff_instances)} Stuff instances inserted into the database." f"{len(stuff_instances)} instances of Stuff inserted into database."
) )
return True return True

View File

@ -1,13 +1,13 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from rotoger import AppStructLogger
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.user import TokenResponse, UserLogin, UserResponse, UserSchema from app.schemas.user import TokenResponse, UserLogin, UserResponse, UserSchema
from app.services.auth import create_access_token from app.services.auth import create_access_token
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
@ -18,7 +18,7 @@ router = APIRouter(prefix="/v1/user")
async def create_user( async def create_user(
payload: UserSchema, request: Request, db_session: AsyncSession = Depends(get_db) payload: UserSchema, request: Request, db_session: AsyncSession = Depends(get_db)
): ):
await logger.ainfo(f"Creating user: {payload}") logger.info(f"Creating user: {payload}")
_user: User = User(**payload.model_dump()) _user: User = User(**payload.model_dump())
await _user.save(db_session) await _user.save(db_session)

View File

@ -1,9 +1,9 @@
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from rotoger import AppStructLogger
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings as global_settings from app.config import settings as global_settings
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
@ -29,5 +29,5 @@ async def get_db() -> AsyncGenerator:
try: try:
yield session yield session
except Exception as e: except Exception as e:
await logger.aerror(f"Error getting database session: {e}") logger.error(f"Error getting database session: {e}")
raise raise

View File

@ -5,7 +5,6 @@ import asyncpg
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from rotoger import AppStructLogger
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.ml import router as ml_router from app.api.ml import router as ml_router
@ -16,6 +15,7 @@ from app.api.user import router as user_router
from app.config import settings as global_settings from app.config import settings as global_settings
from app.redis import get_redis from app.redis import get_redis
from app.services.auth import AuthBearer from app.services.auth import AuthBearer
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
@ -30,7 +30,7 @@ async def lifespan(app: FastAPI):
min_size=5, min_size=5,
max_size=20, max_size=20,
) )
await logger.ainfo("Postgres pool created", idle_size=app.postgres_pool.get_idle_size()) logger.info("Postgres pool created", idle_size=app.postgres_pool.get_idle_size())
yield yield
finally: finally:
await app.redis.close() await app.redis.close()

View File

@ -2,11 +2,12 @@ from typing import Any
from asyncpg import UniqueViolationError from asyncpg import UniqueViolationError
from fastapi import HTTPException, status from fastapi import HTTPException, status
from rotoger import AppStructLogger
from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, declared_attr from sqlalchemy.orm import DeclarativeBase, declared_attr
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
@ -29,7 +30,7 @@ class Base(DeclarativeBase):
db_session.add(self) db_session.add(self)
return await db_session.commit() return await db_session.commit()
except SQLAlchemyError as ex: except SQLAlchemyError as ex:
await logger.aerror(f"Error inserting instance of {self}: {repr(ex)}") logger.error(f"Error inserting instance of {self}: {repr(ex)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)
) from ex ) from ex

View File

@ -3,10 +3,10 @@ import time
import jwt import jwt
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from rotoger import AppStructLogger
from app.config import settings as global_settings from app.config import settings as global_settings
from app.models.user import User from app.models.user import User
from app.utils.logging import AppStructLogger
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()
@ -40,7 +40,7 @@ class AuthBearer(HTTPBearer):
raise HTTPException( raise HTTPException(
status_code=403, detail="Invalid token or expired token." status_code=403, detail="Invalid token or expired token."
) )
await logger.ainfo(f"Token verified: {credentials.credentials}") logger.info(f"Token verified: {credentials.credentials}")
return credentials.credentials return credentials.credentials

View File

@ -15,9 +15,9 @@ logger = AppLogger().get_logger()
async def tick(): async def tick():
async with AsyncSessionFactory() as session: async with AsyncSessionFactory() as session:
stmt = text("select 1;") stmt = text("select 1;")
await logger.ainfo(f">>>> Be or not to be...{datetime.now()}") logger.info(f">>>> Be or not to be...{datetime.now()}")
result = await session.execute(stmt) result = await session.execute(stmt)
await logger.ainfo(f">>>> Result: {result.scalar()}") logger.info(f">>>> Result: {result.scalar()}")
return True return True

View File

@ -5,9 +5,9 @@ from email.mime.text import MIMEText
from attrs import define, field from attrs import define, field
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import EmailStr from pydantic import EmailStr
from rotoger import AppStructLogger
from app.config import settings as global_settings from app.config import settings as global_settings
from app.utils.logging import AppStructLogger
from app.utils.singleton import SingletonMetaNoArgs from app.utils.singleton import SingletonMetaNoArgs
logger = AppStructLogger().get_logger() logger = AppStructLogger().get_logger()

View File

@ -11,63 +11,27 @@ from whenever._whenever import Instant
from app.utils.singleton import SingletonMetaNoArgs from app.utils.singleton import SingletonMetaNoArgs
class RotatingBytesLogger: # TODO: merge this wrapper with the one in structlog under one hood of AppLogger
"""Logger that respects RotatingFileHandler's rotation capabilities.""" class BytesToTextIOWrapper:
def __init__(self, handler, encoding="utf-8"):
def __init__(self, handler):
self.handler = handler self.handler = handler
self.encoding = encoding
def msg(self, message): def write(self, b):
"""Process a message and pass it through the handler's emit method.""" if isinstance(b, bytes):
if isinstance(message, bytes): self.handler.stream.write(b.decode(self.encoding))
message = message.decode("utf-8") else:
self.handler.stream.write(b)
self.handler.flush()
# Create a log record that will trigger rotation checks def flush(self):
record = logging.LogRecord( self.handler.flush()
name="structlog",
level=logging.INFO,
pathname="",
lineno=0,
msg=message.rstrip("\n"),
args=(),
exc_info=None
)
# Check if rotation is needed before emitting def close(self):
if self.handler.shouldRollover(record): self.handler.close()
self.handler.doRollover()
# Emit the record through the handler
self.handler.emit(record)
# Required methods to make it compatible with structlog
def debug(self, message):
self.msg(message)
def info(self, message):
self.msg(message)
def warning(self, message):
self.msg(message)
def error(self, message):
self.msg(message)
def critical(self, message):
self.msg(message)
class RotatingBytesLoggerFactory: @define(slots=True)
"""Factory that creates loggers that respect file rotation."""
def __init__(self, handler):
self.handler = handler
def __call__(self, *args, **kwargs):
return RotatingBytesLogger(self.handler)
@define
class AppStructLogger(metaclass=SingletonMetaNoArgs): class AppStructLogger(metaclass=SingletonMetaNoArgs):
_logger: structlog.BoundLogger = field(init=False) _logger: structlog.BoundLogger = field(init=False)
@ -76,7 +40,8 @@ class AppStructLogger(metaclass=SingletonMetaNoArgs):
_log_path = Path(f"{_log_date}_{os.getpid()}.log") _log_path = Path(f"{_log_date}_{os.getpid()}.log")
_handler = RotatingFileHandler( _handler = RotatingFileHandler(
filename=_log_path, filename=_log_path,
maxBytes=10 * 1024 * 1024, # 10MB mode="a",
maxBytes=10 * 1024 * 1024,
backupCount=5, backupCount=5,
encoding="utf-8" encoding="utf-8"
) )
@ -90,7 +55,9 @@ class AppStructLogger(metaclass=SingletonMetaNoArgs):
structlog.processors.TimeStamper(fmt="iso", utc=True), structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.JSONRenderer(serializer=orjson.dumps), structlog.processors.JSONRenderer(serializer=orjson.dumps),
], ],
logger_factory=RotatingBytesLoggerFactory(_handler) logger_factory=structlog.BytesLoggerFactory(
file=BytesToTextIOWrapper(_handler)
)
) )
self._logger = structlog.get_logger() self._logger = structlog.get_logger()

View File

@ -29,7 +29,8 @@ dependencies = [
"polyfactory>=2.21.0", "polyfactory>=2.21.0",
"granian>=2.3.2", "granian>=2.3.2",
"apscheduler[redis,sqlalchemy]>=4.0.0a6", "apscheduler[redis,sqlalchemy]>=4.0.0a6",
"rotoger", "structlog>=25.4.0",
"whenever>=0.8.5",
] ]
[tool.uv] [tool.uv]

1243
uv.lock generated

File diff suppressed because it is too large Load Diff