async-safe-logger 0.1.3__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.
- async_safe_logger-0.1.3/PKG-INFO +583 -0
- async_safe_logger-0.1.3/README.md +563 -0
- async_safe_logger-0.1.3/pyproject.toml +28 -0
- async_safe_logger-0.1.3/pyproject.toml.orig +30 -0
- async_safe_logger-0.1.3/src/async_safe_logger/__init__.py +5 -0
- async_safe_logger-0.1.3/src/async_safe_logger/logger.py +170 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: async-safe-logger
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: A simple and secure logging library for async operations
|
|
5
|
+
Author: ZHUZIOK
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Async Safe Logger
|
|
22
|
+
|
|
23
|
+
[中文文档](README_zh-CN.md)
|
|
24
|
+
|
|
25
|
+
A simple, thread-safe, and asyncio-friendly logging wrapper built on top of Python's standard `logging` module.
|
|
26
|
+
|
|
27
|
+
`AsyncSafeLogger` moves log formatting, file I/O, log rotation, retention cleanup, and console output to a dedicated background thread using `QueueHandler` and `QueueListener`.
|
|
28
|
+
|
|
29
|
+
Your application code continues to use Python's standard logging API:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import logging
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
logger.info("Hello, world!")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
No `await` is required for normal logging calls.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
* **Built on Python's standard `logging` module**
|
|
46
|
+
* **Asyncio-friendly** — logging calls do not perform file or console I/O on the event-loop thread
|
|
47
|
+
* **Thread-safe** — safe to use from multiple threads and coroutines
|
|
48
|
+
* **Background logging** — formatting and output are handled by a dedicated `QueueListener` thread
|
|
49
|
+
* **Automatic log rotation** — rotate log files when they reach the configured size
|
|
50
|
+
* **Log retention cleanup** — automatically remove expired log files
|
|
51
|
+
* **Console and file output** — independently configurable
|
|
52
|
+
* **Multiple independent logger instances** — no required global mutable state
|
|
53
|
+
* **Synchronous and asynchronous lifecycle APIs**
|
|
54
|
+
* **Context manager support**
|
|
55
|
+
* **Zero runtime dependencies** — only Python's standard library is required
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
* Python **3.13+**
|
|
62
|
+
|
|
63
|
+
The library currently has no third-party runtime dependencies.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
### Using pip
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install async-safe-logger
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Using uv
|
|
76
|
+
|
|
77
|
+
If you use [uv](https://docs.astral.sh/uv/), add the package to your project with:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv add async-safe-logger
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or install it directly into the current environment:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv pip install async-safe-logger
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
# Quick Start
|
|
92
|
+
|
|
93
|
+
The simplest usage is to configure the logger and continue using Python's standard `logging` API.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import logging
|
|
97
|
+
|
|
98
|
+
from async_safe_logger import setup_logging, stop_logging
|
|
99
|
+
|
|
100
|
+
setup_logging()
|
|
101
|
+
|
|
102
|
+
logger = logging.getLogger(__name__)
|
|
103
|
+
|
|
104
|
+
logger.info("Hello, world!")
|
|
105
|
+
logger.warning("Something may be wrong.")
|
|
106
|
+
logger.error("Something went wrong.")
|
|
107
|
+
|
|
108
|
+
stop_logging()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
By default, logs are:
|
|
112
|
+
|
|
113
|
+
* written to `logs/app.log`
|
|
114
|
+
* printed to stdout
|
|
115
|
+
* rotated when the file reaches 2 MB
|
|
116
|
+
* retained according to the configured retention policy
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
# Asyncio Usage
|
|
121
|
+
|
|
122
|
+
`AsyncSafeLogger` is designed for applications using `asyncio`.
|
|
123
|
+
|
|
124
|
+
Normal logging calls remain synchronous from the caller's perspective:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import asyncio
|
|
128
|
+
import logging
|
|
129
|
+
|
|
130
|
+
from async_safe_logger import setup_logging, stop_logging_async
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def main():
|
|
134
|
+
setup_logging()
|
|
135
|
+
|
|
136
|
+
logger = logging.getLogger(__name__)
|
|
137
|
+
|
|
138
|
+
logger.info("Running inside asyncio")
|
|
139
|
+
logger.warning("This logging call does not write directly to disk.")
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
...
|
|
143
|
+
finally:
|
|
144
|
+
await stop_logging_async()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
asyncio.run(main())
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Why `stop_logging_async()`?
|
|
151
|
+
|
|
152
|
+
Stopping a `QueueListener` may wait for its background thread to finish processing queued log records.
|
|
153
|
+
|
|
154
|
+
The synchronous:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
stop_logging()
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
may therefore block while the listener shuts down.
|
|
161
|
+
|
|
162
|
+
When running inside an asyncio event loop, prefer:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
await stop_logging_async()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This moves the potentially blocking shutdown operation to a worker thread.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
# How It Works
|
|
173
|
+
|
|
174
|
+
The library uses Python's standard `QueueHandler` and `QueueListener`.
|
|
175
|
+
|
|
176
|
+
The normal logging path looks approximately like this:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
Application
|
|
180
|
+
│
|
|
181
|
+
│ logger.info(...)
|
|
182
|
+
▼
|
|
183
|
+
logging.Logger
|
|
184
|
+
│
|
|
185
|
+
▼
|
|
186
|
+
QueueHandler
|
|
187
|
+
│
|
|
188
|
+
│ put LogRecord into memory queue
|
|
189
|
+
▼
|
|
190
|
+
queue.Queue
|
|
191
|
+
│
|
|
192
|
+
▼
|
|
193
|
+
QueueListener
|
|
194
|
+
│
|
|
195
|
+
│ background thread
|
|
196
|
+
▼
|
|
197
|
+
┌───────────────┬────────────────┐
|
|
198
|
+
│ FileHandler │ ConsoleHandler │
|
|
199
|
+
└───────────────┴────────────────┘
|
|
200
|
+
│ │
|
|
201
|
+
▼ ▼
|
|
202
|
+
app.log stdout
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The important point is that the application thread or asyncio event-loop thread does not directly perform:
|
|
206
|
+
|
|
207
|
+
* file writes
|
|
208
|
+
* console writes
|
|
209
|
+
* log rotation
|
|
210
|
+
* expired-log cleanup
|
|
211
|
+
|
|
212
|
+
Those operations are handled by the background listener thread.
|
|
213
|
+
|
|
214
|
+
This keeps the normal logging path lightweight and avoids performing blocking output operations directly from an asyncio event loop.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
# Configuration
|
|
219
|
+
|
|
220
|
+
The default logger can be configured through `setup_logging()`.
|
|
221
|
+
|
|
222
|
+
For example:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from async_safe_logger import setup_logging
|
|
226
|
+
|
|
227
|
+
setup_logging(
|
|
228
|
+
log_dir="logs",
|
|
229
|
+
log_filename="app.log",
|
|
230
|
+
level=logging.INFO,
|
|
231
|
+
max_bytes=2 * 1024 * 1024,
|
|
232
|
+
backup_count=20,
|
|
233
|
+
retention_days=5,
|
|
234
|
+
file=True,
|
|
235
|
+
console=True,
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Configuration Options
|
|
240
|
+
|
|
241
|
+
| Parameter | Default | Description |
|
|
242
|
+
| ---------------- | --------------------: | ----------------------------------------------- |
|
|
243
|
+
| `log_dir` | `"logs"` | Directory used for log files |
|
|
244
|
+
| `log_filename` | `"app.log"` | Log file name |
|
|
245
|
+
| `level` | `logging.INFO` | Minimum logging level |
|
|
246
|
+
| `max_bytes` | `2 MB` | Maximum size of a log file before rotation |
|
|
247
|
+
| `backup_count` | `20` | Number of rotated log files to keep |
|
|
248
|
+
| `retention_days` | `5` | Delete log files older than this number of days |
|
|
249
|
+
| `file` | `True` | Enable file logging |
|
|
250
|
+
| `console` | `True` | Enable console logging |
|
|
251
|
+
| `fmt` | See below | Log message format |
|
|
252
|
+
| `datefmt` | `"%Y-%m-%d %H:%M:%S"` | Timestamp format |
|
|
253
|
+
|
|
254
|
+
Default format:
|
|
255
|
+
|
|
256
|
+
```text
|
|
257
|
+
%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
For example:
|
|
261
|
+
|
|
262
|
+
```text
|
|
263
|
+
2026-09-06 20:30:15 | INFO | myapp.main:main:42 - Server started
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
# File and Console Output
|
|
269
|
+
|
|
270
|
+
File and console logging can be independently enabled.
|
|
271
|
+
|
|
272
|
+
### File only
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
AsyncSafeLogger(
|
|
276
|
+
file=True,
|
|
277
|
+
console=False,
|
|
278
|
+
)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Console only
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
AsyncSafeLogger(
|
|
285
|
+
file=False,
|
|
286
|
+
console=True,
|
|
287
|
+
)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### File and console
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
AsyncSafeLogger(
|
|
294
|
+
file=True,
|
|
295
|
+
console=True,
|
|
296
|
+
)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
This is also the default configuration.
|
|
300
|
+
|
|
301
|
+
For safety, the following configuration is rejected:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
AsyncSafeLogger(
|
|
305
|
+
file=False,
|
|
306
|
+
console=False,
|
|
307
|
+
)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
It raises `ValueError` instead of silently discarding all log messages.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
# Multiple Independent Logger Systems
|
|
315
|
+
|
|
316
|
+
You can create multiple `AsyncSafeLogger` instances when different subsystems need different configurations.
|
|
317
|
+
|
|
318
|
+
For example:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
import logging
|
|
322
|
+
|
|
323
|
+
from async_safe_logger import AsyncSafeLogger
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
business_logger = AsyncSafeLogger(
|
|
327
|
+
log_dir="logs/business",
|
|
328
|
+
log_filename="business.log",
|
|
329
|
+
level=logging.INFO,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
debug_logger = AsyncSafeLogger(
|
|
333
|
+
log_dir="logs/debug",
|
|
334
|
+
log_filename="debug.log",
|
|
335
|
+
level=logging.DEBUG,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
business_logger.setup()
|
|
339
|
+
debug_logger.setup()
|
|
340
|
+
|
|
341
|
+
logging.getLogger("business").info("Business event")
|
|
342
|
+
logging.getLogger("debug").debug("Debug information")
|
|
343
|
+
|
|
344
|
+
# ...
|
|
345
|
+
|
|
346
|
+
business_logger.stop()
|
|
347
|
+
debug_logger.stop()
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Each instance owns its own queue, listener, and handlers.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
# Context Manager
|
|
355
|
+
|
|
356
|
+
`AsyncSafeLogger` supports both synchronous and asynchronous context managers.
|
|
357
|
+
|
|
358
|
+
## Synchronous
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
import logging
|
|
362
|
+
|
|
363
|
+
from async_safe_logger import AsyncSafeLogger
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
with AsyncSafeLogger() as logger:
|
|
367
|
+
logging.info("Inside the context manager")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The logger is automatically stopped when leaving the `with` block.
|
|
371
|
+
|
|
372
|
+
## Asynchronous
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
import logging
|
|
376
|
+
|
|
377
|
+
from async_safe_logger import AsyncSafeLogger
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async with AsyncSafeLogger() as logger:
|
|
381
|
+
logging.info("Inside the async context manager")
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
When leaving the `async with` block, the logger is shut down asynchronously.
|
|
385
|
+
|
|
386
|
+
This is the recommended pattern when the surrounding application is already asynchronous.
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
# Standard Logging Compatibility
|
|
391
|
+
|
|
392
|
+
The main goal of this library is to keep application code compatible with Python's standard logging API.
|
|
393
|
+
|
|
394
|
+
You can continue to use:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
import logging
|
|
398
|
+
|
|
399
|
+
logger = logging.getLogger(__name__)
|
|
400
|
+
|
|
401
|
+
logger.debug("Debug message")
|
|
402
|
+
logger.info("Information")
|
|
403
|
+
logger.warning("Warning")
|
|
404
|
+
logger.error("Error")
|
|
405
|
+
logger.critical("Critical error")
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
There is no need to replace your application's logging calls with a custom API such as:
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
await logger.info(...)
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Instead, logging remains familiar:
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
logger.info(...)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
while the output pipeline is handled asynchronously in the background.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
# Log Rotation and Retention
|
|
425
|
+
|
|
426
|
+
The file handler combines size-based rotation with automatic retention cleanup.
|
|
427
|
+
|
|
428
|
+
For example:
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
AsyncSafeLogger(
|
|
432
|
+
log_dir="logs",
|
|
433
|
+
log_filename="app.log",
|
|
434
|
+
max_bytes=2 * 1024 * 1024,
|
|
435
|
+
backup_count=20,
|
|
436
|
+
retention_days=5,
|
|
437
|
+
)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
When `app.log` reaches the configured size, it is rotated according to `RotatingFileHandler`.
|
|
441
|
+
|
|
442
|
+
After rotation, log files older than the configured retention period are removed.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
# Graceful Shutdown
|
|
447
|
+
|
|
448
|
+
It is important to stop the logger before the application exits so that queued log records have an opportunity to be processed.
|
|
449
|
+
|
|
450
|
+
For synchronous applications:
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
stop_logging()
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
For asyncio applications:
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
await stop_logging_async()
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
A typical asyncio application should use:
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
async def main():
|
|
466
|
+
setup_logging()
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
...
|
|
470
|
+
finally:
|
|
471
|
+
await stop_logging_async()
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
This is especially important for applications that produce logs immediately before shutdown.
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
# Design Goals
|
|
479
|
+
|
|
480
|
+
The project intentionally focuses on a small set of goals:
|
|
481
|
+
|
|
482
|
+
### 1. Keep the standard logging API
|
|
483
|
+
|
|
484
|
+
No custom logging syntax is required.
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
logger.info("hello")
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
remains the primary interface.
|
|
491
|
+
|
|
492
|
+
### 2. Keep blocking output away from the event loop
|
|
493
|
+
|
|
494
|
+
The application produces a `LogRecord`, which is placed into an in-memory queue.
|
|
495
|
+
|
|
496
|
+
The listener thread performs the actual output work.
|
|
497
|
+
|
|
498
|
+
### 3. Minimize runtime dependencies
|
|
499
|
+
|
|
500
|
+
The current implementation uses only Python's standard library.
|
|
501
|
+
|
|
502
|
+
### 4. Support both synchronous and asynchronous applications
|
|
503
|
+
|
|
504
|
+
The same logger can be used by:
|
|
505
|
+
|
|
506
|
+
* normal synchronous applications
|
|
507
|
+
* asyncio applications
|
|
508
|
+
* applications using both threads and asyncio
|
|
509
|
+
|
|
510
|
+
### 5. Keep the implementation simple
|
|
511
|
+
|
|
512
|
+
The library intentionally builds on well-tested components already provided by Python:
|
|
513
|
+
|
|
514
|
+
* `logging`
|
|
515
|
+
* `QueueHandler`
|
|
516
|
+
* `QueueListener`
|
|
517
|
+
* `RotatingFileHandler`
|
|
518
|
+
* `queue.Queue`
|
|
519
|
+
* `threading`
|
|
520
|
+
|
|
521
|
+
Rather than implementing a completely new logging framework.
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
# Important Notes
|
|
526
|
+
|
|
527
|
+
## Logging is not completely "non-blocking"
|
|
528
|
+
|
|
529
|
+
`AsyncSafeLogger` moves output operations away from the calling thread, but the initial logging operation still performs normal Python logging work and enqueues a `LogRecord`.
|
|
530
|
+
|
|
531
|
+
Therefore, it should be understood as:
|
|
532
|
+
|
|
533
|
+
> **asyncio-friendly / async-safe logging output**
|
|
534
|
+
|
|
535
|
+
rather than a mathematically guaranteed zero-cost or lock-free logging implementation.
|
|
536
|
+
|
|
537
|
+
## Queue Backpressure
|
|
538
|
+
|
|
539
|
+
The internal queue is currently unbounded.
|
|
540
|
+
|
|
541
|
+
This means normal logging calls do not wait for the background listener to consume records.
|
|
542
|
+
|
|
543
|
+
However, an application that continuously produces logs significantly faster than the output destination can consume them may accumulate an increasing number of `LogRecord` objects in memory.
|
|
544
|
+
|
|
545
|
+
For high-volume logging workloads, monitor queue growth and application memory usage.
|
|
546
|
+
|
|
547
|
+
## Shutdown
|
|
548
|
+
|
|
549
|
+
Always stop the logger during application shutdown:
|
|
550
|
+
|
|
551
|
+
```python
|
|
552
|
+
await stop_logging_async()
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
for asyncio applications, or:
|
|
556
|
+
|
|
557
|
+
```python
|
|
558
|
+
stop_logging()
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
for synchronous applications.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
# Project Status
|
|
566
|
+
|
|
567
|
+
This project is currently in an early development stage.
|
|
568
|
+
|
|
569
|
+
API design and implementation details may change before the `1.0.0` release.
|
|
570
|
+
|
|
571
|
+
The current version is:
|
|
572
|
+
|
|
573
|
+
```text
|
|
574
|
+
0.1.0
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
# License
|
|
580
|
+
|
|
581
|
+
MIT License.
|
|
582
|
+
|
|
583
|
+
See [`LICENSE`](LICENSE) for details.
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
# Async Safe Logger
|
|
2
|
+
|
|
3
|
+
[中文文档](README_zh-CN.md)
|
|
4
|
+
|
|
5
|
+
A simple, thread-safe, and asyncio-friendly logging wrapper built on top of Python's standard `logging` module.
|
|
6
|
+
|
|
7
|
+
`AsyncSafeLogger` moves log formatting, file I/O, log rotation, retention cleanup, and console output to a dedicated background thread using `QueueHandler` and `QueueListener`.
|
|
8
|
+
|
|
9
|
+
Your application code continues to use Python's standard logging API:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
logger.info("Hello, world!")
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No `await` is required for normal logging calls.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
* **Built on Python's standard `logging` module**
|
|
26
|
+
* **Asyncio-friendly** — logging calls do not perform file or console I/O on the event-loop thread
|
|
27
|
+
* **Thread-safe** — safe to use from multiple threads and coroutines
|
|
28
|
+
* **Background logging** — formatting and output are handled by a dedicated `QueueListener` thread
|
|
29
|
+
* **Automatic log rotation** — rotate log files when they reach the configured size
|
|
30
|
+
* **Log retention cleanup** — automatically remove expired log files
|
|
31
|
+
* **Console and file output** — independently configurable
|
|
32
|
+
* **Multiple independent logger instances** — no required global mutable state
|
|
33
|
+
* **Synchronous and asynchronous lifecycle APIs**
|
|
34
|
+
* **Context manager support**
|
|
35
|
+
* **Zero runtime dependencies** — only Python's standard library is required
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
* Python **3.13+**
|
|
42
|
+
|
|
43
|
+
The library currently has no third-party runtime dependencies.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
### Using pip
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install async-safe-logger
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Using uv
|
|
56
|
+
|
|
57
|
+
If you use [uv](https://docs.astral.sh/uv/), add the package to your project with:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add async-safe-logger
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or install it directly into the current environment:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv pip install async-safe-logger
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
# Quick Start
|
|
72
|
+
|
|
73
|
+
The simplest usage is to configure the logger and continue using Python's standard `logging` API.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import logging
|
|
77
|
+
|
|
78
|
+
from async_safe_logger import setup_logging, stop_logging
|
|
79
|
+
|
|
80
|
+
setup_logging()
|
|
81
|
+
|
|
82
|
+
logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
logger.info("Hello, world!")
|
|
85
|
+
logger.warning("Something may be wrong.")
|
|
86
|
+
logger.error("Something went wrong.")
|
|
87
|
+
|
|
88
|
+
stop_logging()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
By default, logs are:
|
|
92
|
+
|
|
93
|
+
* written to `logs/app.log`
|
|
94
|
+
* printed to stdout
|
|
95
|
+
* rotated when the file reaches 2 MB
|
|
96
|
+
* retained according to the configured retention policy
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
# Asyncio Usage
|
|
101
|
+
|
|
102
|
+
`AsyncSafeLogger` is designed for applications using `asyncio`.
|
|
103
|
+
|
|
104
|
+
Normal logging calls remain synchronous from the caller's perspective:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import asyncio
|
|
108
|
+
import logging
|
|
109
|
+
|
|
110
|
+
from async_safe_logger import setup_logging, stop_logging_async
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def main():
|
|
114
|
+
setup_logging()
|
|
115
|
+
|
|
116
|
+
logger = logging.getLogger(__name__)
|
|
117
|
+
|
|
118
|
+
logger.info("Running inside asyncio")
|
|
119
|
+
logger.warning("This logging call does not write directly to disk.")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
...
|
|
123
|
+
finally:
|
|
124
|
+
await stop_logging_async()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
asyncio.run(main())
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Why `stop_logging_async()`?
|
|
131
|
+
|
|
132
|
+
Stopping a `QueueListener` may wait for its background thread to finish processing queued log records.
|
|
133
|
+
|
|
134
|
+
The synchronous:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
stop_logging()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
may therefore block while the listener shuts down.
|
|
141
|
+
|
|
142
|
+
When running inside an asyncio event loop, prefer:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
await stop_logging_async()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This moves the potentially blocking shutdown operation to a worker thread.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
# How It Works
|
|
153
|
+
|
|
154
|
+
The library uses Python's standard `QueueHandler` and `QueueListener`.
|
|
155
|
+
|
|
156
|
+
The normal logging path looks approximately like this:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
Application
|
|
160
|
+
│
|
|
161
|
+
│ logger.info(...)
|
|
162
|
+
▼
|
|
163
|
+
logging.Logger
|
|
164
|
+
│
|
|
165
|
+
▼
|
|
166
|
+
QueueHandler
|
|
167
|
+
│
|
|
168
|
+
│ put LogRecord into memory queue
|
|
169
|
+
▼
|
|
170
|
+
queue.Queue
|
|
171
|
+
│
|
|
172
|
+
▼
|
|
173
|
+
QueueListener
|
|
174
|
+
│
|
|
175
|
+
│ background thread
|
|
176
|
+
▼
|
|
177
|
+
┌───────────────┬────────────────┐
|
|
178
|
+
│ FileHandler │ ConsoleHandler │
|
|
179
|
+
└───────────────┴────────────────┘
|
|
180
|
+
│ │
|
|
181
|
+
▼ ▼
|
|
182
|
+
app.log stdout
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The important point is that the application thread or asyncio event-loop thread does not directly perform:
|
|
186
|
+
|
|
187
|
+
* file writes
|
|
188
|
+
* console writes
|
|
189
|
+
* log rotation
|
|
190
|
+
* expired-log cleanup
|
|
191
|
+
|
|
192
|
+
Those operations are handled by the background listener thread.
|
|
193
|
+
|
|
194
|
+
This keeps the normal logging path lightweight and avoids performing blocking output operations directly from an asyncio event loop.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
# Configuration
|
|
199
|
+
|
|
200
|
+
The default logger can be configured through `setup_logging()`.
|
|
201
|
+
|
|
202
|
+
For example:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from async_safe_logger import setup_logging
|
|
206
|
+
|
|
207
|
+
setup_logging(
|
|
208
|
+
log_dir="logs",
|
|
209
|
+
log_filename="app.log",
|
|
210
|
+
level=logging.INFO,
|
|
211
|
+
max_bytes=2 * 1024 * 1024,
|
|
212
|
+
backup_count=20,
|
|
213
|
+
retention_days=5,
|
|
214
|
+
file=True,
|
|
215
|
+
console=True,
|
|
216
|
+
)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Configuration Options
|
|
220
|
+
|
|
221
|
+
| Parameter | Default | Description |
|
|
222
|
+
| ---------------- | --------------------: | ----------------------------------------------- |
|
|
223
|
+
| `log_dir` | `"logs"` | Directory used for log files |
|
|
224
|
+
| `log_filename` | `"app.log"` | Log file name |
|
|
225
|
+
| `level` | `logging.INFO` | Minimum logging level |
|
|
226
|
+
| `max_bytes` | `2 MB` | Maximum size of a log file before rotation |
|
|
227
|
+
| `backup_count` | `20` | Number of rotated log files to keep |
|
|
228
|
+
| `retention_days` | `5` | Delete log files older than this number of days |
|
|
229
|
+
| `file` | `True` | Enable file logging |
|
|
230
|
+
| `console` | `True` | Enable console logging |
|
|
231
|
+
| `fmt` | See below | Log message format |
|
|
232
|
+
| `datefmt` | `"%Y-%m-%d %H:%M:%S"` | Timestamp format |
|
|
233
|
+
|
|
234
|
+
Default format:
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
For example:
|
|
241
|
+
|
|
242
|
+
```text
|
|
243
|
+
2026-09-06 20:30:15 | INFO | myapp.main:main:42 - Server started
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
# File and Console Output
|
|
249
|
+
|
|
250
|
+
File and console logging can be independently enabled.
|
|
251
|
+
|
|
252
|
+
### File only
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
AsyncSafeLogger(
|
|
256
|
+
file=True,
|
|
257
|
+
console=False,
|
|
258
|
+
)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Console only
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
AsyncSafeLogger(
|
|
265
|
+
file=False,
|
|
266
|
+
console=True,
|
|
267
|
+
)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### File and console
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
AsyncSafeLogger(
|
|
274
|
+
file=True,
|
|
275
|
+
console=True,
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
This is also the default configuration.
|
|
280
|
+
|
|
281
|
+
For safety, the following configuration is rejected:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
AsyncSafeLogger(
|
|
285
|
+
file=False,
|
|
286
|
+
console=False,
|
|
287
|
+
)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
It raises `ValueError` instead of silently discarding all log messages.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
# Multiple Independent Logger Systems
|
|
295
|
+
|
|
296
|
+
You can create multiple `AsyncSafeLogger` instances when different subsystems need different configurations.
|
|
297
|
+
|
|
298
|
+
For example:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
import logging
|
|
302
|
+
|
|
303
|
+
from async_safe_logger import AsyncSafeLogger
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
business_logger = AsyncSafeLogger(
|
|
307
|
+
log_dir="logs/business",
|
|
308
|
+
log_filename="business.log",
|
|
309
|
+
level=logging.INFO,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
debug_logger = AsyncSafeLogger(
|
|
313
|
+
log_dir="logs/debug",
|
|
314
|
+
log_filename="debug.log",
|
|
315
|
+
level=logging.DEBUG,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
business_logger.setup()
|
|
319
|
+
debug_logger.setup()
|
|
320
|
+
|
|
321
|
+
logging.getLogger("business").info("Business event")
|
|
322
|
+
logging.getLogger("debug").debug("Debug information")
|
|
323
|
+
|
|
324
|
+
# ...
|
|
325
|
+
|
|
326
|
+
business_logger.stop()
|
|
327
|
+
debug_logger.stop()
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Each instance owns its own queue, listener, and handlers.
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
# Context Manager
|
|
335
|
+
|
|
336
|
+
`AsyncSafeLogger` supports both synchronous and asynchronous context managers.
|
|
337
|
+
|
|
338
|
+
## Synchronous
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
import logging
|
|
342
|
+
|
|
343
|
+
from async_safe_logger import AsyncSafeLogger
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
with AsyncSafeLogger() as logger:
|
|
347
|
+
logging.info("Inside the context manager")
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The logger is automatically stopped when leaving the `with` block.
|
|
351
|
+
|
|
352
|
+
## Asynchronous
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
import logging
|
|
356
|
+
|
|
357
|
+
from async_safe_logger import AsyncSafeLogger
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async with AsyncSafeLogger() as logger:
|
|
361
|
+
logging.info("Inside the async context manager")
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
When leaving the `async with` block, the logger is shut down asynchronously.
|
|
365
|
+
|
|
366
|
+
This is the recommended pattern when the surrounding application is already asynchronous.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
# Standard Logging Compatibility
|
|
371
|
+
|
|
372
|
+
The main goal of this library is to keep application code compatible with Python's standard logging API.
|
|
373
|
+
|
|
374
|
+
You can continue to use:
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
import logging
|
|
378
|
+
|
|
379
|
+
logger = logging.getLogger(__name__)
|
|
380
|
+
|
|
381
|
+
logger.debug("Debug message")
|
|
382
|
+
logger.info("Information")
|
|
383
|
+
logger.warning("Warning")
|
|
384
|
+
logger.error("Error")
|
|
385
|
+
logger.critical("Critical error")
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
There is no need to replace your application's logging calls with a custom API such as:
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
await logger.info(...)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Instead, logging remains familiar:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
logger.info(...)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
while the output pipeline is handled asynchronously in the background.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
# Log Rotation and Retention
|
|
405
|
+
|
|
406
|
+
The file handler combines size-based rotation with automatic retention cleanup.
|
|
407
|
+
|
|
408
|
+
For example:
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
AsyncSafeLogger(
|
|
412
|
+
log_dir="logs",
|
|
413
|
+
log_filename="app.log",
|
|
414
|
+
max_bytes=2 * 1024 * 1024,
|
|
415
|
+
backup_count=20,
|
|
416
|
+
retention_days=5,
|
|
417
|
+
)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
When `app.log` reaches the configured size, it is rotated according to `RotatingFileHandler`.
|
|
421
|
+
|
|
422
|
+
After rotation, log files older than the configured retention period are removed.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
# Graceful Shutdown
|
|
427
|
+
|
|
428
|
+
It is important to stop the logger before the application exits so that queued log records have an opportunity to be processed.
|
|
429
|
+
|
|
430
|
+
For synchronous applications:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
stop_logging()
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
For asyncio applications:
|
|
437
|
+
|
|
438
|
+
```python
|
|
439
|
+
await stop_logging_async()
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
A typical asyncio application should use:
|
|
443
|
+
|
|
444
|
+
```python
|
|
445
|
+
async def main():
|
|
446
|
+
setup_logging()
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
...
|
|
450
|
+
finally:
|
|
451
|
+
await stop_logging_async()
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
This is especially important for applications that produce logs immediately before shutdown.
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
# Design Goals
|
|
459
|
+
|
|
460
|
+
The project intentionally focuses on a small set of goals:
|
|
461
|
+
|
|
462
|
+
### 1. Keep the standard logging API
|
|
463
|
+
|
|
464
|
+
No custom logging syntax is required.
|
|
465
|
+
|
|
466
|
+
```python
|
|
467
|
+
logger.info("hello")
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
remains the primary interface.
|
|
471
|
+
|
|
472
|
+
### 2. Keep blocking output away from the event loop
|
|
473
|
+
|
|
474
|
+
The application produces a `LogRecord`, which is placed into an in-memory queue.
|
|
475
|
+
|
|
476
|
+
The listener thread performs the actual output work.
|
|
477
|
+
|
|
478
|
+
### 3. Minimize runtime dependencies
|
|
479
|
+
|
|
480
|
+
The current implementation uses only Python's standard library.
|
|
481
|
+
|
|
482
|
+
### 4. Support both synchronous and asynchronous applications
|
|
483
|
+
|
|
484
|
+
The same logger can be used by:
|
|
485
|
+
|
|
486
|
+
* normal synchronous applications
|
|
487
|
+
* asyncio applications
|
|
488
|
+
* applications using both threads and asyncio
|
|
489
|
+
|
|
490
|
+
### 5. Keep the implementation simple
|
|
491
|
+
|
|
492
|
+
The library intentionally builds on well-tested components already provided by Python:
|
|
493
|
+
|
|
494
|
+
* `logging`
|
|
495
|
+
* `QueueHandler`
|
|
496
|
+
* `QueueListener`
|
|
497
|
+
* `RotatingFileHandler`
|
|
498
|
+
* `queue.Queue`
|
|
499
|
+
* `threading`
|
|
500
|
+
|
|
501
|
+
Rather than implementing a completely new logging framework.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
# Important Notes
|
|
506
|
+
|
|
507
|
+
## Logging is not completely "non-blocking"
|
|
508
|
+
|
|
509
|
+
`AsyncSafeLogger` moves output operations away from the calling thread, but the initial logging operation still performs normal Python logging work and enqueues a `LogRecord`.
|
|
510
|
+
|
|
511
|
+
Therefore, it should be understood as:
|
|
512
|
+
|
|
513
|
+
> **asyncio-friendly / async-safe logging output**
|
|
514
|
+
|
|
515
|
+
rather than a mathematically guaranteed zero-cost or lock-free logging implementation.
|
|
516
|
+
|
|
517
|
+
## Queue Backpressure
|
|
518
|
+
|
|
519
|
+
The internal queue is currently unbounded.
|
|
520
|
+
|
|
521
|
+
This means normal logging calls do not wait for the background listener to consume records.
|
|
522
|
+
|
|
523
|
+
However, an application that continuously produces logs significantly faster than the output destination can consume them may accumulate an increasing number of `LogRecord` objects in memory.
|
|
524
|
+
|
|
525
|
+
For high-volume logging workloads, monitor queue growth and application memory usage.
|
|
526
|
+
|
|
527
|
+
## Shutdown
|
|
528
|
+
|
|
529
|
+
Always stop the logger during application shutdown:
|
|
530
|
+
|
|
531
|
+
```python
|
|
532
|
+
await stop_logging_async()
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
for asyncio applications, or:
|
|
536
|
+
|
|
537
|
+
```python
|
|
538
|
+
stop_logging()
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
for synchronous applications.
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
# Project Status
|
|
546
|
+
|
|
547
|
+
This project is currently in an early development stage.
|
|
548
|
+
|
|
549
|
+
API design and implementation details may change before the `1.0.0` release.
|
|
550
|
+
|
|
551
|
+
The current version is:
|
|
552
|
+
|
|
553
|
+
```text
|
|
554
|
+
0.1.0
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
# License
|
|
560
|
+
|
|
561
|
+
MIT License.
|
|
562
|
+
|
|
563
|
+
See [`LICENSE`](LICENSE) for details.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async-safe-logger"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "A simple and secure logging library for async operations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = []
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
14
|
+
"Programming Language :: Python :: 3.9",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: System :: Logging",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[project.authors]]
|
|
24
|
+
name = "ZHUZIOK"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.11.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async-safe-logger"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "A simple and secure logging library for async operations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "ZHUZIOK" }
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: System :: Logging",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.11.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import queue
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, Union
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _DailyCleanupRotatingFileHandler(RotatingFileHandler):
|
|
15
|
+
__slots__ = (
|
|
16
|
+
"retention_days",
|
|
17
|
+
"_log_dir",
|
|
18
|
+
"_log_stem",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args, retention_days: int = 5, **kwargs) -> None:
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
self.retention_days = retention_days
|
|
24
|
+
base_path = Path(self.baseFilename)
|
|
25
|
+
self._log_dir = base_path.parent
|
|
26
|
+
self._log_stem = base_path.name
|
|
27
|
+
|
|
28
|
+
def doRollover(self) -> None:
|
|
29
|
+
super().doRollover()
|
|
30
|
+
self._cleanup_old_logs()
|
|
31
|
+
|
|
32
|
+
def _cleanup_old_logs(self) -> None:
|
|
33
|
+
expire_time = datetime.now() - timedelta(days=self.retention_days)
|
|
34
|
+
for path in self._log_dir.glob(f"{self._log_stem}*"):
|
|
35
|
+
try:
|
|
36
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
|
37
|
+
if mtime < expire_time:
|
|
38
|
+
path.unlink()
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AsyncSafeLogger:
|
|
44
|
+
|
|
45
|
+
__slots__ = (
|
|
46
|
+
"log_dir",
|
|
47
|
+
"log_file",
|
|
48
|
+
"level",
|
|
49
|
+
"max_bytes",
|
|
50
|
+
"backup_count",
|
|
51
|
+
"retention_days",
|
|
52
|
+
"file",
|
|
53
|
+
"console",
|
|
54
|
+
"fmt",
|
|
55
|
+
"datefmt",
|
|
56
|
+
"_queue",
|
|
57
|
+
"_listener",
|
|
58
|
+
"_queue_handler",
|
|
59
|
+
"_lock",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
log_dir: Union[str, Path] = "logs",
|
|
65
|
+
log_filename: str = "app.log",
|
|
66
|
+
level: int = logging.INFO,
|
|
67
|
+
max_bytes: int = 2 * 1024 * 1024, # 2 MB
|
|
68
|
+
backup_count: int = 20,
|
|
69
|
+
retention_days: int = 5,
|
|
70
|
+
file: bool = True,
|
|
71
|
+
console: bool = True,
|
|
72
|
+
fmt: str = "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
|
73
|
+
datefmt: str = "%Y-%m-%d %H:%M:%S",
|
|
74
|
+
) -> None:
|
|
75
|
+
if not file and not console:
|
|
76
|
+
raise ValueError("The values of 'file' and 'console' cannot both be False; otherwise, the logs will be silently discarded.")
|
|
77
|
+
|
|
78
|
+
self.log_dir = Path(log_dir)
|
|
79
|
+
self.log_file = self.log_dir / log_filename
|
|
80
|
+
self.level = level
|
|
81
|
+
self.max_bytes = max_bytes
|
|
82
|
+
self.backup_count = backup_count
|
|
83
|
+
self.retention_days = retention_days
|
|
84
|
+
self.file = file
|
|
85
|
+
self.console = console
|
|
86
|
+
self.fmt = fmt
|
|
87
|
+
self.datefmt = datefmt
|
|
88
|
+
|
|
89
|
+
self._queue: "queue.Queue[logging.LogRecord]" = queue.Queue()
|
|
90
|
+
|
|
91
|
+
self._listener: Optional[QueueListener] = None
|
|
92
|
+
self._queue_handler: Optional[QueueHandler] = None
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
def setup(self) -> None:
|
|
96
|
+
with self._lock:
|
|
97
|
+
if self._listener is not None:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
formatter = logging.Formatter(fmt=self.fmt, datefmt=self.datefmt)
|
|
101
|
+
|
|
102
|
+
handlers: list[logging.Handler] = []
|
|
103
|
+
|
|
104
|
+
if self.file:
|
|
105
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
file_handler = _DailyCleanupRotatingFileHandler(
|
|
108
|
+
filename=str(self.log_file),
|
|
109
|
+
maxBytes=self.max_bytes,
|
|
110
|
+
backupCount=self.backup_count,
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
retention_days=self.retention_days,
|
|
113
|
+
)
|
|
114
|
+
file_handler.setLevel(self.level)
|
|
115
|
+
file_handler.setFormatter(formatter)
|
|
116
|
+
handlers.append(file_handler)
|
|
117
|
+
|
|
118
|
+
if self.console:
|
|
119
|
+
console_handler: logging.StreamHandler = logging.StreamHandler(sys.stdout)
|
|
120
|
+
console_handler.setLevel(self.level)
|
|
121
|
+
console_handler.setFormatter(formatter)
|
|
122
|
+
handlers.append(console_handler)
|
|
123
|
+
|
|
124
|
+
# QueueHandler:业务线程/协程只把 LogRecord 丢进内存队列
|
|
125
|
+
self._queue_handler = QueueHandler(self._queue)
|
|
126
|
+
|
|
127
|
+
root_logger = logging.getLogger()
|
|
128
|
+
root_logger.setLevel(self.level)
|
|
129
|
+
root_logger.addHandler(self._queue_handler)
|
|
130
|
+
|
|
131
|
+
# QueueListener:独立后台线程,真正做格式化 + 落盘 + 打印
|
|
132
|
+
self._listener = QueueListener(
|
|
133
|
+
self._queue,
|
|
134
|
+
*handlers,
|
|
135
|
+
respect_handler_level=True,
|
|
136
|
+
)
|
|
137
|
+
self._listener.start()
|
|
138
|
+
|
|
139
|
+
async def setup_async(self) -> None:
|
|
140
|
+
await asyncio.to_thread(self.setup)
|
|
141
|
+
|
|
142
|
+
def stop(self) -> None:
|
|
143
|
+
with self._lock:
|
|
144
|
+
if self._listener is None:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
root_logger = logging.getLogger()
|
|
148
|
+
if self._queue_handler is not None:
|
|
149
|
+
root_logger.removeHandler(self._queue_handler)
|
|
150
|
+
|
|
151
|
+
self._listener.stop()
|
|
152
|
+
self._listener = None
|
|
153
|
+
self._queue_handler = None
|
|
154
|
+
|
|
155
|
+
async def stop_async(self) -> None:
|
|
156
|
+
await asyncio.to_thread(self.stop)
|
|
157
|
+
|
|
158
|
+
def __enter__(self) -> "AsyncSafeLogger":
|
|
159
|
+
self.setup()
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
163
|
+
self.stop()
|
|
164
|
+
|
|
165
|
+
async def __aenter__(self) -> "AsyncSafeLogger":
|
|
166
|
+
await self.setup_async()
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
170
|
+
await self.stop_async()
|