From ba6936bf345f87aba0d96a9f63399e2a3aa395d3 Mon Sep 17 00:00:00 2001 From: grahamalama Date: Fri, 26 Apr 2024 03:31:45 -0400 Subject: [PATCH 1/4] Add MozlogHandler that integrates renamed formatter (#112) * Add MozlogHandler that integrates renamed formatter * Remove unused fixture from test * Slightly refactor logging test setup - Pass handler and formatter as fixtures to tests - Reset logging after each test * Add tests to assert how `logger_name` is attached to records * Ruff fixes * Use formatter in `assert_records` assertions * Set caplog to INFO in fastapi rid test * Make assertions about LogRecord and formatted output --- docs/django.rst | 18 ++--- docs/fastapi.rst | 15 +--- docs/flask.rst | 15 +--- docs/logging.rst | 15 +--- docs/sanic.rst | 17 ++-- src/dockerflow/fastapi/middleware.py | 6 +- src/dockerflow/logging.py | 42 +++++++--- tests/core/test_logging.py | 113 ++++++++++++++++++++------- tests/django/settings.py | 7 +- tests/fastapi/test_fastapi.py | 7 +- 10 files changed, 146 insertions(+), 109 deletions(-) diff --git a/docs/django.rst b/docs/django.rst index 3a52a82..1bc1245 100644 --- a/docs/django.rst +++ b/docs/django.rst @@ -61,8 +61,8 @@ To install ``python-dockerflow``'s Django support please follow these steps: ] #. :ref:`Configure logging ` to use the - :class:`~dockerflow.logging.JsonLogFormatter` - logging formatter for the ``request.summary`` logger (you may have to + :class:`~dockerflow.logging.MozlogHandler` + logging handler for the ``request.summary`` logger (you may have to extend your existing logging configuration!). .. _`Kubernetes liveness checks`: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ @@ -405,20 +405,15 @@ spec: Logging ------- -Dockerflow provides a :class:`~dockerflow.logging.JsonLogFormatter` Python -logging formatter class. +Dockerflow provides a :class:`~dockerflow.logging.MozlogHandler` Python +logging handler class. This handler formats logs according to the Mozlog schema +and emits them to stdout. To use it, put something like this in your Django ``settings`` file and configure **at least** the ``request.summary`` logger that way:: LOGGING = { 'version': 1, - 'formatters': { - 'json': { - '()': 'dockerflow.logging.JsonLogFormatter', - 'logger_name': 'myproject' - } - }, 'filters': { 'request_id': { '()': 'dockerflow.logging.RequestIdLogFilter', @@ -427,8 +422,7 @@ configure **at least** the ``request.summary`` logger that way:: 'handlers': { 'console': { 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'json', + 'class': 'dockerflow.logging.MozlogHandler', 'filters': ['request_id'] }, }, diff --git a/docs/fastapi.rst b/docs/fastapi.rst index fe2e734..fc010a4 100644 --- a/docs/fastapi.rst +++ b/docs/fastapi.rst @@ -54,7 +54,7 @@ To install ``python-dockerflow``'s FastAPI support please follow these steps: .. seealso:: :ref:`fastapi-versions` for more information -#. Configure logging to use the ``JsonLogFormatter`` logging formatter for the +#. Configure logging to use the ``MozlogHandler`` logging handler for the ``request.summary`` logger (you may have to extend your existing logging configuration), see :ref:`fastapi-logging` for more information. @@ -280,8 +280,8 @@ spec: Logging ------- -Dockerflow provides a :class:`~dockerflow.logging.JsonLogFormatter` Python -logging formatter class. +Dockerflow provides a :class:`~dockerflow.logging.Mozlog` Python +logging handler class. To use it, put something like this **BEFORE** your FastAPI app is initialized for at least the ``request.summary`` logger: @@ -292,12 +292,6 @@ for at least the ``request.summary`` logger: dictConfig({ 'version': 1, - 'formatters': { - 'json': { - '()': 'dockerflow.logging.JsonLogFormatter', - 'logger_name': 'myproject' - } - }, 'filters': { 'request_id': { '()': 'dockerflow.logging.RequestIdLogFilter', @@ -306,9 +300,8 @@ for at least the ``request.summary`` logger: 'handlers': { 'console': { 'level': 'DEBUG', - 'class': 'logging.StreamHandler', + 'class': 'dockerflow.logging.MozlogHandler', 'filters': ['request_id'], - 'formatter': 'json' }, }, 'loggers': { diff --git a/docs/flask.rst b/docs/flask.rst index c36d2c4..6670cd7 100644 --- a/docs/flask.rst +++ b/docs/flask.rst @@ -56,7 +56,7 @@ To install ``python-dockerflow``'s Flask support please follow these steps: .. seealso:: :ref:`flask-versions` for more information -#. Configure logging to use the ``JsonLogFormatter`` logging formatter for the +#. Configure logging to use the ``MozlogHandler`` logging handler for the ``request.summary`` logger (you may have to extend your existing logging configuration), see :ref:`flask-logging` for more information. @@ -425,8 +425,8 @@ spec: Logging ------- -Dockerflow provides a :class:`~dockerflow.logging.JsonLogFormatter` Python -logging formatter class. +Dockerflow provides a :class:`~dockerflow.logging.MozlogHandler` Python +logging handler class. To use it, put something like this **BEFORE** your Flask app is initialized for at least the ``request.summary`` logger:: @@ -435,12 +435,6 @@ for at least the ``request.summary`` logger:: dictConfig({ 'version': 1, - 'formatters': { - 'json': { - '()': 'dockerflow.logging.JsonLogFormatter', - 'logger_name': 'myproject' - } - }, 'filters': { 'request_id': { '()': 'dockerflow.logging.RequestIdLogFilter', @@ -449,8 +443,7 @@ for at least the ``request.summary`` logger:: 'handlers': { 'console': { 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'json', + 'class': 'dockerflow.logging.MozlogHandler', 'filters': ['request_id'] }, }, diff --git a/docs/logging.rst b/docs/logging.rst index d1a8dea..cd5f41d 100644 --- a/docs/logging.rst +++ b/docs/logging.rst @@ -3,8 +3,8 @@ Logging ======= -python-dockerflow provides a :class:`~dockerflow.logging.JsonLogFormatter` -Python logging formatter that produces messages following the JSON schema +python-dockerflow provides a :class:`~dockerflow.logging.MozlogHandler` +Python logging handler that produces messages following the JSON schema for a common application logging format defined by the illustrious Mozilla Cloud Services group. @@ -33,12 +33,6 @@ this:: cfg = { 'version': 1, - 'formatters': { - 'json': { - '()': 'dockerflow.logging.JsonLogFormatter', - 'logger_name': 'myproject' - } - }, 'filters': { 'request_id': { '()': 'dockerflow.logging.RequestIdLogFilter', @@ -47,8 +41,7 @@ this:: 'handlers': { 'console': { 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'json', + 'class': 'dockerflow.logging.MozlogHandler', 'filters': ['request_id'] }, }, @@ -109,7 +102,7 @@ thing as the dictionary based configuratio above: filters = request_id [formatter_json] - class = dockerflow.logging.JsonLogFormatter + class = dockerflow.logging.MozlogFormatter Then load the ini file using the :mod:`logging` module function :func:`logging.config.fileConfig`: diff --git a/docs/sanic.rst b/docs/sanic.rst index 08a97b5..a36667b 100644 --- a/docs/sanic.rst +++ b/docs/sanic.rst @@ -53,7 +53,7 @@ To install ``python-dockerflow``'s Sanic support please follow these steps: .. seealso:: :ref:`sanic-versions` for more information -#. Configure logging to use the ``JsonLogFormatter`` logging formatter for the +#. Configure logging to use the ``MozlogHandler`` logging handler for the ``request.summary`` logger (you may have to extend your existing logging configuration), see :ref:`sanic-logging` for more information. @@ -405,8 +405,8 @@ spec: Logging ------- -Dockerflow provides a :class:`~dockerflow.logging.JsonLogFormatter` Python -logging formatter class. +Dockerflow provides a :class:`~dockerflow.logging.MozlogFormatter` Python +logging handler class. To use it, pass something like this to your Sanic app when it is initialized for at least the ``request.summary`` logger:: @@ -415,12 +415,6 @@ for at least the ``request.summary`` logger:: log_config = { 'version': 1, - 'formatters': { - 'json': { - '()': 'dockerflow.logging.JsonLogFormatter', - 'logger_name': 'myproject' - } - }, 'filters': { 'request_id': { '()': 'dockerflow.logging.RequestIdLogFilter', @@ -429,8 +423,7 @@ for at least the ``request.summary`` logger:: 'handlers': { 'console': { 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'json', + 'class': 'dockerflow.logging.MozlogHandler', 'filters': ['request_id'] }, }, @@ -440,7 +433,7 @@ for at least the ``request.summary`` logger:: 'level': 'DEBUG', }, } - }) + } sanic = Sanic(__name__, log_config=log) diff --git a/src/dockerflow/fastapi/middleware.py b/src/dockerflow/fastapi/middleware.py index 02a844e..e16c07a 100644 --- a/src/dockerflow/fastapi/middleware.py +++ b/src/dockerflow/fastapi/middleware.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import sys import time import urllib from typing import Any, Dict @@ -14,7 +13,7 @@ HTTPScope, ) -from ..logging import JsonLogFormatter, get_or_generate_request_id, request_id_context +from ..logging import MozlogHandler, get_or_generate_request_id, request_id_context class RequestIdMiddleware: @@ -57,9 +56,8 @@ def __init__( if logger is None: logger = logging.getLogger("request.summary") logger.setLevel(logging.INFO) - handler = logging.StreamHandler(sys.stdout) + handler = MozlogHandler() handler.setLevel(logging.INFO) - handler.setFormatter(JsonLogFormatter()) logger.addHandler(handler) self.logger = logger diff --git a/src/dockerflow/logging.py b/src/dockerflow/logging.py index 74e7f5b..337a194 100644 --- a/src/dockerflow/logging.py +++ b/src/dockerflow/logging.py @@ -9,17 +9,31 @@ import sys import traceback import uuid +import warnings from contextvars import ContextVar from typing import ClassVar, Optional +class MozlogHandler(logging.StreamHandler): + def __init__(self, stream=None, name="Dockerflow"): + if stream is None: + stream = sys.stdout + super().__init__(stream=stream) + self.logger_name = name + self.setFormatter(MozlogFormatter()) + + def emit(self, record): + record.logger_name = self.logger_name + super().emit(record) + + class SafeJSONEncoder(json.JSONEncoder): def default(self, o): return repr(o) -class JsonLogFormatter(logging.Formatter): - """Log formatter that outputs machine-readable json. +class MozlogFormatter(logging.Formatter): + """Log formatter that outputs json structured according to the Mozlog schema. This log formatter outputs JSON format messages that are compatible with Mozilla's standard heka-based log aggregation infrastructure. @@ -58,9 +72,10 @@ class JsonLogFormatter(logging.Formatter): "levelname", "levelno", "lineno", + "logger_name", + "message", "module", "msecs", - "message", "msg", "name", "pathname", @@ -75,15 +90,7 @@ class JsonLogFormatter(logging.Formatter): ) def __init__(self, fmt=None, datefmt=None, style="%", logger_name="Dockerflow"): - parent_init = logging.Formatter.__init__ - # The style argument was added in Python 3.1 and since - # the logging configuration via config (ini) files uses - # positional arguments we have to do a version check here - # to decide whether to pass the style argument or not. - if sys.version_info[:2] < (3, 1): - parent_init(self, fmt, datefmt) - else: - parent_init(self, fmt=fmt, datefmt=datefmt, style=style) + super().__init__(fmt=fmt, datefmt=datefmt, style=style) self.logger_name = logger_name self.hostname = socket.gethostname() @@ -104,7 +111,7 @@ def convert_record(self, record): out = { "Timestamp": int(record.created * 1e9), "Type": record.name, - "Logger": self.logger_name, + "Logger": getattr(record, "logger_name", self.logger_name), "Hostname": self.hostname, "EnvVersion": self.LOGGING_FORMAT_VERSION, "Severity": self.SYSLOG_LEVEL_MAP.get( @@ -143,6 +150,15 @@ def format(self, record): return json.dumps(out, cls=SafeJSONEncoder) +class JsonLogFormatter(MozlogFormatter): + def __init__(self, *args, **kwargs): + warnings.warn( + "JsonLogFormatter has been deprecated. Use MozlogFormatter instead", + DeprecationWarning, + ) + super().__init__(*args, **kwargs) + + def safer_format_traceback(exc_typ, exc_val, exc_tb): """Format an exception traceback into safer string. We don't want to let users write arbitrary data into our logfiles, diff --git a/tests/core/test_logging.py b/tests/core/test_logging.py index 54d2b71..6e39ff6 100644 --- a/tests/core/test_logging.py +++ b/tests/core/test_logging.py @@ -11,7 +11,7 @@ import jsonschema import pytest -from dockerflow.logging import JsonLogFormatter +from dockerflow.logging import JsonLogFormatter, MozlogFormatter, MozlogHandler @pytest.fixture() @@ -20,19 +20,24 @@ def _reset_logging(): reload(logging) -logger_name = "tests" -formatter = JsonLogFormatter(logger_name=logger_name) +pytestmark = pytest.mark.usefixtures("_reset_logging") +LOGGER_NAME = "tests" -def assert_records(records): + +@pytest.fixture() +def formatter(): + return MozlogFormatter(logger_name=LOGGER_NAME) + + +def assert_records(formatter, records): assert len(records) == 1 details = json.loads(formatter.format(records[0])) jsonschema.validate(details, JSON_LOGGING_SCHEMA) return details -@pytest.mark.usefixtures("_reset_logging") -def test_initialization_from_ini(caplog, tmpdir): +def test_initialization_from_ini(tmpdir): ini_content = textwrap.dedent( """ [loggers] @@ -42,37 +47,82 @@ def test_initialization_from_ini(caplog, tmpdir): keys = console [formatters] - keys = json + keys = [logger_root] level = INFO handlers = console [handler_console] - class = StreamHandler level = DEBUG - args = (sys.stderr,) - formatter = json - - [formatter_json] - class = dockerflow.logging.JsonLogFormatter + class = dockerflow.logging.MozlogHandler + args = (sys.stdout, 'tests') """ ) ini_file = tmpdir.join("logging.ini") ini_file.write(ini_content) logging.config.fileConfig(str(ini_file)) - logging.info("I am logging in mozlog format now! woo hoo!") logger = logging.getLogger() assert len(logger.handlers) > 0 - assert logger.handlers[0].formatter.logger_name == "Dockerflow" + assert logger.handlers[0].logger_name == LOGGER_NAME + assert isinstance(logger.handlers[0].formatter, MozlogFormatter) + + +def test_set_logger_name_through_handler(caplog): + logger_name = "logger_name_handler" + handler = MozlogHandler(name="logger_name_handler") + logger = logging.getLogger("test") + logger.addHandler(handler) + + logger.warning("hey") + [record] = caplog.records + + assert record.logger_name == logger_name + formatted_record = json.loads(handler.format(record)) + assert formatted_record["Logger"] == logger_name + + +def test_set_logger_name_through_formatter(caplog): + logger_name = "logger_name_formatter" + handler = logging.StreamHandler() + formatter = MozlogFormatter(logger_name=logger_name) + handler.setFormatter(formatter) + + logger = logging.getLogger("test") + logger.addHandler(handler) + logger.warning("hey") + [record] = caplog.records -def test_basic_operation(caplog): + assert not hasattr(record, "logger_name") + formatted_record = json.loads(handler.format(record)) + assert formatted_record["Logger"] == logger_name + + +def test_handler_precedence_logger_name(caplog): + logger_name = "logger_name_handler" + handler = MozlogHandler(name=logger_name) + formatter = MozlogFormatter(logger_name="logger_name_formatter") + handler.setFormatter(formatter) + + logger = logging.getLogger("test") + logger.addHandler(handler) + + logger.warning("hey") + [record] = caplog.records + + assert record.logger_name == logger_name + formatted_record = json.loads(handler.format(record)) + assert formatted_record["Logger"] == logger_name + + +def test_basic_operation(caplog, formatter): """Ensure log formatter contains all the expected fields and values""" message_text = "simple test" caplog.set_level(logging.DEBUG) logging.debug(message_text) - details = assert_records(caplog.records) + details = assert_records(formatter, caplog.records) + assert details == formatter.convert_record(caplog.records[0]) assert "Timestamp" in details @@ -80,16 +130,16 @@ def test_basic_operation(caplog): assert details["Severity"] == 7 assert details["Type"] == "root" assert details["Pid"] == os.getpid() - assert details["Logger"] == logger_name + assert details["Logger"] == LOGGER_NAME assert details["EnvVersion"] == formatter.LOGGING_FORMAT_VERSION assert details["Fields"]["msg"] == message_text -def test_custom_paramters(caplog): +def test_custom_paramters(caplog, formatter): """Ensure log formatter can handle custom parameters""" logger = logging.getLogger("tests.test_logging") logger.warning("custom test %s", "one", extra={"more": "stuff"}) - details = assert_records(caplog.records) + details = assert_records(formatter, caplog.records) assert details == formatter.convert_record(caplog.records[0]) assert details["Type"] == "tests.test_logging" @@ -98,13 +148,13 @@ def test_custom_paramters(caplog): assert details["Fields"]["more"] == "stuff" -def test_non_json_serializable_parameters_are_converted(caplog): +def test_non_json_serializable_parameters_are_converted(caplog, formatter): """Ensure log formatter doesn't fail with non json-serializable params.""" foo = object() foo_repr = repr(foo) logger = logging.getLogger("tests.test_logging") logger.warning("custom test %s", "hello", extra={"foo": foo}) - details = assert_records(caplog.records) + details = assert_records(formatter, caplog.records) assert details["Type"] == "tests.test_logging" assert details["Severity"] == 4 @@ -112,13 +162,13 @@ def test_non_json_serializable_parameters_are_converted(caplog): assert details["Fields"]["foo"] == foo_repr -def test_logging_error_tracebacks(caplog): +def test_logging_error_tracebacks(caplog, formatter): """Ensure log formatter includes exception traceback information""" try: raise ValueError("\n") except Exception: logging.exception("there was an error") - details = assert_records(caplog.records) + details = assert_records(formatter, caplog.records) assert details["Severity"] == 3 assert details["Fields"]["msg"] == "there was an error" @@ -127,14 +177,14 @@ def test_logging_error_tracebacks(caplog): assert "ValueError" in details["Fields"]["traceback"] -def test_logging_exc_info_false(caplog): +def test_logging_exc_info_false(caplog, formatter): """Ensure log formatter does not fail and does not include exception traceback information when exc_info is False""" try: raise ValueError("\n") except Exception: logging.exception("there was an error", exc_info=False) - details = assert_records(caplog.records) + details = assert_records(formatter, caplog.records) assert details["Severity"] == 3 assert details["Fields"]["msg"] == "there was an error" @@ -142,13 +192,13 @@ def test_logging_exc_info_false(caplog): assert "traceback" not in details["Fields"] -def test_ignore_json_message(caplog): +def test_ignore_json_message(caplog, formatter): """Ensure log formatter ignores messages that are JSON already""" try: raise ValueError("\n") except Exception: logging.exception(json.dumps({"spam": "eggs"})) - details = assert_records(caplog.records) + details = assert_records(formatter, caplog.records) assert "msg" not in details["Fields"] assert formatter.is_value_jsonlike('{"spam": "eggs"}') @@ -156,6 +206,13 @@ def test_ignore_json_message(caplog): assert not formatter.is_value_jsonlike('"spam": "eggs"}') +def test_JsonLogFormatter_emits_warning(caplog): + """Initializing a JsonLogFormatter should emit a deprecation warning""" + + with pytest.deprecated_call(): + JsonLogFormatter(logger_name="deprecated") + + # https://mana.mozilla.org/wiki/pages/viewpage.action?pageId=42895640 JSON_LOGGING_SCHEMA = json.loads( """ diff --git a/tests/django/settings.py b/tests/django/settings.py index 55e80c7..34c8a8f 100644 --- a/tests/django/settings.py +++ b/tests/django/settings.py @@ -54,14 +54,11 @@ LOGGING = { "version": 1, - "formatters": { - "json": {"()": "dockerflow.logging.JsonLogFormatter", "logger_name": "tests"} - }, "handlers": { "console": { "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "json", + "class": "dockerflow.logging.MozlogHandler", + "name": "tests", } }, "loggers": {"request.summary": {"handlers": ["console"], "level": "DEBUG"}}, diff --git a/tests/fastapi/test_fastapi.py b/tests/fastapi/test_fastapi.py index 73a008c..53709a8 100644 --- a/tests/fastapi/test_fastapi.py +++ b/tests/fastapi/test_fastapi.py @@ -14,7 +14,7 @@ MozlogRequestSummaryLogger, RequestIdMiddleware, ) -from dockerflow.logging import JsonLogFormatter, RequestIdLogFilter +from dockerflow.logging import MozlogFormatter, RequestIdLogFilter def create_app(): @@ -108,8 +108,9 @@ def test_mozlog_without_correlation_id_middleware(client, caplog): def test_request_id_passed_to_all_log_messages(caplog): + caplog.set_level(logging.INFO) caplog.handler.addFilter(RequestIdLogFilter()) - caplog.handler.setFormatter(JsonLogFormatter()) + caplog.handler.setFormatter(MozlogFormatter()) app = create_app() @@ -194,6 +195,7 @@ def return_error(): }, } + def test_heartbeat_sync(client): @checks.register def sync_ok(): @@ -226,6 +228,7 @@ def test_heartbeat_mixed_sync(client): @checks.register def sync_ok(): return [] + @checks.register async def async_ok(): return [] From 276ff509ce2a82e71d64f5cf3cfdb5a487a4bc80 Mon Sep 17 00:00:00 2001 From: Alpha Date: Wed, 11 Sep 2024 17:48:35 +0100 Subject: [PATCH 2/4] chore: fix a typo (#118) --- src/dockerflow/django/checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dockerflow/django/checks.py b/src/dockerflow/django/checks.py index 3b9639a..d3fac2c 100644 --- a/src/dockerflow/django/checks.py +++ b/src/dockerflow/django/checks.py @@ -24,7 +24,7 @@ def check_database_connected(app_configs, **kwargs): msg = "Could not connect to database: {!s}".format(e) errors.append(checks.Error(msg, id=health.ERROR_CANNOT_CONNECT_DATABASE)) except ImproperlyConfigured as e: - msg = 'Datbase misconfigured: "{!s}"'.format(e) + msg = 'Database misconfigured: "{!s}"'.format(e) errors.append(checks.Error(msg, id=health.ERROR_MISCONFIGURED_DATABASE)) else: if not connection.is_usable(): From 3221444e8994e38cd77e520c87e2a7cc523003a4 Mon Sep 17 00:00:00 2001 From: Jan Brasna <1784648+janbrasna@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:20:41 +0200 Subject: [PATCH 3/4] Fix CI and expand test matrix to Django 5.2 and Python 3.13 (#120) * Bump CI actions * Use non-system env in CI * Add ruff exclusions +FIXME comments * Update Django and Python in CI * Update setup classifiers --- .github/workflows/test.yml | 44 +++++++++++++------------------- pyproject.toml | 7 +++++ setup.py | 12 ++++----- tests/constraints/django-4.0.txt | 1 - tests/constraints/django-4.1.txt | 1 - tests/constraints/django-5.0.txt | 1 - tests/constraints/django-5.1.txt | 1 + tests/constraints/django-5.2.txt | 1 + tox.ini | 21 +++++++-------- 9 files changed, 44 insertions(+), 45 deletions(-) delete mode 100644 tests/constraints/django-4.0.txt delete mode 100644 tests/constraints/django-4.1.txt delete mode 100644 tests/constraints/django-5.0.txt create mode 100644 tests/constraints/django-5.1.txt create mode 100644 tests/constraints/django-5.2.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69b54fb..c898e23 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,10 +14,12 @@ jobs: outputs: envlist: ${{ steps.generate-tox-envlist.outputs.envlist }} steps: - - uses: actions/checkout@v3 - - run: | - python -m pip install --upgrade pip - python -m pip install tox tox-gh-matrix + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.12 + cache: pip + - run: pip install tox tox-gh-matrix - id: generate-tox-envlist run: python -m tox --gh-matrix @@ -29,7 +31,6 @@ jobs: fail-fast: false matrix: tox: ${{ fromJSON(needs.get-tox-envlist.outputs.envlist) }} - # Service containers to run with `container-job` services: # Label used to access the service container @@ -45,25 +46,16 @@ jobs: ports: # Maps port 6379 on service container to the host - 6379:6379 - steps: - - uses: actions/checkout@v2 - - - name: Setup Python ${{ matrix.tox.python.version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.tox.python.spec }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --upgrade tox - - - name: Tox tests - run: python -m tox -v -e ${{ matrix.tox.name }} - - - name: Install codecov - run: python -m pip install codecov - - - name: Upload coverage - run: python -m codecov --name "Python ${{ matrix.tox.python.spec }}" + - uses: actions/checkout@v4 + - name: Setup Python ${{ matrix.tox.python.version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.tox.python.spec }} + cache: pip + - name: Install dependencies + run: pip install tox codecov + - name: Tox tests + run: python -m tox -v -e ${{ matrix.tox.name }} + - name: Upload coverage + run: python -m codecov --name "Python ${{ matrix.tox.python.spec }}" diff --git a/pyproject.toml b/pyproject.toml index f60887f..a6e9fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,13 @@ select = [ ignore = [ # `format` will wrap lines. "E501", + # FIXME: pytest-fixture-no-parentheses + "PT001", + "PT023", + # FIXME: pytest-raises-too-broad + "PT011", + # FIXME: parenthesize-chained-operators + "RUF021", ] [tool.ruff.lint.isort] diff --git a/setup.py b/setup.py index 1c1c3cb..5be4bae 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,8 @@ def read(*parts): description="Python tools and helpers for Mozilla's Dockerflow", long_description=read("README.rst"), long_description_content_type="text/x-rst", - author="Mozilla Foundation", - author_email="dev-webdev@lists.mozilla.org", + author="Mozilla Services Engineering", + author_email="services-engineering+code@mozilla.com", url="https://github.com/mozilla-services/python-dockerflow", license="MPL 2.0", classifiers=[ @@ -28,10 +28,9 @@ def read(*parts): "Environment :: Web Environment :: Mozilla", "Framework :: Django", "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", "Framework :: Flask", "Framework :: FastAPI", "Intended Audience :: Developers", @@ -44,6 +43,7 @@ def read(*parts): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", ], extras_require={ @@ -53,5 +53,5 @@ def read(*parts): "fastapi": ["fastapi", "asgiref"], }, zip_safe=False, - python_requires=">=3.7,<4", + python_requires=">=3.8,<4", ) diff --git a/tests/constraints/django-4.0.txt b/tests/constraints/django-4.0.txt deleted file mode 100644 index bb4c705..0000000 --- a/tests/constraints/django-4.0.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=4.0,<4.1 diff --git a/tests/constraints/django-4.1.txt b/tests/constraints/django-4.1.txt deleted file mode 100644 index a0aaa8b..0000000 --- a/tests/constraints/django-4.1.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=4.1,<4.2 diff --git a/tests/constraints/django-5.0.txt b/tests/constraints/django-5.0.txt deleted file mode 100644 index dc2988f..0000000 --- a/tests/constraints/django-5.0.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=5.0,<5.1 diff --git a/tests/constraints/django-5.1.txt b/tests/constraints/django-5.1.txt new file mode 100644 index 0000000..0763f4c --- /dev/null +++ b/tests/constraints/django-5.1.txt @@ -0,0 +1 @@ +Django>=5.1,<5.2 diff --git a/tests/constraints/django-5.2.txt b/tests/constraints/django-5.2.txt new file mode 100644 index 0000000..7db3eb1 --- /dev/null +++ b/tests/constraints/django-5.2.txt @@ -0,0 +1 @@ +Django>=5.2,<5.3 diff --git a/tox.ini b/tox.ini index 16d032b..a8a066c 100644 --- a/tox.ini +++ b/tox.ini @@ -5,11 +5,13 @@ envlist = py38-lint py311-docs py{38,39,310}-dj32 - py{38,39,310,311}-dj{40,41,42} - py{310,311,312}-dj{50} - py{38,39,310,311,312}-fa100 - py{38,39,310,311}-fl{20,21,22,23,30} - py{38,39,310,311}-s{21,22,23} + py{38,39,310,311,312}-dj42 + py{310,311,312,313}-dj{51,52} + py{38,39,310,311,312,313}-fa100 + py{38,39,310,311}-fl{20,21,22} + py{38,39,310,311,312}-fl{23,30} + py{38,39,310,311}-s{21,22} + py{38,39,310,311,312,313}-s23 [testenv] usedevelop = true @@ -19,15 +21,14 @@ setenv = PYTHONPATH = {toxinidir} deps = -rtests/requirements/default.txt - dj{32,40,41,42,50}: -rtests/requirements/django.txt + dj{32,42,51,52}: -rtests/requirements/django.txt fa100: -rtests/requirements/fastapi.txt fl{20,21,22,23,30}: -rtests/requirements/flask.txt s{21,22,23}: -rtests/requirements/sanic.txt dj32: -ctests/constraints/django-3.2.txt - dj40: -ctests/constraints/django-4.0.txt - dj41: -ctests/constraints/django-4.1.txt dj42: -ctests/constraints/django-4.2.txt - dj50: -ctests/constraints/django-5.0.txt + dj51: -ctests/constraints/django-5.1.txt + dj52: -ctests/constraints/django-5.2.txt fa100: -ctests/constraints/fastapi-0.100.txt fl20: -ctests/constraints/flask-2.0.txt fl21: -ctests/constraints/flask-2.1.txt @@ -39,7 +40,7 @@ deps = s23: -ctests/constraints/sanic-23.txt commands = python --version - dj{32,40,41,42,50}: pytest --no-migrations -o DJANGO_SETTINGS_MODULE=tests.django.settings -o django_find_project=false {posargs:tests/core/ tests/django} + dj{32,42,51,52}: pytest --no-migrations -o DJANGO_SETTINGS_MODULE=tests.django.settings -o django_find_project=false {posargs:tests/core/ tests/django} fa{100}: pytest {posargs: tests/core/ tests/fastapi/} fl{20,21,22,23,30}: pytest {posargs:tests/core/ tests/flask/} s{21,22,23}: pytest {posargs:tests/core/ tests/sanic/} From 72978016597eb8f897d03c04292160ec00b120c7 Mon Sep 17 00:00:00 2001 From: Jan Brasna <1784648+janbrasna@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:46:46 +0100 Subject: [PATCH 4/4] Pin test dependencies to fix CI, drop EOL and add Django 6.0 (#122) * Pin httpx<1 * Pin sanic-testing for httpx<1 * Drop EOL from tox matrix * Update release CI used Python version * Bump CI versions * Ignore ruff unused-unpacked-variable rule * Remove django-3.2 tests * Drop EOL * Remove django-5.1 testing * Add Django 6.0 to test matrix * Update metadata --- .github/workflows/release.yml | 6 +++--- .github/workflows/test.yml | 10 +++++----- pyproject.toml | 2 ++ setup.py | 6 ++---- tests/constraints/django-3.2.txt | 1 - tests/constraints/django-5.1.txt | 1 - tests/constraints/django-6.0.txt | 1 + tests/requirements/fastapi.txt | 2 +- tests/requirements/sanic.txt | 4 ++-- tox.ini | 29 ++++++++++++++--------------- 10 files changed, 30 insertions(+), 32 deletions(-) delete mode 100644 tests/constraints/django-3.2.txt delete mode 100644 tests/constraints/django-5.1.txt create mode 100644 tests/constraints/django-6.0.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57ffd2f..17c032f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,14 +12,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: 3.8 + python-version: "3.9" - name: Install pypa/build run: python3 -m pip install build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c898e23..9d1c941 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,10 +14,10 @@ jobs: outputs: envlist: ${{ steps.generate-tox-envlist.outputs.envlist }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: - python-version: 3.12 + python-version: "3.13" cache: pip - run: pip install tox tox-gh-matrix - id: generate-tox-envlist @@ -47,9 +47,9 @@ jobs: # Maps port 6379 on service container to the host - 6379:6379 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Python ${{ matrix.tox.python.version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.tox.python.spec }} cache: pip diff --git a/pyproject.toml b/pyproject.toml index a6e9fd1..bdec2d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ ignore = [ "PT011", # FIXME: parenthesize-chained-operators "RUF021", + # FIXME: unused-unpacked-variable + "RUF059", ] [tool.ruff.lint.isort] diff --git a/setup.py b/setup.py index 5be4bae..fd07fc9 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,9 @@ def read(*parts): "Development Status :: 5 - Production/Stable", "Environment :: Web Environment :: Mozilla", "Framework :: Django", - "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", - "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "Framework :: Flask", "Framework :: FastAPI", "Intended Audience :: Developers", @@ -38,7 +37,6 @@ def read(*parts): "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -53,5 +51,5 @@ def read(*parts): "fastapi": ["fastapi", "asgiref"], }, zip_safe=False, - python_requires=">=3.8,<4", + python_requires=">=3.9,<4", ) diff --git a/tests/constraints/django-3.2.txt b/tests/constraints/django-3.2.txt deleted file mode 100644 index 807ac90..0000000 --- a/tests/constraints/django-3.2.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=3.2,<4.0 diff --git a/tests/constraints/django-5.1.txt b/tests/constraints/django-5.1.txt deleted file mode 100644 index 0763f4c..0000000 --- a/tests/constraints/django-5.1.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=5.1,<5.2 diff --git a/tests/constraints/django-6.0.txt b/tests/constraints/django-6.0.txt new file mode 100644 index 0000000..f1a4a72 --- /dev/null +++ b/tests/constraints/django-6.0.txt @@ -0,0 +1 @@ +Django>=6.0rc1,<6.1 diff --git a/tests/requirements/fastapi.txt b/tests/requirements/fastapi.txt index 18476e7..337e93c 100644 --- a/tests/requirements/fastapi.txt +++ b/tests/requirements/fastapi.txt @@ -1,3 +1,3 @@ fastapi asgiref -httpx \ No newline at end of file +httpx<1 diff --git a/tests/requirements/sanic.txt b/tests/requirements/sanic.txt index 339801b..1def9f9 100644 --- a/tests/requirements/sanic.txt +++ b/tests/requirements/sanic.txt @@ -3,6 +3,6 @@ aiohttp Sanic sanic_redis -sanic-testing +sanic-testing<23.6 uvloop>=0.14.0rc1 -setuptools \ No newline at end of file +setuptools diff --git a/tox.ini b/tox.ini index a8a066c..a232a29 100644 --- a/tox.ini +++ b/tox.ini @@ -2,16 +2,16 @@ usedevelop = True minversion = 1.8 envlist = - py38-lint + py39-lint py311-docs - py{38,39,310}-dj32 - py{38,39,310,311,312}-dj42 - py{310,311,312,313}-dj{51,52} - py{38,39,310,311,312,313}-fa100 - py{38,39,310,311}-fl{20,21,22} - py{38,39,310,311,312}-fl{23,30} - py{38,39,310,311}-s{21,22} - py{38,39,310,311,312,313}-s23 + py{39,310,311,312}-dj42 + py{310,311,312,313}-dj52 + py{312,313}-dj60 + py{39,310,311,312,313}-fa100 + py{39,310,311}-fl{20,21,22} + py{39,310,311,312}-fl{23,30} + py{39,310,311}-s{21,22} + py{39,310,311,312,313}-s23 [testenv] usedevelop = true @@ -21,14 +21,13 @@ setenv = PYTHONPATH = {toxinidir} deps = -rtests/requirements/default.txt - dj{32,42,51,52}: -rtests/requirements/django.txt + dj{42,52,60}: -rtests/requirements/django.txt fa100: -rtests/requirements/fastapi.txt fl{20,21,22,23,30}: -rtests/requirements/flask.txt s{21,22,23}: -rtests/requirements/sanic.txt - dj32: -ctests/constraints/django-3.2.txt dj42: -ctests/constraints/django-4.2.txt - dj51: -ctests/constraints/django-5.1.txt dj52: -ctests/constraints/django-5.2.txt + dj60: -ctests/constraints/django-6.0.txt fa100: -ctests/constraints/fastapi-0.100.txt fl20: -ctests/constraints/flask-2.0.txt fl21: -ctests/constraints/flask-2.1.txt @@ -40,7 +39,7 @@ deps = s23: -ctests/constraints/sanic-23.txt commands = python --version - dj{32,42,51,52}: pytest --no-migrations -o DJANGO_SETTINGS_MODULE=tests.django.settings -o django_find_project=false {posargs:tests/core/ tests/django} + dj{42,52,60}: pytest --no-migrations -o DJANGO_SETTINGS_MODULE=tests.django.settings -o django_find_project=false {posargs:tests/core/ tests/django} fa{100}: pytest {posargs: tests/core/ tests/fastapi/} fl{20,21,22,23,30}: pytest {posargs:tests/core/ tests/flask/} s{21,22,23}: pytest {posargs:tests/core/ tests/sanic/} @@ -51,8 +50,8 @@ deps = -rdocs/requirements.txt commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html pip_pre = false -[testenv:py38-lint] -basepython = python3.8 +[testenv:py39-lint] +basepython = python3.9 deps = -rtests/requirements/lint.txt commands = ruff check src/ tests/