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.
Files changed (41) hide show
  1. dwyeapi-0.4.0/PKG-INFO +23 -0
  2. dwyeapi-0.4.0/README.md +21 -0
  3. dwyeapi-0.4.0/pyproject.toml +53 -0
  4. dwyeapi-0.4.0/setup.cfg +4 -0
  5. dwyeapi-0.4.0/src/dwyeapi/__init__.py +20 -0
  6. dwyeapi-0.4.0/src/dwyeapi/cache.py +30 -0
  7. dwyeapi-0.4.0/src/dwyeapi/config.py +39 -0
  8. dwyeapi-0.4.0/src/dwyeapi/database.py +42 -0
  9. dwyeapi-0.4.0/src/dwyeapi/dependencies.py +16 -0
  10. dwyeapi-0.4.0/src/dwyeapi/dt.py +52 -0
  11. dwyeapi-0.4.0/src/dwyeapi/exceptions.py +75 -0
  12. dwyeapi-0.4.0/src/dwyeapi/logger.py +205 -0
  13. dwyeapi-0.4.0/src/dwyeapi/masking.py +107 -0
  14. dwyeapi-0.4.0/src/dwyeapi/pagination.py +25 -0
  15. dwyeapi-0.4.0/src/dwyeapi/response.py +19 -0
  16. dwyeapi-0.4.0/src/dwyeapi/security.py +50 -0
  17. dwyeapi-0.4.0/src/dwyeapi/tasks/__init__.py +97 -0
  18. dwyeapi-0.4.0/src/dwyeapi/tasks/context.py +180 -0
  19. dwyeapi-0.4.0/src/dwyeapi/tasks/model.py +42 -0
  20. dwyeapi-0.4.0/src/dwyeapi/tasks/pool.py +81 -0
  21. dwyeapi-0.4.0/src/dwyeapi/tasks/registry.py +83 -0
  22. dwyeapi-0.4.0/src/dwyeapi/tasks/router.py +196 -0
  23. dwyeapi-0.4.0/src/dwyeapi/tasks/schema.py +38 -0
  24. dwyeapi-0.4.0/src/dwyeapi/tasks/service.py +145 -0
  25. dwyeapi-0.4.0/src/dwyeapi/tasks/worker.py +118 -0
  26. dwyeapi-0.4.0/src/dwyeapi.egg-info/PKG-INFO +23 -0
  27. dwyeapi-0.4.0/src/dwyeapi.egg-info/SOURCES.txt +39 -0
  28. dwyeapi-0.4.0/src/dwyeapi.egg-info/dependency_links.txt +1 -0
  29. dwyeapi-0.4.0/src/dwyeapi.egg-info/requires.txt +19 -0
  30. dwyeapi-0.4.0/src/dwyeapi.egg-info/top_level.txt +1 -0
  31. dwyeapi-0.4.0/tests/test_cache.py +28 -0
  32. dwyeapi-0.4.0/tests/test_config.py +65 -0
  33. dwyeapi-0.4.0/tests/test_database.py +42 -0
  34. dwyeapi-0.4.0/tests/test_dependencies.py +70 -0
  35. dwyeapi-0.4.0/tests/test_exceptions.py +93 -0
  36. dwyeapi-0.4.0/tests/test_logger.py +137 -0
  37. dwyeapi-0.4.0/tests/test_masking.py +214 -0
  38. dwyeapi-0.4.0/tests/test_pagination.py +38 -0
  39. dwyeapi-0.4.0/tests/test_response.py +43 -0
  40. dwyeapi-0.4.0/tests/test_security.py +51 -0
  41. 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"
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()