dwyeapi 0.4.0__tar.gz
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.
- dwyeapi-0.4.0/PKG-INFO +23 -0
- dwyeapi-0.4.0/README.md +21 -0
- dwyeapi-0.4.0/pyproject.toml +53 -0
- dwyeapi-0.4.0/setup.cfg +4 -0
- dwyeapi-0.4.0/src/dwyeapi/__init__.py +20 -0
- dwyeapi-0.4.0/src/dwyeapi/cache.py +30 -0
- dwyeapi-0.4.0/src/dwyeapi/config.py +39 -0
- dwyeapi-0.4.0/src/dwyeapi/database.py +42 -0
- dwyeapi-0.4.0/src/dwyeapi/dependencies.py +16 -0
- dwyeapi-0.4.0/src/dwyeapi/dt.py +52 -0
- dwyeapi-0.4.0/src/dwyeapi/exceptions.py +75 -0
- dwyeapi-0.4.0/src/dwyeapi/logger.py +205 -0
- dwyeapi-0.4.0/src/dwyeapi/masking.py +107 -0
- dwyeapi-0.4.0/src/dwyeapi/pagination.py +25 -0
- dwyeapi-0.4.0/src/dwyeapi/response.py +19 -0
- dwyeapi-0.4.0/src/dwyeapi/security.py +50 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/__init__.py +97 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/context.py +180 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/model.py +42 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/pool.py +81 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/registry.py +83 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/router.py +196 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/schema.py +38 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/service.py +145 -0
- dwyeapi-0.4.0/src/dwyeapi/tasks/worker.py +118 -0
- dwyeapi-0.4.0/src/dwyeapi.egg-info/PKG-INFO +23 -0
- dwyeapi-0.4.0/src/dwyeapi.egg-info/SOURCES.txt +39 -0
- dwyeapi-0.4.0/src/dwyeapi.egg-info/dependency_links.txt +1 -0
- dwyeapi-0.4.0/src/dwyeapi.egg-info/requires.txt +19 -0
- dwyeapi-0.4.0/src/dwyeapi.egg-info/top_level.txt +1 -0
- dwyeapi-0.4.0/tests/test_cache.py +28 -0
- dwyeapi-0.4.0/tests/test_config.py +65 -0
- dwyeapi-0.4.0/tests/test_database.py +42 -0
- dwyeapi-0.4.0/tests/test_dependencies.py +70 -0
- dwyeapi-0.4.0/tests/test_exceptions.py +93 -0
- dwyeapi-0.4.0/tests/test_logger.py +137 -0
- dwyeapi-0.4.0/tests/test_masking.py +214 -0
- dwyeapi-0.4.0/tests/test_pagination.py +38 -0
- dwyeapi-0.4.0/tests/test_response.py +43 -0
- dwyeapi-0.4.0/tests/test_security.py +51 -0
- dwyeapi-0.4.0/tests/test_tasks.py +528 -0
dwyeapi-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dwyeapi
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0.0
|
|
10
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
11
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
12
|
+
Requires-Dist: bcrypt>=4.0.0
|
|
13
|
+
Requires-Dist: redis>=5.0.0
|
|
14
|
+
Requires-Dist: loguru>=0.7.3
|
|
15
|
+
Provides-Extra: tasks
|
|
16
|
+
Requires-Dist: arq>=0.26.0; extra == "tasks"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
21
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
23
|
+
Requires-Dist: arq>=0.26.0; extra == "dev"
|
dwyeapi-0.4.0/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# dwyeapi
|
|
2
|
+
|
|
3
|
+
Lightweight FastAPI infrastructure package for shared use across projects.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install dwyeapi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Modules
|
|
12
|
+
|
|
13
|
+
- `config` — BaseSettings with required fields (database_url, redis_url, secret_key)
|
|
14
|
+
- `exceptions` — AppError hierarchy + FastAPI exception handlers
|
|
15
|
+
- `database` — Async SQLAlchemy engine/session factory + Base + TimestampMixin
|
|
16
|
+
- `dependencies` — `get_db()` FastAPI dependency
|
|
17
|
+
- `security` — JWT create/verify + bcrypt hash/verify (stateless)
|
|
18
|
+
- `cache` — Async Redis connection manager
|
|
19
|
+
- `response` — Unified API response helpers
|
|
20
|
+
- `pagination` — PaginationParams + paginate helper
|
|
21
|
+
- `logger` — Loguru-based logger with daily + size rotation and stdlib interception
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dwyeapi"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.100.0",
|
|
9
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
10
|
+
"pydantic>=2.0.0",
|
|
11
|
+
"pydantic-settings>=2.0.0",
|
|
12
|
+
"python-jose[cryptography]>=3.3.0",
|
|
13
|
+
"bcrypt>=4.0.0",
|
|
14
|
+
"redis>=5.0.0",
|
|
15
|
+
"loguru>=0.7.3",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
tasks = [
|
|
20
|
+
"arq>=0.26.0",
|
|
21
|
+
]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0.0",
|
|
24
|
+
"pytest-asyncio>=0.24.0",
|
|
25
|
+
"httpx>=0.27.0",
|
|
26
|
+
"aiosqlite>=0.20.0",
|
|
27
|
+
"ruff>=0.6.0",
|
|
28
|
+
"arq>=0.26.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["setuptools>=68.0"]
|
|
33
|
+
build-backend = "setuptools.build_meta"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
target-version = "py311"
|
|
40
|
+
line-length = 120
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "RUF"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint.per-file-ignores]
|
|
46
|
+
"src/dwyeapi/tasks/router.py" = ["B008"]
|
|
47
|
+
"src/dwyeapi/tasks/worker.py" = ["RUF012"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.isort]
|
|
50
|
+
known-first-party = ["dwyeapi"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
asyncio_mode = "auto"
|
dwyeapi-0.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""dwyeapi — Lightweight FastAPI infrastructure.
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
config — BaseSettings with required fields
|
|
5
|
+
exceptions — AppError hierarchy + FastAPI exception handlers
|
|
6
|
+
database — Async SQLAlchemy engine/session factory + Base + TimestampMixin
|
|
7
|
+
dependencies — get_db() FastAPI dependency factory
|
|
8
|
+
security — JWT + bcrypt helpers (stateless)
|
|
9
|
+
cache — Async Redis connection manager
|
|
10
|
+
response — Unified API response helpers
|
|
11
|
+
pagination — PaginationParams + paginate helper
|
|
12
|
+
logger — Loguru-based logger with daily+size rotation and stdlib interception
|
|
13
|
+
tasks — Async task processing system (ARQ-based, install with [tasks] extra)
|
|
14
|
+
masking — PII data masking utilities
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from . import logger
|
|
18
|
+
|
|
19
|
+
__all__ = ["logger"]
|
|
20
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Async Redis connection manager."""
|
|
2
|
+
|
|
3
|
+
import redis.asyncio as aioredis
|
|
4
|
+
|
|
5
|
+
_redis_url: str | None = None
|
|
6
|
+
_redis: aioredis.Redis | None = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def configure(redis_url: str) -> None:
|
|
10
|
+
"""Set the Redis connection URL."""
|
|
11
|
+
global _redis_url
|
|
12
|
+
_redis_url = redis_url
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def get_redis() -> aioredis.Redis:
|
|
16
|
+
"""Get or create the shared async Redis connection."""
|
|
17
|
+
if _redis_url is None:
|
|
18
|
+
raise RuntimeError("dwyeapi.cache not configured — call cache.configure(redis_url) first")
|
|
19
|
+
global _redis
|
|
20
|
+
if _redis is None:
|
|
21
|
+
_redis = aioredis.from_url(_redis_url, decode_responses=True)
|
|
22
|
+
return _redis
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def close_redis() -> None:
|
|
26
|
+
"""Close the Redis connection. Safe to call even if not connected."""
|
|
27
|
+
global _redis
|
|
28
|
+
if _redis is not None:
|
|
29
|
+
await _redis.close()
|
|
30
|
+
_redis = None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Base Pydantic Settings class for FastAPI projects."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings as PydanticBaseSettings
|
|
5
|
+
from pydantic_settings import SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseSettings(PydanticBaseSettings):
|
|
9
|
+
"""Base settings — subclass and add project-specific fields."""
|
|
10
|
+
|
|
11
|
+
database_url: str
|
|
12
|
+
redis_url: str
|
|
13
|
+
secret_key: str
|
|
14
|
+
jwt_algorithm: str = "HS256"
|
|
15
|
+
access_token_expire_minutes: int = 30
|
|
16
|
+
debug: bool = False
|
|
17
|
+
allowed_origins: list[str] = Field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
# Task module settings (task_ prefix)
|
|
20
|
+
task_max_jobs: int = 5
|
|
21
|
+
task_job_timeout: int = 3600
|
|
22
|
+
task_failure_ttl: int = 86400
|
|
23
|
+
|
|
24
|
+
# Logger module settings (log_ prefix)
|
|
25
|
+
log_level: str = "INFO"
|
|
26
|
+
log_dir: str | None = None
|
|
27
|
+
log_filename: str = "app"
|
|
28
|
+
log_max_bytes: int = 100 * 1024 * 1024
|
|
29
|
+
log_retention: str = "30 days"
|
|
30
|
+
log_console: bool = True
|
|
31
|
+
log_serialize: bool = False
|
|
32
|
+
log_intercept_stdlib: bool = True
|
|
33
|
+
|
|
34
|
+
model_config = SettingsConfigDict(
|
|
35
|
+
env_file=".env",
|
|
36
|
+
env_file_encoding="utf-8",
|
|
37
|
+
env_nested_delimiter="__",
|
|
38
|
+
extra="ignore",
|
|
39
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Async SQLAlchemy engine and session factory, declarative Base, and TimestampMixin."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import func
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Base(DeclarativeBase):
|
|
11
|
+
"""Declarative base class for all ORM models."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TimestampMixin:
|
|
17
|
+
"""Mixin that adds created_at and updated_at columns."""
|
|
18
|
+
|
|
19
|
+
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
20
|
+
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_async_engine_factory(
|
|
24
|
+
database_url: str,
|
|
25
|
+
pool_size: int = 5,
|
|
26
|
+
max_overflow: int = 10,
|
|
27
|
+
echo: bool = False,
|
|
28
|
+
) -> AsyncEngine:
|
|
29
|
+
"""Create an async SQLAlchemy engine."""
|
|
30
|
+
if database_url.startswith("sqlite"):
|
|
31
|
+
return create_async_engine(database_url, echo=echo)
|
|
32
|
+
return create_async_engine(
|
|
33
|
+
database_url,
|
|
34
|
+
pool_size=pool_size,
|
|
35
|
+
max_overflow=max_overflow,
|
|
36
|
+
echo=echo,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
41
|
+
"""Create an async session factory bound to the given engine."""
|
|
42
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""FastAPI dependency factories."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_get_db(session_factory: async_sessionmaker[AsyncSession]):
|
|
9
|
+
"""Create a get_db FastAPI dependency bound to a session factory."""
|
|
10
|
+
|
|
11
|
+
async def get_db() -> AsyncGenerator[AsyncSession]:
|
|
12
|
+
"""Yield a database session, automatically closed after the request."""
|
|
13
|
+
async with session_factory() as session:
|
|
14
|
+
yield session
|
|
15
|
+
|
|
16
|
+
return get_db
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""全局时间工具 — 所有日期操作的唯一入口。
|
|
2
|
+
|
|
3
|
+
业务时间统一 Asia/Shanghai,JWT 等协议场景提供 UTC 方法。
|
|
4
|
+
禁止在业务代码中直接使用 datetime.now(),统一通过本模块访问。
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from dwyeapi import dt
|
|
9
|
+
|
|
10
|
+
dt.now() # naive datetime, 中国时间, 用于数据库
|
|
11
|
+
dt.now_str() # "2026-04-06 14:30:00"
|
|
12
|
+
dt.today() # date(2026, 4, 6)
|
|
13
|
+
dt.timestamp() # Unix 时间戳(秒)
|
|
14
|
+
dt.utc_now() # aware datetime, UTC, 用于 JWT
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from datetime import date, datetime, timedelta
|
|
18
|
+
from zoneinfo import ZoneInfo
|
|
19
|
+
|
|
20
|
+
TZ = ZoneInfo("Asia/Shanghai")
|
|
21
|
+
UTC = ZoneInfo("UTC")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── 业务时间(Asia/Shanghai) ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def now() -> datetime:
|
|
28
|
+
"""当前中国时间(naive,用于数据库存储)。"""
|
|
29
|
+
return datetime.now(TZ).replace(tzinfo=None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def now_str(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|
33
|
+
"""格式化的当前中国时间字符串。"""
|
|
34
|
+
return datetime.now(TZ).strftime(fmt)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def today() -> date:
|
|
38
|
+
"""当前中国日期。"""
|
|
39
|
+
return datetime.now(TZ).date()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def timestamp() -> float:
|
|
43
|
+
"""当前 Unix 时间戳(秒)。"""
|
|
44
|
+
return datetime.now(TZ).timestamp()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── UTC(JWT 等协议场景) ──────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def utc_now() -> datetime:
|
|
51
|
+
"""当前 UTC 时间(aware,用于 JWT exp 等协议字段)。"""
|
|
52
|
+
return datetime.now(UTC)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""AppError exception hierarchy and FastAPI exception handler registration."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request, status
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppError(Exception):
|
|
8
|
+
"""Base class for all application-level errors."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, code: str = "UNKNOWN_ERROR") -> None:
|
|
11
|
+
self.message = message
|
|
12
|
+
self.code = code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotFoundError(AppError):
|
|
16
|
+
"""Resource not found."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, resource: str) -> None:
|
|
19
|
+
super().__init__(message=f"{resource}不存在", code="NOT_FOUND")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BusinessError(AppError):
|
|
23
|
+
"""Business rule validation failure."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermissionDeniedError(AppError):
|
|
29
|
+
"""Insufficient permissions."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__(message="权限不足", code="PERMISSION_DENIED")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(AppError):
|
|
36
|
+
"""Authentication failed or missing."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__(message="认证失败", code="AUTHENTICATION_FAILED")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
43
|
+
"""Register exception-to-HTTP-response handlers on a FastAPI app."""
|
|
44
|
+
|
|
45
|
+
@app.exception_handler(NotFoundError)
|
|
46
|
+
async def not_found_handler(request: Request, exc: NotFoundError) -> JSONResponse:
|
|
47
|
+
"""Handle NotFoundError → 404."""
|
|
48
|
+
return JSONResponse(
|
|
49
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
50
|
+
content={"code": exc.code, "message": exc.message},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@app.exception_handler(BusinessError)
|
|
54
|
+
async def business_error_handler(request: Request, exc: BusinessError) -> JSONResponse:
|
|
55
|
+
"""Handle BusinessError → 422."""
|
|
56
|
+
return JSONResponse(
|
|
57
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
58
|
+
content={"code": exc.code, "message": exc.message},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@app.exception_handler(PermissionDeniedError)
|
|
62
|
+
async def permission_denied_handler(request: Request, exc: PermissionDeniedError) -> JSONResponse:
|
|
63
|
+
"""Handle PermissionDeniedError → 403."""
|
|
64
|
+
return JSONResponse(
|
|
65
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
66
|
+
content={"code": exc.code, "message": exc.message},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@app.exception_handler(AuthenticationError)
|
|
70
|
+
async def auth_error_handler(request: Request, exc: AuthenticationError) -> JSONResponse:
|
|
71
|
+
"""Handle AuthenticationError → 401."""
|
|
72
|
+
return JSONResponse(
|
|
73
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
74
|
+
content={"code": exc.code, "message": exc.message},
|
|
75
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Loguru-based global logger with daily + size rotation and stdlib interception.
|
|
2
|
+
|
|
3
|
+
Exposes a single lifecycle API (``configure``/``get_logger``/``close``) that mirrors
|
|
4
|
+
the pattern used by ``cache.py``. A typical application calls ``configure`` once at
|
|
5
|
+
startup (from ``lifespan``) and ``close`` once at shutdown.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
* Colored console output (stderr).
|
|
9
|
+
* File output with daily rollover (``{filename}_YYYY-MM-DD.log``) and a
|
|
10
|
+
configurable per-file size ceiling — whichever triggers first.
|
|
11
|
+
* Old log retention (default 30 days) with automatic cleanup.
|
|
12
|
+
* Optional JSON serialization for log aggregators.
|
|
13
|
+
* Optional interception of the standard ``logging`` module so libraries like
|
|
14
|
+
uvicorn, SQLAlchemy and third-party packages route through the same sinks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import inspect
|
|
21
|
+
import logging
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import date, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from loguru import logger as _logger
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from loguru import Logger, Record
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_DEFAULT_CONSOLE_FORMAT = (
|
|
34
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
35
|
+
"<level>{level: <8}</level> | "
|
|
36
|
+
"<cyan>{extra[module]}</cyan> - <level>{message}</level>"
|
|
37
|
+
)
|
|
38
|
+
_DEFAULT_FILE_FORMAT = (
|
|
39
|
+
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[module]} | {name}:{function}:{line} - {message}"
|
|
40
|
+
)
|
|
41
|
+
_DEFAULT_EXTRA: dict[str, Any] = {"module": "-"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_handler_ids: list[int] = []
|
|
45
|
+
_intercept_state: dict[str, list[logging.Handler]] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _InterceptHandler(logging.Handler):
|
|
49
|
+
"""Route standard ``logging`` records into the loguru sink chain."""
|
|
50
|
+
|
|
51
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
52
|
+
"""Forward a stdlib record to loguru preserving level, exception and frame depth."""
|
|
53
|
+
try:
|
|
54
|
+
level: str | int = _logger.level(record.levelname).name
|
|
55
|
+
except ValueError:
|
|
56
|
+
level = record.levelno
|
|
57
|
+
|
|
58
|
+
frame = inspect.currentframe()
|
|
59
|
+
depth = 0
|
|
60
|
+
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
|
|
61
|
+
frame = frame.f_back
|
|
62
|
+
depth += 1
|
|
63
|
+
|
|
64
|
+
_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _make_rotator(max_bytes: int) -> Any:
|
|
68
|
+
"""Build a loguru ``rotation`` callable that rolls on date change or size cap.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
max_bytes: Maximum bytes per file before a new file is created.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A function matching the loguru ``rotation`` callable signature.
|
|
75
|
+
"""
|
|
76
|
+
state: dict[str, date | None] = {"date": None}
|
|
77
|
+
|
|
78
|
+
def _rotate(message: Record, file: Any) -> bool:
|
|
79
|
+
today = datetime.now().date()
|
|
80
|
+
if state["date"] is None:
|
|
81
|
+
state["date"] = today
|
|
82
|
+
return False
|
|
83
|
+
if today != state["date"]:
|
|
84
|
+
state["date"] = today
|
|
85
|
+
return True
|
|
86
|
+
return file.tell() + len(str(message)) > max_bytes
|
|
87
|
+
|
|
88
|
+
return _rotate
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def configure(
|
|
92
|
+
*,
|
|
93
|
+
level: str = "INFO",
|
|
94
|
+
log_dir: str | Path | None = None,
|
|
95
|
+
filename: str = "app",
|
|
96
|
+
max_bytes: int = 100 * 1024 * 1024,
|
|
97
|
+
retention: str | int = "30 days",
|
|
98
|
+
console: bool = True,
|
|
99
|
+
console_format: str | None = None,
|
|
100
|
+
file_format: str | None = None,
|
|
101
|
+
serialize: bool = False,
|
|
102
|
+
enqueue: bool = True,
|
|
103
|
+
intercept_stdlib: bool = True,
|
|
104
|
+
intercept_loggers: list[str] | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Configure the global logger. Call once at application startup.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
level: Minimum log level (``DEBUG``/``INFO``/``WARNING``/``ERROR``/``CRITICAL``).
|
|
110
|
+
log_dir: Directory for log files. ``None`` disables file output.
|
|
111
|
+
filename: Base filename; final files look like ``{filename}_YYYY-MM-DD.log``.
|
|
112
|
+
max_bytes: Per-file size cap before rotation (default 100 MB).
|
|
113
|
+
retention: Loguru retention spec (``"30 days"``, ``"1 week"``, int count, ...).
|
|
114
|
+
console: Whether to write colored logs to stderr.
|
|
115
|
+
console_format: Override for the console format string.
|
|
116
|
+
file_format: Override for the file format string.
|
|
117
|
+
serialize: Emit JSON lines to the file sink for log aggregators.
|
|
118
|
+
enqueue: Write asynchronously via a queue — non-blocking and multi-process safe.
|
|
119
|
+
intercept_stdlib: Route stdlib ``logging`` records through loguru.
|
|
120
|
+
intercept_loggers: When intercepting, scope to these logger name prefixes
|
|
121
|
+
(e.g. ``["uvicorn", "sqlalchemy"]``). ``None`` means root (catch-all).
|
|
122
|
+
"""
|
|
123
|
+
close()
|
|
124
|
+
|
|
125
|
+
_logger.configure(extra=_DEFAULT_EXTRA)
|
|
126
|
+
|
|
127
|
+
if console:
|
|
128
|
+
handler_id = _logger.add(
|
|
129
|
+
sys.stderr,
|
|
130
|
+
level=level,
|
|
131
|
+
format=console_format or _DEFAULT_CONSOLE_FORMAT,
|
|
132
|
+
colorize=True,
|
|
133
|
+
enqueue=enqueue,
|
|
134
|
+
backtrace=True,
|
|
135
|
+
diagnose=False,
|
|
136
|
+
)
|
|
137
|
+
_handler_ids.append(handler_id)
|
|
138
|
+
|
|
139
|
+
if log_dir is not None:
|
|
140
|
+
log_path = Path(log_dir)
|
|
141
|
+
log_path.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
file_sink = str(log_path / f"{filename}_{{time:YYYY-MM-DD}}.log")
|
|
144
|
+
handler_id = _logger.add(
|
|
145
|
+
file_sink,
|
|
146
|
+
level=level,
|
|
147
|
+
format=file_format or _DEFAULT_FILE_FORMAT,
|
|
148
|
+
rotation=_make_rotator(max_bytes),
|
|
149
|
+
retention=retention,
|
|
150
|
+
encoding="utf-8",
|
|
151
|
+
enqueue=enqueue,
|
|
152
|
+
serialize=serialize,
|
|
153
|
+
backtrace=True,
|
|
154
|
+
diagnose=False,
|
|
155
|
+
)
|
|
156
|
+
_handler_ids.append(handler_id)
|
|
157
|
+
|
|
158
|
+
if intercept_stdlib:
|
|
159
|
+
_install_stdlib_intercept(level, intercept_loggers)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _install_stdlib_intercept(level: str, targets: list[str] | None) -> None:
|
|
163
|
+
"""Replace handlers on stdlib loggers with ``_InterceptHandler``, remembering originals."""
|
|
164
|
+
handler = _InterceptHandler()
|
|
165
|
+
roots = targets or [""]
|
|
166
|
+
|
|
167
|
+
for name in roots:
|
|
168
|
+
stdlib_logger = logging.getLogger(name) if name else logging.root
|
|
169
|
+
_intercept_state[name] = list(stdlib_logger.handlers)
|
|
170
|
+
stdlib_logger.handlers = [handler]
|
|
171
|
+
stdlib_logger.setLevel(level)
|
|
172
|
+
stdlib_logger.propagate = False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_logger(name: str | None = None) -> Logger:
|
|
176
|
+
"""Return the shared logger, optionally bound to a module name.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
name: Logical module name recorded in the ``module`` extra field.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
A loguru ``Logger`` proxy. If ``configure`` has not been called yet,
|
|
183
|
+
returns the raw default logger (stderr at DEBUG).
|
|
184
|
+
"""
|
|
185
|
+
if name:
|
|
186
|
+
return _logger.bind(module=name)
|
|
187
|
+
return _logger
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def close() -> None:
|
|
191
|
+
"""Remove all handlers, restore stdlib loggers, and flush pending records.
|
|
192
|
+
|
|
193
|
+
Safe to call repeatedly. After ``close`` the module can be reconfigured.
|
|
194
|
+
"""
|
|
195
|
+
for handler_id in _handler_ids:
|
|
196
|
+
# Handler already removed — benign.
|
|
197
|
+
with contextlib.suppress(ValueError):
|
|
198
|
+
_logger.remove(handler_id)
|
|
199
|
+
_handler_ids.clear()
|
|
200
|
+
|
|
201
|
+
for name, original_handlers in _intercept_state.items():
|
|
202
|
+
stdlib_logger = logging.getLogger(name) if name else logging.root
|
|
203
|
+
stdlib_logger.handlers = original_handlers
|
|
204
|
+
stdlib_logger.propagate = True
|
|
205
|
+
_intercept_state.clear()
|