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

amoghrajesh 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 c68e9f16325 Update the metrics registry hook to ban direct imports of 
stats emitting module-level functions (#68675)
c68e9f16325 is described below

commit c68e9f163250efd2970f11d6b506a51350bcd818
Author: Christos Bisias <[email protected]>
AuthorDate: Wed Jul 15 15:03:45 2026 +0300

    Update the metrics registry hook to ban direct imports of stats emitting 
module-level functions (#68675)
---
 .pre-commit-config.yaml                            |   2 +-
 .../prek/check_metrics_synced_with_the_registry.py | 139 ++++++++++++++-
 .../test_check_metrics_synced_with_the_registry.py | 190 +++++++++++++++++++++
 3 files changed, 328 insertions(+), 3 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c5f8e587ddb..c3722a2347f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -945,7 +945,7 @@ repos:
         entry: ./scripts/ci/prek/check_metrics_synced_with_the_registry.py
         language: python
         files: \.py$
-        exclude: ^(tests/|.*/tests/)
+        exclude: 
^(tests/|.*/tests/|airflow-core/src/airflow/observability/stats\.py$|task-sdk/src/airflow/sdk/observability/stats\.py$)
         pass_filenames: true
         additional_dependencies: ["PyYAML>=6.0", "rich>=13.6.0"]
       - id: check-boring-cyborg-configuration
diff --git a/scripts/ci/prek/check_metrics_synced_with_the_registry.py 
b/scripts/ci/prek/check_metrics_synced_with_the_registry.py
index 94b5bc254de..3d42939b6ce 100644
--- a/scripts/ci/prek/check_metrics_synced_with_the_registry.py
+++ b/scripts/ci/prek/check_metrics_synced_with_the_registry.py
@@ -53,6 +53,16 @@ STATS_METHOD_TO_TYPE: dict[str, str] = {
 
 STATS_OBJECTS = {"Stats", "stats"}
 
+# Stats module path suffix. Currently, there are the following possible module 
paths:
+# ``airflow._shared.observability.metrics.stats``,
+# ``airflow.sdk._shared.observability.metrics.stats``, and
+# ``airflow_shared.observability.metrics.stats``.
+STATS_MODULE_SUFFIX = "observability.metrics.stats"
+
+# Names of exception classes that when present in a try-except, then it means 
that
+# the import is used for a back-compat version check, and it should be ignored.
+IMPORT_ERROR_NAMES = {"ImportError", "ModuleNotFoundError"}
+
 METRICS_REGISTRY_PATH = (
     AIRFLOW_ROOT_PATH / 
"shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml"
 )
@@ -177,6 +187,47 @@ class MetricCall:
     is_dynamic: bool
 
 
+@dataclass
+class DirectStatsImport:
+    file_path: str
+    line_num: int
+    module: str
+    imported_names: list[str]
+
+
+def _is_stats_module_path(module: str | None) -> bool:
+    return module is not None and module.endswith(STATS_MODULE_SUFFIX)
+
+
+def _except_handler_catches_expected_error(handler: ast.ExceptHandler) -> bool:
+    """Return True if this except handler catches only names in 
``IMPORT_ERROR_NAMES``."""
+    exc_type = handler.type
+    if isinstance(exc_type, ast.Name):
+        return exc_type.id in IMPORT_ERROR_NAMES
+    if isinstance(exc_type, ast.Tuple):
+        return bool(exc_type.elts) and all(
+            isinstance(elt, ast.Name) and elt.id in IMPORT_ERROR_NAMES for elt 
in exc_type.elts
+        )
+    return False
+
+
+def find_back_compat_check_import_ids(tree: ast.AST) -> set[int]:
+    """Return ``id()`` of every ImportFrom inside a try-except ImportError 
block.
+
+    Imports inside such blocks are used under providers for back-compat checks.
+    """
+    back_compat_check_import_ids: set[int] = set()
+    for node in ast.walk(tree):
+        if isinstance(node, ast.Try) and any(
+            _except_handler_catches_expected_error(h) for h in node.handlers
+        ):
+            for stmt in node.body:
+                for child in ast.walk(stmt):
+                    if isinstance(child, ast.ImportFrom):
+                        back_compat_check_import_ids.add(id(child))
+    return back_compat_check_import_ids
+
+
 def scan_file_for_metrics(file_path: Path) -> list[MetricCall]:
     """Return all Stats metric calls found in the provided file_path."""
     try:
@@ -220,6 +271,53 @@ def scan_file_for_metrics(file_path: Path) -> 
list[MetricCall]:
     return metrics_found
 
 
+def scan_file_for_direct_stats_imports(file_path: Path) -> 
list[DirectStatsImport]:
+    """Return direct imports of stats module-level functions.
+
+    Only the metric-emitting methods in ``STATS_METHOD_TO_TYPE`` (``incr``,
+    ``decr``, ``gauge``, ``timing``, ``timer``) are restricted; other names
+    exported from ``stats.py`` (helpers, the ``Stats`` shim, ``initialize``,
+    etc.) may still be imported directly.
+
+    Imports inside a ``try`` block that catches ``ImportError`` (or
+    ``ModuleNotFoundError``) are exempt: those are intentional
+    back-compat version checks whose success is the signal the code is checking
+    for, and rewriting them to namespace form would defeat the check.
+    """
+    try:
+        source = file_path.read_text(encoding="utf-8")
+        tree = ast.parse(source, filename=str(file_path))
+    except (OSError, UnicodeDecodeError, SyntaxError):
+        return []
+
+    # Imports wrapped with try-except. These should be ignored.
+    back_compat_check_ids = find_back_compat_check_import_ids(tree)
+
+    violations: list[DirectStatsImport] = []
+    for node in ast.walk(tree):
+        if not isinstance(node, ast.ImportFrom):
+            continue
+        if not _is_stats_module_path(node.module):
+            continue
+        if id(node) in back_compat_check_ids:
+            continue
+        offending: list[str] = []
+        for alias in node.names:
+            if alias.name in STATS_METHOD_TO_TYPE:
+                offending.append(f"{alias.name} as {alias.asname}" if 
alias.asname else alias.name)
+        if not offending:
+            continue
+        violations.append(
+            DirectStatsImport(
+                file_path=str(file_path),
+                line_num=node.lineno,
+                module=node.module or "",
+                imported_names=offending,
+            )
+        )
+    return violations
+
+
 def main() -> None:
     parser = argparse.ArgumentParser(
         description="Check that metrics in the codebase are in sync with the 
metrics registry YAML file."
@@ -232,9 +330,26 @@ def main() -> None:
 
     metrics_registry = load_metrics_registry_yaml()
 
-    # Collect all metric calls across all provided files.
+    # The scanner only sees `<obj>.<method>`, but since the class functions 
were
+    # converted to module-level functions, if they are imported directly, the 
scanner
+    # won't recognize them as a metric calls that need validation.
+    # E.g.
+    # This is recognizable for the scanner
+    #       from airflow._shared.observability.metrics import stats
+    #       stats.incr()
+    # This isn't
+    #       from airflow._shared.observability.metrics.stats import incr
+    #       incr()
+    #
+    # The simpler solution is to ban direct imports.
+    direct_stats_imports: list[DirectStatsImport] = []
     code_metrics: dict[str, list[MetricCall]] = {}
     for file_path in [Path(f) for f in args.files]:
+        file_direct_imports = scan_file_for_direct_stats_imports(file_path)
+        if file_direct_imports:
+            direct_stats_imports.extend(file_direct_imports)
+            # If there are direct imports, then skip checking for metric calls 
in this file.
+            continue
         for call in scan_file_for_metrics(file_path):
             code_metrics.setdefault(call.metric_name, []).append(call)
 
@@ -266,7 +381,9 @@ def main() -> None:
     # because the script is comparing the entire YAML against certain files at 
a time.
     # For that to work, the script would have to run against all project files 
EVERY TIME.
 
-    total_violations = len(metrics_not_in_registry) + 
len(metrics_with_type_mismatch)
+    total_violations = (
+        len(metrics_not_in_registry) + len(metrics_with_type_mismatch) + 
len(direct_stats_imports)
+    )
 
     if total_violations:
         console.print(f"[red]Found {total_violations} violation(s).[/red]")
@@ -301,6 +418,24 @@ def main() -> None:
             console.print("    [yellow]Fix the type mismatch in either the 
code or the registry.[/yellow]")
             console.print()
 
+        if direct_stats_imports:
+            console.print(
+                f"    [red]-> {len(direct_stats_imports)} direct import(s) of 
stats functions (use the `stats` namespace instead):[/red]"
+            )
+            for violation in direct_stats_imports:
+                console.print(
+                    f"        [yellow]{violation.file_path}[/yellow] line 
[yellow]{violation.line_num}[/yellow]: "
+                    f"[magenta]from {violation.module} import {', 
'.join(violation.imported_names)}[/magenta]"
+                )
+            console.print(
+                "    [yellow]Replace direct imports with namespace access: "
+                "`from <parent>.observability.metrics import stats` and call 
`stats.<method>(...)`.[/yellow]"
+            )
+            console.print(
+                "    [yellow]Imports inside a `try` block that catches 
`ImportError` are exempt (back-compat checks).[/yellow]"
+            )
+            console.print()
+
         sys.exit(1)
 
 
diff --git 
a/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py 
b/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py
index 98f7f1ee256..fdd91fb4dce 100644
--- a/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py
+++ b/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py
@@ -23,10 +23,13 @@ from pathlib import Path
 import pytest
 from ci.prek.check_metrics_synced_with_the_registry import (
     _PREFIX_MATCHED,
+    _except_handler_catches_expected_error,
+    _is_stats_module_path,
     extract_metric_name_from_ast_node,
     find_registry_match,
     get_stats_obj_name,
     normalize_metric_name,
+    scan_file_for_direct_stats_imports,
     scan_file_for_metrics,
 )
 
@@ -275,3 +278,190 @@ def test_scan_file_with_multiple_calls(code_to_py_file):
 
 def test_scan_file_nonexistent_file_returns_empty(tmp_path):
     assert scan_file_for_metrics(tmp_path / "non_existent.py") == []
+
+
[email protected](
+    "code, expected_imports",
+    [
+        pytest.param(
+            "from airflow._shared.observability.metrics.stats import 
incr\nincr('foo')",
+            [{"imported_names": ["incr"], "line_num": 1}],
+            id="direct_import_of_metric_function",
+        ),
+        pytest.param(
+            "from airflow.sdk._shared.observability.metrics.stats import 
gauge\n",
+            [{"imported_names": ["gauge"]}],
+            id="direct_import_via_sdk_path",
+        ),
+        pytest.param(
+            "from airflow_shared.observability.metrics.stats import timer\n",
+            [{"imported_names": ["timer"]}],
+            id="direct_import_via_shared_path",
+        ),
+        pytest.param(
+            "from airflow._shared.observability.metrics.stats import incr as 
foo\n",
+            [{"imported_names": ["incr as foo"]}],
+            id="aliased_direct_import",
+        ),
+        pytest.param(
+            "from airflow._shared.observability.metrics.stats import incr, 
gauge, timing\n",
+            [{"imported_names": ["incr", "gauge", "timing"]}],
+            id="multiple_methods_one_statement",
+        ),
+        pytest.param(
+            "from airflow._shared.observability.metrics import stats\n",
+            [],
+            id="namespace_import_allowed",
+        ),
+        pytest.param(
+            "from airflow._shared.observability.metrics.stats import 
normalize_name_for_stats\n",
+            [],
+            id="non_metric_function_allowed",
+        ),
+        pytest.param(
+            "from airflow._shared.observability.metrics.stats import Stats\n",
+            [],
+            id="class_shim_allowed",
+        ),
+        pytest.param(
+            "from airflow._shared.observability.metrics.stats import incr, 
normalize_name_for_stats\n",
+            [{"imported_names": ["incr"]}],
+            id="only_metric_function_reported_from_mixed_import",
+        ),
+        pytest.param(
+            "try:\n"
+            "    from airflow._shared.observability.metrics.stats import gauge 
 # noqa: F401\n"
+            "except ImportError:\n"
+            "    gauge = None\n",
+            [],
+            id="exempt_inside_try_except_import_error",
+        ),
+        pytest.param(
+            "try:\n"
+            "    from airflow._shared.observability.metrics.stats import 
gauge\n"
+            "except ModuleNotFoundError:\n"
+            "    gauge = None\n",
+            [],
+            id="exempt_inside_try_except_module_not_found_error",
+        ),
+        pytest.param(
+            "try:\n"
+            "    from airflow._shared.observability.metrics.stats import 
gauge\n"
+            "except (ImportError, ModuleNotFoundError):\n"
+            "    gauge = None\n",
+            [],
+            id="exempt_inside_try_except_tuple_of_only_expected",
+        ),
+        pytest.param(
+            "try:\n"
+            "    from airflow._shared.observability.metrics.stats import 
gauge\n"
+            "except (ImportError, OSError):\n"
+            "    gauge = None\n",
+            [{"imported_names": ["gauge"]}],
+            id="not_exempt_for_tuple_mixing_expected_and_unrelated",
+        ),
+        pytest.param(
+            "try:\n"
+            "    from airflow._shared.observability.metrics.stats import 
gauge\n"
+            "except ValueError:\n"
+            "    gauge = None\n",
+            [{"imported_names": ["gauge"]}],
+            id="not_exempt_for_unrelated_exception",
+        ),
+        pytest.param(
+            "try:\n"
+            "    from airflow._shared.observability.metrics.stats import 
gauge\n"
+            "except:\n"
+            "    gauge = None\n",
+            [{"imported_names": ["gauge"]}],
+            id="not_exempt_for_bare_except",
+        ),
+        pytest.param(
+            "from some.unrelated.module import incr\n",
+            [],
+            id="unrelated_module_import_ignored",
+        ),
+    ],
+)
+def test_scan_file_for_direct_stats_imports(code_to_py_file, code, 
expected_imports):
+    violations = scan_file_for_direct_stats_imports(code_to_py_file(code))
+    assert len(violations) == len(expected_imports)
+    for violation, expected in zip(violations, expected_imports):
+        for field, value in expected.items():
+            assert getattr(violation, field) == value
+
+
+def test_scan_file_for_direct_stats_imports_records_module(code_to_py_file):
+    path = code_to_py_file("from airflow._shared.observability.metrics.stats 
import incr\n")
+    violations = scan_file_for_direct_stats_imports(path)
+    assert len(violations) == 1
+    assert violations[0].module == 
"airflow._shared.observability.metrics.stats"
+    assert violations[0].file_path == str(path)
+
+
+def 
test_scan_file_for_direct_stats_imports_nonexistent_file_returns_empty(tmp_path):
+    assert scan_file_for_direct_stats_imports(tmp_path / "non_existent.py") == 
[]
+
+
[email protected](
+    "module, expected_bool_result",
+    [
+        pytest.param(None, False, id="none_returns_false"),
+        pytest.param("", False, id="empty_string_returns_false"),
+        pytest.param("observability.metrics.stats", True, 
id="bare_suffix_matches"),
+        pytest.param(
+            "airflow._shared.observability.metrics.stats", True, 
id="airflow_core_shared_path_matches"
+        ),
+        pytest.param(
+            "airflow.sdk._shared.observability.metrics.stats", True, 
id="task_sdk_shared_path_matches"
+        ),
+        pytest.param("airflow_shared.observability.metrics.stats", True, 
id="shared_path_matches"),
+        pytest.param(
+            "airflow._shared.observability.metrics.statsd_logger",
+            False,
+            id="module_with_stats_prefix_rejected",
+        ),
+        pytest.param(
+            "airflow._shared.observability.metrics.base_stats_logger",
+            False,
+            id="module_without_stats_at_tail_rejected",
+        ),
+        pytest.param(
+            "airflow._shared.observability.metrics", False, 
id="parent_module_without_stats_rejected"
+        ),
+        pytest.param("some.random.module", False, id="random_module_rejected"),
+    ],
+)
+def test_is_stats_module_path(module, expected_bool_result):
+    assert _is_stats_module_path(module) is expected_bool_result
+
+
+def _create_ast_except_handler(exception_clause: str) -> ast.ExceptHandler:
+    """Parse a ``try`` block with the given except clause and return its 
handler."""
+    suffix = f" {exception_clause}" if exception_clause else ""
+    tree = ast.parse(f"try:\n    pass\nexcept{suffix}:\n    pass\n")
+    return tree.body[0].handlers[0]  # type: ignore[attr-defined]
+
+
[email protected](
+    "exception_clause, expected_bool_result",
+    [
+        pytest.param("ImportError", True, id="import_error_matches"),
+        pytest.param("ModuleNotFoundError", True, 
id="module_not_found_error_matches"),
+        pytest.param("(ImportError, ModuleNotFoundError)", True, 
id="tuple_of_only_expected_matches"),
+        pytest.param("(ImportError, OSError)", False, 
id="tuple_with_unrelated_error_rejected1"),
+        pytest.param(
+            "(OSError, ModuleNotFoundError)",
+            False,
+            id="tuple_with_unrelated_error_rejected2",
+        ),
+        pytest.param("OSError", False, 
id="unrelated_single_exception_rejected"),
+        pytest.param("(OSError, ValueError)", False, 
id="tuple_of_unrelated_rejected"),
+        pytest.param("Exception", False, id="broad_exception_rejected"),
+        pytest.param("BaseException", False, id="base_exception_rejected"),
+        pytest.param("", False, id="empty_except_rejected"),
+    ],
+)
+def test_except_handler_catches_expected_error(exception_clause: str, 
expected_bool_result):
+    handler = _create_ast_except_handler(exception_clause)
+    assert _except_handler_catches_expected_error(handler) is 
expected_bool_result

Reply via email to