This is an automated email from the ASF dual-hosted git repository.
shahar1 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 867327c48dd Run only the tests that import a changed test helper
(#74055)
867327c48dd is described below
commit 867327c48dd8e8768117f110400d6cea79df09ba
Author: Yossi Eliaz <[email protected]>
AuthorDate: Sat Oct 3 11:08:07 2026 +0300
Run only the tests that import a changed test helper (#74055)
* Run only the tests that import a changed test helper
A change to any Python file under devel-common/ or to anything in
airflow-core/tests/unit/utils/ forced the full test matrix on pull requests.
Most tests_common helpers are imported by a handful of test files, the
airflow-core directory holds ordinary unit tests, and the Sphinx extensions
only affect the docs build. Replaying selective checks on two samples of PR
runs (14-18 Sep and 28 Sep-1 Oct) puts the full matrices these rules forced
without need at 106 and 269 runner-hours a day.
* Run all tests when test helper importers cannot be found
If the importer search itself fails, an empty result would have selected no
tests at all for a changed helper. A helper reached through a package
__init__ is loaded as broadly as the pytest plugin, and relative imports
inside tests_common would have hidden an importer.
* Skip dev/ and scripts/ importers when narrowing test helper changes
Nothing in CI collects dev/ or scripts/ files as tests, yet a dev/
importer matched ENVIRONMENT_FILES and silently forced the full matrix
with "env files changed" as the reason, defeating the narrowing for any
helper a dev script imports. The importer unit test now runs against a
temporary tree instead of real repository files, and the Sphinx
extension case pins that core tests still run.
* Fail closed for test helper importers outside the known test trees
A changed test file only selects the job that runs it in some trees.
Narrowing a helper to an importer under clients/python,
airflow-e2e-tests, docker-tests, devel-common/tests or the core
integration tests dropped jobs that the full matrix used to run, so
those importers now force the full matrix again. Importers are no
longer treated as changed files by the common.compat "use next version"
check and the suspended-provider check, which otherwise failed the
build for providers the PR never touched, and the git grep runs even in
dry-run mode so the selection cannot fail open.
* Cover the suspended-provider exclusion for test helper importers
The attribute that forces the full test matrix also holds helpers that were
deleted, whose importer search failed, or that have an importer outside the
known test trees, so its old name pointed readers at the pytest plugin only.
No provider is suspended today, so nothing exercised the rule that keeps a
narrowed helper's importers out of the suspended-provider check; without
it, a
helper imported by a suspended provider's tests would fail the PR.
---------
Co-authored-by: Shahar Epstein <[email protected]>
---
dev/breeze/doc/ci/04_selective_checks.md | 21 +-
.../src/airflow_breeze/utils/selective_checks.py | 161 +++++++++++++-
dev/breeze/tests/test_selective_checks.py | 243 +++++++++++++++++++--
3 files changed, 394 insertions(+), 31 deletions(-)
diff --git a/dev/breeze/doc/ci/04_selective_checks.md
b/dev/breeze/doc/ci/04_selective_checks.md
index d413de863a6..2381870a2da 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -166,7 +166,7 @@ flowchart TD
E2 -->|yes| T
E2 -->|no| E3{git or standard<br/>provider files?}
E3 -->|yes| T
- E3 -->|no| E4{core test utils?<br/>tests/utils}
+ E3 -->|no| E4{test helper loaded by every test run?<br/>tests_common
pytest plugin + its imports}
E4 -->|yes| T
E4 -->|no| E5{'full tests needed' label?}
E5 -->|yes| T
@@ -219,8 +219,10 @@ When unit tests run, selective checks narrow *which* test
types execute, separat
* **Provider test types** (`_get_providers_test_types_to_run`): empty on
non-`main` branches. In full
mode (or when dependencies were upgraded) → `Providers` (all). Otherwise
selective checks compute the
**affected providers** from the changed files and add their **direct
upstream and downstream
- dependents** (not the whole transitive closure). Changes to *common*
provider code (tests/utils that
- don't belong to a single provider) escalate to *all* providers. Suspended
providers are excluded (and
+ dependents** (not the whole transitive closure). Changes to *common*
provider code under
+ `devel-common/` escalate to *all* providers, except a changed `tests_common`
helper, which instead
+ selects the test files that import it (directly or through other helpers) as
if those files had
+ changed. Suspended providers are excluded (and
a PR that touches one fails unless it carries the `allow suspended provider
changes` label).
The same matched-file approach drives the **prek hook skip list**
(`skip_prek_hooks`): each mypy /
@@ -333,10 +335,13 @@ all versions), the cause is almost always a single rule
that fired. To find it:
`scripts/ci/*`, `scripts/docker/*`, (often this is the surprise: editing
CI/breeze itself runs everything);
* **`pyproject.toml`** or generated provider dependencies changed (also
forces `all_versions`);
* the **generated OpenAPI spec** or the client generator changed (the API
contract);
- * **`tests/utils`** or **git/standard provider** files changed;
+ * a **`tests_common` helper loaded by every test run** (the pytest plugin,
anything it imports,
+ conftest or package `__init__` modules), a helper whose importers cannot
be narrowed (it was
+ deleted or renamed, `git grep` failed, or an importer lies outside the
known test trees), or
+ **git/standard provider** files changed;
* the **`full tests needed`** or **`all versions`** label is set on the PR.
4. **All providers running?** That means selective checks decided *all*
providers are affected — usually
- because *common* provider code (shared tests/utils not owned by one
provider) changed, or because
+ because *common* provider code under `devel-common/` changed, or because
dependencies were upgraded, or `full_tests_needed` is on. The reason is
printed in the
provider-selection `[warning]` lines.
5. **Want to confirm an optimisation is safe?** Remember the canary on `main`
always runs everything —
@@ -419,8 +424,10 @@ together using `pytest-xdist` (pytest-xdist distributes
the tests among parallel
miss commit info, or any of the important environment files
(`pyproject.toml`, `Dockerfile`, `scripts`,
etc.) changed, or the API *contract* changed (the generated OpenAPI spec or
the client generator —
plain API source/test edits that leave the committed spec
- untouched do **not** force full tests), or `tests/utils` / git / standard
provider files changed, or
- when the `full tests needed` label is set.
+ untouched do **not** force full tests), or a `tests_common` helper loaded by
every test run or whose
+ importers cannot be narrowed (helper deleted or renamed, `git grep` failed,
or an importer outside the
+ known test trees), or git / standard provider files changed, or when the
`full tests needed` label is
+ set. Any other changed `tests_common` helper only selects the tests that
import it.
That enables all matrix combinations of variables (representative) and all
possible test type. No further
checks are performed. See also [1] note below. Two exceptions narrow this: a
PUSH that changed **only**
`.txt`/`.md` files skips full tests, and a PUSH to a **release branch**
(`v3-X-test`, i.e. not `main`)
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 68dfad6edf7..d8241e0040a 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import ast
import difflib
import itertools
import json
@@ -353,6 +354,7 @@ CI_FILE_GROUP_MATCHES: HashableDict[FileGroupForCi] =
HashableDict(
FileGroupForCi.DOC_FILES: [
r"^docs",
r"^devel-common/src/docs",
+ r"^devel-common/src/sphinx_exts",
r"^\.github/SECURITY\.md",
r"^providers/.*/docs/",
r"^providers/.*/src/.*\.py$",
@@ -473,8 +475,7 @@ CI_FILE_GROUP_MATCHES: HashableDict[FileGroupForCi] =
HashableDict(
r".*pyproject\.toml$",
],
FileGroupForCi.TESTS_UTILS_FILES: [
- r"^airflow-core/tests/unit/utils/",
- r"^devel-common/.*\.py$",
+ r"^devel-common/src/tests_common/.*\.py$",
],
FileGroupForCi.TASK_SDK_FILES: [
r"^task-sdk/src/airflow/sdk/.*\.py$",
@@ -674,6 +675,125 @@ def _matching_files(
return matched_files
+TESTS_COMMON_SOURCE_ROOT = "devel-common/src/"
+TESTS_COMMON_PYTEST_PLUGIN = "devel-common/src/tests_common/pytest_plugin.py"
+# Only in these trees does a changed test file select the job that runs it;
narrowing to an importer
+# elsewhere (e2e, python client, core integration, docker-tests) would skip
tests the full matrix ran.
+TEST_HELPER_IMPORTER_ROOTS = (
+ "airflow-core/tests/system/",
+ "airflow-core/tests/unit/",
+ "airflow-ctl/tests/",
+ "airflow-ctl-tests/",
+ "kubernetes-tests/",
+ "providers/",
+ "shared/",
+ "task-sdk/tests/",
+ "task-sdk-integration-tests/",
+)
+
+
+def _imports_module(text: str, module: str, importer_package: str | None =
None) -> bool:
+ """
+ Whether ``text`` imports ``module``.
+
+ ``importer_package`` is the dotted package of the importing file; relative
imports are only resolved
+ when it is given.
+ """
+ package, _, name = module.rpartition(".")
+ if (
+ not re.search(rf"\b{re.escape(module)}\b", text)
+ and not (f"from {package} import" in text and
re.search(rf"\b{re.escape(name)}\b", text))
+ and not (importer_package and re.search(r"^\s*from \.", text,
re.MULTILINE))
+ ):
+ return False
+ try:
+ tree = ast.parse(text)
+ except SyntaxError:
+ return True
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ if any(alias.name == module or alias.name.startswith(f"{module}.")
for alias in node.names):
+ return True
+ elif isinstance(node, ast.ImportFrom):
+ if node.level == 0:
+ imported = node.module
+ elif importer_package:
+ base_parts = importer_package.split(".")
+ base = ".".join(base_parts[: len(base_parts) - (node.level -
1)])
+ imported = f"{base}.{node.module}" if node.module else base
+ else:
+ continue
+ if not imported:
+ continue
+ if imported == module or imported.startswith(f"{module}."):
+ return True
+ if imported == package and any(alias.name == name for alias in
node.names):
+ return True
+ # Dotted references outside import statements, e.g. `pytest_plugins`
entries.
+ return re.search(rf"\b{re.escape(module)}\b", text) is not None
+
+
+@clearable_cache
+def _find_test_helper_importers(helper: str) -> frozenset[str] | None:
+ """
+ Return the files outside ``tests_common`` that import ``helper``, directly
or through other helpers.
+
+ ``None`` means the change cannot be narrowed down to its importers: the
helper is loaded for every
+ test run (the pytest plugin, anything it imports, conftest and package
``__init__`` modules), it
+ no longer exists, the importers could not be searched, or an importer lies
outside
+ ``TEST_HELPER_IMPORTER_ROOTS``.
+ """
+ if (
+ helper == TESTS_COMMON_PYTEST_PLUGIN
+ or Path(helper).name in ("conftest.py", "__init__.py")
+ or not (AIRFLOW_ROOT_PATH / helper).is_file()
+ ):
+ return None
+ importers: set[str] = set()
+ seen = {helper}
+ pending = [helper]
+ while pending:
+ module = pending.pop()[len(TESTS_COMMON_SOURCE_ROOT)
:].removesuffix(".py").replace("/", ".")
+ result = run_command(
+ ["git", "grep", "-l", "-F", "-w", module.rpartition(".")[2], "--",
"*.py"],
+ capture_output=True,
+ text=True,
+ cwd=AIRFLOW_ROOT_PATH,
+ check=False,
+ dry_run_override=False,
+ )
+ # git grep exits with 1 when nothing matches; anything else means the
search did not happen.
+ if result.returncode not in (0, 1):
+ return None
+ for candidate in result.stdout.splitlines():
+ # CI never runs dev/ or scripts/ files as tests, and a dev/
importer would match
+ # ENVIRONMENT_FILES and force the full test matrix.
+ if candidate in seen or candidate.startswith(("dev/", "scripts/")):
+ continue
+ in_tests_common =
candidate.startswith(f"{TESTS_COMMON_SOURCE_ROOT}tests_common/")
+ importer_package = (
+ str(Path(candidate[len(TESTS_COMMON_SOURCE_ROOT)
:]).parent).replace("/", ".")
+ if in_tests_common
+ else None
+ )
+ text = (AIRFLOW_ROOT_PATH / candidate).read_text(errors="replace")
+ if not _imports_module(text, module, importer_package):
+ continue
+ seen.add(candidate)
+ if in_tests_common:
+ if candidate == TESTS_COMMON_PYTEST_PLUGIN or
Path(candidate).name in (
+ "conftest.py",
+ "__init__.py",
+ ):
+ return None
+ pending.append(candidate)
+ elif candidate.startswith(TEST_HELPER_IMPORTER_ROOTS):
+ importers.add(candidate)
+ else:
+ return None
+ return frozenset(importers)
+
+
def _split_list(input_list, n) -> list[list[str]]:
"""
Splits input_list into exactly n sub-lists, distributing items as evenly
as possible.
@@ -742,6 +862,21 @@ class SelectiveChecks:
platform: str = CI_AMD_PLATFORM,
):
self._files = files
+ # A changed test helper selects the tests that import it, as if those
test files had changed;
+ # only helpers that cannot be narrowed to their importers still force
the full set of tests.
+ self._test_helpers_forcing_full_tests: tuple[str, ...] = ()
+ self._test_helpers_replaced_by_importers: tuple[str, ...] = ()
+ helper_importers: set[str] = set()
+ for helper in _matching_files(files, FileGroupForCi.TESTS_UTILS_FILES,
CI_FILE_GROUP_MATCHES):
+ importers = _find_test_helper_importers(helper)
+ if importers is None:
+ self._test_helpers_forcing_full_tests += (helper,)
+ else:
+ self._test_helpers_replaced_by_importers += (helper,)
+ helper_importers |= importers
+ self._test_helper_importers = frozenset(helper_importers - set(files))
+ if helper_importers:
+ self._files = tuple(sorted(set(files) | helper_importers))
self._default_branch = default_branch
self._default_constraints_branch = default_constraints_branch
self._commit_ref = commit_ref
@@ -880,11 +1015,11 @@ class SelectiveChecks:
"and for now we have core tests depending on them.[/]"
)
return True
- if self._matching_files(
- FileGroupForCi.TESTS_UTILS_FILES,
- CI_FILE_GROUP_MATCHES,
- ):
- console_print("[warning]Running full set of tests because
tests/utils changed[/]")
+ if self._test_helpers_forcing_full_tests:
+ console_print(
+ "[warning]Running full set of tests because test helpers that
cannot be narrowed to their "
+ f"importers changed: {',
'.join(self._test_helpers_forcing_full_tests)}[/]"
+ )
return True
if FULL_TESTS_NEEDED_LABEL in self._pr_labels:
console_print(
@@ -1986,12 +2121,15 @@ class SelectiveChecks:
all_providers_affected = False
suspended_providers: set[str] = set()
for changed_file in self._files:
+ if changed_file in self._test_helpers_replaced_by_importers:
+ continue
provider = find_provider_affected(changed_file,
include_docs=include_docs)
if provider == "Providers":
all_providers_affected = True
elif provider is not None:
if provider not in get_provider_dependencies():
- suspended_providers.add(provider)
+ if changed_file not in self._test_helper_importers:
+ suspended_providers.add(provider)
else:
affected_providers.add(provider)
if self.run_api_tests:
@@ -2218,12 +2356,17 @@ class SelectiveChecks:
def _has_common_compat_changed(self) -> bool:
"""Check if any common.compat provider file was changed."""
- return any(f.startswith("providers/common/compat/") for f in
self._files)
+ return any(
+ f.startswith("providers/common/compat/") and f not in
self._test_helper_importers
+ for f in self._files
+ )
def _get_changed_providers_excluding_common_compat(self) -> set[str]:
"""Get set of changed providers excluding common.compat itself."""
changed_providers: set[str] = set()
for changed_file in self._files:
+ if changed_file in self._test_helper_importers:
+ continue
provider = find_provider_affected(changed_file, include_docs=False)
if provider and provider not in ["common.compat", "Providers"]:
changed_providers.add(provider)
diff --git a/dev/breeze/tests/test_selective_checks.py
b/dev/breeze/tests/test_selective_checks.py
index 3c81c0ee901..a115a2637a2 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -18,6 +18,7 @@ from __future__ import annotations
import json
import re
+import subprocess
from typing import Any
from unittest.mock import Mock, patch
@@ -41,10 +42,13 @@ from airflow_breeze.global_constants import (
from airflow_breeze.utils.functools_cache import clearable_cache
from airflow_breeze.utils.packages import get_available_distributions
from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
+from airflow_breeze.utils.provider_dependencies import
get_provider_dependencies
from airflow_breeze.utils.selective_checks import (
ALL_CI_SELECTIVE_TEST_TYPES,
SelectiveChecks,
+ _find_test_helper_importers,
_get_test_list_as_json,
+ _imports_module,
_split_list,
)
@@ -1313,25 +1317,16 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
pytest.param(
("airflow-core/tests/unit/utils/test_cli_util.py",),
{
- "selected-providers-list-as-string":
ALL_PROVIDERS_AFFECTED,
- "all-python-versions":
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
- "all-python-versions-list-as-string":
DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
- "python-versions":
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
- "python-versions-list-as-string":
DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
+ "selected-providers-list-as-string": None,
"ci-image-build": "true",
- "prod-image-build": "true",
- "run-helm-tests": "true",
+ "prod-image-build": "false",
"run-unit-tests": "true",
- "run-amazon-tests": "true",
- "docs-build": "true",
- "full-tests-needed": "true",
- "skip-prek-hooks":
ALL_SKIPPED_COMMITS_BY_DEFAULT_ON_ALL_TESTS_NEEDED,
- "upgrade-to-newer-dependencies": "false",
+ "full-tests-needed": "false",
"core-test-types-list-as-strings-in-json":
ALL_CI_SELECTIVE_TEST_TYPES_AS_JSON,
- "providers-test-types-list-as-strings-in-json":
ALL_PROVIDERS_SELECTIVE_TEST_TYPES_AS_JSON,
- "run-mypy-providers": "true",
+ "providers-test-types-list-as-strings-in-json": "null",
+ "run-mypy-providers": "false",
},
- id="All tests should be run when tests/utils/ change",
+ id="Core tests only when airflow-core/tests/unit/utils/
change",
)
),
(
@@ -1362,6 +1357,53 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
id="All tests should be run when devel-common/ change",
)
),
+ (
+ pytest.param(
+ ("devel-common/src/tests_common/pytest_plugin.py",),
+ {
+ "full-tests-needed": "true",
+ "providers-test-types-list-as-strings-in-json":
ALL_PROVIDERS_SELECTIVE_TEST_TYPES_AS_JSON,
+ },
+ id="All tests should be run when the tests_common pytest
plugin changes",
+ )
+ ),
+ (
+ pytest.param(
+ ("devel-common/src/tests_common/test_utils/mock_plugins.py",),
+ {
+ "full-tests-needed": "true",
+ "providers-test-types-list-as-strings-in-json":
ALL_PROVIDERS_SELECTIVE_TEST_TYPES_AS_JSON,
+ },
+ id="All tests should be run when a test helper imported by the
pytest plugin changes",
+ )
+ ),
+ (
+ pytest.param(
+ ("devel-common/src/tests_common/test_utils/permissions.py",),
+ {
+ "selected-providers-list-as-string": "common.compat fab",
+ "full-tests-needed": "false",
+ "run-unit-tests": "true",
+ "providers-test-types-list-as-strings-in-json": json.dumps(
+ [{"description": "common.compat,fab", "test_types":
"Providers[common.compat,fab]"}]
+ ),
+ },
+ id="Only the tests importing a test helper should run when it
changes",
+ )
+ ),
+ (
+ pytest.param(
+ ("devel-common/src/sphinx_exts/exampleinclude.py",),
+ {
+ "full-tests-needed": "false",
+ "docs-build": "true",
+ "run-unit-tests": "true",
+ "core-test-types-list-as-strings-in-json":
ALL_CI_SELECTIVE_TEST_TYPES_AS_JSON,
+ "providers-test-types-list-as-strings-in-json": "null",
+ },
+ id="Docs build and core tests, not the full matrix, when a
Sphinx extension changes",
+ )
+ ),
(
pytest.param(
("airflow-core/src/airflow/ui/src/index.tsx",),
@@ -4270,3 +4312,174 @@ def test_helm_test_kubernetes_versions(
default_branch="main",
)
assert_outputs_are_printed(expected_outputs, str(stderr))
+
+
[email protected](
+ ("source", "importer_package", "expected"),
+ [
+ pytest.param(
+ "from tests_common.test_utils.mock_context import mock_context\n",
None, True, id="from-module"
+ ),
+ pytest.param("from tests_common.test_utils import mock_context\n",
None, True, id="from-package"),
+ pytest.param(
+ "from tests_common.test_utils import (\n db,\n
mock_context,\n)\n",
+ None,
+ True,
+ id="multiline-from",
+ ),
+ pytest.param("import tests_common.test_utils.mock_context as mc\n",
None, True, id="import"),
+ pytest.param(
+ 'pytest_plugins = ["tests_common.test_utils.mock_context"]\n',
None, True, id="dotted-string"
+ ),
+ pytest.param("mock_context = {}\n", None, False,
id="same-name-variable"),
+ pytest.param(
+ "from tests_common.test_utils.mock_context_extra import x\n",
None, False, id="longer-module"
+ ),
+ pytest.param(
+ "# mock_context\nfrom tests_common.test_utils import db\n", None,
False, id="other-package-member"
+ ),
+ pytest.param(
+ "from ..mock_context import mock_context\n",
+ "tests_common.test_utils.operators",
+ True,
+ id="relative-from-parent",
+ ),
+ pytest.param("from . import mock_context\n",
"tests_common.test_utils", True, id="relative-package"),
+ pytest.param("from .mock_context import mock_context\n", None, False,
id="relative-without-package"),
+ pytest.param(
+ "from .mock_context import mock_context\n",
+ "tests_common.other",
+ False,
+ id="relative-other-package",
+ ),
+ ],
+)
+def test_imports_module(source: str, importer_package: str | None, expected:
bool):
+ assert _imports_module(source, "tests_common.test_utils.mock_context",
importer_package) is expected
+
+
[email protected](
+ ("grep_results", "expected"),
+ [
+ pytest.param(
+ [(0, "airflow-core/tests/unit/utils/test_db.py\n")],
+ frozenset({"airflow-core/tests/unit/utils/test_db.py"}),
+ id="importer",
+ ),
+ pytest.param(
+ [(0,
"dev/airflow_perf/x.py\nairflow-core/tests/unit/utils/test_db.py\n")],
+ frozenset({"airflow-core/tests/unit/utils/test_db.py"}),
+ id="skips-dev-importer",
+ ),
+ pytest.param(
+ [
+ (0,
"devel-common/src/tests_common/test_utils/other_helper.py\n"),
+ (0, "providers/fab/tests/unit/fab/test_x.py\n"),
+ ],
+ frozenset({"providers/fab/tests/unit/fab/test_x.py"}),
+ id="transitive-importer",
+ ),
+ pytest.param([(1, "")], frozenset(), id="no-importers"),
+ pytest.param([(128, "")], None, id="search-failed"),
+ pytest.param(
+ [(0, "devel-common/src/tests_common/test_utils/__init__.py\n")],
+ None,
+ id="imported-by-package-init",
+ ),
+ pytest.param(
+ [(0, "devel-common/src/tests_common/pytest_plugin.py\n")], None,
id="imported-by-pytest-plugin"
+ ),
+ pytest.param([(0, "clients/python/test_python_client.py\n")], None,
id="importer-outside-test-trees"),
+ pytest.param(
+ [(0, "airflow-core/tests/integration/otel/test_otel.py\n")],
+ None,
+ id="importer-in-core-integration-tests",
+ ),
+ ],
+)
+@patch("airflow_breeze.utils.selective_checks._imports_module", autospec=True,
return_value=True)
+@patch("airflow_breeze.utils.selective_checks.run_command", autospec=True)
+def test_find_test_helper_importers(
+ mock_run_command, mock_imports_module, grep_results, expected, tmp_path,
monkeypatch
+):
+ helper = "devel-common/src/tests_common/test_utils/mock_context.py"
+ for name in [helper, *(line for _, output in grep_results for line in
output.splitlines())]:
+ (tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
+ (tmp_path / name).touch()
+
monkeypatch.setattr("airflow_breeze.utils.selective_checks.AIRFLOW_ROOT_PATH",
tmp_path)
+ mock_run_command.side_effect = [
+ subprocess.CompletedProcess(args=[], returncode=returncode,
stdout=output)
+ for returncode, output in grep_results
+ ]
+ assert _find_test_helper_importers(helper) == expected
+ assert mock_run_command.call_args.kwargs["dry_run_override"] is False
+
+
+@patch("airflow_breeze.utils.selective_checks.run_command", autospec=True)
+def test_find_test_helper_importers_missing_helper(mock_run_command, tmp_path,
monkeypatch):
+
monkeypatch.setattr("airflow_breeze.utils.selective_checks.AIRFLOW_ROOT_PATH",
tmp_path)
+ assert
_find_test_helper_importers("devel-common/src/tests_common/test_utils/removed.py")
is None
+ mock_run_command.assert_not_called()
+
+
[email protected](
+ ("files", "importers"),
+ [
+ pytest.param(
+ (
+
"providers/common/compat/src/airflow/providers/common/compat/check.py",
+ "devel-common/src/tests_common/test_utils/permissions.py",
+ ),
+
frozenset({"providers/fab/tests/unit/fab/auth_manager/test_security.py"}),
+ id="importer-in-other-provider",
+ ),
+ pytest.param(
+ (
+ "providers/ftp/src/airflow/providers/ftp/hooks/ftp.py",
+ "devel-common/src/tests_common/test_utils/permissions.py",
+ ),
+
frozenset({"providers/common/compat/tests/unit/common/compat/test_check.py"}),
+ id="importer-in-common-compat",
+ ),
+ ],
+)
+@patch("airflow_breeze.utils.selective_checks._find_test_helper_importers",
autospec=True)
+@patch("airflow_breeze.utils.selective_checks.run_command", autospec=True)
+def test_common_compat_check_ignores_test_helper_importers(
+ mock_run_command, mock_find_test_helper_importers, files, importers
+):
+ mock_find_test_helper_importers.return_value = importers
+ mock_run_command.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0,
stdout='"apache-airflow-providers-common-compat>=1.8.0",\n'
+ )
+ selective_checks = SelectiveChecks(
+ files=files,
+ commit_ref=NEUTRAL_COMMIT,
+ pr_labels=(),
+ github_event=GithubEvents.PULL_REQUEST,
+ default_branch="main",
+ )
+ assert selective_checks.common_compat_changed_without_next_version is False
+
+
+@patch("airflow_breeze.utils.selective_checks.get_provider_dependencies",
autospec=True)
+@patch("airflow_breeze.utils.selective_checks._find_test_helper_importers",
autospec=True)
+@patch("airflow_breeze.utils.selective_checks.run_command", autospec=True)
+def test_suspended_provider_check_ignores_test_helper_importers(
+ mock_run_command, mock_find_test_helper_importers,
mock_get_provider_dependencies
+):
+ mock_get_provider_dependencies.return_value = {
+ provider: deps for provider, deps in
get_provider_dependencies().items() if provider != "fab"
+ }
+ mock_find_test_helper_importers.return_value = frozenset(
+ {"providers/fab/tests/unit/fab/auth_manager/test_security.py"}
+ )
+ mock_run_command.return_value = subprocess.CompletedProcess(args=[],
returncode=0, stdout="")
+ selective_checks = SelectiveChecks(
+ files=("devel-common/src/tests_common/test_utils/permissions.py",),
+ commit_ref=NEUTRAL_COMMIT,
+ pr_labels=(),
+ github_event=GithubEvents.PULL_REQUEST,
+ default_branch="main",
+ )
+ assert selective_checks.selected_providers_list_as_string is None