From d5fafce3e8681f89f495bd20ba3bde7cf3910dba Mon Sep 17 00:00:00 2001 From: Roma R Date: Fri, 28 Nov 2025 18:07:16 +0200 Subject: [PATCH] feat: add github actions, move to ruff --- .github/CODEOWNERS | 1 + .github/workflows/test.yml | 29 ++++++++ README.md | 137 +++++++++++++++++++++---------------- logging_loki/__init__.py | 3 +- logging_loki/emitter.py | 15 ++-- logging_loki/handlers.py | 30 ++------ pyproject.toml | 36 ++++++++++ setup.py | 8 +-- tests/test_emitter_v0.py | 2 +- tests/test_emitter_v1.py | 2 +- tests/test_emitter_v2.py | 22 +++--- tox.ini | 33 ++++----- 12 files changed, 187 insertions(+), 131 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/test.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..04f48a4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @RomanR-dev diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8ead16b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Python Tests with Tox + +on: + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install tox + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + + - name: Run tox + run: tox diff --git a/README.md b/README.md index 1e14060..18a0680 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,120 @@ -## python-logging-loki-v2 +# 🚀 python-logging-loki-v2 -# [Based on: https://github.com/GreyZmeem/python-logging-loki.] -=================== +> Modern Python logging handler for Grafana Loki [![PyPI version](https://img.shields.io/pypi/v/python-logging-loki-v2.svg)](https://pypi.org/project/python-logging-loki-v2/) -[![Python version](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8-blue.svg)](https://www.python.org/) -[![License](https://img.shields.io/pypi/l/python-logging-loki.svg)](https://opensource.org/licenses/MIT) +[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/) -[//]: # ([![Build Status](https://travis-ci.org/GreyZmeem/python-logging-loki.svg?branch=master)](https://travis-ci.org/GreyZmeem/python-logging-loki)) +Send Python logs directly to [Grafana Loki](https://grafana.com/loki) with minimal configuration. -Python logging handler for Loki. -https://grafana.com/loki +--- -New -=========== -0.4.0: support to headers (ability to pass tenants for multi tenant loki configuration) +## ✨ Features +- 📤 **Direct Integration** - Send logs straight to Loki +- 🔐 **Authentication Support** - Basic auth and custom headers +- 🏷️ **Custom Labels** - Flexible tagging system +- ⚡ **Async Support** - Non-blocking queue handler included +- 🔒 **SSL Verification** - Configurable SSL/TLS settings +- 🎯 **Multi-tenant** - Support for Loki multi-tenancy + +--- + +## 📦 Installation -Installation -============ ```bash pip install python-logging-loki-v2 ``` -Usage -===== +--- + +## 🎯 Quick Start + +### Basic Usage ```python import logging import logging_loki - handler = logging_loki.LokiHandler( - url="https://my-loki-instance/loki/api/v1/push", - tags={"application": "my-app"}, + url="https://loki.example.com/loki/api/v1/push", + tags={"app": "my-application"}, auth=("username", "password"), - version="2", - verify_ssl=True + version="2" ) -logger = logging.getLogger("my-logger") +logger = logging.getLogger("my-app") logger.addHandler(handler) -logger.error( - "Something happened", - extra={"tags": {"service": "my-service"}}, -) +logger.info("Application started", extra={"tags": {"env": "production"}}) ``` -Example above will send `Something happened` message along with these labels: -- Default labels from handler -- Message level as `serverity` -- Logger's name as `logger` -- Labels from `tags` item of `extra` dict +### Async/Non-blocking Mode -The given example is blocking (i.e. each call will wait for the message to be sent). -But you can use the built-in `QueueHandler` and` QueueListener` to send messages in a separate thread. +For high-throughput applications, use the queue handler to avoid blocking: ```python import logging.handlers import logging_loki from multiprocessing import Queue - -queue = Queue(-1) -handler = logging.handlers.QueueHandler(queue) -handler_loki = logging_loki.LokiHandler( - url="https://my-loki-instance/loki/api/v1/push", - tags={"application": "my-app"}, - auth=("username", "password"), - version="2", - verify_ssl=True +handler = logging_loki.LokiQueueHandler( + Queue(-1), + url="https://loki.example.com/loki/api/v1/push", + tags={"app": "my-application"}, + version="2" ) -logging.handlers.QueueListener(queue, handler_loki) -logger = logging.getLogger("my-logger") +logger = logging.getLogger("my-app") logger.addHandler(handler) -logger.error(...) +logger.info("Non-blocking log message") ``` -Or you can use `LokiQueueHandler` shortcut, which will automatically create listener and handler. +--- -```python -import logging.handlers -import logging_loki -from multiprocessing import Queue +## ⚙️ Configuration Options +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `url` | `str` | *required* | Loki push endpoint URL | +| `tags` | `dict` | `{}` | Default labels for all logs | +| `auth` | `tuple` | `None` | Basic auth credentials `(username, password)` | +| `headers` | `dict` | `None` | Custom HTTP headers (e.g., for multi-tenancy) | +| `version` | `str` | `"1"` | Loki API version (`"0"`, `"1"`, or `"2"`) | +| `verify_ssl` | `bool` | `True` | Enable/disable SSL certificate verification | -handler = logging_loki.LokiQueueHandler( - Queue(-1), - url="https://my-loki-instance/loki/api/v1/push", - tags={"application": "my-app"}, - auth=("username", "password"), - version="2", - verify_ssl=True +--- + +## 🏷️ Labels + +Logs are automatically labeled with: +- **severity** - Log level (INFO, ERROR, etc.) +- **logger** - Logger name +- **Custom tags** - From handler and `extra={"tags": {...}}` + +```python +logger.error( + "Database connection failed", + extra={"tags": {"service": "api", "region": "us-east"}} ) +``` -logger = logging.getLogger("my-logger") -logger.addHandler(handler) -logger.error(...) +--- + +## 🔐 Multi-tenant Setup + +```python +handler = logging_loki.LokiHandler( + url="https://loki.example.com/loki/api/v1/push", + headers={"X-Scope-OrgID": "tenant-1"}, + tags={"app": "my-app"} +) ``` + +--- +Based on [python-logging-loki](https://github.com/GreyZmeem/python-logging-loki) by GreyZmeem. + +### Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +--- diff --git a/logging_loki/__init__.py b/logging_loki/__init__.py index f9d6949..2e7fef4 100644 --- a/logging_loki/__init__.py +++ b/logging_loki/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- -from logging_loki.handlers import LokiHandler -from logging_loki.handlers import LokiQueueHandler +from logging_loki.handlers import LokiHandler, LokiQueueHandler __all__ = ["LokiHandler", "LokiQueueHandler"] __version__ = "0.3.1" diff --git a/logging_loki/emitter.py b/logging_loki/emitter.py index 51b62c6..f5f484c 100644 --- a/logging_loki/emitter.py +++ b/logging_loki/emitter.py @@ -5,13 +5,8 @@ import functools import logging import time - from logging.config import ConvertingDict -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from typing import Tuple +from typing import Any, Dict, List, Optional, Tuple import requests import rfc3339 @@ -31,7 +26,7 @@ class LokiEmitter(abc.ABC): label_replace_with = const.label_replace_with session_class = requests.Session - def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None, headers: Optional[dict] = None, verify_ssl: bool = True): + def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict | None = None, verify_ssl: bool = True): """ Create new Loki emitter. @@ -52,7 +47,7 @@ def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None #: Verfify the host's ssl certificate self.verify_ssl = verify_ssl - self._session: Optional[requests.Session] = None + self._session: requests.Session | None = None def __call__(self, record: logging.LogRecord, line: str): """Send log record to Loki.""" @@ -118,7 +113,7 @@ def build_payload(self, record: logging.LogRecord, line) -> dict: labels = self.build_labels(record) ts = rfc3339.format_microsecond(record.created) stream = { - "labels" : labels, + "labels": labels, "entries": [{"ts": ts, "line": line}], } return {"streams": [stream]} @@ -154,7 +149,7 @@ class LokiEmitterV2(LokiEmitterV1): Enables passing additional headers to requests """ - def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None, headers: dict = None): + def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict = None): super().__init__(url, tags, auth, headers) def __call__(self, record: logging.LogRecord, line: str): diff --git a/logging_loki/handlers.py b/logging_loki/handlers.py index d576c81..030aa2b 100644 --- a/logging_loki/handlers.py +++ b/logging_loki/handlers.py @@ -2,15 +2,11 @@ import logging import warnings -from logging.handlers import QueueHandler -from logging.handlers import QueueListener +from logging.handlers import QueueHandler, QueueListener from queue import Queue -from typing import Dict -from typing import Optional -from typing import Type +from typing import Dict, Type -from logging_loki import const -from logging_loki import emitter +from logging_loki import const, emitter class LokiQueueHandler(QueueHandler): @@ -19,7 +15,7 @@ class LokiQueueHandler(QueueHandler): def __init__(self, queue: Queue, **kwargs): """Create new logger handler with the specified queue and kwargs for the `LokiHandler`.""" super().__init__(queue) - self.handler = LokiHandler(**kwargs) # noqa: WPS110 + self.handler = LokiHandler(**kwargs) self.listener = QueueListener(self.queue, self.handler) self.listener.start() @@ -31,21 +27,9 @@ class LokiHandler(logging.Handler): `Loki API `_ """ - emitters: Dict[str, Type[emitter.LokiEmitter]] = { - "0": emitter.LokiEmitterV0, - "1": emitter.LokiEmitterV1, - "2": emitter.LokiEmitterV2 - } - - def __init__( - self, - url: str, - tags: Optional[dict] = None, - auth: Optional[emitter.BasicAuth] = None, - version: Optional[str] = None, - headers: Optional[dict] = None, - verify_ssl: bool = True - ): + emitters: Dict[str, Type[emitter.LokiEmitter]] = {"0": emitter.LokiEmitterV0, "1": emitter.LokiEmitterV1, "2": emitter.LokiEmitterV2} + + def __init__(self, url: str, tags: dict | None = None, auth: emitter.BasicAuth | None = None, version: str | None = None, headers: dict | None = None, verify_ssl: bool = True): """ Create new Loki logging handler. diff --git a/pyproject.toml b/pyproject.toml index 5428332..c05ed4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ [build-system] requires = ["setuptools >= 40.0.0"] build-backend = "setuptools.build_meta" + + +[tool.ruff] +line-length = 200 + +[tool.ruff.lint] +# Enable flake8-style rules and more +# You can customize this based on wemake-python-styleguide rules you want +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify +] + +# Ignore specific rules if needed +ignore = [ + "UP009", # UTF-8 encoding declaration is unnecessary + "UP035", # typing.X is deprecated, use x instead + "UP006", # Use x instead of X for type annotation + "UP030", # Use implicit references for positional format fields + "UP032", # Use f-string instead of format call + "UP015", # Unnecessary mode argument + "UP045", # New union syntax + "B019", # Use of functools.lru_cache on methods can lead to memory leaks + "B028", # No explicit stacklevel keyword argument found +] + +[tool.ruff.format] +# Use double quotes (ruff default, similar to black) +quote-style = "double" diff --git a/setup.py b/setup.py index 6625e27..94bd39f 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,13 @@ import setuptools -with open("README.md", "r") as fh: +with open("README.md", encoding="utf-8") as fh: long_description = fh.read() setuptools.setup( name="python-logging-loki-v2", - version="0.4.4", - description="Python logging handler for Grafana Loki, with support to headers.", + version="1.0.0", + description="Python logging handler for Grafana Loki", long_description=long_description, long_description_content_type="text/markdown", license="MIT", @@ -16,7 +16,7 @@ author_email="cryos10@gmail.com", url="https://github.com/RomanR-dev/python-logging-loki", packages=setuptools.find_packages(exclude=("tests",)), - python_requires=">=3.6", + python_requires=">=3.11", install_requires=["rfc3339>=6.1", "requests"], classifiers=[ "Development Status :: 4 - Beta", diff --git a/tests/test_emitter_v0.py b/tests/test_emitter_v0.py index 0aafd8d..dff2e2d 100644 --- a/tests/test_emitter_v0.py +++ b/tests/test_emitter_v0.py @@ -157,7 +157,7 @@ def test_session_is_closed(emitter_v0): emitter(create_record(), "") emitter.close() session().close.assert_called_once() - assert emitter._session is None # noqa: WPS437 + assert emitter._session is None def test_can_build_tags_from_converting_dict(emitter_v0): diff --git a/tests/test_emitter_v1.py b/tests/test_emitter_v1.py index b5656e1..2963474 100644 --- a/tests/test_emitter_v1.py +++ b/tests/test_emitter_v1.py @@ -152,7 +152,7 @@ def test_session_is_closed(emitter_v1): emitter(create_record(), "") emitter.close() session().close.assert_called_once() - assert emitter._session is None # noqa: WPS437 + assert emitter._session is None def test_can_build_tags_from_converting_dict(emitter_v1): diff --git a/tests/test_emitter_v2.py b/tests/test_emitter_v2.py index 4ed7275..359f131 100644 --- a/tests/test_emitter_v2.py +++ b/tests/test_emitter_v2.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import logging - from typing import Tuple from unittest.mock import MagicMock @@ -12,12 +11,12 @@ emitter_url: str = "https://example.net/loki/api/v1/push/" headers = {"X-Scope-OrgID": "some_tenant"} record_kwargs = { - "name" : "test", - "level" : logging.WARNING, - "fn" : "", - "lno" : "", - "msg" : "Test", - "args" : None, + "name": "test", + "level": logging.WARNING, + "fn": "", + "lno": "", + "msg": "Test", + "args": None, "exc_info": None, } @@ -84,9 +83,9 @@ def test_default_tags_added_to_payload(emitter_v2): stream = get_stream(session) level = logging.getLevelName(record_kwargs["level"]).lower() expected = { - emitter.level_tag : level, + emitter.level_tag: level, emitter.logger_tag: record_kwargs["name"], - "app" : "emitter", + "app": "emitter", } assert stream["stream"] == expected @@ -97,7 +96,7 @@ def test_headers_added(emitter_v2): emitter(create_record(), "") kwargs = get_request(session) - assert kwargs['headers']['X-Scope-OrgID'] == headers['X-Scope-OrgID'] + assert kwargs["headers"]["X-Scope-OrgID"] == headers["X-Scope-OrgID"] def test_no_headers_added(emitter_v2_no_headers): @@ -106,11 +105,12 @@ def test_no_headers_added(emitter_v2_no_headers): emitter(create_record(), "") kwargs = get_request(session) - assert kwargs['headers'] is not None and kwargs['headers'] == {} + assert kwargs["headers"] is not None and kwargs["headers"] == {} def test_soemthing_fun(): import os + a = "a" b = "b" c = "/c" diff --git a/tox.ini b/tox.ini index a611104..7eda15d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,8 @@ [tox] -envlist = - py{36,37,38}, - flake8, - black +# List of Python versions to test against +envlist = py311,py312,ruff isolated_build = true -[travis] -python = - 3.6: py36 - 3.7: py37, flake8, black - 3.8: py38 - [testenv] setenv = LC_ALL = en_US.UTF-8 @@ -21,14 +13,15 @@ deps = freezegun commands = coverage run -m pytest [] -[testenv:flake8] -skip_install = true -basepython = python3.7 -deps = wemake-python-styleguide -commands = flake8 . - -[testenv:black] +[testenv:ruff] skip_install = true -basepython = python3.7 -deps = black==19.10b0 -commands = black --check --diff -l 120 -t py36 . +# Use whatever python3 is available on your system +basepython = python3.11 +deps = ruff +commands = + # First fix auto-fixable issues + ruff check --fix . + ruff format . + # Then check for any remaining issues + ruff check . + ruff format --check .