diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c997c..8b1237f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2] - 2026-03-17 + +### Added +- `LOG_LEVEL_DEBUG`, `LOG_LEVEL_INFO`, `LOG_LEVEL_WARNING`, `LOG_LEVEL_ERROR`, and `LOG_LEVEL_CRITICAL` named constants exported from the top-level package, replacing bare integer literals throughout the API. +- `AsyncLogger.close()` method for an explicit per-instance best-effort flush before discarding an async logger. +- `CONTRIBUTING.md` with setup, testing, style, and pull-request guidelines (the README linked to this file but it was previously missing). + +### Changed +- `shutdown_async_logging` is now registered with `atexit` so buffered async messages are flushed when the interpreter exits rather than being silently dropped. +- `shutdown_async_logging()` now drains the queue via a `None` sentinel and waits for the background worker to finish before returning, ensuring pending messages are processed. +- `AsyncLogger.flush()` and `AsyncLogger.close()` now use a deterministic synchronization barrier (`_FlushSignal`) so callers block until all preceding messages are fully written, replacing the previous timing-based best-effort approach. +- Removed legacy root-level `__init__.py` that shadowed the `kakashi/` package in editable installs. +- Removed `setup.py`; `pyproject.toml` is now the single canonical build configuration. + +### Fixed +- README license text corrected from MIT to LGPL-2.1, matching the `LICENSE` file and packaging metadata. + ## [0.2.1] - 2026-02-05 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cfb7a9a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to Kakashi + +Thank you for your interest in contributing! The following guidelines will help you get started. + +## Reporting Issues + +- Search [existing issues](https://github.com/IntegerAlex/kakashi/issues) before opening a new one. +- Include a minimal, reproducible example and your Python version when reporting bugs. + +## Development Setup + +```bash +# Clone the repository +git clone https://github.com/IntegerAlex/kakashi.git +cd kakashi + +# Install with development dependencies +pip install -e ".[dev]" +``` + +## Running Tests + +```bash +pytest +``` + +## Code Style + +- Format code with [black](https://black.readthedocs.io/): `black .` +- Lint with [flake8](https://flake8.pycqa.org/): `flake8 kakashi/` +- Type-check with [mypy](https://mypy.readthedocs.io/): `mypy kakashi/` + +## Submitting a Pull Request + +1. Fork the repository and create a feature branch from `main`. +2. Write tests for any new functionality. +3. Ensure all tests, lint, and type checks pass. +4. Open a pull request with a clear description of the change. + +## License + +By contributing you agree that your code will be released under the project's [LGPL-2.1 license](LICENSE). diff --git a/README.md b/README.md index 56080d6..540bef1 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) f ## 📄 License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the GNU Lesser General Public License v2.1 (LGPL-2.1) - see the [LICENSE](LICENSE) file for details. ## ⚖️ Legal Disclaimers diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 06c1d98..0000000 --- a/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Kakashi - Enterprise-grade logging for Python applications. - -This is the main package entry point. -""" - -# Import everything from the kakashi package -from kakashi import * - -# Re-export the version and author -__version__ = "0.2.0" -__author__ = "Akshat Kotpalliwar" diff --git a/kakashi/__init__.py b/kakashi/__init__.py index fc8d643..d3dc044 100644 --- a/kakashi/__init__.py +++ b/kakashi/__init__.py @@ -40,9 +40,18 @@ # Main logger classes and entry points from .core.logger import ( - Logger, AsyncLogger, LogFormatter, - get_logger, get_async_logger, clear_logger_cache, - shutdown_async_logging + Logger, + AsyncLogger, + LogFormatter, + get_logger, + get_async_logger, + clear_logger_cache, + shutdown_async_logging, + LOG_LEVEL_DEBUG, + LOG_LEVEL_INFO, + LOG_LEVEL_WARNING, + LOG_LEVEL_ERROR, + LOG_LEVEL_CRITICAL, ) # ============================================================================ @@ -81,11 +90,15 @@ # VERSION AND METADATA # ============================================================================ -__version__ = "2.0.0" +__version__ = "0.2.2" __author__ = "Kakashi Development Team" __description__ = "Professional High-Performance Logging Library" __url__ = "https://github.com/kakashi/logging" +# Backward-compatible aliases for callers using `kakashi.version`/`kakashi.author`. +version = __version__ +author = __author__ + # ============================================================================ # MAIN EXPORTS # ============================================================================ @@ -99,6 +112,13 @@ "get_async_logger", # Async logger entry point "clear_logger_cache", "shutdown_async_logging", + + # ---- LOG LEVEL CONSTANTS ---- + "LOG_LEVEL_DEBUG", + "LOG_LEVEL_INFO", + "LOG_LEVEL_WARNING", + "LOG_LEVEL_ERROR", + "LOG_LEVEL_CRITICAL", # ---- CORE DATA STRUCTURES ---- "LogRecord", @@ -135,6 +155,8 @@ # ---- VERSION AND METADATA ---- "__version__", "__author__", + "version", + "author", "__description__", "__url__", -] \ No newline at end of file +] diff --git a/kakashi/core/logger.py b/kakashi/core/logger.py index 0681927..06123c1 100644 --- a/kakashi/core/logger.py +++ b/kakashi/core/logger.py @@ -12,6 +12,7 @@ - Professional, maintainable code structure """ +import atexit import threading import time import sys @@ -21,12 +22,19 @@ # Pre-computed constants for fast access _LEVEL_NAMES = { 10: 'DEBUG', - 20: 'INFO', + 20: 'INFO', 30: 'WARNING', 40: 'ERROR', 50: 'CRITICAL' } +# Named log-level constants (use these instead of bare integers) +LOG_LEVEL_DEBUG = 10 +LOG_LEVEL_INFO = 20 +LOG_LEVEL_WARNING = 30 +LOG_LEVEL_ERROR = 40 +LOG_LEVEL_CRITICAL = 50 + # Thread-local storage for lock-free operation _thread_local = threading.local() @@ -35,6 +43,18 @@ _async_worker = None _async_shutdown = threading.Event() +# Set to True before draining on shutdown so _log_async drops new items +_async_shutting_down = False + + +class _FlushSignal: + """Queue item used to signal a synchronous flush barrier.""" + + __slots__ = ("event",) + + def __init__(self, event: threading.Event): + self.event = event + @@ -43,39 +63,61 @@ def _async_worker_thread(): """Background worker for async logging.""" batch = [] batch_size = 50 # Optimal batch size for throughput/latency balance - - while not _async_shutdown.is_set(): + + while True: + batch.clear() + try: - # Collect batch - batch.clear() - timeout = 0.1 # 100ms batch timeout - + item = _async_queue.get(timeout=0.1) + except queue.Empty: + continue + + # Handle the first item from the blocking get + if item is None: + _async_queue.task_done() + break + + if isinstance(item, _FlushSignal): + # No prior items in this batch; signal the barrier immediately + item.event.set() + _async_queue.task_done() + continue + + batch.append(item) + + # Collect additional items (non-blocking), stopping at any sentinel + flush_signal = None + shutdown_requested = False + for _ in range(batch_size - 1): try: - # Get first item (blocking) - item = _async_queue.get(timeout=timeout) - if item is None: # Shutdown signal - break - batch.append(item) - - # Collect additional items (non-blocking) - for _ in range(batch_size - 1): - try: - item = _async_queue.get_nowait() - if item is None: # Shutdown signal - break - batch.append(item) - except queue.Empty: - break - + extra = _async_queue.get_nowait() except queue.Empty: - continue - - # Process batch - if batch: - _process_async_batch(batch) - - except Exception: - pass # Ignore errors in background thread + break + + if extra is None: + shutdown_requested = True + break + if isinstance(extra, _FlushSignal): + # Stop collecting; process prior items before signalling + flush_signal = extra + break + batch.append(extra) + + # Process all collected log items before acknowledging any barrier + try: + _process_async_batch(batch) + finally: + for _ in range(len(batch)): + _async_queue.task_done() + + # Signal the flush barrier AFTER all preceding messages are written + if flush_signal is not None: + flush_signal.event.set() + _async_queue.task_done() + + if shutdown_requested: + _async_queue.task_done() # for the None sentinel + break def _process_async_batch(batch): @@ -103,8 +145,9 @@ def _process_async_batch(batch): def _ensure_async_worker(): """Ensure async worker thread is running.""" - global _async_worker + global _async_worker, _async_shutting_down if _async_worker is None or not _async_worker.is_alive(): + _async_shutting_down = False _async_worker = threading.Thread(target=_async_worker_thread, daemon=True) _async_worker.start() @@ -146,7 +189,7 @@ class Logger: __slots__ = ('name', 'min_level', 'formatter') - def __init__(self, name: str, min_level: int = 20): + def __init__(self, name: str, min_level: int = LOG_LEVEL_INFO): self.name = name self.min_level = min_level self.formatter = LogFormatter() @@ -236,19 +279,27 @@ class AsyncLogger: - Superior throughput vs sync logging """ - def __init__(self, name: str, min_level: int = 20): + def __init__(self, name: str, min_level: int = LOG_LEVEL_INFO): self.name = name self.min_level = min_level - + # Ensure async worker is running _ensure_async_worker() + + def close(self) -> None: + """Flush all pending async messages before returning.""" + self.flush() def _log_async(self, level: int, message: str, fields: Optional[Dict[str, Any]] = None) -> None: """True asynchronous logging - non-blocking enqueue.""" # Fast level check if level < self.min_level: return - + + # Drop messages once shutdown has started to prevent shutdown deadlock + if _async_shutting_down: + return + # Non-blocking enqueue to background worker try: timestamp = time.time() @@ -291,10 +342,11 @@ def exception(self, message: str, **fields) -> None: self._log_async(40, message, fields) def flush(self) -> None: - """Flush pending messages (best effort).""" - # For async logger, we can't force immediate flush - # but we can yield to allow background processing - time.sleep(0.001) + """Block until all queued async messages are processed.""" + _ensure_async_worker() + marker = _FlushSignal(threading.Event()) + _async_queue.put(marker) + marker.event.wait() # Lock-free logger cache using thread-local storage @@ -302,7 +354,7 @@ def flush(self) -> None: _cache_lock = threading.RLock() -def get_logger(name: str, min_level: int = 20) -> Logger: +def get_logger(name: str, min_level: int = LOG_LEVEL_INFO) -> Logger: """ Get a high-performance logger instance with minimal lock contention. @@ -324,7 +376,7 @@ def get_logger(name: str, min_level: int = 20) -> Logger: return logger -def get_async_logger(name: str, min_level: int = 20) -> AsyncLogger: +def get_async_logger(name: str, min_level: int = LOG_LEVEL_INFO) -> AsyncLogger: """ Get an asynchronous logger instance with minimal lock contention. @@ -353,16 +405,22 @@ def clear_logger_cache() -> None: def shutdown_async_logging() -> None: - """Shutdown async logging gracefully.""" - global _async_worker + """Shutdown async logging gracefully, draining all pending messages first.""" + global _async_worker, _async_shutting_down if _async_worker and _async_worker.is_alive(): - # Signal shutdown - _async_shutdown.set() - try: - _async_queue.put_nowait(None) # Shutdown signal - except queue.Full: - pass - + # Prevent new messages from being enqueued so join() cannot hang + _async_shutting_down = True + + # Drain all pre-shutdown items before sending the stop sentinel + _async_queue.join() + + # Send shutdown sentinel so the worker exits its loop + _async_queue.put(None) + # Wait for worker to finish (with timeout) _async_worker.join(timeout=1.0) _async_worker = None + + +# Ensure buffered async messages are flushed when the interpreter exits +atexit.register(shutdown_async_logging) diff --git a/performance_tests/test_api_compatibility.py b/performance_tests/test_api_compatibility.py index 3279bf6..bc5ffb2 100644 --- a/performance_tests/test_api_compatibility.py +++ b/performance_tests/test_api_compatibility.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any import tempfile +import threading # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -240,3 +241,54 @@ def log_messages(): # If we get here without errors, concurrent access works assert True + + +class TestAsyncFlushRegression: + """Regression coverage for AsyncLogger.flush synchronization semantics.""" + + def test_async_flush_waits_for_worker_processing(self, monkeypatch): + """ + Ensure flush is synchronization-based, not timing-based. + + The worker is deliberately blocked while processing a batch. flush() + must block until processing is released. + """ + from kakashi import get_async_logger, shutdown_async_logging + import kakashi.core.logger as core_logger + + original_process = core_logger._process_async_batch + processing_started = threading.Event() + release_processing = threading.Event() + flush_finished = threading.Event() + + def blocking_process(batch): + processing_started.set() + # If flush is truly synchronized with queue processing, it will + # remain blocked until this event is released. + release_processing.wait(timeout=2.0) + original_process(batch) + + monkeypatch.setattr(core_logger, "_process_async_batch", blocking_process) + + logger = get_async_logger("test_async_flush_regression") + logger.info("must be processed before flush returns") + + # If flush regresses to a fixed sleep, this worker blocking won't matter. + flush_thread = threading.Thread( + target=lambda: (logger.flush(), flush_finished.set()), + daemon=True, + ) + flush_thread.start() + + assert processing_started.wait(timeout=1.0), "Worker never started processing" + assert not flush_finished.wait(timeout=0.05), ( + "flush() returned before queued work finished; likely timing-based regression" + ) + + release_processing.set() + flush_thread.join(timeout=1.0) + assert not flush_thread.is_alive(), "flush() did not finish after worker was released" + assert flush_finished.is_set(), "flush() did not complete successfully" + + # Keep test isolation strong for following tests. + shutdown_async_logging() diff --git a/pyproject.toml b/pyproject.toml index aec6f4e..662a98a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "kakashi" -version = "0.2.1" +version = "0.2.2" description = "High-performance logging utility for Python applications with advanced features" readme = "README.md" requires-python = ">=3.7" diff --git a/setup.py b/setup.py deleted file mode 100644 index eddf457..0000000 --- a/setup.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -""" -Setup script for Kakashi package. -""" - -from setuptools import setup, find_packages -import os - -# Read the README file -def read_readme(): - with open("README.md", "r", encoding="utf-8") as fh: - return fh.read() - -# Read requirements from pyproject.toml -def get_requirements(): - requirements = [] - if os.path.exists("requirements.txt"): - with open("requirements.txt", "r", encoding="utf-8") as fh: - requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")] - return requirements - -setup( - name="kakashi", - version="0.2.1", - author="Akshat Kotpalliwar", - author_email="akshatkot@gmail.com", - description="High-performance logging utility for Python applications with advanced features", - long_description=read_readme(), - long_description_content_type="text/markdown", - url="https://github.com/IntegerAlex/kakashi", - project_urls={ - "Bug Reports": "https://github.com/IntegerAlex/kakashi/issues", - "Documentation": "https://github.com/IntegerAlex/kakashi/wiki", - }, - packages=find_packages(), - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: System :: Logging", - "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", - ], - python_requires=">=3.7", - install_requires=get_requirements(), - extras_require={ - "fastapi": ["fastapi>=0.68.0", "starlette>=0.14.0"], - "flask": ["flask>=1.0.0"], - "django": ["django>=3.0.0"], - "web": ["fastapi>=0.68.0", "starlette>=0.14.0", "flask>=1.0.0", "django>=3.0.0"], - # GUI extra intentionally has no install-time dependencies: - # tkinter is part of the Python standard library and not a PyPI package. - "gui": [], - "dev": [ - "pytest>=6.0", - "pytest-asyncio>=0.18.0", - "black>=21.0.0", - "flake8>=3.8.0", - "mypy>=0.910", - "pre-commit>=2.15.0", - "build>=0.8.0", - "twine>=4.0.0", - ], - "performance": [ - "orjson>=3.8.0", - "uvloop>=0.17.0;sys_platform!='win32'", - ], - "all": [ - "fastapi>=0.68.0", - "starlette>=0.14.0", - "flask>=1.0.0", - "django>=3.0.0", - "orjson>=3.8.0", - "uvloop>=0.17.0;sys_platform!='win32'", - ], - }, - entry_points={ - "console_scripts": [ - "kakashi-demo=kakashi.examples.basic_usage:main", - "kakashi-basic=kakashi.examples.basic_usage:main", - "kakashi-web=kakashi.examples.web_framework_examples:run_all_examples", - "kakashi-gui=kakashi.examples.gui_application_example:gui_example", - "kakashi-cli=kakashi.examples.cli_application_example:cli_example", - ], - }, - include_package_data=True, - zip_safe=False, - keywords=["logging", "logger", "fastapi", "middleware", "colored-logs", "performance", "singleton", "rotation"], - license="LGPL-2.1", -)