diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 93d37ee..fd1bce8 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v7 with: python-version: 3.9 - name: Install dependencies @@ -35,16 +35,17 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] steps: - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: "Git checkout" - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Install dependencies run: | python -m pip install --upgrade pip @@ -58,16 +59,18 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: backend: ["fastapi", "flask", "quart", "sanic"] + python-version: ["3.9", "3.14"] steps: - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v7 with: - python-version: 3.9 + python-version: ${{ matrix.python-version }} - name: "Git checkout" - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/AGENTS.md b/AGENTS.md index 3261866..0be1019 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,9 @@ Key locations: ## Local checks Create and activate a virtual environment before installing test requirements. -The existing CI covers Python 3.9–3.13; this is not a claim that every framework -dependency supports every version. Verify actual installation and runtime. +The CI runs unit tests on Python 3.9–3.14 and framework smoke tests on Python 3.9 and 3.14; +this is not a claim that every framework dependency supports every version. Verify actual +installation and runtime. ```bash python -m pip install -r test-requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9f475..d43e624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). ## Unreleased +- fix: `init_sanic()` no longer raises `KeyError: 'format'` when Sanic 24.x and later omit + formatter `format` keys; older Sanic logging configuration remains supported (PR #133). + - fix: FastAPI application logs and `json_logging.get_correlation_id()` now use the request's correlation id instead of `-` in synchronous (`def`) endpoints (fixes #124, PR #123). When request instrumentation is enabled with `init_request_instrument(app)` (Python 3.9+): diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 4b52648..f92e377 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -47,7 +47,7 @@ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics ``` The GitHub Actions workflow `.github/workflows/code_quality.yml` runs -unit tests on Python 3.9–3.13, lint, and separate smoke tests for FastAPI, +unit tests on Python 3.9–3.14, lint, and separate smoke tests on Python 3.9 and 3.14 for FastAPI, Flask, Quart, and Sanic. The shared smoke-test runner starts an example service on localhost:5000; that port must be available for local runs. diff --git a/json_logging/framework/sanic/__init__.py b/json_logging/framework/sanic/__init__.py index 9edba8b..5724b00 100644 --- a/json_logging/framework/sanic/__init__.py +++ b/json_logging/framework/sanic/__init__.py @@ -39,12 +39,12 @@ def config(self): LOGGING_CONFIG_DEFAULTS["formatters"]["generic"][ "class" ] = "json_logging.JSONLogFormatter" - del LOGGING_CONFIG_DEFAULTS["formatters"]["generic"]["format"] + LOGGING_CONFIG_DEFAULTS["formatters"]["generic"].pop("format", None) LOGGING_CONFIG_DEFAULTS["formatters"]["access"][ "class" ] = "json_logging.JSONLogFormatter" - del LOGGING_CONFIG_DEFAULTS["formatters"]["access"]["format"] + LOGGING_CONFIG_DEFAULTS["formatters"]["access"].pop("format", None) # logging.config.dictConfig(LOGGING_CONFIG_DEFAULTS) diff --git a/tests/smoketests/quart/api.py b/tests/smoketests/quart/api.py index f7728fa..84e5767 100644 --- a/tests/smoketests/quart/api.py +++ b/tests/smoketests/quart/api.py @@ -1,4 +1,4 @@ -import asyncio, logging, sys, json_logging, quart +import logging, sys, json_logging, quart app = quart.Quart(__name__) json_logging.init_quart(enable_json=True) @@ -17,5 +17,5 @@ async def home(): return "Hello world" if __name__ == "__main__": - loop = asyncio.get_event_loop() - app.run(host='0.0.0.0', port=int(5000), use_reloader=False, loop=loop) + # Quart manages its own event loop; get_event_loop() raises without a loop on Python 3.14. + app.run(host='0.0.0.0', port=int(5000), use_reloader=False) diff --git a/tests/smoketests/sanic/requirements.txt b/tests/smoketests/sanic/requirements.txt index 4e01c5e..06a78ea 100644 --- a/tests/smoketests/sanic/requirements.txt +++ b/tests/smoketests/sanic/requirements.txt @@ -1,4 +1,7 @@ -sanic==20.3.0 +# Preserve coverage of the legacy Sanic integration on Python 3.9. +sanic==20.3.0; python_version < "3.10" +# Sanic 25.12+ supports Python 3.14; 20.3.0 pulls in httpx importing removed cgi. +sanic>=25.12,<26; python_version >= "3.10" requests pytest -e . diff --git a/tests/test_sanic_logging_config.py b/tests/test_sanic_logging_config.py new file mode 100644 index 0000000..c9117b4 --- /dev/null +++ b/tests/test_sanic_logging_config.py @@ -0,0 +1,74 @@ +"""Regression tests for Sanic default logging formatter compatibility. + +Sanic 24+ removes the legacy 'format' keys from generic/access formatters. +Run each initialization in a fresh Python process because json_logging.init_sanic() +changes process-global state and intentionally cannot be called twice. +""" +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.parametrize("config_shape", ["legacy", "modern", "installed"]) +def test_init_sanic_handles_logging_formats_and_emits_json(config_shape): + """Both old and new Sanic config shapes initialize and produce parseable JSON records.""" + code = textwrap.dedent(""" + import io + import json + import logging + import sanic.log + import json_logging + + config_shape = %(config_shape)r + if config_shape != "installed": + formatters = { + "generic": {"format": "%%(message)s"} if config_shape == "legacy" else {}, + "access": {"format": "%%(message)s"} if config_shape == "legacy" else {}, + } + sanic.log.LOGGING_CONFIG_DEFAULTS = { + "disable_existing_loggers": True, + "formatters": formatters, + } + + json_logging.init_sanic(enable_json=True) + defaults = sanic.log.LOGGING_CONFIG_DEFAULTS + assert defaults["disable_existing_loggers"] is False + logger = logging.getLogger("sanic-formatter-test") + logger.setLevel(logging.INFO) + logger.propagate = False + + for name in ("generic", "access"): + formatter_config = defaults["formatters"][name] + assert formatter_config["class"] == "json_logging.JSONLogFormatter" + assert "format" not in formatter_config + + output = io.StringIO() + handler = logging.StreamHandler(output) + formatter_class = getattr(json_logging, formatter_config["class"].rsplit(".", 1)[-1]) + handler.setFormatter(formatter_class()) + logger.addHandler(handler) + try: + logger.info("sanic json logging", extra={"service": name}) + finally: + logger.removeHandler(handler) + handler.close() + record = json.loads(output.getvalue().strip()) + assert record["type"] == "log" + assert record["msg"] == "sanic json logging" + assert record["service"] == name + print("PASS") + """ % {"config_shape": config_shape}) + env = dict(os.environ, PYTHONWARNINGS="ignore") + result = subprocess.run( + [sys.executable, "-c", code], cwd=str(ROOT), env=env, + capture_output=True, text=True, timeout=40, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "PASS" in result.stdout