This is an automated email from the ASF dual-hosted git repository.

uranusjr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 16e1abaaeac Improve namespace logging level config parsing (#68839)
16e1abaaeac is described below

commit 16e1abaaeac6aa7041daada0c29a20843e491fa6
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Tue Jun 23 16:30:34 2026 +0800

    Improve namespace logging level config parsing (#68839)
---
 .../src/airflow_shared/logging/structlog.py        | 68 +++++++++++++++++-----
 shared/logging/tests/logging/test_structlog.py     | 60 ++++++++++++++++++-
 2 files changed, 111 insertions(+), 17 deletions(-)

diff --git a/shared/logging/src/airflow_shared/logging/structlog.py 
b/shared/logging/src/airflow_shared/logging/structlog.py
index 74cb6dc1966..72884cfbde7 100644
--- a/shared/logging/src/airflow_shared/logging/structlog.py
+++ b/shared/logging/src/airflow_shared/logging/structlog.py
@@ -26,7 +26,7 @@ import re
 import sys
 import weakref
 from collections.abc import Callable, Iterable, Mapping, Sequence
-from functools import cache, cached_property, partial
+from functools import cache, cached_property
 from pathlib import Path
 from types import ModuleType
 from typing import TYPE_CHECKING, Any, BinaryIO, Generic, TextIO, TypeVar, cast
@@ -184,6 +184,56 @@ PER_LOGGER_LEVELS.update(
 )
 
 
+_NAMESPACE_LEVEL_SEPARATORS = re.compile(r"[\s,]+")
+
+
+def parse_namespace_log_levels(value: str | Mapping[str, str] | None) -> 
dict[str, int]:
+    """
+    Parse the namespace logging levels configuration into per-logger levels.
+
+    Real callers always pass a string with a series of ``<logger>=<level>``
+    pairs separated by whitespaces and/or commas, or *None* if the 
configuration
+    is not set. See documentation on configuration for details.
+
+    Parsing is best-effort. Invalid entries are skipped with an ERROR level log
+    message.
+
+    An already-split ``Mapping`` of logger name to level name is also accepted
+    as a convenience for programmatic (test) callers; it is trusted and merely
+    resolved to numeric levels without validation.
+
+    :meta private:
+    """
+    if value is None:
+        return {}
+    if not isinstance(value, str):
+        return {name: NAME_TO_LEVEL[level.lower()] for name, level in 
value.items()}
+
+    levels: dict[str, int] = {}
+    errors: list[str] = []
+    for entry in _NAMESPACE_LEVEL_SEPARATORS.split(value.strip()):
+        if not entry:
+            continue
+        logger_name, sep, level_name = entry.partition("=")
+        if not sep:
+            errors.append(f"malformed entry {entry!r}, expected 
'<logger>=<level>'")
+            continue
+        if not (logger_name := logger_name.strip()):
+            errors.append(f"malformed entry {entry!r}, logger name is empty")
+            continue
+        try:
+            levels[logger_name] = NAME_TO_LEVEL[(level_name := 
level_name.strip()).lower()]
+        except KeyError:
+            errors.append(
+                f"invalid level {level_name!r} for logger {logger_name!r}, "
+                f"expected one of: {', '.join(sorted(NAME_TO_LEVEL))}"
+            )
+
+    for error in errors:
+        log.error("Ignoring invalid namespace_levels entry: %s", error)
+    return levels
+
+
 def make_filtering_logger() -> Callable[..., BindableLogger]:
     def maker(logger: WrappedLogger, *args, **kwargs):
         # If the logger is a NamedBytesLogger/NamedWriteLogger (an Airflow 
specific subclass) then
@@ -555,21 +605,7 @@ def configure_logging(
     extra_processors = extra_processors or ()
 
     PER_LOGGER_LEVELS[""] = NAME_TO_LEVEL[log_level.lower()]
-
-    # Extract per-logger-tree levels and set them
-    if isinstance(namespace_log_levels, str):
-        log_from_level = partial(re.compile(r"\s*=\s*").split, maxsplit=2)
-        namespace_log_levels = {
-            log: level for log, level in map(log_from_level, 
re.split(r"[\s,]+", namespace_log_levels))
-        }
-    if namespace_log_levels:
-        for log, level in namespace_log_levels.items():
-            try:
-                loglevel = NAME_TO_LEVEL[level.lower()]
-            except KeyError:
-                raise ValueError(f"Invalid log level for logger {log!r}: 
{level!r}") from None
-            else:
-                PER_LOGGER_LEVELS[log] = loglevel
+    PER_LOGGER_LEVELS.update(parse_namespace_log_levels(namespace_log_levels))
 
     shared_pre_chain, for_stdlib, for_structlog = structlog_processors(
         json_output,
diff --git a/shared/logging/tests/logging/test_structlog.py 
b/shared/logging/tests/logging/test_structlog.py
index a723467c3d4..ca85bb4f20e 100644
--- a/shared/logging/tests/logging/test_structlog.py
+++ b/shared/logging/tests/logging/test_structlog.py
@@ -34,7 +34,11 @@ from structlog.dev import BLUE, BRIGHT, CYAN, DIM, GREEN, 
MAGENTA, RESET_ALL as
 from structlog.processors import CallsiteParameter
 
 from airflow_shared.logging import structlog as structlog_module
-from airflow_shared.logging.structlog import configure_logging
+from airflow_shared.logging.structlog import (
+    NAME_TO_LEVEL,
+    configure_logging,
+    parse_namespace_log_levels,
+)
 
 # We avoid the caplog fixture for most tests here; the main purpose of this 
file is to capture the
 # _rendered_ output of the tests to make sure it is correct.
@@ -728,3 +732,57 @@ def test_named_write_logger_preserves_name():
 
     assert NamedWriteLogger("my.logger").name == "my.logger"
     assert NamedWriteLogger().name is None
+
+
+class TestParseNamespaceLogLevels:
+    """Unit tests for the best-effort ``namespace_levels`` parser."""
+
+    @pytest.mark.parametrize("value", (None, "", "   ", " , , "))
+    def test_blank_yields_no_overrides(self, value):
+        assert parse_namespace_log_levels(value) == {}
+
+    def test_parses_string_pairs(self):
+        assert parse_namespace_log_levels("sqlalchemy=INFO 
sqlalchemy.engine=DEBUG") == {
+            "sqlalchemy": NAME_TO_LEVEL["info"],
+            "sqlalchemy.engine": NAME_TO_LEVEL["debug"],
+        }
+
+    def test_accepts_commas_and_mixed_separators(self):
+        expected = {"a": NAME_TO_LEVEL["info"], "b": NAME_TO_LEVEL["debug"]}
+        assert parse_namespace_log_levels("a=INFO,b=DEBUG") == expected
+        assert parse_namespace_log_levels("  a=INFO ,, b=DEBUG ") == expected
+
+    def test_level_names_are_case_insensitive(self):
+        assert parse_namespace_log_levels("a=info") == {"a": 
NAME_TO_LEVEL["info"]}
+
+    def test_last_value_wins(self):
+        assert parse_namespace_log_levels("a=INFO a=ERROR") == {"a": 
NAME_TO_LEVEL["error"]}
+
+    def test_accepts_mapping_input(self):
+        assert parse_namespace_log_levels({"my.logger": "warning"}) == {
+            "my.logger": NAME_TO_LEVEL["warning"],
+        }
+
+    def test_valid_input_logs_nothing(self, caplog):
+        with caplog.at_level(logging.ERROR):
+            parse_namespace_log_levels("a=INFO")
+        assert not caplog.records
+
+    def test_entry_without_equals_is_skipped(self, caplog):
+        with caplog.at_level(logging.ERROR):
+            result = parse_namespace_log_levels("sqlalchemy=INFO botocore")
+        assert result == {"sqlalchemy": NAME_TO_LEVEL["info"]}
+        assert "botocore" in caplog.text
+        assert "<logger>=<level>" in caplog.text
+
+    def test_empty_logger_name_is_skipped(self, caplog):
+        with caplog.at_level(logging.ERROR):
+            result = parse_namespace_log_levels("=INFO a=DEBUG")
+        assert result == {"a": NAME_TO_LEVEL["debug"]}
+        assert "logger name is empty" in caplog.text
+
+    def test_unknown_level_is_skipped(self, caplog):
+        with caplog.at_level(logging.ERROR):
+            result = parse_namespace_log_levels("sqlalchemy=VERBOSE")
+        assert result == {}
+        assert "VERBOSE" in caplog.text

Reply via email to