Framework-agnostic AOP-style logging decorators with optional loguru support
Project description
logcraft
A lightweight AOP-style logging toolkit for Python with framework-agnostic design.
It helps remove manual logger.info(...) boilerplate by injecting structured logs through decorators and context managers.
Key Features:
- 🎯 Framework-agnostic: Works with Python's standard
loggingmodule by default, with optionallogurusupport - 🔍 Conditional logging: Filter decorator support for fine-grained logging control
- 📝 Fully documented: Comprehensive docstrings on all public APIs
- 🛠️ Code quality: Built-in linting, formatting, and testing tools
Install
pip install logcraft-aop
Optional backends:
# For loguru backend (optional)
pip install logcraft-aop[loguru]
Quick start
from logcraft import get_logger, log_calls, log_class, no_log, log_context, setup_logging
# Initialize with stdlib backend (default)
setup_logging(level="INFO", enable_file=False)
logger = get_logger(__name__)
@log_calls
def add(a: int, b: int) -> int:
return a + b
# Conditional logging with filter
@log_calls(filter=lambda x, y: x > 0)
def conditional_add(x: int, y: int) -> int:
return x + y # Only logs when x > 0
@log_class(default=True, exclude=["helper"])
class Service:
def run(self, x: int) -> int:
return x * 2
@no_log
def helper(self) -> str:
return "skip"
with log_context("batch.process", size=10) as ctx:
ctx.bind(done=10)
Backend Selection
Logcraft supports multiple logging backends. Python's standard logging module is used by default, making it framework-agnostic.
Using stdlib backend (default)
from logcraft import setup_logging
# Default is stdlib backend
setup_logging(level="INFO", log_dir="logs")
Using loguru backend
from logcraft import setup_logging
# Specify loguru backend in setup_logging
setup_logging(backend="loguru", level="INFO", log_dir="logs")
API Reference
setup_logging()
Initialize the logging system. Call once at program startup.
from logcraft import setup_logging
setup_logging(
level="INFO", # or set via LOG_LEVEL env var
log_dir="logs", # or set via LOGCRAFT_LOG_DIR env var
backend="stdlib", # "stdlib" (default) or "loguru"
**options # Backend-specific options
)
If level is omitted, it falls back to the LOG_LEVEL environment variable, then "INFO". If log_dir is omitted, it falls back to LOGCRAFT_LOG_DIR, then "logs".
Backend-specific options:
For stdlib backend:
format: Custom log format stringdatefmt: Date format stringenable_console: Enable console output (default:True)enable_file: Enable file output (default:True)
For loguru backend:
enable_console: Enable console output (default:True)enable_file: Enable file output (default:True)rotation: Log rotation setting (default:"1 day")retention: Number of rotated files to keep (default:3)
The log format is structured and includes timestamp, level, PID, thread ID, module, and line number:
2026-05-15 10:30:00.123 || INFO || 12345 || 67890 || myapp:42 || add.done || a=1, b=2, result=3
get_logger(name)
Return a LoggerProtocol instance bound to the given module name.
logger = get_logger(__name__)
logger.info("user.login", user_id=42)
LoggerProtocol
The protocol that all logger backends implement. This replaces the previous Logger class.
| Method | Description |
|---|---|
debug(event, **fields) |
Emit at DEBUG level |
info(event, **fields) |
Emit at INFO level |
warning(event, **fields) |
Emit at WARNING level |
error(event, **fields) |
Emit at ERROR level |
exception(event, **fields) |
Emit at ERROR level with traceback attached |
bind(**ctx) |
Return a new Logger with persistent context fields |
context(event, **fields) |
Return a LogContext manager (see below) |
bind() is useful for attaching correlation fields that propagate across calls:
req_log = logger.bind(request_id="abc-123")
req_log.info("handler.start")
req_log.info("handler.end", status=200)
# both lines include request_id=abc-123
@log_calls
Decorator that automatically logs function entry (.done) and exceptions (.error).
@log_calls
def add(a: int, b: int) -> int:
return a + b
Calling add(1, 2) emits:
add.done || a=1, b=2, result=3
If the function raises, it emits .error and re-raises the exception:
@log_calls
def fail():
raise ValueError("bad")
Calling fail() emits:
fail.error || error=ValueError: bad
Parameters:
| Parameter | Default | Description |
|---|---|---|
include_result |
True |
Attach the return value as result= in the .done log |
message |
None |
Custom event name prefix (default is fn.__qualname__) |
level |
"info" |
Log level for .done events (.error always uses error) |
filter |
None |
Callable that receives function args and returns bool to decide whether to log |
Conditional logging with filter:
The filter parameter intelligently passes only the parameters it accepts. If filter has **kwargs, all parameters are passed. Otherwise, only explicitly named parameters are passed. Special parameter func_name is available if filter has it.
1. Filter accepts specific parameters only:
# Only uses x parameter - y is ignored
@log_calls(filter=lambda x: x > 0)
def process(x: int, y: int) -> int:
return x + y
# Only uses user_id - action is ignored
@log_calls(filter=lambda user_id: user_id.startswith("admin_"))
def admin_action(user_id: str, action: str) -> None:
pass
2. Filter accepts func_name for name-based filtering:
# Only uses func_name - all other params ignored
@log_calls(filter=lambda func_name: "important" in func_name)
def important_operation(data: dict) -> None:
pass
# Combines func_name with other params
@log_calls(filter=lambda user_id, func_name: "payment" in func_name and user_id.startswith("admin_"))
def process_payment(user_id: str, amount: float) -> str:
return "tx_123"
**3. Filter accepts kwargs (receives everything):
@log_calls(filter=lambda **kwargs: kwargs.get("amount", 0) > 1000)
def process_payment(user_id: str, amount: float) -> str:
return "tx_123"
Custom event name:
@log_calls(message="payment.charge", include_result=False)
def charge(user_id: str, amount: float) -> str:
return "tx_123"
Emits payment.charge.done || user_id=alice, amount=9.99 (no result=).
Async support: @log_calls detects coroutines automatically and wraps them with async def.
@log_calls
async def fetch(url: str) -> str:
...
@log_class
Class decorator that applies @log_calls to selected methods. It also injects a self._log attribute (a Logger instance) if the class doesn't already define one.
@log_class(default=True, exclude=["helper"])
class Service:
def run(self, x: int) -> int:
return x * 2
def helper(self) -> str:
return "skip"
run gets logged as Service.run.done, while helper is excluded.
Parameters:
| Parameter | Default | Description |
|---|---|---|
default |
True |
If True, log all methods (minus exclude). If False, only log methods in include. |
include |
None |
List of method names to log (used when default=False) |
exclude |
None |
List of method names to skip (used when default=True) |
skip_private |
True |
Automatically skip methods starting with _ (dunder methods are always skipped) |
include_result |
False |
Whether to include return values in .done logs |
level |
"info" |
Log level for .done events |
filter |
None |
Callable that receives method args and returns bool to decide whether to log |
Conditional logging with filter:
The filter parameter intelligently passes only the parameters it accepts. If filter has **kwargs, all parameters are passed. Otherwise, only explicitly named parameters are passed. Special parameter func_name is available if filter has it.
# Only log methods with "process" in the name
@log_class(filter=lambda func_name: "process" in func_name)
class Processor:
def process(self, value: int) -> int:
return value * 2
def validate(self, value: int) -> bool:
return value > 0 # Not logged (no "process" in name)
# Filter by value only - self and func_name ignored
@log_class(filter=lambda value: value > 100)
class Processor2:
def process(self, value: int) -> int:
return value * 2
Opt-in mode (default=False):
@log_class(default=False, include=["run"])
class Service:
def run(self, x: int) -> int:
return x * 2
def helper(self) -> str:
return "skip"
Only run is logged.
Custom logger on the instance:
If __init__ sets self._log, @log_class preserves it and uses it for all wrapped methods:
@log_class(default=True, include=["run"])
class Manual:
def __init__(self):
self._log = get_logger("manual").bind(tag="x")
def run(self) -> str:
return "ok"
Emits Manual.run.done || tag=x.
@no_log
Mark a method to be skipped by @log_class, even if it would otherwise be included.
@log_class(default=True)
class Worker:
def run(self): ... # logged
def process(self): ... # logged
@no_log
def internal(self): ... # skipped
log_context()
Context manager for block-level logging. Emits .done on normal exit, .error on exception.
with log_context("batch.process", size=100) as ctx:
ctx.bind(processed=42)
Normal exit emits:
batch.process.done || size=100, processed=42
If an exception occurs:
with log_context("batch.process", size=100):
raise RuntimeError("disk full")
Emits:
batch.process.error || size=100, error=RuntimeError: disk full
Parameters:
| Parameter | Default | Description |
|---|---|---|
event |
required | Event name prefix |
logger |
None |
Custom Logger instance (default: uses the global logcraft logger) |
level |
"info" |
Log level for .done events |
**fields |
— | Static fields included in both .done and .error |
ctx.bind(**fields) adds fields that accumulate during the block and are emitted at exit.
Async support: log_context works with async with:
async with logger.context("api.call", endpoint="/users") as ctx:
ctx.bind(status=200)
LogContext
The object returned by log_context() and logger.context(). You typically don't construct it directly.
| Method | Description |
|---|---|
bind(**fields) |
Accumulate fields to include in the final log |
__enter__ / __exit__ |
Synchronous context manager protocol |
__aenter__ / __aexit__ |
Async context manager protocol |
Configuration
from logcraft import setup_logging
setup_logging(
level="INFO",
log_dir="logs",
when="D",
interval=1,
backup_count=3,
enable_file=True,
)
Environment variables
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
"INFO" |
Default log level when level param is not passed |
LOGCRAFT_LOG_DIR |
"logs" |
Default log directory when log_dir param is not passed |
Development shortcuts
make help
make install
make test
make build
make clean
make lint # run ruff linting and formatting
make publish-test # publish to TestPyPI
make publish # publish to PyPI
How it works
- Decorators capture function parameters via
inspect.signature(...) self/clsparameters are automatically stripped from logged fields- Success path emits
<event>.done - Error path emits
<event>.errorand re-raises exception @log_classonly wraps methods defined incls.__dict__(not inherited ones) to avoid side effects@log_classinjectsself._logon first call if the instance doesn't already have one@no_logsetsfn._no_log = Truewhich@log_classchecks before wrapping
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file logcraft_aop-2026.5.18.tar.gz.
File metadata
- Download URL: logcraft_aop-2026.5.18.tar.gz
- Upload date:
- Size: 17.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.0 CPython/3.11.15 Linux/6.17.0-1013-azure
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58f8f9f8f2490edb966622391e14594f448cf61f1120199d6d736500bf8a6f20
|
|
| MD5 |
378f2d691390d32fce96533857a9ee48
|
|
| BLAKE2b-256 |
7b94f4f1881158b1bd5810db765cbbf022314ce5c7417f439de708c3e7b991f5
|
File details
Details for the file logcraft_aop-2026.5.18-py3-none-any.whl.
File metadata
- Download URL: logcraft_aop-2026.5.18-py3-none-any.whl
- Upload date:
- Size: 16.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.0 CPython/3.11.15 Linux/6.17.0-1013-azure
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c151df2b462db1c76d233b05059dca54af56cd0cf8a16cd223126bea3f9c9b7b
|
|
| MD5 |
3b4340f68e005676f7a642f28e788279
|
|
| BLAKE2b-256 |
3653f41ef7316cf83b1aba6103bc5fea1891317b5e7f47cc818ed4dfd000692e
|