Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
- [ ] I have updated the documentation to reflect the changes.
- [ ] I have thought about how this code may affect other services.
- [ ] This PR fixes an issue.
- [ ] This PR add/remove/change unit tests.
- [ ] This PR adds something new (e.g. new method or parameters).
- [ ] This PR have unit tests (e.g. tests added/removed/changed)
- [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed)
- [ ] This PR is **not** a code change (e.g. documentation, README, ...)

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
[![PyPi](https://img.shields.io/pypi/v/pythonLogs.svg)](https://pypi.python.org/pypi/pythonLogs)
[![PyPI Downloads](https://static.pepy.tech/badge/pythonLogs)](https://pepy.tech/projects/pythonLogs)
[![codecov](https://codecov.io/gh/ddc/pythonLogs/graph/badge.svg?token=QsjwsmYzgD)](https://codecov.io/gh/ddc/pythonLogs)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![CI/CD Pipeline](https://github.com/ddc/pythonLogs/actions/workflows/workflow.yml/badge.svg)](https://github.com/ddc/pythonLogs/actions/workflows/workflow.yml)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ddc_pythonLogs&metric=alert_status)](https://sonarcloud.io/dashboard?id=ddc_pythonLogs)
[![Build Status](https://img.shields.io/endpoint.svg?url=https%3A//actions-badge.atrox.dev/ddc/pythonLogs/badge?ref=main&label=build&logo=none)](https://actions-badge.atrox.dev/ddc/pythonLogs/goto?ref=main)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/pypi/pyversions/pythonLogs.svg)](https://www.python.org/downloads)

[![Support me on GitHub](https://img.shields.io/badge/Support_me_on_GitHub-154c79?style=for-the-badge&logo=github)](https://github.com/sponsors/ddc)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "pythonLogs"
version = "4.0.5"
version = "4.0.6"
description = "High-performance Python logging library with file rotation and optimized caching for better performance"
license = "MIT"
readme = "README.md"
Expand Down
9 changes: 8 additions & 1 deletion pythonLogs/basic_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ def init(self):
logger.setLevel(self.level)
logging.Formatter.converter = get_timezone_function(self.timezone)
_format = get_format(self.showlocation, self.appname, self.timezone)
logging.basicConfig(datefmt=self.datefmt, encoding=self.encoding, format=_format)

# Only add handler if logger doesn't have any handlers
if not logger.handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter(_format, datefmt=self.datefmt)
handler.setFormatter(formatter)
logger.addHandler(handler)

self.logger = logger
# Register weak reference for memory tracking
register_logger_weakref(logger)
Expand Down
133 changes: 75 additions & 58 deletions pythonLogs/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import threading
import time
from dataclasses import dataclass
from enum import Enum
from typing import Dict, Optional, Tuple, Union
from pythonLogs.basic_log import BasicLog
Expand All @@ -12,6 +13,25 @@
from pythonLogs.timed_rotating import TimedRotatingLog


@dataclass
class LoggerConfig:
"""Configuration class to group logger parameters"""
level: Optional[Union[LogLevel, str]] = None
name: Optional[str] = None
directory: Optional[str] = None
filenames: Optional[list | tuple] = None
encoding: Optional[str] = None
datefmt: Optional[str] = None
timezone: Optional[str] = None
streamhandler: Optional[bool] = None
showlocation: Optional[bool] = None
maxmbytes: Optional[int] = None
when: Optional[Union[RotateWhen, str]] = None
sufix: Optional[str] = None
rotateatutc: Optional[bool] = None
daystokeep: Optional[int] = None


class LoggerType(str, Enum):
"""Available logger types"""
BASIC = "basic"
Expand Down Expand Up @@ -80,7 +100,7 @@ def get_or_create_logger(

# Check if logger already exists in the registry
if name in cls._logger_registry:
logger, timestamp = cls._logger_registry[name]
logger, _ = cls._logger_registry[name]
# Update timestamp for LRU tracking
cls._logger_registry[name] = (logger, time.time())
return logger
Expand Down Expand Up @@ -189,42 +209,17 @@ def get_registered_loggers(cls) -> dict[str, logging.Logger]:
@staticmethod
def create_logger(
logger_type: Union[LoggerType, str],
level: Optional[Union[LogLevel, str]] = None,
name: Optional[str] = None,
directory: Optional[str] = None,
filenames: Optional[list | tuple] = None,
encoding: Optional[str] = None,
datefmt: Optional[str] = None,
timezone: Optional[str] = None,
streamhandler: Optional[bool] = None,
showlocation: Optional[bool] = None, # Size rotating specific
maxmbytes: Optional[int] = None, # Timed rotating specific
when: Optional[Union[RotateWhen, str]] = None,
sufix: Optional[str] = None,
rotateatutc: Optional[bool] = None,
# Common
daystokeep: Optional[int] = None,
config: Optional[LoggerConfig] = None,
**kwargs
) -> logging.Logger:

"""
Factory method to create loggers based on type.

Args:
logger_type: Type of logger to create (LoggerType enum or string)
level: Log level (LogLevel enum or string: DEBUG, INFO, WARNING, ERROR, CRITICAL)
name: Logger name
directory: Log directory path
filenames: List/tuple of log filenames
encoding: File encoding
datefmt: Date format string
timezone: Timezone for timestamps
streamhandler: Enable console output
showlocation: Show file location in logs
maxmbytes: Max file size in MB (size rotating only)
when: When to rotate (RotateWhen enum or string: MIDNIGHT, HOURLY, DAILY, etc.)
sufix: Date suffix for rotated files (timed rotating only)
rotateatutc: Rotate at UTC time (timed rotating only)
daystokeep: Days to keep old logs
config: LoggerConfig object with logger parameters
**kwargs: Individual logger parameters (for backward compatibility)

Returns:
Configured logger instance
Expand All @@ -239,50 +234,72 @@ def create_logger(
except ValueError:
raise ValueError(f"Invalid logger type: {logger_type}. Valid types: {[t.value for t in LoggerType]}")

# Merge config and kwargs (kwargs take precedence for backward compatibility)
if config is None:
config = LoggerConfig()

# Create a new config with kwargs overriding config values
final_config = LoggerConfig(
level=kwargs.get('level', config.level),
name=kwargs.get('name', config.name),
directory=kwargs.get('directory', config.directory),
filenames=kwargs.get('filenames', config.filenames),
encoding=kwargs.get('encoding', config.encoding),
datefmt=kwargs.get('datefmt', config.datefmt),
timezone=kwargs.get('timezone', config.timezone),
streamhandler=kwargs.get('streamhandler', config.streamhandler),
showlocation=kwargs.get('showlocation', config.showlocation),
maxmbytes=kwargs.get('maxmbytes', config.maxmbytes),
when=kwargs.get('when', config.when),
sufix=kwargs.get('sufix', config.sufix),
rotateatutc=kwargs.get('rotateatutc', config.rotateatutc),
daystokeep=kwargs.get('daystokeep', config.daystokeep)
)

# Convert enum values to strings for logger classes
level_str = level.value if isinstance(level, LogLevel) else level
when_str = when.value if isinstance(when, RotateWhen) else when
level_str = final_config.level.value if isinstance(final_config.level, LogLevel) else final_config.level
when_str = final_config.when.value if isinstance(final_config.when, RotateWhen) else final_config.when

# Create logger based on type
match logger_type:
case LoggerType.BASIC:
logger_instance = BasicLog(
level=level_str,
name=name,
encoding=encoding,
datefmt=datefmt,
timezone=timezone,
showlocation=showlocation, )
name=final_config.name,
encoding=final_config.encoding,
datefmt=final_config.datefmt,
timezone=final_config.timezone,
showlocation=final_config.showlocation, )

case LoggerType.SIZE_ROTATING:
logger_instance = SizeRotatingLog(
level=level_str,
name=name,
directory=directory,
filenames=filenames,
maxmbytes=maxmbytes,
daystokeep=daystokeep,
encoding=encoding,
datefmt=datefmt,
timezone=timezone,
streamhandler=streamhandler,
showlocation=showlocation, )
name=final_config.name,
directory=final_config.directory,
filenames=final_config.filenames,
maxmbytes=final_config.maxmbytes,
daystokeep=final_config.daystokeep,
encoding=final_config.encoding,
datefmt=final_config.datefmt,
timezone=final_config.timezone,
streamhandler=final_config.streamhandler,
showlocation=final_config.showlocation, )

case LoggerType.TIMED_ROTATING:
logger_instance = TimedRotatingLog(
level=level_str,
name=name,
directory=directory,
filenames=filenames,
name=final_config.name,
directory=final_config.directory,
filenames=final_config.filenames,
when=when_str,
sufix=sufix,
daystokeep=daystokeep,
encoding=encoding,
datefmt=datefmt,
timezone=timezone,
streamhandler=streamhandler,
showlocation=showlocation,
rotateatutc=rotateatutc, )
sufix=final_config.sufix,
daystokeep=final_config.daystokeep,
encoding=final_config.encoding,
datefmt=final_config.datefmt,
timezone=final_config.timezone,
streamhandler=final_config.streamhandler,
showlocation=final_config.showlocation,
rotateatutc=final_config.rotateatutc, )

case _:
raise ValueError(f"Unsupported logger type: {logger_type}")
Expand Down
2 changes: 1 addition & 1 deletion pythonLogs/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
DEFAULT_ROTATE_SUFFIX,
DEFAULT_TIMEZONE,
LogLevel,
RotateWhen
RotateWhen,
)


Expand Down
50 changes: 33 additions & 17 deletions pythonLogs/thread_safety.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# -*- encoding: utf-8 -*-
import functools
import threading
from typing import Any, Callable, Dict, TypeVar, Type
from typing import Any, Callable, Dict, Type, TypeVar


F = TypeVar('F', bound=Callable[..., Any])

Expand Down Expand Up @@ -58,34 +59,49 @@ def wrapper(self, *args, **kwargs):
return wrapper


def _get_wrappable_methods(cls: Type) -> list:
"""Helper function to get methods that should be made thread-safe."""
return [
method_name for method_name in dir(cls)
if (callable(getattr(cls, method_name, None)) and
not method_name.startswith('_') and
method_name not in ['__enter__', '__exit__', '__init__'])
]


def _ensure_class_has_lock(cls: Type) -> None:
"""Ensure the class has a lock attribute."""
if not hasattr(cls, '_lock'):
cls._lock = threading.RLock()


def _should_wrap_method(cls: Type, method_name: str, original_method: Any) -> bool:
"""Check if a method should be wrapped with thread safety."""
return (hasattr(cls, method_name) and
callable(original_method) and
not hasattr(original_method, '_thread_safe_wrapped'))


def auto_thread_safe(thread_safe_methods: list = None):
"""Class decorator that adds automatic thread safety to specified methods."""

def decorator(cls: Type) -> Type:
# Add lock to class if not present
if not hasattr(cls, '_lock'):
cls._lock = threading.RLock()
_ensure_class_has_lock(cls)

# Store thread-safe methods list
if thread_safe_methods:
cls._thread_safe_methods = thread_safe_methods

# Get methods to make thread-safe
methods_to_wrap = thread_safe_methods or [
method_name for method_name in dir(cls)
if (callable(getattr(cls, method_name, None)) and
not method_name.startswith('_') and
method_name not in ['__enter__', '__exit__', '__init__'])
]
methods_to_wrap = thread_safe_methods or _get_wrappable_methods(cls)

# Wrap each method
for method_name in methods_to_wrap:
if hasattr(cls, method_name):
original_method = getattr(cls, method_name)
if callable(original_method) and not hasattr(original_method, '_thread_safe_wrapped'):
wrapped_method = thread_safe(original_method)
wrapped_method._thread_safe_wrapped = True
setattr(cls, method_name, wrapped_method)
original_method = getattr(cls, method_name, None)
if _should_wrap_method(cls, method_name, original_method):
wrapped_method = thread_safe(original_method)
wrapped_method._thread_safe_wrapped = True
setattr(cls, method_name, wrapped_method)

return cls

Expand Down Expand Up @@ -132,4 +148,4 @@ def __enter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self.lock.release()
self.lock.release()
2 changes: 1 addition & 1 deletion pythonLogs/timed_rotating.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
get_logger_and_formatter,
get_stream_handler,
gzip_file_with_sufix,
remove_old_logs,
remove_old_logs,
)
from pythonLogs.memory_utils import cleanup_logger_handlers, register_logger_weakref
from pythonLogs.settings import get_log_settings
Expand Down
20 changes: 8 additions & 12 deletions tests/context_management/test_context_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,21 @@
class TestContextManagers:
"""Test context manager functionality for resource management."""

def setup_method(self):
@pytest.fixture(autouse=True)
def setup_temp_dir(self):
"""Set up test fixtures before each test method."""
# Clear any existing loggers
clear_logger_registry()

# Create temporary directory for log files
self.temp_dir = tempfile.mkdtemp()
self.log_file = "test.log"

def teardown_method(self):
"""Clean up after each test method."""
# Create temporary directory for log files using context manager
with tempfile.TemporaryDirectory() as temp_dir:
self.temp_dir = temp_dir
self.log_file = "test.log"
yield

# Clear registry after each test
clear_logger_registry()

# Clean up temporary files
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir, ignore_errors=True)

def test_basic_log_context_manager(self):
"""Test BasicLog as context manager."""
logger_name = "test_basic_context"
Expand Down
Loading