This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 35dd6f89dc0 [v3-3-test] Enable ruff B023 (function-uses-loop-variable)
and fix violations (#70640) (#72273)
35dd6f89dc0 is described below
commit 35dd6f89dc0de82ddb37b73f7de8b2942437ce80
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Aug 29 18:48:20 2026 -0500
[v3-3-test] Enable ruff B023 (function-uses-loop-variable) and fix
violations (#70640) (#72273)
B023 catches the late-binding closure-over-loop-variable footgun where
a function defined inside a loop captures the loop variable by
reference, so every function in the resulting list sees the same
(final) value — a classic silent-bug source in Python
(https://docs.python.org/3/faq/programming.html#why-do-lambdas-defined-in-a-loop-with-different-values-all-return-the-same-result).
The one user-visible fix is in providers/standard/.../triggers/file.py,
where the FileTrigger's os.walk lambda was dispatched to a worker
thread via anyio.to_thread.run_sync while the outer glob iteration
could advance, potentially walking the wrong path.
The rest are pre-existing latent-bug or false-positive sites in a
migration script, breeze translation helpers, sphinx extensions, a
system-test example DAG, the SMTP OAuth2 auth callback, secrets_masker
subclass compat shim, and two provider unit tests — fixed by binding
the loop-derived variable as a default argument on the inner function
or lambda.
(cherry picked from commit af0a3770a687458a357cd8820cbda07918a90cda)
Co-authored-by: Deepak kumar <[email protected]>
Co-authored-by: Jarek Potiuk <[email protected]>
---
.../0101_3_2_0_ui_improvements_for_deadlines.py | 6 ++++++
.../src/airflow_breeze/commands/ui_commands.py | 4 ++--
.../src/sphinx_exts/providers_extensions.py | 4 ++--
.../unit/apache/hive/transfers/test_s3_to_hive.py | 4 +++-
.../exasol/tests/unit/exasol/hooks/test_sql.py | 2 +-
.../cloud/cloud_sql/example_cloud_sql_query.py | 8 +++++++-
.../smtp/src/airflow/providers/smtp/hooks/smtp.py | 2 +-
.../airflow/providers/standard/triggers/file.py | 2 +-
pyproject.toml | 1 +
.../secrets_masker/secrets_masker.py | 22 ++++++++++++++--------
10 files changed, 38 insertions(+), 17 deletions(-)
diff --git
a/airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py
b/airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py
index 3e1a5c5d54f..a77e780b683 100644
---
a/airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py
+++
b/airflow-core/src/airflow/migrations/versions/0101_3_2_0_ui_improvements_for_deadlines.py
@@ -28,6 +28,12 @@ Revises: e79fc784f145
Create Date: 2025-10-17 16:04:55.016272
"""
+# ruff: noqa: B023
+# _migrate_dag_deadlines is defined inside a for-loop but is invoked and fully
drained
+# (via `list(_migrate_dag_deadlines(dag_conn))`) within the same iteration, so
the
+# captured loop values never leak across iterations. This migration shipped in
3.2.0 —
+# do not rewrite the body for lint hygiene; document the invariant instead.
+
from __future__ import annotations
import contextlib
diff --git a/dev/breeze/src/airflow_breeze/commands/ui_commands.py
b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
index 983b31b8160..1f17b4f7d00 100644
--- a/dev/breeze/src/airflow_breeze/commands/ui_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
@@ -518,7 +518,7 @@ def add_missing_translations(language: str, summary:
dict[str, LocaleSummary]):
lang_data = {} # Start with an empty dict if the file doesn't
exist
# Helper to recursively add missing keys, including plural forms
- def add_keys(src, dst, prefix=""):
+ def add_keys(src, dst, prefix="", missing_keys=missing_keys):
for k, v in src.items():
full_key = f"{prefix}.{k}" if prefix else k
base = get_plural_base(full_key, suffixes)
@@ -575,7 +575,7 @@ def remove_unused_translations(language: str, summary:
dict[str, LocaleSummary])
continue
# Helper to recursively remove unused keys
- def remove_keys(dst, prefix=""):
+ def remove_keys(dst, prefix="", unused_keys=unused_keys):
keys_to_remove = []
for k, v in list(dst.items()):
full_key = f"{prefix}.{k}" if prefix else k
diff --git a/devel-common/src/sphinx_exts/providers_extensions.py
b/devel-common/src/sphinx_exts/providers_extensions.py
index ddd2bbafebb..5aa40ea9b59 100644
--- a/devel-common/src/sphinx_exts/providers_extensions.py
+++ b/devel-common/src/sphinx_exts/providers_extensions.py
@@ -379,8 +379,8 @@ def _get_providers_class_registry(
.replace("/", ".")
),
class_extras={
- "provider_name": lambda **kwargs:
provider_yaml_content["package-name"],
- "provider_version": lambda **kwargs:
provider_yaml_content["versions"][0],
+ "provider_name": lambda pyc=provider_yaml_content,
**kwargs: pyc["package-name"],
+ "provider_version": lambda pyc=provider_yaml_content,
**kwargs: pyc["versions"][0],
**(class_extras or {}),
},
)
diff --git
a/providers/apache/hive/tests/unit/apache/hive/transfers/test_s3_to_hive.py
b/providers/apache/hive/tests/unit/apache/hive/transfers/test_s3_to_hive.py
index 68ba1f04499..7e5999001bf 100644
--- a/providers/apache/hive/tests/unit/apache/hive/transfers/test_s3_to_hive.py
+++ b/providers/apache/hive/tests/unit/apache/hive/transfers/test_s3_to_hive.py
@@ -223,7 +223,9 @@ class TestS3ToHiveTransfer:
# against expected file output
tests_common.test_utils.file_loading.load_file_from_resources.side_effect = (
- lambda *args, **kwargs: self._load_file_side_effect(args,
op_fn, ext)
+ lambda *args, _op_fn=op_fn, _ext=ext, **kwargs:
self._load_file_side_effect(
+ args, _op_fn, _ext
+ )
)
# Execute S3ToHiveTransfer
s32hive = S3ToHiveOperator(**self.kwargs)
diff --git a/providers/exasol/tests/unit/exasol/hooks/test_sql.py
b/providers/exasol/tests/unit/exasol/hooks/test_sql.py
index 87013320616..33e3b5a4958 100644
--- a/providers/exasol/tests/unit/exasol/hooks/test_sql.py
+++ b/providers/exasol/tests/unit/exasol/hooks/test_sql.py
@@ -265,7 +265,7 @@ def test_query(
cursors = []
for index in range(len(cursor_descriptions)):
cur = mock.MagicMock(
- rowcount=lambda: len(cursor_results[index]),
+ rowcount=lambda _idx=index: len(cursor_results[_idx]),
)
cur.columns.return_value = get_columns(cursor_descriptions[index])
cur.fetchall.return_value = cursor_results[index]
diff --git
a/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql_query.py
b/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql_query.py
index e4cc7b82930..1b2c0f5f2b6 100644
---
a/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql_query.py
+++
b/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql_query.py
@@ -475,7 +475,13 @@ with DAG(
return connection_id
@task_group(group_id=f"create_connections_{database_type}")
- def create_connections(instance: str, db_type: str, ip_address: str,
port: str):
+ def create_connections(
+ instance: str,
+ db_type: str,
+ ip_address: str,
+ port: str,
+ database_type: str = database_type,
+ ):
for conn in CONNECTIONS:
conn_id = f"{conn.id}_{database_type}"
create_connection(
diff --git a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
index 991d13c1abc..b3c5bf1baad 100644
--- a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
+++ b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
@@ -140,7 +140,7 @@ class SmtpHook(BaseHook):
)
self._smtp_client.auth(
"XOAUTH2",
- lambda _=None: build_xoauth2_string(user_identity,
self._access_token),
+ lambda _=None, ui=user_identity:
build_xoauth2_string(ui, self._access_token),
)
elif self.smtp_user and self.smtp_password:
self._smtp_client.login(self.smtp_user,
self.smtp_password)
diff --git a/providers/standard/src/airflow/providers/standard/triggers/file.py
b/providers/standard/src/airflow/providers/standard/triggers/file.py
index 8c7b8894c26..f581ac2a96c 100644
--- a/providers/standard/src/airflow/providers/standard/triggers/file.py
+++ b/providers/standard/src/airflow/providers/standard/triggers/file.py
@@ -84,7 +84,7 @@ class FileTrigger(BaseTrigger):
self.log.info("Found File %s last modified: %s", path,
mod_time)
yield TriggerEvent(True)
return
- for _, _, files in await anyio.to_thread.run_sync(lambda:
list(os.walk(path))):
+ for _, _, files in await anyio.to_thread.run_sync(lambda p:
list(os.walk(p)), path):
if files:
yield TriggerEvent(True)
return
diff --git a/pyproject.toml b/pyproject.toml
index 32af09e3b87..be55a72d7de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -663,6 +663,7 @@ extend-select = [
"B012", # Checks for `break`, `continue`, and `return` statements in
`finally` blocks
"B017", # Checks for pytest.raises context managers that catch Exception
or BaseException.
"B019", # Use of functools.lru_cache or functools.cache on methods can
lead to memory leaks
+ "B023", # Function definition does not bind loop variable (late binding of
closure over loop var)
"B028", # No explicit stacklevel keyword argument found
"TRY002", # Prohibit use of `raise Exception`, use specific exceptions
instead.
"RET505", # Unnecessary {branch} after return statement
diff --git
a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
index 9157a91a7fb..05c86073492 100644
--- a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
+++ b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
@@ -219,14 +219,20 @@ class SecretsMasker(logging.Filter):
break
else:
# Block only runs if no break above.
-
- f = cls._redact
-
- @functools.wraps(f)
- def _redact(*args, replacement: str = "***", **kwargs):
- return f(*args, **kwargs)
-
- cls._redact = _redact
+ # Use a factory so the captured subclass method is held in the
factory's
+ # closure rather than as a default argument on `_redact`. A
default arg
+ # would leak the name into `_redact`'s keyword signature
(visible via
+ # `inspect.signature(..., follow_wrapped=False)` and
callable-visible via
+ # `**kwargs`), which would let a caller silently substitute a
different
+ # function inside the secrets-masking path.
+ def _make_redact(f):
+ @functools.wraps(f)
+ def _redact(*args, replacement: str = "***", **kwargs):
+ return f(*args, **kwargs)
+
+ return _redact
+
+ cls._redact = _make_redact(cls._redact)
...
@classmethod